SLOPSHOPPER

safety-net

Blocks destructive tool calls before they run (force pushes, rm -rf outside the project, DROP TABLE, terraform destroy, writes to .env and keys, ...) and…

newguardcommandtoaststatus
★ 1v0.1.0no licenseupdated 2026-10-08TroyJLorents-GH/mod-squad/mods/safety-net
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · safety-net
› fix the failing auth test and add an audit log call ╭─────────────────────────────────────────╮ │ safety-net │ ⏺ Read(src/auth.ts) │ 🛡 safety-net blocked `git push │ ⎿ Read 6 lines │ --force` │ ⏺ Update(src/auth.ts) ╰─────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ⏺ Bash(rm -rf build && git push --force origin main) ⎿ Denied by safety-net: safety-net: blocked `git push --force` — it overwrites remote history others may hav ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /safety-net ⎿ safety-net: Safety net: on · 1 blocked this session. ⎿ safety-net: 8:53:20 AM Bash: `git push --force` — it overwrites remote history others may have pulled; use --force-with ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ safety-net: 🛡 safety-net: 1 blocked
README

safety-net

A guard on Claude Code's tool calls: destructive shell commands and writes to secret files are refused before they run, and Claude is told why.

/plugin marketplace add troyjlorents-gh/mod-squad
/plugin install safety-net@mod-squad

Blocked shell commands (also inside && / ; / | chains, after sudo/env/VAR=x, and inside bash -c '...'):

  • git push --force / -f / +branch (--force-with-lease is allowed), git reset --hard, git clean -fd / -fdx, git branch -D main|master
  • rm -rf (or -fr, -r -f, --recursive --force) on /, ~, $HOME, *, .., the project root itself, or any absolute path outside the project. rm -rf node_modules, rm -rf ./dist and other in-project paths pass.
  • DROP DATABASE / DROP TABLE / DROP SCHEMA / TRUNCATE sent to psql, mysql, sqlcmd, sqlite3 and friends
  • terraform destroy (and apply -destroy), kubectl delete namespace|ns, chmod -R 777
  • curl … | sh / wget … | bash, bash <(curl …), sh -c "$(curl …)"
  • mkfs*, dd of=/dev/… (except /dev/null and friends)

Protected files (Edit / Write / MultiEdit / NotebookEdit): .env, .env.* (but not .env.example/.sample/.template), *.pem, *.key, *.p12, *.pfx, id_rsa*, id_ed25519*, anything under .git/, secrets.*, credentials*, .npmrc, .pypirc.

Each block shows a toast, and the status line keeps a count (🛡 safety-net: 2 blocked).

  • Commands: /safety-net (status and the last few blocks), /safety-net off (disable for this session), /safety-net on.
  • Settings: extraProtected — comma-separated globs for more protected files (*.tfstate, config/prod.*; no slash = match the file name). allowForceWithLease (default true).

What it is not: this is a pattern-based guard, not a sandbox. It reads the command text, so a determined script (a variable holding the path, a command written to a file and run later, an alias, a language runtime deleting files) gets past it. It catches the common accidents; keep your backups and permission settings.

Test: claude plugin test mods/safety-net. Validate: claude plugin validate mods/safety-net.

