SLOPSHOPPER

safety-guard-mod

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

newbandguardtoasttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · safety-guard-mod
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(rm -rf build && git push --force origin main) ⎿ Denied by safety-guard-mod: safety-guard-mod: blocked git push --force: rm -rf build && git push --force o ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

safety-guard-mod

Function-hooks replacement for .claude/hooks/safety-guard.sh.

What it hooks / draws

  • 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.

Ladder (every Bash call; commands with no trigger word pass straight through)

  1. Hard block (checked first so no rewrite launders it): 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).
  2. Silent rewrite (simple commands only: no quotes, ;, |, $):
  3. rm [-rf] <paths> → mkdir -p state/.trash/<ts> && mv <paths> state/.trash/<ts>/
  4. git reset --hard X → git stash push -u -m "safety-guard <iso>" ; git reset --hard X
  5. git checkout -- . → git stash push -m ... ; git checkout -- .
  6. 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.
  7. Band: 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.

State contract (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.

Disable

/plugin, then select safety-guard-mod and disable it. Re-enable the shell hook in .claude/settings.json if you remove the mod.

Eval (claude plugin test, 2026-10-22)

RowResult
Dangerous handled10/10 (4 rewritten, 6 blocked)
Handled without prompt10/10
Safe untouched10/10
Latency, 100 safe callsmean ~1.1-1.4 ms
Band auto-default at 20 s (mock clock)Block
Source 2 files
hooks/register.tsx 164 lines
1import 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}
164
types/index.d.ts 23 lines
1export 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