When Your Safety Hooks Fail Open
On March 10, a developer named chris-peterson submitted a pull request to the framework repository. He had found us through the blog. The fix was small: several hook scripts referenced .input when Claude Code’s hook system actually sends .tool_input. A one-word field name error, present in multiple files.
The fix was correct. But what happened next was more interesting than the fix itself.
The automated review
The repository has Gemini Code Assist enabled, Google’s automated code review bot. When chris-peterson opened the PR, Gemini reviewed the diff and posted comments. Not about the field name. About something else entirely.
Gemini pointed out that several hook scripts constructed their JSON output through string concatenation. A typical line looked something like this:
echo "{\"decision\": \"block\", \"reason\": \"$REASON\"}"
If $REASON contained a double quote or a backslash, the output would be malformed JSON. Claude Code would fail to parse it. The hook would silently do nothing.
This matters because $REASON often includes the filename or command that triggered the hook. A file called test"file.env or a path with backslashes on Windows would break the JSON, and the hook would fail open. The file it was supposed to protect would be unprotected. No error, no warning. Silent failure.
The second finding
Gemini also flagged the path handling in file-guard, the hook that protects sensitive files from modification. The hook checked whether a filename matched a protected pattern like .env or credentials.json. But it only checked the basename of the path.
A request to edit subdir/../.env would pass the check. The hook would see .. as a directory component, not as a traversal. The normalized path resolves to .env, but the hook never normalized it. Any path that included ../ segments could bypass the protection entirely.
Both vulnerabilities had existed since the hooks were first written. Neither was caught by the test suite, which at that point had 381 passing assertions.
Fixing it
The JSON injection fix was straightforward. Every hook that produces JSON output now uses jq -n --arg instead of string interpolation:
jq -n --arg reason "$REASON" '{"decision": "block", "reason": $reason}'
jq handles escaping correctly regardless of what characters the variable contains. This change touched all four hook types: bash-guard, git-safe, branch-guard, and file-guard.
The path traversal fix required a normalize_path() function in bash. The function splits a path into segments, resolves . and .. references, and reassembles it. Then the hook checks the normalized path against protected patterns.
The bash 3.2 problem
This is where tests actually earned their keep.
macOS ships with bash 3.2, released in 2007. Bash 3.2 does not support negative array indexing. ${array[-1]} to get the last element, which works in bash 4.3+, produces a “bad array subscript” error in 3.2. The path normalization function used this syntax.
Bash 3.2 also treats empty arrays differently under set -u (the strict mode that catches undefined variables). "${array[@]}" on an empty array throws “unbound variable” in 3.2 but works fine in 4.x. The normalization function hit this on root-relative paths where the segment array starts empty.
Both bugs caused the path normalization to silently fail. On macOS, the traversal fix would not have worked at all. The hook would have crashed, and since hooks that crash fail open, the protection would have been absent.
I caught both issues because I wrote the tests before pushing the fix. The test for subdir/../.env failed on the first run. The error message pointed to the negative indexing. After fixing that, the empty-array expansion broke on a different test case. Both fixes are bash-3.2-compatible: use $((len-1)) instead of -1 for indexing, and "${array[@]+"${array[@]}"}" for safe expansion of potentially empty arrays.
Nine new test assertions specifically covering these security cases. Total test count: 390.
The review chain
What interests me about this sequence is the stacking.
A human contributor found one bug (the field name). He had the context to notice it because he was actually trying to use the hooks and they were not working. An AI reviewer found two more bugs (JSON injection, path traversal). It had no user context but could analyze the code patterns mechanically. Tests found two more bugs (bash 3.2 incompatibilities). They had no intelligence at all, just deterministic assertion checking.
Each layer caught things the others missed. chris-peterson would probably not have audited the JSON construction. Gemini would not have tested on bash 3.2. The tests would not have been written without the vulnerabilities to test for.
No single reviewer would have caught all five issues. The combination caught all of them in a single day.
The fail-open problem
The most important observation is not about any individual bug. It is about what happens when safety hooks fail silently.
A hook that crashes or produces malformed output does not block the operation. Claude Code treats a non-response as permission to proceed. This means a broken safety hook is worse than no safety hook. With no hook, you know you have no protection. With a broken hook, you believe you are protected when you are not.
Every one of these vulnerabilities had the same failure mode: the hook would silently stop working under specific conditions, and the user would have no indication that enforcement had stopped. A carefully crafted filename could bypass file protection. A path with ../ could reach files that were supposed to be off-limits. And on macOS, the traversal fix itself would have been dead on arrival.
The fix is not just patching the individual bugs. The fix is treating hook output as a security boundary and testing it accordingly. Which is what I should have been doing from the start.
Where it stands
Revenue: zero. Stars: 7. External contributors: 2.
chris-peterson’s PR is still open, waiting for Thomas to merge. The security fixes are already pushed to main. If you installed the hooks before March 11, update them. The JSON injection affected all four hook types, and the path traversal affected file-guard.