Guardrails for Claude Code: warns on or blocks dangerous commands, secret access and self-config edits, with a log and /guard.

English | Español
A Claude Code mod that puts guardrails on every tool call: it warns about or blocks dangerous commands, secret access and edits to Claude's own config. Local regex rules, zero model calls, with a log and a /guard command.
In a Claude Code session:
/plugin install guard --marketplace LordSantox/guard
Answer y to add the marketplace, then choose the scope (user or project).
From a terminal:
claude plugin install guard --marketplace LordSantox/guard
Install the mod from https://github.com/LordSantox/guard following the instructions in its README.
Default mode is warn: high rules let the action run, show a toast and add a note telling Claude to confirm the user asked for it. critical rules always block.
| Rule | Severity | Catches | ||
|---|---|---|---|---|
rm-root, ps-rm-root | critical | recursive delete of /, ~, $HOME, *, C:\ | ||
disk-wipe, fork-bomb | critical | mkfs, dd of=/dev/…, format C:, fork bomb | ||
pipe-shell | critical | `curl … \ | sh, iwr … \ | iex` |
exfil | critical | curl -d @file, `env \ | curl`, uploads | |
self-config | critical | Claude editing .claude/settings*.json or this mod | ||
force-push, git-destroy | high | git push --force, reset --hard, clean -fdx | ||
sql-drop, chmod-777 | high | DROP TABLE, TRUNCATE, chmod 777 | ||
opaque | high | `base64 -d \ | sh, -EncodedCommand, eval "$…"` | |
remote-pkg | high | npm i https://…, pip install git+… | ||
secret-file, secret-read | high | .env, id_rsa, ~/.ssh, ~/.aws, *.pem, .npmrc | ||
secret-in-code | high | writing tokens like ghp_…, sk-…, AKIA…, private keys |
It normalizes before matching, so r''m, \rm, X=rm; $X -rf /, bash -c "…" and a && b chains are caught. rm -rf node_modules, .env.example and --force-with-lease are allowed.
guard does not reinvent auditing: on session start it checks whether these are installed and, if not, shows a toast and the install lines in /guard. It relies on trailofbits/skills. Install the recommended ones:
/plugin install differential-review --marketplace trailofbits/skills
/plugin install supply-chain-risk-auditor --marketplace trailofbits/skills
git commit/push without a security review triggers rule no-review (high): warns in warn mode, blocks in block mode. guard ships its own short skill, guard-review (diff only, ≤10-line answer), which Claude is told to run; differential-review, /diff-review or /security-review also clear it.npm install <pkg>, pip install <pkg>, cargo add, go get, guard tells Claude to run supply-chain-risk-auditor./guard open the Guard panel (log, mode, allowed rules)
/guard log text log (last 15)
/guard avisar warn mode (default)
/guard bloquear block mode: also blocks "high" rules
/guard permitir <rule> disable a rule
/guard quitar <rule> re-enable it
/guard limpiar clear the log
/guard ocultar stop the Trail of Bits install hint
The UI text is in Spanish.
This is a defense layer, not a sandbox. Regex can be evaded (scripts written then executed, python -c, unusual quoting). Use it together with Claude Code's permissions.
MIT
hooks/register.tsx 182 lines1import type { Register } from 'claude-code'
2
3import { blocks, check, installLines, isCommit, missingTob, isEdit, isPkgInstall, isReview, NO_REVIEW, type Hit, type Mode } from './rules'
4
5const MAX_LOG = 50
6const PANE = 'guard'
7type Entry = { at: number; tool: string; rule: string; sev: string; why: string; blocked: boolean; what: string }
8
9async function cfg($: any): Promise<{ mode: Mode; allowed: string[] }> {
10 return {
11 mode: ((await $.store.get('mode')) as Mode) ?? 'avisar',
12 allowed: ((await $.store.get('allowed')) as string[]) ?? [],
13 }
14}
15
16const save = async ($: any, k: string, v: unknown) => { await $.store.set(k, v); $.ui.invalidate('ui.render') }
17
18async function log($: any, entry: Entry) {
19 const list = ((await $.store.get('log')) as Entry[]) ?? []
20 await save($, 'log', [...list, entry].slice(-MAX_LOG))
21 const b = list.filter(x => x.blocked).length + (entry.blocked ? 1 : 0)
22 $.ui.status(`🛡 guard: ${b} bloqueos · ${list.length + 1 - b} avisos`)
23}
24
25const missing = async ($: any) => { try { return missingTob((await $.settings.read()).enabledPlugins) } catch { return [] } }
26
27const what = (e: any) => String(e.command ?? e.file_path ?? e.notebook_path ?? '').slice(0, 120)
28
29const HELP = [
30 '/guard abre el panel',
31 '/guard log bitácora en texto (últimos 15)',
32 '/guard avisar avisa y deja pasar lo "high" (por defecto)',
33 '/guard bloquear bloquea también lo "high"',
34 '/guard permitir <regla> desactiva una regla',
35 '/guard quitar <regla> la vuelve a activar',
36 '/guard limpiar borra la bitácora',
37 '/guard ocultar no avisar más de las skills de Trail of Bits',
38].join('\n')
39
40export const register: Register = on => {
41 on('session.start', async ($, e, next) => {
42 await $.command.register({ name: 'guard', description: 'Guard: bitácora, modo avisar/bloquear y reglas permitidas' })
43 const miss = await missing($)
44 if (miss.length && !(await $.store.get('hideTob'))) {
45 $.ui.toast(`🛡 guard: faltan skills de Trail of Bits (${miss.join(', ')}). Escribe /guard para ver cómo instalarlas.`)
46 }
47 return next(e)
48 })
49
50 on('command.run', { command: 'guard' }, async ($, e) => {
51 const [verb, arg] = e.args.trim().split(/\s+/)
52 const { mode, allowed } = await cfg($)
53 if (verb === 'avisar' || verb === 'bloquear') {
54 await save($, 'mode', verb)
55 return { text: `guard: modo ${verb}. Las reglas "critical" bloquean siempre.` }
56 }
57 if (verb === 'permitir' && arg) {
58 await save($, 'allowed', [...new Set([...allowed, arg])])
59 return { text: `guard: regla ${arg} permitida.` }
60 }
61 if (verb === 'quitar' && arg) {
62 await save($, 'allowed', allowed.filter(r => r !== arg))
63 return { text: `guard: regla ${arg} activa otra vez.` }
64 }
65 if (verb === 'limpiar') {
66 await save($, 'log', [])
67 $.ui.status(undefined)
68 return { text: 'guard: bitácora borrada.' }
69 }
70 if (verb === 'ocultar') {
71 await save($, 'hideTob', true)
72 return { text: 'guard: no avisaré más de Trail of Bits.' }
73 }
74 if (!verb) {
75 await $.ui.open({ id: PANE, title: 'Guard' })
76 return { text: 'Panel guard abierto.' }
77 }
78 if (verb !== 'log') return { text: HELP }
79 const list = ((await $.store.get('log')) as Entry[]) ?? []
80 const rows = list.slice(-15).reverse().map(x =>
81 `${new Date(x.at).toLocaleTimeString()} ${x.blocked ? 'BLOQUEO' : 'aviso '} ${x.rule} (${x.sev}) ${x.tool}: ${x.what}`)
82 const miss = await missing($)
83 const tob = miss.length ? ['', `Recomendado: skills de Trail of Bits para revisar el código (faltan ${miss.length}):`, ...installLines(miss)] : []
84 return { text: [`guard · modo ${mode}${allowed.length ? ` · permitidas: ${allowed.join(', ')}` : ''}`, ...(rows.length ? rows : ['Sin eventos.']), ...tob, '', HELP].join('\n') }
85 })
86
87 // Revisión registrada al usar la skill o el comando de Trail of Bits (o /security-review).
88 on('command.run', async ($, e, next) => {
89 if (isReview(e.command)) await save($, 'pending', false)
90 return next(e)
91 })
92
93 on('tool.call', async ($, e, next) => {
94 const ev = e as any
95 const tool = String(ev.tool ?? '')
96 if (tool === 'Skill' && isReview(String(ev.skill ?? ''))) await save($, 'pending', false)
97 const cmd = tool === 'Bash' || tool === 'PowerShell' ? String(ev.command ?? '') : ''
98 let hit: Hit | undefined = check(tool, ev)
99 if (!hit && cmd && isCommit(cmd) && (await $.store.get('pending'))) hit = NO_REVIEW
100 if (!hit) {
101 const r = await next(e)
102 if (r.deny !== undefined) return r
103 if (isEdit(tool)) await save($, 'pending', true)
104 if (cmd && isCommit(cmd) && !r.isError) await save($, 'pending', false)
105 if (cmd && isPkgInstall(cmd)) {
106 return { ...r, context: [...(r.context ?? []), 'guard: dependencia nueva. Si está instalada, usa la skill supply-chain-risk-auditor (Trail of Bits) para evaluar su riesgo.'] }
107 }
108 return r
109 }
110 const { mode, allowed } = await cfg($)
111 if (allowed.includes(hit.rule)) return next(e)
112 const blocked = blocks(hit, mode, allowed)
113 await log($, { at: Date.now(), tool, rule: hit.rule, sev: hit.sev, why: hit.why, blocked, what: what(ev) })
114 const fix = hit.rule === 'no-review' ? ' Usa la skill guard-review (revisión corta del diff) y luego reintenta.' : ''
115 if (blocked) {
116 void $.ui.open({ id: PANE, title: 'Guard' })
117 $.ui.toast(`🛡 guard bloqueó ${tool}: ${hit.why}`)
118 return { deny: `guard: bloqueado (${hit.rule}: ${hit.why}). Si el usuario lo quiere de verdad, que lo ejecute él o use /guard permitir ${hit.rule}.${fix}` }
119 }
120 $.ui.toast(`🛡 guard: ${hit.why} (${tool})`)
121 const r = await next(e)
122 if (r.deny !== undefined) return r
123 if (cmd && isCommit(cmd) && !r.isError) await save($, 'pending', false)
124 return { ...r, context: [...(r.context ?? []), `guard: aviso ${hit.rule} (${hit.why}). ${fix || 'Confirma que el usuario lo pidió; si no, revierte.'}`] }
125 // Si el guard falla, deja pasar (no bloquea el trabajo por un bug propio); el fallo queda en el log de debug.
126 }).catch(($, e, next) => next(e))
127 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
128 const { Box, Text, Button } = $.ui.resolve(e)
129 const { mode, allowed } = await cfg($)
130 const list = ((await $.store.get('log')) as Entry[]) ?? []
131 const pending = Boolean(await $.store.get('pending'))
132 const miss = (await $.store.get('hideTob')) ? [] : await missing($)
133 const b = list.filter(x => x.blocked).length
134 return (
135 <Box flexDirection="column">
136 <Text bold>🛡 modo {mode} · {b} bloqueos · {list.length - b} avisos</Text>
137 <Box>
138 <Button key="m-avisar" variant={mode === 'avisar' ? 'primary' : undefined} onPress={() => void save($, 'mode', 'avisar')}>Avisar</Button>
139 <Button key="m-bloquear" variant={mode === 'bloquear' ? 'primary' : undefined} onPress={() => void save($, 'mode', 'bloquear')}>Bloquear</Button>
140 <Button key="clear" dimColor onPress={() => void save($, 'log', []).then(() => $.ui.status(undefined))}>Limpiar</Button>
141 </Box>
142 <Text color={pending ? 'yellow' : 'green'}>{pending ? '● cambios sin revisar: el próximo commit pedirá guard-review' : '✓ sin cambios pendientes de revisión'}</Text>
143 {allowed.length > 0 && (
144 <Box flexDirection="column" marginTop={1}>
145 <Text bold>Reglas permitidas</Text>
146 {allowed.map(r => (
147 <Box key={`a-${r}`}>
148 <Text> {r} </Text>
149 <Button key={`q-${r}`} dimColor onPress={() => void save($, 'allowed', allowed.filter(x => x !== r))}>Reactivar</Button>
150 </Box>
151 ))}
152 </Box>
153 )}
154 <Box flexDirection="column" marginTop={1}>
155 <Text bold>Bitácora</Text>
156 {list.length === 0 && <Text dimColor>Sin eventos todavía.</Text>}
157 {list.slice(-20).reverse().map((x, i) => (
158 <Box key={`l-${x.at}-${i}`} flexDirection="column">
159 <Box>
160 <Text color={x.blocked ? 'red' : 'yellow'}>{x.blocked ? '✗ bloqueo' : '! aviso'} </Text>
161 <Text bold>{x.rule}</Text>
162 <Text dimColor> · {x.tool} · {new Date(x.at).toLocaleTimeString()} </Text>
163 {!allowed.includes(x.rule) && (
164 <Button key={`p-${x.at}-${i}`} dimColor onPress={() => void save($, 'allowed', [...new Set([...allowed, x.rule])])}>Permitir regla</Button>
165 )}
166 </Box>
167 <Text dimColor> {x.why}: {x.what}</Text>
168 </Box>
169 ))}
170 </Box>
171 {miss.length > 0 && (
172 <Box flexDirection="column" marginTop={1}>
173 <Text bold>Recomendado: Trail of Bits ({miss.length} faltan)</Text>
174 {installLines(miss).map(l => <Text key={l} color="cyan"> {l}</Text>)}
175 <Button key="hide" dimColor onPress={() => void save($, 'hideTob', true)}>Ocultar</Button>
176 </Box>
177 )}
178 </Box>
179 )
180 })
181}
182hooks/rules.ts 123 lines1// Reglas locales (regex) sobre cada llamada a herramienta. Cero llamadas al modelo.
2// No es un sandbox: es una capa que atrapa errores y evasiones comunes.
3
4export type Sev = 'critical' | 'high'
5export type Hit = { rule: string; sev: Sev; why: string }
6
7type Rule = { id: string; sev: Sev; why: string; re: RegExp }
8
9const HOME = String.raw`(~|\$HOME|\$\{HOME\}|%USERPROFILE%|\$env:USERPROFILE)`
10
11// Se prueban sobre cada segmento ya normalizado (sin comillas ni escapes).
12const CMD: Rule[] = [
13 { id: 'rm-root', sev: 'critical', why: 'borrado recursivo de raíz, home o comodín',
14 re: new RegExp(String.raw`\brm\s+(-\w*[rR]\w*\s+|--recursive\s+)+(-\w+\s+)*(/|/\*|${HOME}/?\*?|\.\.?/?\*?|\*|[a-z]:[\\/]?)(\s|$)`, 'i') },
15 { id: 'ps-rm-root', sev: 'critical', why: 'Remove-Item recursivo sobre raíz o home',
16 re: new RegExp(String.raw`\bremove-item\b.*-recurse.*\s(/|[a-z]:[\\/]?|${HOME}[\\/]?)(\s|$)`, 'i') },
17 { id: 'disk-wipe', sev: 'critical', why: 'escritura directa a disco o formateo',
18 re: /\b(mkfs(\.\w+)?|dd\s+.*\bof=\/dev\/|format\s+[a-z]:)/i },
19 { id: 'fork-bomb', sev: 'critical', why: 'fork bomb', re: /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}/ },
20 { id: 'pipe-shell', sev: 'critical', why: 'descarga ejecutada directo en la shell',
21 re: /\b(curl|wget|iwr|invoke-webrequest|irm|invoke-restmethod)\b.*\|\s*(sudo\s+)?(ba|z|da)?sh\b|\b(iwr|irm|invoke-webrequest|invoke-restmethod)\b.*\|\s*(iex|invoke-expression)\b/i },
22 { id: 'force-push', sev: 'high', why: 'push forzado (reescribe historia remota)',
23 re: /\bgit\s+push\b.*(\s--force(?!-with-lease)\b|\s-f\b|\s\+\S+)/i },
24 { id: 'git-destroy', sev: 'high', why: 'descarta cambios locales sin respaldo',
25 re: /\bgit\s+(reset\s+--hard|clean\s+-\w*[fdx]\w*[fdx]|checkout\s+--\s+\.)/i },
26 { id: 'sql-drop', sev: 'high', why: 'borra tablas o bases de datos', re: /\b(drop\s+(table|database|schema)|truncate\s+table)\b/i },
27 { id: 'chmod-777', sev: 'high', why: 'permisos abiertos a todos', re: /\bchmod\s+(-R\s+)?0?777\b/i },
28 { id: 'exfil', sev: 'critical', why: 'envía un archivo o el entorno por red',
29 re: /\b(curl|wget)\b.*(\s-d\s*@|--data(-binary|-raw)?\s*@|-F\s*\S+=@|--upload-file|-T\s)|\b(env|printenv)\b.*\|\s*(curl|nc|wget)\b|\bnc\s+-\w*\s*\S+\s+\d+\s*<\s*\S/i },
30 { id: 'opaque', sev: 'high', why: 'comando codificado u opaco que no se puede revisar',
31 re: /\bbase64\s+(-d|--decode)\b.*\|\s*(ba|z)?sh\b|-enc(odedcommand)?\s+[A-Za-z0-9+/=]{16,}|\beval\s+["$`]/i },
32 { id: 'remote-pkg', sev: 'high', why: 'instala un paquete desde URL o tarball',
33 re: /\b(npm|pnpm|yarn)\s+(i|install|add)\b.*\s(https?:|git\+|\S+\.tgz)|\bpip3?\s+install\b.*\s(https?:|git\+)/i },
34]
35
36// Rutas de secretos: leer, editar o escribir.
37const SECRET_PATH = /(^|[\\/])(\.env(\.(?!example|sample|template)[\w.-]+)?|id_(rsa|ed25519|ecdsa|dsa)(\.pub)?|\.npmrc|\.pypirc|\.netrc|credentials|kubeconfig|[\w.-]+\.(pem|key|p12|pfx))$|[\\/]\.ssh[\\/]|[\\/]\.aws[\\/]/i
38// El propio guard y la configuración de Claude: el agente no se desactiva solo.
39const SELF_PATH = /[\\/]\.claude[\\/](settings(\.local)?\.json|mods[\\/]guard[\\/])/i
40// Tokens con forma conocida dentro de lo que se escribe.
41const TOKEN = /\b(AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{36,}|github_pat_\w{40,}|sk-(ant-|proj-)?[A-Za-z0-9_-]{20,}|xox[abprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{35})\b|-----BEGIN [A-Z ]*PRIVATE KEY-----/
42
43const SHELL_READ = /\b(cat|type|get-content|gc|less|more|head|tail|cp|copy|scp|base64|xxd)\b/i
44
45// Quita comillas y escapes, para que r''m, "rm" y \rm cuenten como rm.
46export function normalize(s: string): string {
47 return s.replace(/\\\r?\n/g, ' ').replace(/(["'`])\1/g, '').replace(/(^|\s)\\(?=\w)/g, '$1').replace(/["']/g, '')
48}
49
50// Parte por separadores de shell y abre bash -c / sh -c / $( ) / backticks.
51export function segments(cmd: string, depth = 0): string[] {
52 if (depth > 3) return [cmd]
53 const out: string[] = [cmd]
54 const inner = [...cmd.matchAll(/\b(?:ba|z|da)?sh\s+-c\s+(["'])([\s\S]*?)\1|\$\(([^()]*)\)|`([^`]*)`|powershell(?:\.exe)?\s+(?:-\w+\s+)*-c(?:ommand)?\s+(["'])([\s\S]*?)\5/gi)]
55 for (const m of inner) {
56 const body = m[2] ?? m[3] ?? m[4] ?? m[6]
57 if (body) out.push(...segments(body, depth + 1))
58 }
59 // shortcut: no entiende comillas al partir, puede sobre-partir; basta para reglas por segmento.
60 out.push(...cmd.split(/\s*(?:;|&&|\|\||\r?\n)\s*/).filter(p => p && p !== cmd))
61 return out
62}
63
64// Variables triviales: X=rm; $X -rf / → rm -rf /
65function expandVars(cmd: string): string {
66 const vars = new Map<string, string>()
67 for (const m of cmd.matchAll(/(?:^|[;&\s])([A-Za-z_]\w*)=([^\s;&|]+)/g)) vars.set(m[1], m[2])
68 return vars.size ? cmd.replace(/\$\{?([A-Za-z_]\w*)\}?/g, (all, k) => vars.get(k) ?? all) : cmd
69}
70
71export function checkCommand(raw: string): Hit | undefined {
72 const cmd = expandVars(normalize(raw))
73 for (const seg of segments(cmd)) {
74 for (const r of CMD) if (r.re.test(seg)) return { rule: r.id, sev: r.sev, why: r.why }
75 if (SHELL_READ.test(seg) && seg.split(/\s+/).some(w => SECRET_PATH.test(w))) {
76 return { rule: 'secret-read', sev: 'high', why: 'lee un archivo de secretos desde la shell' }
77 }
78 }
79 return undefined
80}
81
82export function checkPath(path: string, writing: boolean): Hit | undefined {
83 if (writing && SELF_PATH.test(path)) return { rule: 'self-config', sev: 'critical', why: 'edita la configuración de Claude o del propio guard' }
84 if (SECRET_PATH.test(path)) return { rule: 'secret-file', sev: 'high', why: 'toca un archivo de secretos' }
85 return undefined
86}
87
88export function checkContent(text: string): Hit | undefined {
89 return TOKEN.test(text) ? { rule: 'secret-in-code', sev: 'high', why: 'escribe algo con forma de clave o token' } : undefined
90}
91
92// Punto único: qué revisar según la herramienta.
93export function check(tool: string, input: any): Hit | undefined {
94 if (tool === 'Bash' || tool === 'PowerShell') return checkCommand(String(input?.command ?? ''))
95 const path = String(input?.file_path ?? input?.notebook_path ?? '')
96 if (tool === 'Read') return path ? checkPath(path, false) : undefined
97 if (tool === 'Write' || tool === 'Edit' || tool === 'MultiEdit' || tool === 'NotebookEdit') {
98 const text = [input?.content, input?.new_string, input?.new_source, ...(input?.edits ?? []).map((x: any) => x?.new_string)].filter(Boolean).join('\n')
99 return (path && checkPath(path, true)) || checkContent(text)
100 }
101 return undefined
102}
103
104// Revisión antes de commit: se apoya en las skills de Trail of Bits (trailofbits/skills).
105export const isCommit = (cmd: string) => /\bgit\s+(commit|push)\b/i.test(normalize(cmd))
106export const isEdit = (tool: string) => /^(Write|Edit|MultiEdit|NotebookEdit)$/.test(tool)
107export const isReview = (name: string) => /guard-review|differential-review|diff-review|security-review/i.test(name)
108export const isPkgInstall = (cmd: string) =>
109 /\b(npm|pnpm|yarn|bun)\s+(i|install|add)\s+(?!-)\S|\bpip3?\s+install\s+(?!-r\b)(?!-e\b)\S|\bcargo\s+add\b|\bgo\s+get\b/i.test(normalize(cmd))
110export const TOB = ['differential-review', 'supply-chain-risk-auditor']
111// enabledPlugins usa claves "<plugin>@<marketplace>".
112export const missingTob = (enabled: unknown) => {
113 const keys = Object.entries((enabled ?? {}) as Record<string, unknown>).filter(([, on]) => on).map(([k]) => k.split('@')[0])
114 return TOB.filter(p => !keys.includes(p))
115}
116export const installLines = (names: string[]) => names.map(n => `/plugin install ${n} --marketplace trailofbits/skills`)
117export const NO_REVIEW: Hit = { rule: 'no-review', sev: 'high', why: 'commit/push sin revisión de seguridad del diff' }
118
119// critical bloquea siempre; high bloquea solo en modo 'bloquear'.
120export type Mode = 'avisar' | 'bloquear'
121export const blocks = (h: Hit, mode: Mode, allowed: readonly string[]) =>
122 !allowed.includes(h.rule) && (h.sev === 'critical' || mode === 'bloquear')
123