Claude Code 2.1.288 and the Error Path, Why Agent Permission Hooks Have to Fail Closed
A Friday release turned a broken hook into a blocked tool call. Your exit codes, timeouts, and release channel still decide whether the rest of your guardrails do anything.
Most agent guardrails get judged by what they block. The more revealing question is what they do when they break.
Claude Code 2.1.288, published to npm at 18:30 UTC on October 2, is mostly a long list of fixes. Read it with that question in mind and a pattern jumps out. Several of the most important lines describe a check that existed, worked in the normal case, and then did nothing at all when something unusual happened. The tool call went through. Nobody was asked.
That is the error path, and for agents it is where the real security lives.
Three fixes that share one shape
Here are the lines from the 2.1.288 changelog that matter most for anyone running guardrails on top of Claude Code.
First: "Fixed PreToolUse and PermissionRequest hooks being skipped when matching them failed or the tool's input could not be serialized to JSON; the call is now blocked." Before this release, if your hook could not be matched against a tool call, or the input was too strange to turn into JSON, the hook was skipped, and the call was left to the normal permission flow as if you had written no hook at all. Now it stops.
Second: "Fixed a dangerous rm (such as one on / or the home directory) inside a bash -c or sh -c script running without a prompt in bypassPermissions mode or under a shell allow rule." The original issue (#96300), filed against version 2.1.280, shows the mechanics. A plain rm -rf on the working directory stopped for confirmation under bypassPermissions. The same command wrapped as sh -c "rm -rf <cwd>" or bash -c ran and deleted the files. The checker unwrapped subshells and command substitutions looking for rm, but when rm arrived as a string argument to sh -c, it only saw sh. In bypass mode, that check was the only prompt standing between an agent and your home directory.
Third, smaller but the same species: a BASHPID assignment whose value the shell would evaluate as arithmetic used to be allowed silently. Now it prompts.
Each of these is a guard that worked on the input its authors pictured and waved through the input they did not. That is the definition of failing open.
Why the error path matters more for agents than for people
A human at a terminal who types sh -c "rm -rf ~" has chosen to do something unwise. An agent that emits it might be following a prompt injection buried in a README it read three tool calls ago. The guard exists for the second case, and the second case is exactly the one where inputs get weird.
Attackers do not send the clean case. They send the wrapped command, the oversized payload, the tool input with a character your serializer chokes on. A guard that holds on the happy path and folds on the strange one is a guard tuned against honest mistakes and useless against hostile ones.
So when I read a changelog like this one, I am less interested in the new features than in every sentence shaped like "X was skipped when Y failed." Each one marks a place where the system used to trust its own failure.
The fail-open defaults that are still there, on purpose
Here is the part worth slowing down for. 2.1.288 closes the matching and serialization gap, but the hooks reference still documents several paths where a hook that misbehaves lets the call through. These are design choices, not bugs, and you need to know them.
Exit code 1 does not block. The docs are blunt: "For most hook events, exit code 2 is the only exit code that blocks through the code alone. Without valid JSON on stdout, Claude Code treats exit code 1 as a non-blocking error and proceeds with the action, even though 1 is the conventional Unix failure code." If your PreToolUse script crashes, hits a missing dependency, or ends on a failing grep, it probably exits 1, and the tool runs.
A timed-out command hook does not block. For PreToolUse, "a timed-out command, http, or mcp_tool hook doesn't block the tool call. The call continues through the normal permission flow, so don't count on a stalled hook to act as a gate." There is one exception worth knowing: an Agent SDK callback hook that exceeds its timeout does block the call. Same event, two hook families, opposite failure behavior.
Regex matchers are unanchored. A matcher containing anything beyond letters, digits, and a few separators becomes a JavaScript regular expression tested with RegExp.prototype.test, "which succeeds on a match anywhere in the value." The docs give the example themselves: Edit.* matches both Edit and NotebookEdit. Over-matching is the safer mistake for a blocking hook, but the same looseness on an allowing hook widens what you approved.
PermissionRequest ignores exit code 2. For that event, "Exit code 2 isn't honored ... and the permission flow proceeds unchanged." You deny through the decision object in JSON output. A hook author who learned "exit 2 means no" on PreToolUse and reused the script here has built a hook that cannot say no.
None of this is hidden. It is all in the reference. The trouble is that each rule is reasonable on its own, and together they mean a security hook is only fail-closed if you deliberately make it so.
The other half of this release: scope that grows mid-call
One more 2.1.288 line belongs in this story: "Added a re-authenticate prompt when an MCP server asks for more OAuth scope during a tool call."
The MCP authorization spec (2025-11-25) defines this case under "Scope Challenge Handling." A server that receives a token with too little scope "SHOULD" answer with HTTP 403 Forbidden and a WWW-Authenticate header carrying error="insufficient_scope" and the scopes it needs. Clients acting for a user "SHOULD attempt the step-up authorization flow," and should cap retries rather than loop.
That is a reasonable protocol design. It lets a server start with read access and ask for write access only when a task needs it. It also means the set of things an MCP server can do on your behalf can grow in the middle of an agent's run. A client that handled the upgrade silently would turn "read my files" into "read and write my files" without a person seeing the change. Putting a prompt in front of it is the same instinct as the hook fix: when the boundary moves, stop and ask.
The release channel is a security setting
Here is the uncomfortable footnote. On the morning of October 3, the npm dist-tags showed latest at 2.1.288 and stable at 2.1.285.
The setup docs describe stable as "a version that is typically about one week old, skipping releases with major regressions." That is a sensible trade for most teams. Fewer surprises, fewer broken mornings. It also means a team that chose stability is, for about a week, running the version where a failed hook match was skipped and sh -c walked past the rm guard.
I do not think the answer is "everyone move to latest." I think the answer is to stop treating the channel as a convenience preference and start treating it like any other security control: a choice you make on purpose, with a floor.
Put this into practice
The lowest-friction moves, in the order I would make them:
1. Pin a floor without abandoning stable. The minimumVersion setting stops auto-updates and claude update from installing anything below a value, and the docs say moving to stable "does not downgrade you if you are already on a newer 'latest' build."
{
"autoUpdatesChannel": "stable",
"minimumVersion": "2.1.288"
}
For an organization, set the same in managed settings, where it "enforces an organization-wide minimum that user and project settings cannot override." If you need Claude Code to refuse to start below a version, that is requiredMinimumVersion.
2. Make every blocking hook end in an explicit decision. Wrap your PreToolUse script so unexpected failures exit 2 instead of 1. In bash, an EXIT trap that maps any non-zero status to 2 catches more than an ERR trap does, including unbound variables and failures inside functions:
#!/usr/bin/env bash
set -Eeuo pipefail
trap 'rc=$?; if [ $rc -ne 0 ]; then echo "guard: blocking (exit $rc)" >&2; exit 2; fi' EXIT
# ... your checks; call exit 2 to block on purpose ...
exit 0
I ran that skeleton against a failing command, an unbound variable, a failing function, a failing command substitution, and a jq parse error, and each ended in exit 2. A hook killed by its timeout never reaches the trap, which is the next point.
Then test the failure path on purpose. Delete a dependency, feed it malformed JSON, and confirm the tool call stops.
3. Do not use a timeout as a gate. Keep command hooks fast enough that they never hit their timeout, because a stalled one lets the call through. If a check genuinely needs to be slow and must block on timeout, the docs point to Agent SDK callback hooks as the family that fails closed.
4. Anchor your matchers. Write ^Bash$ rather than Bash.* when you mean one tool, and read every regex matcher asking what else it hits.
5. Audit any PermissionRequest hook for exit-code logic. If it tries to deny with exit 2, rewrite it to return a decision with behavior: "deny".
6. Re-run your own wrapper tests. Try sh -c and bash -c versions of whatever your guards are meant to stop, on the version you actually run.
Honest limitations
A few things this piece cannot tell you.
The changelog has no dates and does not say when each fail-open path was introduced. Issue #96300 names 2.1.280, but I could not establish how far back the hook-matching gap goes, so I cannot tell you how long you were exposed.
I have not tested every hook family against every failure mode. The behaviors above come from the published reference and changelog. Read the reference for the version you run, because these rules have changed before and will again.
The rm fix narrows one class of wrapper. It does not make bypassPermissions safe. Commands can delete data through find -delete, a Python one-liner, or a script written to disk and run later, and no pattern-based guard catches all of them. The sandboxing layer and a disposable environment do more for you there than any hook.
And a fail-closed hook has a cost. A guard that blocks on every internal error will block real work when it breaks, which pushes people to disable it. Fail closed, then make sure the guard is boring and well-tested enough that it rarely fails at all.
What to do with this
Pick one guard you rely on today and break it on purpose. Kill its dependency, make it slow, hand it a wrapped command. Watch what the agent does next.
If the tool call goes through, you have learned something more useful than any feature announcement. If it stops, you have a guardrail. The difference between those two outcomes is the error path, and it is yours to decide.
Sources: Claude Code changelog · npm registry · Hooks reference · Advanced setup and release channels · Issue #96300 · MCP authorization spec 2025-11-25
Medium metadata
- Title: Claude Code 2.1.288 and the Error Path, Why Agent Permission Hooks Have to Fail Closed
- Subtitle: A Friday release turned a broken hook into a blocked tool call. Your exit codes, timeouts, and release channel still decide whether the rest of your guardrails do anything.
- Tags: Claude Code, AI Agents, AI Security, Developer Tools, MCP
- Canonical URL: fervorai.dev article URL
- Reading time: about 8 minutes