SLOPSHOPPER

kill-guard

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

newguard
v0.1.0no licenseupdated 2026-10-14cpeaustriajc/cj-stack/mods/kill-guard
A shopper browsing a rack in a slop shop
README

kill-guard

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.

Why

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.

Install

/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.

Example

  1. Claude runs pkill -f "wrangler dev" ; pkill -f workerd to restart a server.
  2. The hook finds pkill at the start of a command.
  3. The hook denies the command. Claude reads: "kill-guard: a kill by name or port can stop another checkout's process."
  4. Claude runs lsof -a -p <pid> -d cwd, sees that the PID belongs to this worktree, and runs kill <pid>.

What it does

The hook checks every Bash command. It ignores text inside quotes and comments, so echo "do not use pkill" passes.

CommandDecision
pkill or killall at the start of a command, or after ;, &&, `\, $(, sudo, xargs`Deny.
fuser -kDeny.
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 killDeny.
kill 18427, kill $!, kill %1, kill $(cat app.pid)Pass.
kill -0 <pid>, pgrep -fl vitest, grep pkill README.mdPass.
The hook fails on a commandDeny 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.

Settings

No settings and no allow list.

To turn the mod off, run claude plugin disable kill-guard@cj-stack.

Limits

  • It checks only the Bash tool. Code that kills by name from a script file passes.
  • It does not read the text of bash -c "..." or eval.
  • A PID in a variable passes: P=$(pgrep vite); kill $P.
  • It does not check that a named PID belongs to this worktree.
  • If the hook fails, it denies. Retry, or kill a PID you saved.

More

Source 1 files
hooks/register.ts 148 lines
1import 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