Skip to content

Commit a8ffca5

Browse files
Never let the guard fail silently
The guard shipped broken on every Linux host. It used "$TMPDIR" in a case pattern; TMPDIR is routinely unset there; set -u makes that fatal mid-script; and the hook then emitted nothing at all — the one outcome its own header promises cannot happen. macOS sets TMPDIR, so it passed here and failed on Linux, Alpine and Windows. Two fixes, and the second is the one that matters. ${TMPDIR:-/nonexistent} fixes this instance. An EXIT trap that emits `ask` unless a decision was already printed fixes the class: no future edit can reintroduce silence, whatever else it gets wrong. Verified by injecting a fatal error before any decision — the guard now asks instead of returning nothing. Check 23 gained the case that would have caught it: the same three decisions run again with TMPDIR, HOME, USER and LANG stripped. Reintroducing the exact defect turns it red naming the empty output. I also pushed the previous commit while the install matrix was red — I printed the result and chained the commit after it instead of gating on it. The matrix was green on re-run and CI caught the real problem, but the discipline failed before CI did, which is the part worth recording. verify.sh 25/25, gate-falsifiability.sh 26/26, install-matrix 17/17, all gated before commit this time.
1 parent 5d2ea77 commit a8ffca5

2 files changed

Lines changed: 31 additions & 2 deletions

File tree

.claude/verify.sh

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -841,6 +841,18 @@ if command -v jq >/dev/null; then
841841
g_want 'npm test' allow
842842
g_want 'git push origin feature-x' allow
843843
g_want 'git commit -m "wip"' allow
844+
# The same decisions with a hostile environment. This is the case that would have caught the
845+
# guard shipping broken on every Linux host: it used "$TMPDIR" in a case pattern, TMPDIR is
846+
# routinely unset there, set -u made that fatal, and the hook emitted nothing at all. macOS
847+
# sets TMPDIR, so it passed locally and failed on three platforms in CI.
848+
for probe in 'rm -rf /:deny' 'rm -rf /etc/nginx:ask' 'rm -rf node_modules:allow'; do
849+
pc=${probe%:*}; pw=${probe##*:}
850+
pd=$(printf '{"tool_input":{"command":%s}}' "$(jq -Rn --arg c "$pc" '$c')" \
851+
| env -u TMPDIR -u HOME -u USER -u LANG bash claude/hooks/guard-destructive.sh 2>/dev/null \
852+
| jq -r '.hookSpecificOutput.permissionDecision' 2>/dev/null)
853+
[ "$pd" = "$pw" ] || errs="$errs\nwith a stripped environment, '$pc' -> ${pd:-<no output>}, expected $pw"
854+
done
855+
844856
# Unparseable or absent input must never reach allow. A guard that opens on malformed input
845857
# has inverted its own purpose, and malformed input is exactly what an attacker sends.
846858
for bad in 'not json' ''; do

claude/hooks/guard-destructive.sh

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,28 @@
2626

2727
set -uo pipefail
2828

29+
# Anything that reaches the end of this script without having emitted a decision has crashed,
30+
# and a crash must not be silence. This shipped broken on every Linux host for exactly that
31+
# reason: `"$TMPDIR"*` in a case pattern, TMPDIR routinely unset there, set -u turns that into
32+
# a fatal error mid-script, and the hook produced no output at all — the one outcome the header
33+
# above promises cannot happen. macOS sets TMPDIR, so it passed locally and failed on three
34+
# platforms in CI.
35+
#
36+
# The trap is the structural fix rather than the one-line one: no future edit can reintroduce
37+
# silence, whatever it gets wrong.
38+
_guard_emitted=0
39+
_guard_trap() {
40+
[ "$_guard_emitted" = 1 ] && return 0
41+
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"[guard] the guard itself failed while inspecting this command. Approve only if you know what it does."}}\n'
42+
}
43+
trap _guard_trap EXIT
44+
2945
emit() { # <allow|ask|deny> <reason>
46+
_guard_emitted=1
3047
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"%s","permissionDecisionReason":"%s"}}\n' "$1" "$2"
3148
exit 0
3249
}
33-
allow() { printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}\n'; exit 0; }
50+
allow() { _guard_emitted=1; printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}\n'; exit 0; }
3451

3552
payload=$(cat 2>/dev/null || true)
3653
[ -n "$payload" ] || emit ask "[guard] no tool payload to inspect — approve only if you know what this does"
@@ -119,7 +136,7 @@ case "$CMD" in
119136
node_modules|dist|build|target|coverage|.next|.turbo|.cache|.venv|__pycache__|.pytest_cache) continue ;;
120137
*/node_modules|*/node_modules/*|*/dist|*/dist/*|*/build|*/build/*|*/target|*/target/*) continue ;;
121138
*/coverage|*/coverage/*|*.next|*.next/*|*.turbo|*.turbo/*|*.cache|*.cache/*) continue ;;
122-
*/__pycache__|*/__pycache__/*|*.venv|*.venv/*|/tmp/*|"$TMPDIR"*) continue ;;
139+
*/__pycache__|*/__pycache__/*|*.venv|*.venv/*|/tmp/*|"${TMPDIR:-/nonexistent}"*) continue ;;
123140
esac
124141
_unsafe=1
125142
done

0 commit comments

Comments
 (0)