Source 3 files
hooks/register.ts 88 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Block } from '../types'
5import { classifyCommand, denyText, parseExtra, protectedFile } from './rules'
6import type { Verdict } from './rules'
7
8const RECENT = 5
9const FILE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
10
11const enabled = atom({ plugin: 'safety-net', key: 'enabled' } as const, true)
12const blocked = atom({ plugin: 'safety-net', key: 'blocked' } as const, 0)
13const recent = atom({ plugin: 'safety-net', key: 'recent' } as const, [] as Block[])
14
15type Options = { extraProtected?: string; allowForceWithLease?: boolean }
16
17/** Judges one tool call; null lets it run. */
18async function judge($: EngineInterface, e: Record<string, unknown>, opts: Options): Promise<Verdict | null> {
19  const tool = String(e.tool)
20  if (tool === 'Bash' && typeof e.command === 'string') {
21    const root = await $.session.root()
22    const cwd = await $.session.cwd()
23    const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || undefined
24    return classifyCommand(e.command, { root, cwd, home, allowForceWithLease: opts.allowForceWithLease !== false })
25  }
26  if (FILE_TOOLS.has(tool)) {
27    const path = 'file_path' in e ? e.file_path : 'notebook_path' in e ? e.notebook_path : undefined
28    if (typeof path === 'string') return protectedFile(path, parseExtra(opts.extraProtected))
29  }
30  return null
31}
32
33async function recordBlock($: EngineInterface, tool: string, v: Verdict) {
34  const at = await $.clock.now()
35  const n = await update($, blocked, x => x + 1)
36  await update($, recent, list => [...list, { at, tool, what: v.what, why: v.why }].slice(-RECENT))
37  $.ui.toast(`🛡 safety-net blocked ${v.what}`, { timeoutMs: 6000 })
38  $.ui.status(`🛡 safety-net: ${n} blocked`)
39}
40
41async function setEnabled($: EngineInterface, on: boolean) {
42  await update($, enabled, () => on)
43  const n = await read($, blocked)
44  $.ui.status(on ? (n > 0 ? `🛡 safety-net: ${n} blocked` : undefined) : '🛡 safety-net: off')
45}
46
47export const register: Register = (on, options) => {
48  const opts = (options ?? {}) as Options
49
50  on('session.start', async ($, e, next) => {
51    await $.command.register({
52      name: 'safety-net',
53      description: 'Safety net: show recent blocks, or turn the guard off/on for this session',
54      argumentHint: '[on | off]',
55      immediate: true,
56    })
57    return next(e)
58  })
59
60  on('tool.call', async ($, e, next) => {
61    if (!(await read($, enabled))) return next(e)
62    const verdict = await judge($, e as unknown as Record<string, unknown>, opts)
63    if (!verdict) return next(e)
64    await recordBlock($, String(e.tool), verdict)
65    return { deny: denyText(verdict) }
66  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'safety-net: its guard failed.' }))
67
68  on('command.run', { command: 'safety-net' }, async ($, e) => {
69    const verb = e.args.trim().toLowerCase()
70    if (verb === 'off') {
71      await setEnabled($, false)
72      return { text: 'Safety net: off for this session. Destructive commands and protected-file writes will run unchecked. /safety-net on to re-enable.' }
73    }
74    if (verb === 'on') {
75      await setEnabled($, true)
76      return { text: 'Safety net: on.' }
77    }
78    if (verb !== '') return { text: 'Usage: /safety-net [on | off]' }
79
80    const isOn = await read($, enabled)
81    const n = await read($, blocked)
82    const list = await read($, recent)
83    const lines = [`Safety net: ${isOn ? 'on' : 'off'} · ${n} blocked this session.`]
84    for (const b of [...list].reverse()) lines.push(`  ${new Date(b.at).toLocaleTimeString()}  ${b.tool}: ${b.what} — ${b.why}`)
85    return { text: lines.join('\n') }
86  })
87}
88
hooks/rules.ts 417 lines
1// Pure classification for safety-net: no engine calls, so it is unit-tested directly.
2
3export type Verdict = { what: string; why: string }
4
5export type CommandContext = {
6  /** Project root, absolute. Paths inside it are fair game for `rm -rf`. */
7  root: string
8  /** Directory relative paths resolve against (the shell's cwd); defaults to root. */
9  cwd?: string
10  /** Home directory, when known; `~` and `$HOME` expand to it. */
11  home?: string
12  /** Let `git push --force-with-lease` through (default true). */
13  allowForceWithLease?: boolean
14}
15
16// ---------------------------------------------------------------- shell parsing
17
18type Segment = { words: string[]; raw: string; op: string }
19
20/**
21 * Splits a command line into simple commands at `&&`, `||`, `;`, `|`, `&` and
22 * newlines, honouring quotes and backslashes. Words come back unquoted; each
23 * segment records the operator that ends it.
24 */
25export function splitCommand(input: string): Segment[] {
26  const segments: Segment[] = []
27  let words: string[] = []
28  let word = ''
29  let hasWord = false
30  let raw = ''
31  let quote: '"' | "'" | null = null
32  let subst = 0
33
34  const endWord = () => {
35    if (hasWord) words.push(word)
36    word = ''
37    hasWord = false
38  }
39  const endSegment = (op: string) => {
40    endWord()
41    if (words.length > 0) segments.push({ words, raw: raw.trim(), op })
42    words = []
43    raw = ''
44  }
45
46  for (let i = 0; i < input.length; i++) {
47    const c = input[i] as string
48    if (quote) {
49      raw += c
50      if (c === quote) quote = null
51      else if (c === '\\' && quote === '"' && i + 1 < input.length) {
52        word += input[++i]
53        raw += input[i]
54      } else word += c
55      continue
56    }
57    if (c === "'" || c === '"') {
58      quote = c
59      hasWord = true
60      raw += c
61      continue
62    }
63    if (c === '\\' && i + 1 < input.length) {
64      const n = input[++i] as string
65      raw += c + n
66      if (n !== '\n') (word += n), (hasWord = true)
67      continue
68    }
69    const two = input.slice(i, i + 2)
70    if (two === '&&' || two === '||') {
71      endSegment(two)
72      i++
73      continue
74    }
75    if (c === ';' || c === '\n' || c === '|' || (c === '&' && input[i + 1] !== '>' && input[i - 1] !== '>')) {
76      endSegment(c === '\n' ? ';' : c)
77      continue
78    }
79    // `$(…)`, `<(…)` stay inside the word; other parens are subshells and
80    // separate commands: `(cd x && rm -rf /)`.
81    if (c === '(' && /[$<>]$/.test(word)) subst++
82    else if (c === ')' && subst > 0) subst--
83    else if (c === '(' || c === ')') {
84      endSegment(';')
85      continue
86    }
87    raw += c
88    if (c === ' ' || c === '\t') endWord()
89    else (word += c), (hasWord = true)
90  }
91  endSegment('')
92  return segments
93}
94
95const WRAPPERS = new Set(['sudo', 'doas', 'env', 'command', 'exec', 'nohup', 'time', 'nice', 'ionice', 'builtin', 'then', 'do', 'else', '!', 'xargs'])
96// Wrapper options that take a value as the next word.
97const WRAPPER_ARG_FLAGS: Record<string, Set<string>> = {
98  sudo: new Set(['-u', '-g', '-h', '-p', '-C', '-D', '-r', '-t', '-U']),
99  doas: new Set(['-u', '-C']),
100  env: new Set(['-u', '-C', '-S']),
101  nice: new Set(['-n']),
102  ionice: new Set(['-c', '-n']),
103  xargs: new Set(['-I', '-n', '-P', '-L', '-d', '-E', '-s', '-a']),
104}
105
106/** Drops `sudo`, `env`, `FOO=bar` and similar prefixes, leaving the real command first. */
107export function stripPrefixes(words: string[]): string[] {
108  let w = words
109  for (let guard = 0; guard < 20 && w.length > 0; guard++) {
110    const head = w[0] as string
111    if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(head)) {
112      w = w.slice(1)
113      continue
114    }
115    const name = basename(head)
116    if (!WRAPPERS.has(name)) break
117    let i = 1
118    const argFlags = WRAPPER_ARG_FLAGS[name] ?? new Set<string>()
119    while (i < w.length) {
120      const x = w[i] as string
121      if (x === '--') {
122        i++
123        break
124      }
125      if (name === 'env' && /^[A-Za-z_][A-Za-z0-9_]*=/.test(x)) {
126        i++
127        continue
128      }
129      if (!x.startsWith('-')) break
130      i += argFlags.has(x) ? 2 : 1
131    }
132    w = w.slice(i)
133  }
134  return w
135}
136
137function basename(p: string): string {
138  const parts = p.split('/')
139  return parts[parts.length - 1] ?? p
140}
141
142/** Short flag letters (`-rf` → r,f) and long flags of a word list, up to `--`. */
143function flagsOf(args: string[]): { short: Set<string>; long: Set<string>; operands: string[] } {
144  const short = new Set<string>()
145  const long = new Set<string>()
146  const operands: string[] = []
147  let done = false
148  for (const a of args) {
149    if (done) operands.push(a)
150    else if (a === '--') done = true
151    else if (a.startsWith('--')) long.add(a.split('=')[0] as string)
152    else if (a.startsWith('-') && a.length > 1) for (const ch of a.slice(1)) short.add(ch)
153    else operands.push(a)
154  }
155  return { short, long, operands }
156}
157
158// ---------------------------------------------------------------- paths
159
160function normalize(path: string): string {
161  const abs = path.startsWith('/')
162  const out: string[] = []
163  for (const part of path.split('/')) {
164    if (part === '' || part === '.') continue
165    if (part === '..') out.pop()
166    else out.push(part)
167  }
168  return (abs ? '/' : '') + out.join('/')
169}
170
171function isWithin(path: string, root: string): boolean {
172  const r = normalize(root)
173  return path === r || path.startsWith(r === '/' ? '/' : r + '/')
174}
175
176const TEMP_ROOTS = ['/tmp', '/var/tmp', '/private/tmp', '/var/folders', '/private/var/folders']
177
178const HOME_RE = /^(~|\$HOME|\$\{HOME\})(?=\/|$)/
179
180/** Why `rm -rf <target>` is dangerous, or null when it stays inside the project. */
181export function rmTargetDanger(target: string, ctx: CommandContext): string | null {
182  const t = target.trim()
183  if (t === '') return null
184  const root = normalize(ctx.root)
185  const cwd = normalize(ctx.cwd ?? ctx.root)
186
187  if (/^\/+\*?$/.test(t) || t === '/.' || t === '/..') return 'that wipes the whole filesystem'
188  if (/^(~|\$HOME|\$\{HOME\})\/?\*?$/.test(t)) return 'that wipes your home directory'
189  if (t === '*' || t === './*' || t === '.*' || t === '*/') return 'a bare glob deletes everything in the current directory'
190
191  let abs: string
192  if (HOME_RE.test(t)) {
193    if (!ctx.home) return 'it is outside the project (in your home directory)'
194    abs = normalize(t.replace(HOME_RE, ctx.home))
195  } else if (t.startsWith('/')) abs = normalize(t)
196  else if (/^\$/.test(t)) return null // unknown variable: can't judge, let it pass
197  else abs = normalize(cwd + '/' + t)
198
199  // Strip trailing glob segments so `/etc/*` is judged as `/etc`.
200  const judged = normalize(abs.replace(/\/[^/]*[*?][^/]*$/, '')) || '/'
201  if (judged === '/') return 'that wipes the whole filesystem'
202  if (ctx.home && judged === normalize(ctx.home)) return 'that wipes your home directory'
203  if (judged === root) return 'that deletes the whole project'
204  // Scratch space under a temp directory is fair game (the temp root itself is not).
205  if (TEMP_ROOTS.some(r => judged !== r && isWithin(judged, r))) return null
206  if (!isWithin(judged, root)) return `${judged} is outside the project`
207  return null
208}
209
210// ---------------------------------------------------------------- commands
211
212const SQL_CLIENTS = new Set(['psql', 'mysql', 'mariadb', 'sqlcmd', 'sqlite3', 'sqlite', 'pgcli', 'mycli', 'cockroach', 'clickhouse-client', 'duckdb', 'sql', 'snowsql', 'bq', 'osql', 'isql'])
213const SQL_DESTRUCTIVE = /\b(drop\s+(database|table|schema)|truncate(\s+table)?)\b/i
214const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'fish', 'ash'])
215const SAFE_DD_TARGETS = /^\/dev\/(null|zero|stdout|stderr|tty|fd\/\d+)$/
216
217function git(args: string[], ctx: CommandContext): Verdict | null {
218  // Skip global options: -C <dir>, -c <k=v>, --git-dir=..., --no-pager, ...
219  let i = 0
220  while (i < args.length) {
221    const a = args[i] as string
222    if (a === '-C' || a === '-c' || a === '--git-dir' || a === '--work-tree' || a === '--namespace') i += 2
223    else if (a.startsWith('-')) i++
224    else break
225  }
226  const sub = args[i]
227  const rest = args.slice(i + 1)
228  const { short, long, operands } = flagsOf(rest)
229
230  if (sub === 'push') {
231    const lease = [...long].some(l => l === '--force-with-lease')
232    const force = long.has('--force') || short.has('f') || operands.some(o => o.startsWith('+') && o.length > 1)
233    if (force) return { what: '`git push --force`', why: 'it overwrites remote history others may have pulled; use --force-with-lease' }
234    if (lease && ctx.allowForceWithLease === false)
235      return { what: '`git push --force-with-lease`', why: 'force pushes are switched off by the allowForceWithLease setting' }
236    return null
237  }
238  if (sub === 'reset' && long.has('--hard'))
239    return { what: '`git reset --hard`', why: 'it throws away every uncommitted change with no undo' }
240  if (sub === 'clean') {
241    const dry = short.has('n') || long.has('--dry-run')
242    const forced = short.has('f') || long.has('--force')
243    const dirs = short.has('d')
244    if (forced && dirs && !dry)
245      return { what: '`git clean -fd`', why: 'it permanently deletes untracked files and directories (not recoverable from git)' }
246    return null
247  }
248  if (sub === 'branch') {
249    const forceDelete = short.has('D') || ((short.has('d') || long.has('--delete')) && (short.has('f') || long.has('--force')))
250    const hit = operands.find(o => /^(origin\/)?(main|master)$/.test(o))
251    if (forceDelete && hit) return { what: `\`git branch -D ${hit}\``, why: 'it force-deletes the main branch, unmerged commits included' }
252    return null
253  }
254  return null
255}
256
257function classifySegment(seg: Segment, all: Segment[], index: number, ctx: CommandContext, depth: number): Verdict | null {
258  const words = stripPrefixes(seg.words)
259  if (words.length === 0) return null
260  const cmd = basename(words[0] as string)
261  const args = words.slice(1)
262
263  // `bash -c '...'`, `sh -c`, `eval ...`: judge the inner script too.
264  if (depth < 3 && (SHELLS.has(cmd) || cmd === 'eval')) {
265    const ci = args.indexOf('-c')
266    const inner = cmd === 'eval' ? args.join(' ') : ci >= 0 ? args[ci + 1] : undefined
267    if (inner) {
268      const v = classifyCommandAt(inner, ctx, depth + 1)
269      if (v) return v
270    }
271  }
272
273  // A shell fed by a download: `curl ... | sh`.
274  if (SHELLS.has(cmd) && index > 0 && all[index - 1]?.op === '|') {
275    for (let j = index - 1; j >= 0 && (j === index - 1 || all[j]?.op === '|'); j--) {
276      const prev = stripPrefixes(all[j]?.words ?? [])
277      const name = basename(prev[0] ?? '')
278      if (name === 'curl' || name === 'wget')
279        return { what: `\`${name} … | ${cmd}\``, why: 'it runs a script straight from the internet without you reading it' }
280      if (j === 0 || all[j - 1]?.op !== '|') break
281    }
282  }
283
284  switch (cmd) {
285    case 'git':
286      return git(args, ctx)
287    case 'rm': {
288      const { short, long, operands } = flagsOf(args)
289      const recursive = short.has('r') || short.has('R') || long.has('--recursive')
290      const force = short.has('f') || long.has('--force')
291      if (!recursive || !force) return null
292      for (const t of operands) {
293        const why = rmTargetDanger(t, ctx)
294        if (why) return { what: `\`rm -rf ${t}\``, why }
295      }
296      return null
297    }
298    case 'terraform':
299    case 'tofu':
300    case 'terragrunt': {
301      if (args.includes('destroy') || (args.includes('apply') && args.includes('-destroy')))
302        return { what: `\`${cmd} destroy\``, why: 'it tears down real infrastructure' }
303      return null
304    }
305    case 'kubectl':
306    case 'oc': {
307      const di = args.indexOf('delete')
308      if (di < 0) return null
309      const kind = args.slice(di + 1).find(a => !a.startsWith('-'))
310      if (kind && /^(ns|namespace|namespaces)(\/|$)/.test(kind))
311        return { what: '`kubectl delete namespace`', why: 'it deletes every resource in the namespace' }
312      return null
313    }
314    case 'chmod': {
315      const { short, long, operands } = flagsOf(args)
316      if ((short.has('R') || long.has('--recursive')) && /^0?777$|^(a|ugo)[+=]rwx$/.test(operands[0] ?? ''))
317        return { what: '`chmod -R 777`', why: 'it makes a whole tree world-writable' }
318      return null
319    }
320    case 'dd': {
321      const of = args.find(a => a.startsWith('of='))?.slice(3)
322      if (of && of.startsWith('/dev/') && !SAFE_DD_TARGETS.test(of))
323        return { what: `\`dd of=${of}\``, why: 'it overwrites a raw device' }
324      return null
325    }
326  }
327  if (cmd === 'mkfs' || cmd.startsWith('mkfs.') || cmd === 'mke2fs' || cmd === 'wipefs')
328    return { what: `\`${cmd}\``, why: 'it formats a disk' }
329
330  return null
331}
332
333function classifyCommandAt(command: string, ctx: CommandContext, depth: number): Verdict | null {
334  const segments = splitCommand(command)
335  for (let i = 0; i < segments.length; i++) {
336    const v = classifySegment(segments[i] as Segment, segments, i, ctx, depth)
337    if (v) return v
338  }
339
340  // Process substitution / command substitution feeding a shell.
341  const m = /\b(?:ba|z|da|k)?sh\s+(?:-\w+\s+)*(?:<\(|-c\s+["']?\$\()\s*(curl|wget)\b/.exec(command)
342  if (m) return { what: `\`${m[1]} … | sh\``, why: 'it runs a script straight from the internet without you reading it' }
343
344  // Destructive SQL handed to a database client (inline, via -c/-e, a heredoc or a pipe).
345  const usesClient = segments.some(s => SQL_CLIENTS.has(basename(stripPrefixes(s.words)[0] ?? '')))
346  const sql = SQL_DESTRUCTIVE.exec(command)
347  if (usesClient && sql) {
348    const what = (sql[1] as string).replace(/\s+/g, ' ').toUpperCase()
349    return { what: `\`${what}\``, why: 'it permanently deletes database data' }
350  }
351  return null
352}
353
354/** Returns why a shell command is destructive, or null when it may run. */
355export function classifyCommand(command: string, ctx: CommandContext): Verdict | null {
356  return classifyCommandAt(command, ctx, 0)
357}
358
359// ---------------------------------------------------------------- files
360
361const ENV_OK = /^\.env\.(example|sample|template|dist|defaults)$/i
362
363/** Converts a simple glob (`*`, `**`, `?`) to an anchored regex. */
364export function globToRegExp(glob: string): RegExp {
365  let re = ''
366  for (let i = 0; i < glob.length; i++) {
367    const c = glob[i] as string
368    if (c === '*') {
369      if (glob[i + 1] === '*') {
370        re += '.*'
371        i++
372        if (glob[i + 1] === '/') i++
373      } else re += '[^/]*'
374    } else if (c === '?') re += '[^/]'
375    else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
376  }
377  return new RegExp(`^${re}$`)
378}
379
380export function parseExtra(extra: string | undefined): string[] {
381  return (extra ?? '')
382    .split(',')
383    .map(s => s.trim())
384    .filter(Boolean)
385}
386
387/** Why writing to `path` is refused, or null when it is an ordinary file. */
388export function protectedFile(path: string, extra: string[] = []): Verdict | null {
389  const p = path.replace(/\\/g, '/')
390  const parts = p.split('/').filter(Boolean)
391  const base = parts[parts.length - 1] ?? ''
392  const lower = base.toLowerCase()
393  const hit = (why: string): Verdict => ({ what: `a write to ${base}`, why })
394
395  const gi = parts.indexOf('.git')
396  if (gi >= 0 && gi < parts.length - 1) return { what: `a write inside .git/`, why: 'editing git internals can corrupt the repository' }
397
398  if (lower === '.env' || (lower.startsWith('.env.') && !ENV_OK.test(lower)))
399    return hit('.env files hold secrets; edit .env.example instead')
400  if (/\.(pem|key|p12|pfx)$/.test(lower)) return hit('it is a private key or certificate bundle')
401  if (/^id_(rsa|ed25519|ecdsa|dsa)/.test(lower)) return hit('it is an SSH key')
402  if (lower.startsWith('secrets.')) return hit('it is a secrets file')
403  if (/^\.?credentials(\.(json|ya?ml|toml|ini|xml|txt|env|csv|cfg|conf))?$/.test(lower)) return hit('it is a credentials file')
404  if (lower === '.npmrc' || lower === '.pypirc') return hit('it holds registry auth tokens')
405
406  for (const glob of extra) {
407    const re = globToRegExp(glob)
408    const matches = glob.includes('/') ? re.test(p) || parts.some((_, i) => re.test(parts.slice(i).join('/'))) : re.test(base)
409    if (matches) return hit(`it matches your extraProtected pattern "${glob}"`)
410  }
411  return null
412}
413
414export function denyText(v: Verdict): string {
415  return `safety-net: blocked ${v.what} — ${v.why}. Run it yourself if you really mean it, or /safety-net off for this session.`
416}
417
types/index.d.ts 21 lines
1export type Block = {
2  /** When it was blocked, ms since epoch. */
3  at: number
4  tool: string
5  what: string
6  why: string
7}
8
9declare module 'claude-code' {
10  interface PluginState {
11    'safety-net': {
12      /** False after /safety-net off, for the rest of the session. */
13      enabled: boolean
14      /** Blocks this session. */
15      blocked: number
16      /** The most recent blocks, newest last. */
17      recent: Block[]
18    }
19  }
20}
21