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:
@@ -24,10 +24,17 @@ const deny = (reason) => {
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
// Read fd 0 directly. Claude Code delivers the payload on a socket, and opening it by path
|
||||
// ('/dev/stdin' -> /proc/self/fd/0) fails ENXIO. readFileSync(0) is read() with no open().
|
||||
let input;
|
||||
try {
|
||||
input = JSON.parse(readFileSync('/dev/stdin', 'utf8'));
|
||||
} catch {
|
||||
input = JSON.parse(readFileSync(0, 'utf8'));
|
||||
} catch (e) {
|
||||
// Never swallow this silently: a guard that dies here is indistinguishable from one that
|
||||
// never ran, which is exactly what hid the ENXIO bug for three sessions.
|
||||
try {
|
||||
appendFileSync('/tmp/bash-guard-mode.log', `stdin-FAILED err=${e?.message ?? e}\n`);
|
||||
} catch {}
|
||||
process.exit(0);
|
||||
}
|
||||
const command = input?.tool_input?.command ?? '';
|
||||
|
||||
@@ -11,46 +11,6 @@
|
||||
"statusMessage": "Checking jj/attribution policy; gating push on build + tests..."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/probe.mjs\" P1-plain"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/probe.mjs\" P2-timeout-only",
|
||||
"timeout": 600
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/probe.mjs\" P3-status-only",
|
||||
"statusMessage": "probe P3 status-only"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/probe.mjs\" P4-both",
|
||||
"timeout": 600,
|
||||
"statusMessage": "probe P4 both"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
@@ -63,16 +23,6 @@
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/probe.mjs\" P5-post-status",
|
||||
"statusMessage": "probe P5 post status"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Diagnostic probe for hook dispatch. Inert: logs and exits 0, never emits a decision.
|
||||
//
|
||||
// Logs BEFORE reading stdin, so three outcomes stay distinguishable:
|
||||
// no line at all -> entry never dispatched
|
||||
// phase=spawned only -> dispatched, but the stdin read threw (bash-guard's line 29)
|
||||
// phase=stdin-ok -> dispatched and payload readable; records permission_mode
|
||||
//
|
||||
// Each hooks.json entry passes a distinct label so the transcript's `command` field
|
||||
// and this log both identify which config variant ran.
|
||||
|
||||
import { appendFileSync, readFileSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
const label = process.argv[2] ?? '<none>';
|
||||
const stamp = new Date().toISOString();
|
||||
|
||||
// Two sinks: a sandboxed hook child might see a private /tmp but still reach $HOME.
|
||||
const log = (line) => {
|
||||
for (const p of ['/tmp/guard-probe.log', join(homedir(), 'guard-probe.log')]) {
|
||||
try { appendFileSync(p, line); } catch {}
|
||||
}
|
||||
};
|
||||
|
||||
log(`${stamp} probe=${label} phase=spawned\n`);
|
||||
|
||||
try {
|
||||
const input = JSON.parse(readFileSync('/dev/stdin', 'utf8'));
|
||||
log(`${stamp} probe=${label} phase=stdin-ok mode=${input?.permission_mode ?? '<absent>'}`
|
||||
+ ` tool=${input?.tool_name ?? '?'} cmd=${(input?.tool_input?.command ?? '').slice(0, 40)}\n`);
|
||||
} catch (e) {
|
||||
log(`${stamp} probe=${label} phase=stdin-FAILED err=${e?.message ?? e}\n`);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user