Appearance
block-playwright-selfkill.sh
.claude/hooks/block-playwright-selfkill.sh
PreToolUse
HARD BLOCK on the Playwright reap self-kill footgun. pkill -f "playwright" (and killall / pgrep -f playwright | kill variants) match their OWN shell command line — the pattern string "playwright" appears in the very command the agent is running — so the kill reaps the running shell AND the Claude Code Bash wrapper that spawned it. The session dies mid-run with "An error occurred while executing Claude Code", losing in-flight work. This is the documented self-kill footgun (bug #501, docs/learnings/2026-06-13-playwright-run-server-orphan-hang.md), and warning about it in a learning wasn't enough — an agent hand-rolled the pkill anyway. This hook makes it a wall, not a guideline. The project already ships the safe reaper — php artisan browser:reap — which force-kills stray run-server processes via a [p]laywright character-class pattern that CANNOT match its own command line, and clears the stale endpoint file. It's already wired into composer test:browser and the SessionStart hook, so an agent almost never needs to reap by hand; when it does, the command is php artisan browser:reap, never a raw pkill. Detection is TARGET-SCOPED: it fires only when a kill verb's own argument list — up to the next ;/&&/||/|/& separator — names "playwright". So an unrelated kill sharing the line (grep playwright docs && killall node) is left alone, a pkill chained AFTER the reaper (browser:reap && pkill -f playwright) is still caught, and the [p]laywright pattern (no literal "playwright" substring) never trips it — no allow-list short-circuit needed. A leading sudo/env VAR=… on the kill verb is tolerated (a common habit here). Residual (accepted): a deeper wrapper that fully re-quotes the command — bash -c "pkill …", xargs pkill, or a kill split across two separate tool calls — still slips past. This is an accident-guard, not an anti-adversary control; the realistic hand-rolled forms are covered. Registered in settings.json under PreToolUse (Bash). jq parses the stdin JSON. Tested by block-playwright-selfkill.test.sh.
Source
bash
#!/usr/bin/env bash
#
# PreToolUse (Bash) hook — HARD BLOCK on the Playwright reap self-kill footgun.
#
# `pkill -f "playwright"` (and `killall` / `pgrep -f playwright | kill` variants)
# match their OWN shell command line — the pattern string "playwright" appears in
# the very command the agent is running — so the kill reaps the running shell AND
# the Claude Code Bash wrapper that spawned it. The session dies mid-run with
# "An error occurred while executing Claude Code", losing in-flight work. This is
# the documented self-kill footgun (bug #501,
# docs/learnings/2026-06-13-playwright-run-server-orphan-hang.md), and warning
# about it in a learning wasn't enough — an agent hand-rolled the `pkill` anyway.
# This hook makes it a wall, not a guideline.
#
# The project already ships the safe reaper — `php artisan browser:reap` — which
# force-kills stray run-server processes via a `[p]laywright` character-class
# pattern that CANNOT match its own command line, and clears the stale endpoint
# file. It's already wired into `composer test:browser` and the SessionStart
# hook, so an agent almost never needs to reap by hand; when it does, the command
# is `php artisan browser:reap`, never a raw `pkill`.
#
# Detection is TARGET-SCOPED: it fires only when a kill verb's own argument list
# — up to the next `;`/`&&`/`||`/`|`/`&` separator — names "playwright". So an
# unrelated kill sharing the line (`grep playwright docs && killall node`) is left
# alone, a `pkill` chained AFTER the reaper (`browser:reap && pkill -f playwright`)
# is still caught, and the `[p]laywright` pattern (no literal "playwright"
# substring) never trips it — no allow-list short-circuit needed.
#
# A leading `sudo`/`env VAR=…` on the kill verb is tolerated (a common habit
# here). Residual (accepted): a deeper wrapper that fully re-quotes the command —
# `bash -c "pkill …"`, `xargs pkill`, or a kill split across two separate tool
# calls — still slips past. This is an accident-guard, not an anti-adversary
# control; the realistic hand-rolled forms are covered.
#
# Registered in settings.json under PreToolUse (Bash). jq parses the stdin JSON.
# Tested by block-playwright-selfkill.test.sh.
set -euo pipefail
input="$(cat)"
tool="$(jq -r '.tool_name // empty' <<<"$input")"
cmd="$(jq -r '.tool_input.command // empty' <<<"$input")"
[ "$tool" = "Bash" ] || exit 0
# An optional `sudo`/`env VAR=…` wrapper between the command boundary and the
# kill verb — tolerated so it doesn't shift the verb off the boundary.
prefix='((sudo|env)[[:space:]]+([^;&|[:space:]]+[[:space:]]+)*)?'
# A `pkill`/`killall` at a command boundary (start, or after ; && || | & ( `)
# whose own arguments — before the next separator — name "playwright".
kill_targets_playwright() {
grep -qiE "(^|[;&|(\`])[[:space:]]*${prefix}(pkill|killall)[^;&|]*playwright" <<<"$cmd"
}
# The indirect form: a `grep`/`pgrep` for "playwright" whose matched PIDs are fed
# to a `kill` via a pipe, `$( )` substitution, or a for/while loop. A plain
# `grep playwright` self-matches its own command line exactly as `pkill` does —
# that is the whole reason the safe reaper greps for the `[p]laywright` character
# class — so a hand-rolled `ps aux | grep playwright | xargs kill` is the same
# footgun and must be caught too.
grep_feeds_kill() {
grep -qiE "(^|[;&|(\`])[[:space:]]*${prefix}(pgrep|grep)[^;&|]*playwright" <<<"$cmd" \
&& grep -qE '(^|[^[:alnum:]_-])kill([[:space:];&|)]|$)' <<<"$cmd"
}
if kill_targets_playwright; then
reason="\`pkill\`/\`killall\` targeting \"playwright\""
elif grep_feeds_kill; then
reason="a \`grep\`/\`pgrep\` for \"playwright\" fed into a kill"
else
exit 0
fi
read -r -d '' message <<EOF || true
Blocked: $reason matches its OWN shell command line (the word "playwright" is in the pattern), so it kills the running shell and the Claude Code session wrapper — the run dies mid-flight with "An error occurred while executing Claude Code" and in-flight work is lost. This is the self-kill footgun from bug #501.
Use the safe reaper instead — it kills stray run-server processes with a \`[p]laywright\` pattern that can't match its own command line, and clears the stale endpoint file:
php artisan browser:reap
It's already the first step of \`composer test:browser\` and runs in the SessionStart hook, so you rarely need to reap by hand. If you must reap manually, run \`php artisan browser:reap\` — never a raw \`pkill -f playwright\`.
EOF
jq -n --arg r "$message" \
'{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: $r}}'
exit 0