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…

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.
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 deletereset --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&&/;/| chains, $(…) and backticks, bash -c "…", eval, sudo/env/xargs/nohup wrappers. git commit -m "rm -rf /" is fine; echo $(rm -rf ~) is not./bash-guard <command> dry-runs any command against the rules; /bash-guard shows what it blocked this session and all time./plugin marketplace add Singh-AP/awesome-claude-mods
/plugin install bash-guard@awesome-claude-mods
Requires Claude Code 2.1.287 or later.
Set these in /config, or under pluginConfigs in settings.json.
| Option | Default | What it does |
|---|---|---|
risky | confirm | Risky commands: confirm asks you, block refuses, allow runs them. Catastrophic commands are always blocked. |
customBlock | empty | Regex. Matching commands are always blocked, e.g. deploy\.sh.*--prod. |
customConfirm | empty | Regex. Matching commands ask first. |
| Event | Why |
|---|---|
tool.call on Bash | Parses 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 |
$.store | Keeps the all-time blocked count |
The analyzer (hooks/analyze.ts) is pure TypeScript with no $, so you can import it into your own mod.
claude plugin test mods/safety/bash-guard # 101 tests
./cleanup.sh) is checked by name, not by contents.hooks/register.ts 93 lines1import 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}
93hooks/analyze.ts 420 lines1// 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