Guards Bash calls: rewrites destructive commands to safe forms, blocks hard-dangerous ones, asks via a band for the rest.

Function-hooks replacement for .claude/hooks/safety-guard.sh.
tool.call on Bash: the escalation ladder below. A .catch denies if the guard fails or overruns.ui.render on AbovePrompt: a band (rule, exact command, "Allow once" / "Block") only while a step-3 decision is pending.$.ui.toast one line per rewrite. No network, no $.http.rm on /, rm -r /, git push --force, git push -f , DROP TABLE, DROP DATABASE, format c:, mkfs, dd if=, fork bomb. Silent { deny }, logged block. The settings.json permissions.deny entries are hardcoded (see comment in hooks/register.tsx).;, |, $):rm [-rf] <paths> → mkdir -p state/.trash/<ts> && mv <paths> state/.trash/<ts>/git reset --hard X → git stash push -u -m "safety-guard <iso>" ; git reset --hard Xgit checkout -- . → git stash push -m ... ; git checkout -- .git clean -f... → git clean -n... with an explanatory comment Rewritable patterns that are too complex to rewrite (e.g. echo "rm -rf ...") are blocked, matching the shell script.DELETE FROM (flagged when no WHERE), truncate table, other git clean, rm on absolute/~/../glob-only paths. Default Block, auto-selected after 20 s via $.clock.after. Caveat: the engine's hook budget is 10 s and a $.clock wait counts against it, so in a live session an unanswered band ends at ~10 s through the .catch, which also denies (same outcome: Block). There is no Rewrite button: every rewritable command is already handled at step 2.types/index.d.ts)PluginState['safety-guard-mod']:
ledger: { at, kind: 'rewrite'|'block'|'ask', rule, command, rewritten?, outcome? }[] (last 500)pending: { rule, command } | null (drives the band)A sibling such as session-ledger-mod lists "safety-guard-mod" under dependencies in its plugin.json (so the engine lays this contract into its .claude-plugin/types) and reads: const { value = [] } = await $.state.get({ plugin: 'safety-guard-mod', key: 'ledger' } as const). Only this mod can write it.
/plugin, then select safety-guard-mod and disable it. Re-enable the shell hook in .claude/settings.json if you remove the mod.
| Row | Result |
|---|---|
| Dangerous handled | 10/10 (4 rewritten, 6 blocked) |
| Handled without prompt | 10/10 |
| Safe untouched | 10/10 |
| Latency, 100 safe calls | mean ~1.1-1.4 ms |
| Band auto-default at 20 s (mock clock) | Block |
hooks/register.tsx 164 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import type { SafetyGuardEntry, SafetyGuardPending } from '../types'
4
5const LEDGER = { plugin: 'safety-guard-mod', key: 'ledger' } as const
6const PENDING = { plugin: 'safety-guard-mod', key: 'pending' } as const
7const AUTO_BLOCK_MS = 20_000
8const LEDGER_MAX = 500
9
10type Outcome = 'allow' | 'block'
11
12// Hard rules: no safe rewrite, blocked silently. Ported from
13// .claude/hooks/safety-guard.sh (case-insensitive substring). The four
14// settings.json permissions.deny entries are hardcoded rather than read per
15// call: Bash(rm -rf *) and Bash(git reset --hard *) are covered by the
16// rewrite-or-block path below, Bash(git push --force *) and Bash(git push -f *)
17// by this list.
18const HARD = [
19 'rm -r /',
20 'git push --force',
21 'git push -f ',
22 'drop table',
23 'drop database',
24 'format c:',
25 'mkfs',
26 'dd if=',
27 ':(){:|:&};',
28]
29const ROOT_RM = /\brm\s+(-[a-z]+\s+)*(\/|\/\*)(\s|$)/i
30// Shell-script rules that are rewritable; blocked when the command is too
31// complex (chained, quoted) to rewrite safely.
32const REWRITABLE = ['rm -rf', 'git reset --hard', 'git checkout -- .', 'git clean -f']
33// A command simple enough to rewrite: no chaining, substitution or quoting.
34const SIMPLE = /^[\w@%+=:,./~*\s-]+$/
35// Fast pre-filter: a command with none of these never reaches the rules.
36const TRIGGER = /rm|git|drop|delete|truncate|format|mkfs|dd|:\(\)/i
37
38const has = (cmd: string, pat: string) => cmd.toLowerCase().includes(pat.toLowerCase())
39const stamp = (ms: number) => new Date(ms).toISOString().replace(/[:.]/g, '-')
40
41type Rewrite = { rule: string; command: string } | { rule: string; ask: true }
42
43function rewrite(cmd: string, ts: string, iso: string): Rewrite | undefined {
44 const c = cmd.trim()
45 if (!SIMPLE.test(c)) return undefined
46 const rm = /^rm((?:\s+-[rRf]+)*)\s+(.+)$/.exec(c)
47 if (rm) {
48 const paths = (rm[2] ?? '').split(/\s+/)
49 const outside = paths.some(
50 p =>
51 p.startsWith('/') ||
52 p.startsWith('~') ||
53 p.startsWith('-') ||
54 p.split('/').includes('..') ||
55 /^[*.]+$/.test(p),
56 )
57 if (outside) return { rule: 'rm outside repo', ask: true }
58 const dir = `state/.trash/${ts}`
59 return { rule: 'rm', command: `mkdir -p ${dir} && mv ${paths.join(' ')} ${dir}/` }
60 }
61 if (/^git\s+reset\s+--hard(\s+.*)?$/i.test(c)) {
62 return { rule: 'git reset --hard', command: `git stash push -u -m "safety-guard ${iso}" ; ${c}` }
63 }
64 if (/^git\s+checkout\s+--\s+\.$/.test(c)) {
65 return { rule: 'git checkout -- .', command: `git stash push -m "safety-guard ${iso}" ; ${c}` }
66 }
67 const clean = /^git\s+clean\s+(.*)$/i.exec(c)
68 const flags = clean?.[1] ?? ''
69 if (clean && /(^|\s)-[a-z]*f/i.test(flags)) {
70 const dry = flags.replace(/(^|\s)-([a-z]*)f([a-z]*)/gi, '$1-$2n$3')
71 return {
72 rule: 'git clean -f',
73 command: `git clean ${dry} # safety-guard-mod: dry run of "${c}" (git clean -f deletes untracked files)`,
74 }
75 }
76 return undefined
77}
78
79function hardRule(cmd: string): string | undefined {
80 if (ROOT_RM.test(cmd)) return 'rm on /'
81 return HARD.find(p => has(cmd, p))
82}
83
84function softRule(cmd: string): string | undefined {
85 const lower = cmd.toLowerCase()
86 if (lower.includes('delete from')) return /\bwhere\b/.test(lower) ? 'DELETE FROM' : 'DELETE FROM without WHERE'
87 if (lower.includes('truncate table')) return 'truncate table'
88 if (lower.includes('git clean')) return 'git clean'
89 return undefined
90}
91
92async function log($: Pick<EngineInterface, 'state'>, entry: SafetyGuardEntry) {
93 const held = await $.state.get(LEDGER)
94 await $.state.set(LEDGER, [...(held.value ?? []), entry].slice(-LEDGER_MAX))
95}
96
97export const register: Register = on => {
98 let answer: ((o: Outcome) => void) | undefined
99
100 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
101 const cmd = e.command
102 if (!TRIGGER.test(cmd)) return next(e)
103
104 const now = await $.clock.now()
105 const iso = new Date(now).toISOString()
106
107 // Step 2 (checked first so a rewrite never launders a hard rule).
108 const hard = hardRule(cmd)
109 if (hard) {
110 await log($, { at: iso, kind: 'block', rule: hard, command: cmd })
111 return { deny: `safety-guard-mod: blocked ${hard}: ${cmd}` }
112 }
113
114 // Step 1: silent safe rewrite.
115 const rw = rewrite(cmd, stamp(now), iso)
116 if (rw && 'command' in rw) {
117 await log($, { at: iso, kind: 'rewrite', rule: rw.rule, command: cmd, rewritten: rw.command })
118 $.ui.toast(`safety-guard: rewrote ${rw.rule} to a safe form`)
119 return next({ ...e, command: rw.command })
120 }
121
122 const soft = rw ? rw.rule : softRule(cmd)
123 const unrewritable = REWRITABLE.find(p => has(cmd, p))
124 if (!soft && unrewritable) {
125 await log($, { at: iso, kind: 'block', rule: unrewritable, command: cmd })
126 return { deny: `safety-guard-mod: blocked ${unrewritable}: ${cmd}` }
127 }
128 if (!soft) return next(e)
129
130 // Step 3: the band; Block by default after 20 s.
131 await $.state.set(PENDING, { rule: soft, command: cmd } satisfies SafetyGuardPending)
132 const outcome = await new Promise<Outcome>(resolve => {
133 const timer = $.clock.after(AUTO_BLOCK_MS, () => resolve('block'))
134 answer = o => {
135 timer.cancel()
136 resolve(o)
137 }
138 })
139 answer = undefined
140 await $.state.set(PENDING, null)
141 await log($, { at: iso, kind: 'ask', rule: soft, command: cmd, outcome })
142 return outcome === 'allow' ? next(e) : { deny: `safety-guard-mod: blocked ${soft}: ${cmd}` }
143 }).catch(($, e, next) =>
144 // Failed or overran the 10 s hook budget: fall to the band's default, Block.
145 next.called ? next(e) : { deny: `safety-guard-mod: blocked (guard failed or timed out): ${e.command}` },
146 )
147
148 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
149 const { value: pending } = await $.state.get(PENDING)
150 if (!pending) return next(e)
151 const { Box, Button, Text } = $.ui.resolve(e)
152 return (
153 <Box flexDirection="column">
154 <Text color="yellow">safety-guard: {pending.rule}</Text>
155 <Text>{pending.command}</Text>
156 <Box>
157 <Button key="allow" label="Allow once" onPress={() => answer?.('allow')} />
158 <Button key="block" label="Block (default in 20 s)" onPress={() => answer?.('block')} />
159 </Box>
160 </Box>
161 )
162 })
163}
164types/index.d.ts 23 lines1export type SafetyGuardEntry = {
2 at: string
3 kind: 'rewrite' | 'block' | 'ask'
4 rule: string
5 command: string
6 rewritten?: string
7 outcome?: 'allow' | 'block'
8}
9
10export type SafetyGuardPending = {
11 rule: string
12 command: string
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'safety-guard-mod': {
18 ledger: SafetyGuardEntry[]
19 pending: SafetyGuardPending | null
20 }
21 }
22}
23