SLOPSHOPPER

bash-guard

Block Bash commands that truncate output, detach processes, or mass-kill by name.

newguard
v2.0.0no licenseupdated 2026-10-03mary-ext/claude-skills/plugins/bash-guard
A shopper browsing a rack in a slop shop
README

mary-skills

personal Claude Code skills.

Source 2 files
hooks/register.ts 11 lines
1import type { Register } from 'claude-code';
2
3import { analyze, denyMessage } from './guard.ts';
4
5export const register: Register = (on) => {
6	on('tool.call', { tool: 'Bash' }, ($, e, next) => {
7		const blocked = analyze(e.command);
8		return blocked ? { deny: denyMessage(blocked) } : next(e);
9	});
10};
11
hooks/guard.ts 354 lines
1type Token = { type: 'word' | 'op'; value: string };
2type Heredoc = { delim: string; stripTabs: boolean };
3type Blocked =
4	// | { kind: 'pipe'; name: string }
5	{ kind: 'detach' | 'kill' | 'wait'; name: string } | { kind: 'background' };
6
7// const BLOCK = new Set(['head', 'tail', 'less', 'more', 'grep', 'egrep', 'fgrep', 'rg']);
8
9const DETACH = new Set(['disown', 'setsid', 'coproc']);
10
11const KILL = new Set(['pkill', 'killall']);
12
13const WAIT = new Set(['sleep']);
14
15// Transparent command wrappers.
16const WRAPPERS = new Set(['sudo', 'command', 'env', 'nice', 'time', 'stdbuf', 'nohup', 'builtin', 'exec']);
17
18const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'ash']);
19
20// Operators that start a simple command. Redirections are excluded.
21const CMD_START_OPS = new Set(['|', '||', '|&', '&', '&&', ';', ';;', ';&', ';;&', '(']);
22
23// Reserved words that expect a command next.
24const KEYWORDS = new Set(['if', 'then', 'elif', 'else', 'while', 'until', 'do', '{', '!']);
25
26// const PIPE_OPS = new Set(['|', '|&']);
27
28// The word after a redirection is a target, not a command.
29const REDIR_OPS = new Set(['<', '<&', '<<', '<<-', '<<<', '>', '>&', '>>', '>|', '&>', '&>>']);
30
31// `<<<` is a here-string, not a heredoc.
32const HEREDOC_OPS = new Set(['<<', '<<-']);
33
34// Other backslashes inside double quotes remain literal.
35const DQUOTE_ESCAPES = new Set(['"', '\\', '$', '`']);
36
37const basename = (w: string) => (w.includes('/') ? w.slice(w.lastIndexOf('/') + 1) : w);
38const isAssignment = (w: string) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(w);
39
40// Match the longest operator at `cmd[i]`.
41function matchOp(cmd: string, i: number): { value: string; len: number } | null {
42	const c = cmd[i];
43	const c2 = cmd[i + 1];
44	const c3 = cmd[i + 2];
45	switch (c) {
46		case '|':
47			if (c2 === '&') return { value: '|&', len: 2 };
48			if (c2 === '|') return { value: '||', len: 2 };
49			return { value: '|', len: 1 };
50		case '&':
51			if (c2 === '>' && c3 === '>') return { value: '&>>', len: 3 };
52			if (c2 === '>') return { value: '&>', len: 2 };
53			if (c2 === '&') return { value: '&&', len: 2 };
54			return { value: '&', len: 1 };
55		case ';':
56			if (c2 === ';' && c3 === '&') return { value: ';;&', len: 3 };
57			if (c2 === ';') return { value: ';;', len: 2 };
58			if (c2 === '&') return { value: ';&', len: 2 };
59			return { value: ';', len: 1 };
60		case '>':
61			if (c2 === '&') return { value: '>&', len: 2 };
62			if (c2 === '>') return { value: '>>', len: 2 };
63			if (c2 === '|') return { value: '>|', len: 2 };
64			return { value: '>', len: 1 };
65		case '<':
66			// `<>` can be treated as repeated `<` for classification.
67			if (c2 === '&') return { value: '<&', len: 2 };
68			if (c2 === '<' && c3 === '<') return { value: '<<<', len: 3 };
69			if (c2 === '<' && c3 === '-') return { value: '<<-', len: 3 };
70			if (c2 === '<') return { value: '<<', len: 2 };
71			return { value: '<', len: 1 };
72		case '(':
73		case ')':
74			return { value: c, len: 1 };
75		default:
76			return null;
77	}
78}
79
80// Skip pending heredoc bodies after a newline.
81function skipHeredocBodies(cmd: string, i: number, heredocs: Heredoc[]): number {
82	for (const { delim, stripTabs } of heredocs) {
83		while (i < cmd.length) {
84			let eol = cmd.indexOf('\n', i);
85			if (eol === -1) eol = cmd.length;
86			const line = cmd.slice(i, eol);
87			i = Math.min(eol + 1, cmd.length);
88			if ((stripTabs ? line.replace(/^\t+/, '') : line) === delim) break;
89		}
90	}
91	return i;
92}
93
94// Tokenize shell words and operators, skipping heredoc bodies.
95function tokenize(cmd: string): Token[] {
96	const tokens: Token[] = [];
97	let word = '';
98	// Preserve empty quoted words.
99	let hasWord = false;
100	let heredocs: Heredoc[] = [];
101	let heredocOp: string | null = null;
102	const pushWord = () => {
103		if (hasWord) {
104			if (heredocOp) {
105				heredocs.push({ delim: word, stripTabs: heredocOp === '<<-' });
106				heredocOp = null;
107			}
108			tokens.push({ type: 'word', value: word });
109			word = '';
110			hasWord = false;
111		}
112	};
113
114	let i = 0;
115	const n = cmd.length;
116	while (i < n) {
117		const c = cmd[i];
118		if (c === "'") {
119			hasWord = true;
120			i++;
121			while (i < n && cmd[i] !== "'") {
122				word += cmd[i];
123				i++;
124			}
125			i++;
126			continue;
127		}
128		if (c === '"') {
129			hasWord = true;
130			i++;
131			while (i < n && cmd[i] !== '"') {
132				if (cmd[i] === '\\' && i + 1 < n) {
133					const next = cmd[i + 1]!;
134					if (next === '\n') {
135						i += 2;
136					} else if (DQUOTE_ESCAPES.has(next)) {
137						word += next;
138						i += 2;
139					} else {
140						word += cmd[i];
141						i++;
142					}
143				} else {
144					word += cmd[i];
145					i++;
146				}
147			}
148			i++;
149			continue;
150		}
151		if (c === '\\') {
152			if (i + 1 < n) {
153				if (cmd[i + 1] === '\n') {
154					i += 2;
155				} else {
156					word += cmd[i + 1];
157					hasWord = true;
158					i += 2;
159				}
160			} else {
161				i++;
162			}
163			continue;
164		}
165		if (c === '#' && !hasWord) {
166			while (i < n && cmd[i] !== '\n') i++;
167			continue;
168		}
169		if (c === ' ' || c === '\t' || c === '\n' || c === '\r') {
170			pushWord();
171			i++;
172			if (c === '\n' && heredocs.length) {
173				i = skipHeredocBodies(cmd, i, heredocs);
174				heredocs = [];
175			}
176			continue;
177		}
178		const op = matchOp(cmd, i);
179		if (op) {
180			pushWord();
181			tokens.push({ type: 'op', value: op.value });
182			if (HEREDOC_OPS.has(op.value)) heredocOp = op.value;
183			i += op.len;
184			continue;
185		}
186		word += c;
187		hasWord = true;
188		i++;
189	}
190	pushWord();
191	return tokens;
192}
193
194// Yield each simple command's starting token.
195function* commandStarts(tokens: Token[]): Generator<number> {
196	let atStart = true;
197	for (let i = 0; i < tokens.length; i++) {
198		const tk = tokens[i]!;
199		if (tk.type === 'op') {
200			atStart = CMD_START_OPS.has(tk.value);
201			continue;
202		}
203		if (atStart && KEYWORDS.has(tk.value)) continue;
204		if (atStart) yield i;
205		atStart = false;
206	}
207}
208
209// Resolve a command past assignments and wrappers.
210function resolveCommand(tokens: Token[], start: number): { name: string; index: number } | null {
211	let inWrapper = false;
212	for (let j = start; j < tokens.length; j++) {
213		const tk = tokens[j]!;
214		if (tk.type === 'op') {
215			if (tk.value === '(') continue;
216			return null;
217		}
218		if (isAssignment(tk.value)) continue;
219		const name = basename(tk.value);
220		if (WRAPPERS.has(name)) {
221			inWrapper = true;
222			continue;
223		}
224		if (inWrapper && tk.value.startsWith('-')) continue;
225		return { name, index: j };
226	}
227	return null;
228}
229
230// function firstBlockedPipe(tokens) {
231// 	for (let i = 0; i < tokens.length; i++) {
232// 		const t = tokens[i];
233// 		if (t.type !== 'op' || !PIPE_OPS.has(t.value)) continue;
234// 		const cmd = resolveCommand(tokens, i + 1);
235// 		if (cmd && BLOCK.has(cmd.name)) return { kind: 'pipe', name: cmd.name };
236// 	}
237// 	return null;
238// }
239
240function firstDetach(tokens: Token[]): Blocked | null {
241	for (const i of commandStarts(tokens)) {
242		const cmd = resolveCommand(tokens, i);
243		if (cmd && DETACH.has(cmd.name)) return { kind: 'detach', name: cmd.name };
244	}
245	return null;
246}
247
248// Block mass-kill commands and `kill` jobspecs.
249function firstKill(tokens: Token[]): Blocked | null {
250	for (const i of commandStarts(tokens)) {
251		const cmd = resolveCommand(tokens, i);
252		if (!cmd) continue;
253		if (KILL.has(cmd.name)) return { kind: 'kill', name: cmd.name };
254		if (cmd.name === 'kill') {
255			for (let j = cmd.index + 1; j < tokens.length; j++) {
256				const tk = tokens[j]!;
257				if (tk.type === 'op') break;
258				if (tk.value.startsWith('%')) return { kind: 'kill', name: 'kill' };
259			}
260		}
261	}
262	return null;
263}
264
265function firstWait(tokens: Token[]): Blocked | null {
266	for (const i of commandStarts(tokens)) {
267		const cmd = resolveCommand(tokens, i);
268		if (cmd && WAIT.has(cmd.name)) return { kind: 'wait', name: cmd.name };
269	}
270	return null;
271}
272
273// Find `&` after a command, excluding redirection syntax.
274function firstBackground(tokens: Token[]): Blocked | null {
275	let sawCmdWord = false;
276	let expectRedirTarget = false;
277	for (const t of tokens) {
278		if (t.type === 'op') {
279			if (t.value === '&') {
280				if (sawCmdWord) return { kind: 'background' };
281				sawCmdWord = false;
282				expectRedirTarget = false;
283				continue;
284			}
285			if (REDIR_OPS.has(t.value)) {
286				expectRedirTarget = true;
287				continue;
288			}
289			if (CMD_START_OPS.has(t.value)) {
290				sawCmdWord = false;
291			}
292			expectRedirTarget = false;
293			continue;
294		}
295		if (expectRedirTarget) {
296			expectRedirTarget = false;
297			continue;
298		}
299		sawCmdWord = true;
300	}
301	return null;
302}
303
304// Inspect `<shell> -c <string>` recursively.
305function firstBlockedShellC(tokens: Token[], depth: number): Blocked | null {
306	for (const i of commandStarts(tokens)) {
307		const cmd = resolveCommand(tokens, i);
308		if (!cmd || !SHELLS.has(cmd.name)) continue;
309		for (let j = cmd.index + 1; j < tokens.length; j++) {
310			const tk = tokens[j]!;
311			if (tk.type === 'op') break;
312			if (tk.value !== '-c' && !/^-[A-Za-z]*c$/.test(tk.value)) continue;
313			const arg = tokens[j + 1];
314			if (arg?.type !== 'word') continue;
315			const nested = analyzeAt(arg.value, depth + 1);
316			if (nested) return nested;
317		}
318	}
319	return null;
320}
321
322export const analyze = (cmd: string) => analyzeAt(cmd, 0);
323
324function analyzeAt(cmd: string, depth: number): Blocked | null {
325	if (depth > 5) return null;
326	const tokens = tokenize(cmd);
327	return (
328		// firstBlockedPipe(tokens) ||
329		firstDetach(tokens) ||
330		firstKill(tokens) ||
331		firstBackground(tokens) ||
332		firstWait(tokens) ||
333		firstBlockedShellC(tokens, depth)
334	);
335}
336
337export function denyMessage(result: Blocked): string {
338	switch (result.kind) {
339		// case 'pipe': {
340		// 	return `Drop \`| ${result.name}\` from the command. When the output is long, Claude Code saves the full result to a file that can be read from`;
341		// }
342		case 'background':
343		case 'detach': {
344			return `Use Bash(run_in_background: true) to run a command in the background`;
345		}
346		case 'kill': {
347			return `Use TaskStop to stop a command you started with Bash(run_in_background: true), or \`kill <pid>\` for any other process`;
348		}
349		case 'wait': {
350			return `Drop \`${result.name}\`. Claude Code provides much more suitable tools for this purpose`;
351		}
352	}
353}
354