Claude Code skill that builds, debugs, audits and packages Claude Code mods from a plain-language description

A safety net that stops dangerous commands until you say yes, and keeps Claude away from files that hold passwords and keys.
Claude Code is Anthropic's assistant for working with code, in the terminal or in the Claude desktop app. A mod is a small add-on for it: it can add a panel, a bar above the box where you type, a new /command, or a rule that checks what Claude is about to do. This folder is one complete mod you can install and use as it is, or copy as a starting point.
Claude can run commands on your computer. Most are harmless, but a few can delete a folder for good, throw away work that was not saved in Git, or rewrite history that other people share. This mod watches every command before it runs. When it sees one of those risky patterns it pauses and asks you, in plain words, what the command would do. It also refuses any edit to files that usually hold secrets, such as .env files and private keys.
block (always refuse) in the settings.rm -rf, Remove-Item -Recurse -Force, rd /s)git push --force, -f, +branch)git reset --hard and git clean -f (throw away unsaved or untracked work)DROP TABLE, TRUNCATE)chmod -R, chown -R)mkfs, format, Format-Volume, Clear-Disk).env and .env.something, id_rsa, id_ed25519, files ending in .pem, .key, .p12, .pfx, and credentials / credentials.json.
| Key | What it does |
|---|---|
Arrow keys and Enter | Choose Run it or Refuse in the question |
None. It works on its own.
Claude Code asks for these when you enable the mod. To change them later, open /config and find the mod by name.
| Setting | What it means | Default |
|---|---|---|
mode (Risky command mode) | ask: pause and ask you. block: always refuse. off: guard turned off. | ask |
language (Language) | Language of the text the mod shows: en (English) or pt-BR (Brazilian Portuguese). | en |
off turns everything off, including the protection of secret files.You need Claude Code 2.1.287 or newer in the terminal, or 2.1.286 or newer in the desktop app.
_claude-plugin to .claude-plugin (with a dot at the start). It ships without the dot so the examples never load by accident.claude --plugin-dir path/to/shell-guard
To keep it installed for good, ask the claude-mod-forge skill to package it ("package this mod").
From the skill folder (skills/claude-mod-forge), this runs the manifest check, a strict type check and the 16 automated tests of this mod:
node scripts/check-mod.mjs examples/shell-guard
The tests cover the mod's logic and what it draws in the terminal and the desktop app. How it looks in your own window is worth a quick look in a real session.
For developers: the examples table lists the hooks and API calls this mod demonstrates.
hooks/register.ts 46 lines1import type { Register } from 'claude-code'
2
3import { strings } from './i18n'
4import { failureAnswer, isSecretPath, riskOf } from './rules'
5
6// Every guard here fails CLOSED: if the hook throws or times out before deciding,
7// the .catch handler refuses. A guard without .catch would let the command through.
8// What the user sees follows options.language; deny reasons stay English (Claude reads them).
9export const register: Register = (on, options) => {
10 const mode = options.mode === 'block' || options.mode === 'off' ? options.mode : 'ask'
11 const t = strings(options.language)
12
13 // One guard for every tool that runs a command. Monitor's command is optional.
14 on('tool.call', { tool: ['Bash', 'PowerShell', 'Monitor'] }, async ($, e, next) => {
15 if (mode === 'off') return next(e)
16 const risk = riskOf(String(e.command ?? ''))
17 if (risk === undefined) return next(e)
18
19 if (mode === 'block') {
20 $.ui.toast(t.blocked(t.label(risk.label)))
21 return { deny: `${$.plugin.name} blocked a ${risk.label} (${risk.why}). Ask the user before trying another way.` }
22 }
23
24 // Start from the safe answer: a dismissed question, or a claude -p run, refuses.
25 let answer = t.refuse
26 try {
27 // The wait happens inside a $ call, so it does not count against the hook's time limit.
28 const question = t.question(t.label(risk.label), t.why(risk.why), String(e.command ?? '').slice(0, 400))
29 answer = await $.ui.ask(question, [t.runIt, t.refuse])
30 } catch {
31 // Nobody could answer.
32 }
33 if (answer !== t.runIt) {
34 return { deny: `The user declined this ${risk.label}. Ask before trying a different approach.` }
35 }
36 // Approved here only means "go on": the normal permission check still runs after next().
37 return next(e)
38 }).catch(($, e, next) => failureAnswer($.plugin.name, next.called, 'command') ?? next(e))
39
40 on('tool.call', { tool: ['Edit', 'Write'] }, ($, e, next) =>
41 mode !== 'off' && isSecretPath(e.file_path)
42 ? { deny: `${$.plugin.name}: ${e.file_path} holds secrets and is not edited by Claude here.` }
43 : next(e),
44 ).catch(($, e, next) => failureAnswer($.plugin.name, next.called, 'edit') ?? next(e))
45}
46hooks/i18n.ts 54 lines1// The text the USER sees (question, options, toast), per language. Pure: no $ calls.
2// Anything other than a known language falls back to English.
3// Deny reasons are not here on purpose: Claude reads them, so they stay in English.
4
5export type Language = 'en' | 'pt-BR'
6
7const en = {
8 // Risk labels and reasons come from rules.ts in English; a table maps them for display.
9 label: (english: string) => english,
10 why: (english: string) => english,
11 question: (label: string, why: string, command: string) => `${label} (${why}). Run this command?\n\n${command}`,
12 runIt: 'Run it',
13 refuse: 'Refuse',
14 blocked: (label: string) => `Blocked: ${label}`,
15}
16
17const LABELS: Record<string, string> = {
18 'recursive force delete': 'exclusão recursiva forçada',
19 'force push': 'push forçado',
20 'hard reset': 'reset --hard',
21 'git clean': 'git clean',
22 'destructive SQL': 'SQL destrutivo',
23 'recursive permission change': 'mudança recursiva de permissões',
24 'disk format': 'formatação de disco',
25 'recursive delete': 'exclusão recursiva',
26}
27
28const WHYS: Record<string, string> = {
29 'deletes files with no undo': 'apaga arquivos sem como desfazer',
30 'rewrites shared history': 'reescreve o histórico compartilhado',
31 'drops uncommitted work': 'descarta trabalho não commitado',
32 'deletes untracked files': 'apaga arquivos não rastreados',
33 'removes data': 'remove dados',
34 'changes many files at once': 'altera muitos arquivos de uma vez',
35 'erases a volume': 'apaga um volume',
36 'deletes a folder tree': 'apaga uma árvore de pastas',
37}
38
39const ptBR: typeof en = {
40 label: english => LABELS[english] ?? english,
41 why: english => WHYS[english] ?? english,
42 question: (label, why, command) => `${label} (${why}). Executar este comando?\n\n${command}`,
43 runIt: 'Executar',
44 refuse: 'Recusar',
45 blocked: label => `Bloqueado: ${label}`,
46}
47
48export type Strings = typeof en
49const TABLE: Record<Language, Strings> = { en, 'pt-BR': ptBR }
50
51export function strings(language: unknown): Strings {
52 return language === 'pt-BR' ? TABLE['pt-BR'] : TABLE.en
53}
54hooks/rules.ts 49 lines1// Pure rules, kept apart from the hooks so tests can cover them directly.
2// Every tool that runs commands must be guarded: Bash, PowerShell (Windows) and Monitor.
3// List them from the tools types (claude-code-tools) for the build you target.
4// A regex on command text is a reminder layer, not a sandbox: spellings it misses get through.
5// Pair it with permission rules (deny/ask) and branch protection for anything that must hold.
6
7export type Risk = { label: string; why: string }
8
9const RISKS: { pattern: RegExp; label: string; why: string }[] = [
10 // rm with a recursive AND a force flag, in any order or spelling, within the same command:
11 // -rf, -fr, -r -f, -f -r, -R -f, --recursive --force; a flag may touch a quote: rm -rf"$DIR".
12 {
13 pattern: /\brm\b(?=[^|;&\n]*\s(?:-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)(?=[\s"']|$))(?=[^|;&\n]*\s(?:-[a-zA-Z]*f[a-zA-Z]*|--force)(?=[\s"']|$))/,
14 label: 'recursive force delete',
15 why: 'deletes files with no undo',
16 },
17 // --force, --force-with-lease, -f, or a +refspec, quoted or not (git push origin "+main" forces that branch).
18 { pattern: /\bgit\s+push\b.*(--force\b|--force-with-lease\b|\s-f\b|\s["']?\+[^\s+"'])/i, label: 'force push', why: 'rewrites shared history' },
19 { pattern: /\bgit\s+reset\s+--hard\b/i, label: 'hard reset', why: 'drops uncommitted work' },
20 { pattern: /\bgit\s+clean\s+-[a-z]*f/i, label: 'git clean', why: 'deletes untracked files' },
21 { pattern: /\b(drop|truncate)\s+(table|database|schema)\b/i, label: 'destructive SQL', why: 'removes data' },
22 { pattern: /\b(chmod|chown)\s+-R\b/i, label: 'recursive permission change', why: 'changes many files at once' },
23 { pattern: /\b(mkfs|format)\b/i, label: 'disk format', why: 'erases a volume' },
24 // PowerShell / cmd (Windows): the PowerShell tool runs commands too, not only Bash.
25 { pattern: /\bRemove-Item\b(?=.*\s-Recurse\b)(?=.*\s-Force\b)/i, label: 'recursive force delete', why: 'deletes files with no undo' },
26 { pattern: /\b(rd|rmdir)\s+\/s\b/i, label: 'recursive delete', why: 'deletes a folder tree' },
27 { pattern: /\b(Format-Volume|Clear-Disk|Initialize-Disk)\b/i, label: 'disk format', why: 'erases a volume' },
28]
29
30export function riskOf(command: string): Risk | undefined {
31 const hit = RISKS.find(r => r.pattern.test(command))
32 return hit && { label: hit.label, why: hit.why }
33}
34
35// Both separators: Windows paths use backslashes.
36const SECRET_FILE = /(^|[\\/])(\.env(\.[\w-]+)?|id_(rsa|ed25519)(\.pub)?|[^\\/]+\.(pem|key|p12|pfx)|credentials(\.json)?)$/i
37
38export function isSecretPath(path: string): boolean {
39 return SECRET_FILE.test(path)
40}
41
42// What the guards' .catch answers. Kept pure so the fail-closed decision is tested directly:
43// before next() was called the guard refuses (undefined would let the call through);
44// after next() it hands back what that call already settled to (the caller returns next(e)).
45export function failureAnswer(plugin: string, nextWasCalled: boolean, what: 'command' | 'edit'): { deny: string } | undefined {
46 if (nextWasCalled) return undefined
47 return { deny: `${plugin}: the check failed, so the ${what === 'command' ? 'command was not run' : 'edit was not made'}.` }
48}
49