SLOPSHOPPER

bash-guard

A seatbelt for YOLO mode: blocks catastrophic shell commands (rm -rf ~, mkfs, dd to a disk, fork bombs) and asks before risky ones (force-push, reset --hard…

newguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bash-guard
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ bash-guard │ ⏺ Read(src/auth.ts) │ bash-guard blocked: force-pushes over a │ ⎿ Read 6 lines │ protected branch │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ⏺ Bash(rm -rf build && git push --force origin main) ⎿ Denied by bash-guard: bash-guard blocked this command because it force-pushes over a protected branch. Do ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /bash-guard ⎿ bash-guard: on (risky commands: confirm). ⎿ bash-guard: This session: 1 blocked, 0 run after you confirmed. ⎿ bash-guard: All time: 1 blocked. ⎿ bash-guard: Last blocked: rm -rf build && git push --force origin main ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

🛡️ bash-guard

A seatbelt for YOLO mode. Blocks the shell commands nobody means to run, and asks before the ones you might regret, even with --dangerously-skip-permissions.

<img src="../../../assets/screens/bash-guard.svg" alt="bash-guard in a real Claude Code session" width="100%">

● Bash(rm -rf ~/)
  ⎿  Error: bash-guard blocked this command because it recursively deletes ~/.

╭─ bash-guard ────────────────────────────────────────────────╮
│ bash-guard: this command force-pushes over a protected       │
│ branch.                                                      │
│                                                              │
│   $ git push --force origin main                             │
│                                                              │
│ Run it?                                                      │
│ ❯ 1. Run it                                                  │
│   2. Block it                                                │
╰──────────────────────────────────────────────────────────────╯

Permission prompts get tuned out, and bypass mode turns them off. bash-guard is a mod: it sits on every Bash tool call (subagents included) below the permission system, so it holds even when nothing else does.

Features

  • Blocks outright (the model gets the reason, so it stops and asks you): rm -rf on /, ~, $HOME or system dirs · rm --no-preserve-root · mkfs · dd of=/dev/disk… · > /dev/sda · fork bombs · chmod/chown -R on system dirs · shutdown/reboot · kill -9 -1 · find / -delete · diskutil erase… · gh repo delete
  • Asks first (a real dialog; a headless run refuses): force-push · reset --hard · clean -f · checkout -- . · stash clear · branch -D · history rewrites · curl … | sh · DROP TABLE / TRUNCATE / DELETE without WHERE · FLUSHALL · terraform destroy · kubectl delete · helm uninstall · cloud delete-* / terminate-* · docker system prune · npm|cargo publish · sudo · rm -rf * / .git · crontab -r · overwriting ~/.zshrc
  • Understands shell, not just regexes: quotes, &&/;/| chains, $(…) and backticks, bash -c "…", eval, sudo/env/xargs/nohup wrappers. git commit -m "rm -rf /" is fine; echo $(rm -rf ~) is not.
  • Your own rules: a block pattern and a confirm pattern (regular expressions).
  • /bash-guard <command> dry-runs any command against the rules; /bash-guard shows what it blocked this session and all time.

Install

/plugin marketplace add Singh-AP/awesome-claude-mods
/plugin install bash-guard@awesome-claude-mods

Requires Claude Code 2.1.287 or later.

Configuration

Set these in /config, or under pluginConfigs in settings.json.

OptionDefaultWhat it does
riskyconfirmRisky commands: confirm asks you, block refuses, allow runs them. Catastrophic commands are always blocked.
customBlockemptyRegex. Matching commands are always blocked, e.g. deploy\.sh.*--prod.
customConfirmemptyRegex. Matching commands ask first.

How it works

EventWhy
tool.call on BashParses the command, returns { deny } for a block, asks with $.ui.ask for a confirm, else next(e)
command.run/bash-guard status and dry-run
$.storeKeeps the all-time blocked count

The analyzer (hooks/analyze.ts) is pure TypeScript with no $, so you can import it into your own mod.

Test it

claude plugin test mods/safety/bash-guard   # 101 tests

Limitations

  • It reads the command text. A script file the model writes and then runs (./cleanup.sh) is checked by name, not by contents.
  • It's a seatbelt, not a sandbox. For real isolation, run Claude Code in a container or VM.
Source 2 files
hooks/register.ts 93 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { analyze, matchesCustom, type Finding } from './analyze'
4
5const RUN = 'Run it'
6const STOP = 'Block it'
7
8// This session's tally; a reload starts it over, the all-time count is in $.store.
9let blocked = 0
10let confirmed = 0
11let lastBlocked = ''
12
13async function refuse($: EngineInterface, command: string, why: string) {
14  blocked += 1
15  lastBlocked = command
16  const total = Number((await $.store.get('blockedTotal')) ?? 0) + 1
17  await $.store.set('blockedTotal', total)
18  $.ui.toast(`bash-guard blocked: ${why}`)
19
20  return {
21    deny:
22      `bash-guard blocked this command because it ${why}. ` +
23      'Do not retry it or work around the guard; tell the user what you wanted to do and let them run it themselves if they mean it.',
24  }
25}
26
27export const register: Register = (on, options) => {
28  const risky = String(options.risky ?? 'confirm')
29  const customBlock = String(options.customBlock ?? '')
30  const customConfirm = String(options.customConfirm ?? '')
31
32  const findingsFor = (command: string): Finding[] => {
33    const found = analyze(command)
34    if (matchesCustom(command, customBlock)) found.unshift({ rule: 'custom-block', level: 'block', why: 'matches your customBlock pattern' })
35    if (matchesCustom(command, customConfirm)) found.push({ rule: 'custom-confirm', level: 'confirm', why: 'matches your customConfirm pattern' })
36    return found
37  }
38
39  on('session.start', async ($, e, next) => {
40    await $.command.register({
41      name: 'bash-guard',
42      description: 'Show what bash-guard has blocked, or test a command: /bash-guard <command>',
43      argumentHint: '[command to test]',
44    })
45    return next(e)
46  })
47
48  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
49    const findings = findingsFor(e.command)
50    const hard = findings.find(f => f.level === 'block')
51    if (hard !== undefined) return refuse($, e.command, hard.why)
52
53    const soft = findings.filter(f => f.level === 'confirm')
54    if (soft.length === 0 || risky === 'allow') return next(e)
55    if (risky === 'block') return refuse($, e.command, soft[0]!.why)
56
57    const reasons = soft.map(f => f.why).join('; ')
58    const shown = e.command.length > 300 ? `${e.command.slice(0, 300)}…` : e.command
59    let answer = STOP
60    try {
61      answer = await $.ui.ask(`bash-guard: this command ${reasons}.\n\n  $ ${shown}\n\nRun it?`, {
62        header: 'bash-guard',
63        options: [RUN, STOP],
64      })
65    } catch {
66      // Nobody to ask (a -p run) or the dialog was dismissed: stay safe.
67    }
68    if (answer === RUN) {
69      confirmed += 1
70      return next(e)
71    }
72    return refuse($, e.command, reasons)
73  })
74
75  on('command.run', { command: 'bash-guard' }, async ($, e) => {
76    const probe = e.args.trim()
77    if (probe !== '') {
78      const findings = findingsFor(probe)
79      if (findings.length === 0) return { text: `allowed: no rule matches \`${probe}\`.` }
80      const lines = findings.map(f => `  ${f.level === 'block' ? 'BLOCK  ' : 'CONFIRM'} ${f.rule}: ${f.why}`)
81      return { text: `would stop \`${probe}\`:\n${lines.join('\n')}` }
82    }
83    const total = Number((await $.store.get('blockedTotal')) ?? 0)
84    const parts = [
85      `on (risky commands: ${risky}).`,
86      `This session: ${blocked} blocked, ${confirmed} run after you confirmed.`,
87      `All time: ${total} blocked.`,
88    ]
89    if (lastBlocked !== '') parts.push(`Last blocked: ${lastBlocked.slice(0, 120)}`)
90    return { text: parts.join('\n') }
91  })
92}
93
hooks/analyze.ts 420 lines
1// Pure command analysis: no `$`, so tests and other mods can import it.
2
3export type Level = 'block' | 'confirm'
4
5export type Finding = {
6  rule: string
7  level: Level
8  why: string
9}
10
11// Paths whose recursive removal (or chmod/chown) is never what anyone meant.
12const SYSTEM_DIRS = new Set([
13  '/', '/*', '/.', '/..',
14  '~', '~/', '~/*', '~/.', '~/..',
15  '$HOME', '$HOME/', '$HOME/*', '${HOME}', '${HOME}/', '${HOME}/*',
16  '/Users', '/Users/', '/Users/*', '/home', '/home/', '/home/*',
17  '/root', '/etc', '/usr', '/usr/local', '/bin', '/sbin', '/lib', '/var',
18  '/opt', '/boot', '/dev', '/sys', '/proc', '/private', '/System',
19  '/Library', '/Applications', '/Volumes', '/mnt',
20])
21
22// Commands that run the rest of their arguments as a command.
23const PREFIXES = new Set([
24  'sudo', 'doas', 'command', 'exec', 'nohup', 'time', 'nice', 'env',
25  'xargs', 'builtin', 'caffeinate', 'timeout', 'stdbuf',
26])
27
28/** Splits one shell segment into words, honouring quotes and backslashes. */
29export function words(segment: string): string[] {
30  const out: string[] = []
31  let current = ''
32  let quote: '"' | "'" | null = null
33  let hasWord = false
34
35  for (let i = 0; i < segment.length; i++) {
36    const ch = segment[i] ?? ''
37    if (quote !== null) {
38      if (ch === quote) quote = null
39      else if (ch === '\\' && quote === '"' && i + 1 < segment.length) current += segment[++i]
40      else current += ch
41      continue
42    }
43    if (ch === '"' || ch === "'") {
44      quote = ch
45      hasWord = true
46    } else if (ch === '\\' && i + 1 < segment.length) {
47      current += segment[++i]
48      hasWord = true
49    } else if (/\s/.test(ch)) {
50      if (hasWord) out.push(current)
51      current = ''
52      hasWord = false
53    } else {
54      current += ch
55      hasWord = true
56    }
57  }
58  if (hasWord) out.push(current)
59
60  return out
61}
62
63/** Drops leading `VAR=value` words and wrapper commands (`sudo`, `xargs`...). */
64function unwrap(argv: string[]): { argv: string[]; isSudo: boolean } {
65  let rest = argv
66  let isSudo = false
67  for (;;) {
68    const head = rest[0]
69    if (head === undefined) break
70    if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(head)) {
71      rest = rest.slice(1)
72      continue
73    }
74    const base = head.split('/').pop() ?? head
75    if (!PREFIXES.has(base)) break
76    if (base === 'sudo' || base === 'doas') isSudo = true
77    rest = rest.slice(1)
78    // Skip the wrapper's own flags (`sudo -u root`, `timeout 5`, `nice -n 10`).
79    while (rest[0] !== undefined && (rest[0].startsWith('-') || /^\d+[smhd]?$/.test(rest[0]))) {
80      const flag = rest[0]
81      rest = rest.slice(1)
82      if ((flag === '-u' || flag === '-n' || flag === '-g') && rest[0] !== undefined) rest = rest.slice(1)
83    }
84  }
85  return { argv: rest, isSudo }
86}
87
88function hasShortFlag(args: string[], letters: string): boolean {
89  return args.some(a => /^-[A-Za-z]+$/.test(a) && [...letters].some(l => a.includes(l)))
90}
91
92function isSystemPath(target: string): boolean {
93  const t = target.replace(/\/+$/, '') || '/'
94  return SYSTEM_DIRS.has(target) || SYSTEM_DIRS.has(t)
95}
96
97function base(word: string | undefined): string {
98  return (word ?? '').split('/').pop() ?? ''
99}
100
101function checkArgv(raw: string[], findings: Finding[], depth: number): void {
102  const { argv, isSudo } = unwrap(raw)
103  const cmd = base(argv[0])
104  const args = argv.slice(1)
105  const sub = args[0]
106  const operands = args.filter(a => !a.startsWith('-'))
107
108  if (isSudo) {
109    findings.push({ rule: 'sudo', level: 'confirm', why: 'runs a command as root' })
110  }
111
112  // `bash -c "..."`, `sh -c`, `eval "..."`: look inside.
113  if (depth < 3 && ['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish'].includes(cmd)) {
114    const at = args.indexOf('-c')
115    const inner = at >= 0 ? args[at + 1] : undefined
116    if (inner !== undefined) scan(inner, findings, depth + 1)
117  }
118  if (depth < 3 && cmd === 'eval' && args.length > 0) scan(args.join(' '), findings, depth + 1)
119
120  switch (cmd) {
121    case 'rm': {
122      const isRecursive = hasShortFlag(args, 'rR') || args.includes('--recursive')
123      if (args.includes('--no-preserve-root')) {
124        findings.push({ rule: 'rm-no-preserve-root', level: 'block', why: 'rm --no-preserve-root can erase the whole disk' })
125      }
126      if (isRecursive && operands.some(isSystemPath)) {
127        const target = operands.find(isSystemPath) ?? ''
128        findings.push({ rule: 'rm-system', level: 'block', why: `recursively deletes ${target}` })
129      } else if (isRecursive && operands.some(o => o === '*' || o === '.' || o === '..' || o === './*')) {
130        findings.push({ rule: 'rm-wildcard', level: 'confirm', why: 'recursively deletes everything in the current directory' })
131      } else if (isRecursive && operands.some(o => /(^|\/)\.git\/?$/.test(o))) {
132        findings.push({ rule: 'rm-git', level: 'confirm', why: 'deletes the repository history (.git)' })
133      } else if (isRecursive && operands.length === 0 && raw[0] !== undefined && base(raw[0]) === 'xargs') {
134        findings.push({ rule: 'rm-xargs', level: 'confirm', why: 'recursively deletes whatever is piped in' })
135      }
136      break
137    }
138    case 'chmod':
139    case 'chown':
140    case 'chgrp': {
141      const isRecursive = hasShortFlag(args, 'R') || args.includes('--recursive')
142      if (isRecursive && operands.slice(1).some(isSystemPath)) {
143        findings.push({ rule: `${cmd}-system`, level: 'block', why: `${cmd} -R on a system directory` })
144      } else if (cmd === 'chmod' && operands.some(o => o === '777' || o === 'a+rwx')) {
145        findings.push({ rule: 'chmod-777', level: 'confirm', why: 'makes files world-writable' })
146      }
147      break
148    }
149    case 'dd':
150      if (args.some(a => /^of=\/dev\/(?!null$|zero$|stdout$|stderr$)/.test(a))) {
151        findings.push({ rule: 'dd-device', level: 'block', why: 'dd writes straight onto a device' })
152      }
153      break
154    case 'shred':
155    case 'wipefs':
156    case 'fdisk':
157    case 'sfdisk':
158    case 'parted':
159      findings.push({ rule: cmd, level: 'block', why: `${cmd} destroys disk data` })
160      break
161    case 'diskutil':
162      if (sub !== undefined && /^(erase|zero|partition|secureErase|reformat|apfs)/i.test(sub)) {
163        findings.push({ rule: 'diskutil-erase', level: 'block', why: `diskutil ${sub} erases a disk` })
164      }
165      break
166    case 'shutdown':
167    case 'reboot':
168    case 'halt':
169    case 'poweroff':
170      findings.push({ rule: 'power', level: 'block', why: `${cmd} turns the machine off` })
171      break
172    case 'kill':
173      if (/(^| )-(9|KILL|SIGKILL|s KILL) -1( |$)/.test(args.join(' '))) {
174        findings.push({ rule: 'kill-all', level: 'block', why: 'kill -9 -1 kills every process you own' })
175      }
176      break
177    case 'find':
178      if (args.includes('-delete') || args.some((a, i) => a === '-exec' && base(args[i + 1]) === 'rm')) {
179        const root = operands[0] ?? '.'
180        findings.push(isSystemPath(root)
181          ? { rule: 'find-delete-system', level: 'block', why: `find ${root} -delete deletes system files` }
182          : { rule: 'find-delete', level: 'confirm', why: 'find deletes every file it matches' })
183      }
184      break
185    case 'git':
186      checkGit(args, findings)
187      break
188    case 'crontab':
189      if (args.includes('-r')) findings.push({ rule: 'crontab-remove', level: 'confirm', why: 'removes every cron job' })
190      break
191    case 'terraform':
192    case 'tofu':
193    case 'pulumi':
194    case 'cdk':
195      if (args.includes('destroy')) findings.push({ rule: 'iac-destroy', level: 'confirm', why: `${cmd} destroy tears down infrastructure` })
196      break
197    case 'kubectl':
198      if (sub === 'delete' || sub === 'drain') findings.push({ rule: 'kubectl-delete', level: 'confirm', why: `kubectl ${sub} changes a live cluster` })
199      break
200    case 'helm':
201      if (sub === 'uninstall' || sub === 'delete') findings.push({ rule: 'helm-uninstall', level: 'confirm', why: 'removes a Helm release' })
202      break
203    case 'aws':
204      if (args.some(a => /^(delete|terminate|remove|deregister|purge)-/.test(a)) || (args[0] === 's3' && (args[1] === 'rb' || (args[1] === 'rm' && args.includes('--recursive'))))) {
205        findings.push({ rule: 'cloud-delete', level: 'confirm', why: 'deletes cloud resources' })
206      }
207      break
208    case 'gcloud':
209    case 'az':
210    case 'doctl':
211    case 'flyctl':
212    case 'fly':
213    case 'heroku':
214      if (args.some(a => /^(delete|destroy|remove|rm)$/.test(a) || /^apps:destroy$/.test(a))) {
215        findings.push({ rule: 'cloud-delete', level: 'confirm', why: 'deletes cloud resources' })
216      }
217      break
218    case 'docker':
219    case 'podman':
220      if ((sub === 'system' && args[1] === 'prune') || (sub === 'volume' && (args[1] === 'rm' || args[1] === 'prune'))) {
221        findings.push({ rule: 'docker-prune', level: 'confirm', why: 'deletes containers, images or volumes' })
222      }
223      break
224    case 'npm':
225    case 'pnpm':
226    case 'yarn':
227    case 'cargo':
228    case 'gem':
229    case 'twine':
230    case 'poetry':
231    case 'flit':
232      if (sub === 'publish' || (cmd === 'gem' && sub === 'push') || (cmd === 'twine' && sub === 'upload')) {
233        findings.push({ rule: 'publish', level: 'confirm', why: `publishes a package to a public registry` })
234      }
235      break
236    case 'gh':
237      if (sub === 'repo' && args[1] === 'delete') findings.push({ rule: 'gh-repo-delete', level: 'block', why: 'deletes a GitHub repository' })
238      else if (sub === 'release' && args[1] === 'delete') findings.push({ rule: 'gh-release-delete', level: 'confirm', why: 'deletes a GitHub release' })
239      break
240  }
241}
242
243function checkGit(args: string[], findings: Finding[]): void {
244  // Skip global options: `git -C dir push`, `git -c k=v push`.
245  let i = 0
246  while (args[i] !== undefined && args[i]!.startsWith('-')) i += args[i] === '-C' || args[i] === '-c' ? 2 : 1
247  const sub = args[i]
248  const rest = args.slice(i + 1)
249  const operands = rest.filter(a => !a.startsWith('-'))
250
251  switch (sub) {
252    case 'push': {
253      const isForce = rest.some(a => a === '--force' || a === '-f' || a.startsWith('--force-with-lease') || a === '--mirror' || a === '--delete' || a === '-d') ||
254        hasShortFlag(rest, 'f') || operands.slice(1).some(r => r.startsWith('+') || r.startsWith(':'))
255      if (isForce) {
256        const toMain = operands.some(r => /(^|[:+/])(main|master|trunk|prod|production|release)$/.test(r))
257        findings.push({ rule: 'git-force-push', level: 'confirm', why: toMain ? 'force-pushes over a protected branch' : 'force-pushes, rewriting remote history' })
258      }
259      break
260    }
261    case 'reset':
262      if (rest.includes('--hard') || rest.includes('--merge')) findings.push({ rule: 'git-reset-hard', level: 'confirm', why: 'git reset --hard throws away uncommitted work' })
263      break
264    case 'clean':
265      if (hasShortFlag(rest, 'f') || rest.includes('--force')) findings.push({ rule: 'git-clean', level: 'confirm', why: 'git clean deletes untracked files' })
266      break
267    case 'checkout':
268      if (rest.includes('--') && operands.some(o => o === '.' || o === '*') || operands.length === 1 && operands[0] === '.') {
269        findings.push({ rule: 'git-discard', level: 'confirm', why: 'discards uncommitted changes' })
270      }
271      break
272    case 'restore':
273      if (!rest.includes('--staged') && operands.some(o => o === '.' || o === '*' || o === ':/')) {
274        findings.push({ rule: 'git-discard', level: 'confirm', why: 'discards uncommitted changes' })
275      }
276      break
277    case 'stash':
278      if (rest[0] === 'clear' || rest[0] === 'drop') findings.push({ rule: 'git-stash-drop', level: 'confirm', why: 'deletes stashed work' })
279      break
280    case 'branch':
281      if (rest.includes('-D') || (rest.includes('--delete') && rest.includes('--force'))) findings.push({ rule: 'git-branch-delete', level: 'confirm', why: 'force-deletes a branch' })
282      break
283    case 'filter-branch':
284    case 'filter-repo':
285      findings.push({ rule: 'git-rewrite', level: 'confirm', why: 'rewrites the whole history' })
286      break
287    case 'update-ref':
288      if (rest.includes('-d')) findings.push({ rule: 'git-ref-delete', level: 'confirm', why: 'deletes a ref' })
289      break
290    case 'reflog':
291      if (rest[0] === 'expire' || rest[0] === 'delete') findings.push({ rule: 'git-reflog', level: 'confirm', why: 'erases the reflog, the safety net for lost commits' })
292      break
293  }
294}
295
296/** Patterns no tokenizer is needed for. */
297function checkRaw(command: string, findings: Finding[]): void {
298  if (/:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;?\s*:/.test(command)) {
299    findings.push({ rule: 'fork-bomb', level: 'block', why: 'a fork bomb freezes the machine' })
300  }
301  if (/>\s*\/dev\/(sd[a-z]|disk\d|nvme\d|hd[a-z]|mmcblk\d)/.test(command)) {
302    findings.push({ rule: 'device-overwrite', level: 'block', why: 'writes straight onto a disk device' })
303  }
304  if (/\bmkfs(\.\w+)?\b/.test(command)) {
305    findings.push({ rule: 'mkfs', level: 'block', why: 'mkfs formats a disk' })
306  }
307  if (/\b(curl|wget|fetch)\b[^|]*\|\s*(sudo\s+)?(ba|z|da|k)?sh\b/.test(command) || /\b(ba|z)?sh\s+(-c\s+)?["']?\$\(\s*(curl|wget)\b/.test(command) || /\b(ba|z)?sh\s+<\(\s*(curl|wget)\b/.test(command)) {
308    findings.push({ rule: 'pipe-to-shell', level: 'confirm', why: 'runs a script straight from the internet' })
309  }
310  if (/\b(DROP\s+(DATABASE|SCHEMA|TABLE)|TRUNCATE\s+(TABLE\s+)?\w)/i.test(command)) {
311    findings.push({ rule: 'sql-drop', level: 'confirm', why: 'drops or truncates database objects' })
312  }
313  if (/\bDELETE\s+FROM\s+[\w."`]+\s*(;|"|'|$)/i.test(command)) {
314    findings.push({ rule: 'sql-delete-all', level: 'confirm', why: 'DELETE without WHERE empties a table' })
315  }
316  if (/\bFLUSHALL\b|\bFLUSHDB\b|dropDatabase\(\)/.test(command)) {
317    findings.push({ rule: 'db-flush', level: 'confirm', why: 'wipes a database' })
318  }
319  if (/>\s*~?\/?(\.\w*)?\/?\.(bashrc|zshrc|profile|bash_profile|zprofile|ssh\/authorized_keys|gitconfig)\b/.test(command) && !/>>/.test(command)) {
320    findings.push({ rule: 'dotfile-truncate', level: 'confirm', why: 'overwrites a shell or ssh config file' })
321  }
322}
323
324/**
325 * Splits a command line at `;`, `&&`, `||`, `|`, `&` and newlines outside
326 * quotes, and lifts out `$(...)` and backtick substitutions, which run too.
327 */
328export function segments(command: string): { parts: string[]; inner: string[] } {
329  const parts: string[] = []
330  const inner: string[] = []
331  let current = ''
332  let quote: '"' | "'" | null = null
333
334  for (let i = 0; i < command.length; i++) {
335    const ch = command[i] ?? ''
336    const next = command[i + 1] ?? ''
337    if (ch === '\\' && quote !== "'") {
338      current += ch + next
339      i++
340      continue
341    }
342    if (quote === "'") {
343      if (ch === "'") quote = null
344      current += ch
345      continue
346    }
347    if (ch === '$' && next === '(' && command[i + 2] !== '(') {
348      let depth = 1
349      let j = i + 2
350      for (; j < command.length && depth > 0; j++) {
351        if (command[j] === '(') depth++
352        else if (command[j] === ')') depth--
353      }
354      inner.push(command.slice(i + 2, j - 1))
355      current += command.slice(i, j)
356      i = j - 1
357      continue
358    }
359    if (ch === '`') {
360      const end = command.indexOf('`', i + 1)
361      const stop = end < 0 ? command.length : end
362      inner.push(command.slice(i + 1, stop))
363      current += command.slice(i, stop + 1)
364      i = stop
365      continue
366    }
367    if (quote === '"') {
368      if (ch === '"') quote = null
369      current += ch
370      continue
371    }
372    if (ch === '"' || ch === "'") {
373      quote = ch
374      current += ch
375      continue
376    }
377    const isAmp = ch === '&' && next !== '>' && command[i - 1] !== '>' && command[i - 1] !== '<'
378    if (ch === ';' || ch === '\n' || ch === '|' || isAmp) {
379      parts.push(current)
380      current = ''
381      if ((ch === '|' && next === '|') || (ch === '&' && next === '&')) i++
382      continue
383    }
384    current += ch
385  }
386  parts.push(current)
387
388  return { parts: parts.map(p => p.trim()).filter(p => p !== ''), inner }
389}
390
391function scan(command: string, findings: Finding[], depth: number): void {
392  checkRaw(command, findings)
393  const { parts, inner } = segments(command)
394  for (const part of parts) {
395    const argv = words(part.replace(/^[({\s!]+|[)}\s]+$/g, ''))
396    if (argv.length > 0) checkArgv(argv, findings, depth)
397  }
398  if (depth < 3) for (const sub of inner) scan(sub, findings, depth + 1)
399}
400
401/** Every rule the command trips, most severe first, one finding per rule. */
402export function analyze(command: string): Finding[] {
403  const findings: Finding[] = []
404  scan(command, findings, 0)
405  const seen = new Set<string>()
406  return findings
407    .filter(f => (seen.has(f.rule) ? false : (seen.add(f.rule), true)))
408    .sort((a, b) => (a.level === b.level ? 0 : a.level === 'block' ? -1 : 1))
409}
410
411/** Whether `command` matches the person's own pattern; a bad pattern never matches. */
412export function matchesCustom(command: string, pattern: string): boolean {
413  if (pattern.trim() === '') return false
414  try {
415    return new RegExp(pattern, 'i').test(command)
416  } catch {
417    return false
418  }
419}
420