Read hook payload from fd 0, not /dev/stdin

Claude Code delivers the hook payload on a socket. Opening it by path
('/dev/stdin' -> /proc/self/fd/0) fails with ENXIO, so bash-guard.mjs threw on
every invocation and its bare catch exited 0 silently. The guard looked like it
was never dispatched; it was dying on line 29 each time.

readFileSync(0) is read() on the descriptor with no open(), which works on a
socket. The catch now logs instead of swallowing, so this failure mode can never
again masquerade as non-dispatch.

Adds test-bash-guard.py, which drives the guard over a socketpair. A pipe would
not reproduce the bug, so the socket is load-bearing. Verified failing against
the pre-fix guard (silent, no output) and passing after.

Removes the five diagnostic probes and probe.mjs; they served their purpose.
Bumps to 1.0.2 because the plugin cache is keyed by version and an unchanged
version silently skips reinstall.
This commit is contained in:
2026-07-21 10:24:06 -05:00
parent a651c2cb8c
commit 129354cda8
7 changed files with 335 additions and 89 deletions
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Regression test for bash-guard.mjs.
The payload MUST be delivered over a socketpair, not a pipe. Claude Code hands hook children
their stdin as a socket, where opening '/dev/stdin' fails ENXIO -- that failure mode is the
entire point of this test and a pipe would not reproduce it.
Run: python3 test-bash-guard.py
"""
import json
import os
import socket
import subprocess
import sys
GUARD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "bash-guard.mjs")
def run_guard(payload):
"""Invoke the guard with fd 0 as a socket. Returns its parsed stdout, or None if silent."""
parent, child = socket.socketpair()
proc = subprocess.Popen(
["node", GUARD], stdin=child.fileno(), stdout=subprocess.PIPE, close_fds=False
)
child.close()
parent.sendall(json.dumps(payload).encode())
parent.shutdown(socket.SHUT_WR)
out, _ = proc.communicate(timeout=30)
parent.close()
return json.loads(out) if out.strip() else None
def bash(command):
return {"tool_name": "Bash", "tool_input": {"command": command}}
def decision(result):
return (result or {}).get("hookSpecificOutput", {}).get("permissionDecision")
def reason(result):
return (result or {}).get("hookSpecificOutput", {}).get("permissionDecisionReason", "")
failures = []
def check(name, condition, detail=""):
if condition:
print(f" PASS {name}")
else:
print(f" FAIL {name} {detail}")
failures.append(name)
print("bash-guard over socket stdin:")
# The regression: before the fd-0 fix this returned None for every input, because the guard
# died on readFileSync('/dev/stdin') and exited 0 silently.
r = run_guard(bash('git commit -m "hook test"'))
check("git commit is denied", decision(r) == "deny", f"got {decision(r)!r}")
check("denial names the jj equivalent", "jj describe" in reason(r), f"got {reason(r)!r}")
r = run_guard(bash("git status"))
check("read-only git is allowed", decision(r) != "deny", f"got {decision(r)!r}")
r = run_guard(bash("ls -la"))
check("unrelated command is allowed", decision(r) != "deny", f"got {decision(r)!r}")
r = run_guard(bash('git commit -m "x\n\nCo-Authored-By: Claude <[email protected]>"'))
check("Claude attribution is denied", decision(r) == "deny", f"got {decision(r)!r}")
sys.exit(1 if failures else 0)