Bloquea acciones que Claude no debe hacer por su cuenta: commit, push y otras escrituras de git que el usuario no pidió, borrados fuera del repositorio…

Barreras para sesiones que corren con --dangerously-skip-permissions: bloquea las acciones que Claude no debe ejecutar por su cuenta y deja pasar las que el usuario pidió en su mensaje.
La comprobación se hace sobre el texto del mensaje del usuario que inició el turno, nunca sobre lo que el modelo escribe, así que el modelo no puede convencerse a sí mismo de que tenía permiso. Una acción bloqueada devuelve al modelo una explicación y la instrucción de dejar el trabajo preparado para que el usuario la ejecute.
| Regla | Qué bloquea | Pasa cuando |
|---|---|---|
git-write | git commit, push, merge, tag, revert, cherry-pick, restore, clean, borrar ramas | el mensaje del usuario pide esa acción (commit, push, sube, mergea, tag, publica...) |
git-history | reset --hard, rebase, clean -f, checkout --, stash drop | el mensaje pide descartar, resetear o limpiar |
git-force | push --force, -f, --force-with-lease, push +rama | solo con /guard allow |
rm-outside | rm -r sobre /, ~, $HOME, .. o * | solo con /guard allow |
disk | mkfs, dd if=, escritura en /dev/sd*, chmod -R 777 /, diskutil erase | solo con /guard allow |
k8s-prod | kubectl, helm u oc que cambian algo con prod en el comando o el contexto | el mensaje menciona prod |
sql-drop | DROP TABLE/DATABASE/SCHEMA, TRUNCATE, DELETE FROM sin WHERE | el mensaje pide drop, truncate o vaciar |
secret-file | Edit o Write sobre .env* (menos .env.example), claves .pem, .key, id_rsa, credentials.json | el mensaje nombra el archivo |
git add y git status nunca se bloquean. Los subagentes pasan por las mismas reglas.
En el prompt de una sesión de terminal:
/plugin install guard-rails --marketplace Jmezas/claude-mods
/guard estado, reglas y número de bloqueos./guard allow deja pasar una vez la próxima acción bloqueada. Después vuelve a pedirla./guard off y /guard on desactivan o activan todas las reglas en esta sesión y las siguientes./guard log últimos bloqueos de la sesión.hooks/register.ts 207 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// Each rule names what the user must have asked for in their current message
4// for the action to pass. The check is on the prompt the turn started with,
5// never on the model's own text, so the model cannot talk itself past a rule.
6
7type Rule = {
8 id: string
9 test: RegExp
10 // Words that, present in the user's prompt, mean they asked for it.
11 asked: RegExp
12 // Blocked even when asked, unless `/guard allow` was run first.
13 isHard?: boolean
14 why: string
15}
16
17const GIT_WRITE =
18 /\bgit\b(?:\s+-[-\w=]+)*\s+(commit|push|merge|rebase|reset|tag|cherry-pick|revert|clean|restore|checkout\s+--|branch\s+-[dD]|stash\s+(?:drop|clear|pop)|filter-branch|update-ref|reflog\s+expire|gc\s+--prune)\b/i
19
20const RULES: Rule[] = [
21 {
22 id: 'git-force',
23 test: /\bgit\b.*\bpush\b.*(?:\s--force\b|\s-f\b|\s--force-with-lease\b|\s\+[\w/]+)/i,
24 asked: /force|forzad|forzar|--force/i,
25 isHard: true,
26 why: 'git push forzado reescribe el historial remoto',
27 },
28 {
29 id: 'git-history',
30 test: /\bgit\b(?:\s+-[-\w=]+)*\s+(?:reset\s+--hard|rebase|filter-branch|reflog\s+expire|gc\s+--prune|clean\s+-\w*[fdx]|checkout\s+--\s|restore\b(?!\s+--staged)|stash\s+(?:drop|clear))/i,
31 asked: /reset|rebase|limpia|clean|descarta|restaur|revert|stash|deshaz|deshacer/i,
32 why: 'descarta trabajo local o reescribe historial',
33 },
34 {
35 id: 'git-write',
36 test: GIT_WRITE,
37 asked:
38 /\b(commit|commitea|push|pushea|sube|subir|súbelo|subelo|merge|mergea|fusiona|tag|etiqueta|revert|cherry[- ]pick|publica|release)\b/i,
39 why: 'el usuario no pidió commit, push ni otra escritura de git en este mensaje',
40 },
41 {
42 id: 'rm-outside',
43 test: /\brm\b\s+(?:-[a-zA-Z]*[rR][a-zA-Z]*\s+|-[a-zA-Z]*\s+)*(?:"|')?(?:\/(?!(?:private\/)?tmp\/|Users\/[^/]+\/\.claude\/)|~|\$HOME|\.\.(?:\/|\s|$)|\*\s*$)/,
44 asked: /borra todo|elimina todo|rm -rf|fuera del repo/i,
45 isHard: true,
46 why: 'rm recursivo fuera del repositorio',
47 },
48 {
49 id: 'disk',
50 test: /\b(?:mkfs(?:\.\w+)?|dd\s+if=|>\s*\/dev\/(?:sd|disk|nvme)|chmod\s+-R\s+777\s+\/|chown\s+-R\s+\w+\s+\/\s*$|diskutil\s+erase)/i,
51 asked: /(?!)/,
52 isHard: true,
53 why: 'operación destructiva sobre disco o permisos del sistema',
54 },
55 {
56 id: 'k8s-prod',
57 test: /\b(?:kubectl|helm|oc)\b.*\b(?:apply|delete|rollout|scale|drain|cordon|patch|replace|install|upgrade|uninstall|exec)\b.*\b(?:prod|production|produccion|producción|prd)\b|\b(?:kubectl|helm|oc)\b.*--context[= ]\S*prod/i,
58 asked: /\b(?:prod|production|producción|produccion|prd)\b/i,
59 why: 'cambio en un clúster de producción',
60 },
61 {
62 id: 'sql-drop',
63 test: /\b(?:DROP\s+(?:TABLE|DATABASE|SCHEMA|INDEX)|TRUNCATE\s+(?:TABLE\s+)?\w+|DELETE\s+FROM\s+\w+\s*;?\s*$)\b/i,
64 asked: /\b(?:drop|truncate|borra la tabla|elimina la tabla|vacía|vacia)\b/i,
65 why: 'DROP, TRUNCATE o DELETE sin WHERE',
66 },
67]
68
69const SECRET_FILE =
70 /(^|\/)(\.env(?:\.(?!example|sample|template|dist)[\w.-]+)?|id_rsa|id_ed25519|[\w.-]+\.(?:pem|key|p12|pfx|jks|keystore)|credentials(?:\.json)?|service-account[\w.-]*\.json)$/i
71
72const short = (text: string, max = 70) => text.replace(/\s+/g, ' ').trim().slice(0, max)
73
74type Denial = { deny: string }
75
76const FAILED: Denial = { deny: 'guard-rails: la comprobación falló, acción detenida por seguridad.' }
77
78function block($: EngineInterface, log: string[], rule: string, why: string, what: string): Denial {
79 log.push(`${rule}: ${what}`)
80 if (log.length > 30) log.shift()
81 $.ui.status(`guard: bloqueado ${rule}`)
82 $.ui.toast(`guard-rails bloqueó ${rule}: ${short(what, 50)}`)
83
84 return {
85 deny: `guard-rails bloqueó esta acción (${rule}: ${why}). No la repitas ni la rodees. Si hace falta, deja el trabajo preparado y dile al usuario que la ejecute él, o que corra /guard allow y vuelva a pedirla.`,
86 }
87}
88
89// Which rule a Bash command trips, given the user's prompt; null when none.
90function judgeCommand(command: string, prompt: string, allowOnce: boolean): Rule | 'allowed-once' | null {
91 for (const rule of RULES) {
92 if (!rule.test.test(command)) continue
93 if (allowOnce) return 'allowed-once'
94 if (!rule.isHard && rule.asked.test(prompt)) return null
95
96 return rule
97 }
98
99 return null
100}
101
102function judgeFile(filePath: string, prompt: string, allowOnce: boolean): 'blocked' | 'allowed-once' | null {
103 if (!SECRET_FILE.test(filePath)) return null
104 const name = filePath.split('/').pop() ?? filePath
105 if (allowOnce) return 'allowed-once'
106 if (prompt.includes(name) || (/\.env\b/i.test(prompt) && /\.env/i.test(name))) return null
107
108 return 'blocked'
109}
110
111export const register: Register = on => {
112 let isOn = true
113 let allowOnce = false
114 let prompt = ''
115 const log: string[] = []
116
117 on('session.start', async ($, e, next) => {
118 isOn = (await $.store.get('enabled')) !== false
119 await $.command.register({
120 name: 'guard',
121 description: 'guard-rails: status | on | off | allow (permite la próxima acción bloqueada) | log',
122 argumentHint: '[status|on|off|allow|log]',
123 })
124
125 return next(e)
126 })
127
128 on('command.run', { command: 'guard' }, async ($, e) => {
129 const arg = e.args.trim().toLowerCase()
130 if (arg === 'on' || arg === 'off') {
131 isOn = arg === 'on'
132 await $.store.set('enabled', isOn)
133
134 return { text: `guard-rails ${isOn ? 'activado' : 'desactivado'}` }
135 }
136 if (arg === 'allow') {
137 allowOnce = true
138
139 return { text: 'guard-rails: la próxima acción que una regla bloquee pasará una vez. Vuelve a pedirla.' }
140 }
141 if (arg === 'log') {
142 return {
143 text: log.length
144 ? ['guard-rails, últimos bloqueos:', ...log].join('\n')
145 : 'guard-rails: sin bloqueos en esta sesión',
146 }
147 }
148
149 return {
150 text: [
151 `guard-rails: ${isOn ? 'activo' : 'inactivo'}${allowOnce ? ', con un permiso pendiente' : ''}`,
152 'reglas: git-force (dura), git-history, git-write, rm-outside (dura), disk (dura), k8s-prod, sql-drop, secret-file',
153 'una regla pasa cuando el mensaje actual del usuario pide esa acción; las duras solo con /guard allow',
154 `bloqueos en esta sesión: ${log.length}`,
155 ].join('\n'),
156 }
157 })
158
159 on('prompt.submit', ($, e, next) => {
160 if (e.command === undefined && e.isMeta === undefined && e.origin.kind !== 'plugin') {
161 prompt = e.text
162 }
163
164 return next(e)
165 })
166
167 on('tool.call', { tool: 'Bash' }, ($, e, next) => {
168 if (!isOn) return next(e)
169 const verdict = judgeCommand(e.command, prompt, allowOnce)
170 if (verdict === null) return next(e)
171 if (verdict === 'allowed-once') {
172 allowOnce = false
173 log.push(`permitido una vez: ${short(e.command)}`)
174
175 return next(e)
176 }
177
178 return block($, log, verdict.id, verdict.why, short(e.command))
179 }).catch(($, e, next) => (next.called ? next(e) : FAILED))
180
181 on('tool.call', { tool: 'Edit' }, ($, e, next) => {
182 if (!isOn) return next(e)
183 const verdict = judgeFile(e.file_path, prompt, allowOnce)
184 if (verdict === null) return next(e)
185 if (verdict === 'allowed-once') {
186 allowOnce = false
187
188 return next(e)
189 }
190
191 return block($, log, 'secret-file', 'archivo de credenciales o entorno', e.file_path)
192 }).catch(($, e, next) => (next.called ? next(e) : FAILED))
193
194 on('tool.call', { tool: 'Write' }, ($, e, next) => {
195 if (!isOn) return next(e)
196 const verdict = judgeFile(e.file_path, prompt, allowOnce)
197 if (verdict === null) return next(e)
198 if (verdict === 'allowed-once') {
199 allowOnce = false
200
201 return next(e)
202 }
203
204 return block($, log, 'secret-file', 'archivo de credenciales o entorno', e.file_path)
205 }).catch(($, e, next) => (next.called ? next(e) : FAILED))
206}
207