Denies a Bash command that stops processes by name or by port, because it can also stop another checkout's dev server; a kill of a named PID passes

A mod (a plugin that adds a hook to Claude Code) that denies a shell command which stops processes by name or by port. Claude then kills only a PID it can name.
Claude starts dev servers and test runners, and later stops them. A command such as pkill -f "node \(vitest" or kill $(lsof -ti:3000) stops every match on the machine. That includes the server of another checkout or another session.
kill-guard denies the command and tells Claude to kill a PID it saved, or a PID it checked.
/plugin marketplace add cpeaustriajc/cj-stack
/plugin install kill-guard@cj-stack
To see that it is on, run claude plugin list and look for kill-guard. The mod is optional: cj-stack does not install it.
pkill -f "wrangler dev" ; pkill -f workerd to restart a server.pkill at the start of a command.lsof -a -p <pid> -d cwd, sees that the PID belongs to this worktree, and runs kill <pid>.The hook checks every Bash command. It ignores text inside quotes and comments, so echo "do not use pkill" passes.
| Command | Decision | |
|---|---|---|
pkill or killall at the start of a command, or after ;, &&, `\ | , $(, sudo, xargs` | Deny. |
fuser -k | Deny. | |
kill with an argument made by lsof, pgrep, pidof, ps, ss, netstat or fuser, such as kill $(lsof -ti:3000) | Deny. | |
lsof, pgrep or ps piped into xargs kill | Deny. | |
kill 18427, kill $!, kill %1, kill $(cat app.pid) | Pass. | |
kill -0 <pid>, pgrep -fl vitest, grep pkill README.md | Pass. | |
| The hook fails on a command | Deny with "could not check". |
The deny text gives two ways out. Kill the PID you saved ($! or a pid file). Or check the PID's working directory with lsof -a -p <pid> -d cwd or ps -o command= -p <pid>, then kill it.
No settings and no allow list.
To turn the mod off, run claude plugin disable kill-guard@cj-stack.
bash -c "..." or eval.P=$(pgrep vite); kill $P.hooks/register.ts 148 lines1import type { Register } from 'claude-code'
2
3const MOD = 'kill-guard'
4
5const WAY_OUT =
6 'Kill the PID you saved (`$!` or a pid file), or check the PID\'s working directory is inside this worktree ' +
7 '(`lsof -a -p <pid> -d cwd` or `ps -o command= -p <pid>`) and kill that PID.'
8
9const DENY_TEXT = `${MOD}: a kill by name or port can stop another checkout's process. ${WAY_OUT}`
10
11const FINDERS = new Set(['lsof', 'pgrep', 'pidof', 'ps', 'ss', 'netstat', 'fuser'])
12const KILL_BY_NAME = new Set(['pkill', 'killall'])
13const PREFIX = new Set(['!', 'if', 'then', 'do', 'else', 'elif', 'while', 'until', '{', 'time', 'exec', 'command', 'builtin', 'nohup', 'sudo', 'doas', 'env', 'nice'])
14const XARGS_WITH_ARG = new Set(['-I', '-n', '-P', '-L', '-d', '-E', '-s', '-a'])
15
16type Frame = { parent?: Seg; segs: Seg[] }
17type Seg = { text: string; piped: boolean; frame: Frame; words?: string[]; via?: string }
18
19function scan(input: string): Frame[] {
20 const frames: Frame[] = []
21 const open = (parent?: Seg): Frame => {
22 const frame: Frame = { parent, segs: [] }
23 frames.push(frame)
24 return frame
25 }
26 const stack: { frame: Frame; kind: 'top' | 'sub' | 'grp' | 'bt' | 'dq' | 'sq' }[] = [{ frame: open(), kind: 'top' }]
27 const begin = (frame: Frame, piped: boolean) => frame.segs.push({ text: '', piped, frame })
28 const cur = () => {
29 const frame = stack[stack.length - 1].frame
30 if (!frame.segs.length) begin(frame, false)
31 return frame.segs[frame.segs.length - 1]
32 }
33 const split = (piped: boolean) => begin(stack[stack.length - 1].frame, piped)
34 const enter = (kind: 'sub' | 'grp' | 'bt') => {
35 const parent = kind === 'grp' ? undefined : cur()
36 const frame = kind === 'grp' ? stack[stack.length - 1].frame : open(parent)
37 stack.push({ frame, kind })
38 split(false)
39 }
40
41 for (let i = 0; i < input.length; i++) {
42 const c = input[i]
43 const top = stack[stack.length - 1].kind
44 const next = input[i + 1]
45 if (top === 'sq') {
46 if (c === "'") stack.pop()
47 cur().text += '_'
48 } else if (top === 'dq') {
49 if (c === '\\') {
50 cur().text += '__'
51 i++
52 } else if (c === '"') {
53 stack.pop()
54 cur().text += '_'
55 } else if (c === '$' && next === '(') {
56 enter('sub')
57 i++
58 } else if (c === '`') {
59 enter('bt')
60 } else cur().text += '_'
61 } else if (c === '\\') {
62 cur().text += next === '\n' ? ' ' : '__'
63 i++
64 } else if (c === "'") {
65 stack.push({ frame: stack[stack.length - 1].frame, kind: 'sq' })
66 cur().text += '_'
67 } else if (c === '"') {
68 stack.push({ frame: stack[stack.length - 1].frame, kind: 'dq' })
69 cur().text += '_'
70 } else if (c === '#' && (i === 0 || /\s/.test(input[i - 1]))) {
71 while (i + 1 < input.length && input[i + 1] !== '\n') i++
72 } else if (c === '$' && next === '(') {
73 enter('sub')
74 i++
75 } else if (c === '`') {
76 if (top === 'bt') {
77 stack.pop()
78 cur()
79 } else enter('bt')
80 } else if (c === '(') {
81 enter('grp')
82 } else if (c === ')') {
83 if (top === 'sub' || top === 'grp') stack.pop()
84 split(false)
85 } else if (c === '|' && next === '|') {
86 split(false)
87 i++
88 } else if (c === '|') {
89 split(true)
90 } else if (c === '&' && next === '&') {
91 split(false)
92 i++
93 } else if (c === '&' && input[i - 1] === '>') {
94 cur().text += c
95 } else if (c === ';' || c === '&' || c === '\n') {
96 split(false)
97 } else {
98 cur().text += c
99 }
100 }
101 return frames
102}
103
104function command(seg: Seg): string | undefined {
105 const words = seg.text.split(/\s+/).filter(Boolean)
106 let i = 0
107 while (i < words.length) {
108 const w = words[i]
109 const base = w.replace(/^.*\//, '')
110 if (/^[A-Za-z_]\w*=/.test(w) || PREFIX.has(base)) {
111 i++
112 if (base === 'sudo' || base === 'doas') while (words[i]?.startsWith('-')) i += /^-[ughCT]$/.test(words[i]) ? 2 : 1
113 } else if (base === 'xargs') {
114 seg.via = 'xargs'
115 i++
116 while (words[i]?.startsWith('-')) i += XARGS_WITH_ARG.has(words[i]) ? 2 : 1
117 } else break
118 }
119 seg.words = words.slice(i)
120 return seg.words[0]?.replace(/^.*\//, '')
121}
122
123function judge(input: string): boolean {
124 const frames = scan(input)
125 for (const frame of frames) {
126 let pipeline: Seg[] = []
127 for (const seg of frame.segs) {
128 pipeline = seg.piped ? [...pipeline, seg] : [seg]
129 const name = command(seg)
130 if (!name) continue
131 if (KILL_BY_NAME.has(name)) return true
132 if (name === 'fuser' && seg.words!.slice(1).some(w => /^-[A-Za-z]*k/.test(w))) return true
133 if (name === 'kill' && seg.via === 'xargs' && pipeline.slice(0, -1).some(p => FINDERS.has(command(p) ?? ''))) return true
134 }
135 const parent = frame.parent && command(frame.parent)
136 if (parent === 'kill' && frame.segs.some(s => FINDERS.has(command(s) ?? ''))) return true
137 }
138 return false
139}
140
141export const register: Register = on => {
142 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
143 return judge(e.command) ? { deny: DENY_TEXT } : next(e)
144 }).catch(($, e, next) =>
145 next.called ? next(e) : { deny: `${MOD}: could not check this command (${next.error.message}). ${WAY_OUT}` },
146 )
147}
148