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

personal Claude Code skills.
hooks/register.ts 11 lines1import 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};
11hooks/guard.ts 354 lines1type 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