[BUG] PreToolUse permissionDecision: "allow" no longer suppresses prompt for Bash with dangerouslyDisableSandbox: true (2.1.116+ regression)
Summary
In 2.1.116 / 2.1.117, a PreToolUse hook returning permissionDecision: "allow" for a Bash tool call with dangerouslyDisableSandbox: true no longer suppresses the "Bash command (unsandboxed) — Do you want to proceed?" confirmation. In 2.1.112, the identical hook output and identical tool input ran silently. The hooks docs state unambiguously that "allow" skips the permission prompt, with no carve-out for unsandboxed Bash calls.
This breaks the "hook-gated autonomy" pattern where a local policy hook (LLM judge, rule-based approver, etc.) decides whether a command may run unsandboxed and returns allow to let it through without user interaction.
Repro
Minimal PreToolUse hook that always allows (save as ~/.claude/hooks/always_allow.py and wire it in settings.json):
#!/usr/bin/env python3
import json, sys
data = json.load(sys.stdin)
if data.get("tool_name") == "Bash":
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "allowed by policy hook"
},
"suppressOutput": True
}))
sys.exit(0)
Tool call (via any agent or stream-json -p):
{"name": "Bash", "input": {"command": "hostname; sysctl hw.model", "dangerouslyDisableSandbox": true}}
Expected (and 2.1.112 actual)
Command runs silently. No prompt. Output goes straight back to the model.
Per https://code.claude.com/docs/en/hooks:
"allow" skips the permission prompt.
No caveat about sandbox state.
Actual on 2.1.116 / 2.1.117
A "Bash command (unsandboxed) — Do you want to proceed? 1. Yes / 2. No" confirmation fires. Hook's allow is effectively ignored for the unsandboxed-execution gate.
Evidence
- Same machine (macOS 15, arm64), same settings, same hook, same tool input.
- Downgrade path:
ln -sfn ~/.local/share/claude/versions/2.1.112 ~/.local/bin/claude→ silent run, as documented. - Re-upgrade to 2.1.117 → prompt appears.
- In both versions, the hook actually executes and emits
permissionDecision: allow(confirmable via a debug log or by observing the Bash tool output when confirmed). Only the downstream suppression differs.
Decision-log cross-reference (from an internal policy hook that records every approve decision, across ~4,300 Bash tool_use entries in stored transcripts):
- 2026-04-20 (2.1.112): 85 / 96 matched
llm_approved*decisions were Bash calls withdangerouslyDisableSandbox: true. All ran silently. - 2026-04-21 (2.1.117): same hook, same decisions, every
dangerouslyDisableSandbox: truecall now prompts.
Related but distinct issues
- #36176 (2.1.79, still open): "Don't ask again" on the unsandboxed prompt is a no-op. That bug assumes the unsandboxed prompt is a separate permanent gate. This issue claims the opposite: in 2.1.112 the gate was fully suppressible by hook
allow, matching the documented contract. The regression is that 2.1.116/117 made it unsuppressible. - #43713:
autoAllowBashIfSandboxedbypassed for shell expansions. Related sandbox/permission plumbing regression, different symptom.
Environment
- Claude Code 2.1.117 (regression), last-known-good 2.1.112
- macOS 15.1, arm64 (MacBookPro18,3)
- Installed via native build at
~/.local/share/claude/versions/
Suggested fix
Honor PreToolUse permissionDecision: "allow" for the unsandboxed-execution gate as well as the permission gate, matching both the docs and the 2.1.112 behavior. If a separate gate is intentional, document it and expose a hook-level opt-in (e.g. permissionDecision: "allow_unsandboxed") so policy hooks can still signal full approval.
31 Comments
Found 3 possible duplicate issues:
This issue will be automatically closed as a duplicate in 3 days.
🤖 Generated with Claude Code
Still reproduces in 2.1.118. Posting code-level evidence from the binary to help triage.
The unsandboxed prompt fires from a finalizer that runs after
PreToolUsehook decisions are merged. From the 2.1.118 binary:And
ok7:The \
decisionReason.type\for hook-produced decisions is \\"hook\"\(per the schema enum \[\"rule\",\"mode\",\"subcommandResults\",\"permissionPromptTool\",\"hook\",\"asyncAgent\",\"sandboxOverride\",\"workingDir\",\"safetyCheck\",\"classifier\",\"other\"]\). \ok7\only returns true for \\"rule\"\(and recursively for \\"subcommandResults\"\whose subcommands are all rules). So:permissions.allow\rule match → \type:\"rule\"\→ bypasses the override gate → silent run ✅PreToolUse\hook returning \permissionDecision:\"allow\"\→ \type:\"hook\"\→ gate fires → \\"Run outside of the sandbox\"\prompt ❌This is the asymmetry. The hooks docs (https://code.claude.com/docs/en/hooks) state \
\"allow\"\skips the permission prompt with no carve-out, so this is either a missed branch in the override logic or an undocumented intentional restriction. Either way, the third-party \"policy hook decides; Claude trusts it\" pattern is broken for any command that is sandbox-eligible but invoked with \dangerouslyDisableSandbox: true\.Workarounds available today:
permissions.allow\rules (loses dynamic context, transcript-aware judging, etc.).sandbox.allowUnsandboxedCommands: false\— flips \iL(H)\to true, the \!iL(H)\guard fails, the override gate is skipped. But this also forces every command (including those legitimately in \excludedCommands\) through the sandbox, breaking them. Not viable.A minimal fix would extend \
ok7\(or the override gate condition) to honor \type:\"hook\"\when the hook explicitly returned \\"allow\"\— analogous to how \\"deny\"\from a hook is already trusted absolutely on the same line above.Follow-up while investigating workarounds for this regression: the
excludedCommandsparsing has an exact-vs-prefix subtlety that's worth flagging because it amplifies this bug for everyone using the documented bare-command form.From the v2.1.118 binary:
So
excludedCommands: ["git"]is parsed astype:"exact"and only matches the literal stringgit(a bare invocation with no arguments). Any actual usage likegit status --shortdoes not match. The user must write["git:*"]to get atype:"prefix"rule that matchesgit anything.Why this matters for this issue: when
TW5(the excludedCommands check) returns false,iL({...H, dangerouslyDisableSandbox:false})returns true, so the override gate's last condition is satisfied and the prompt fires — even when the hook returnedpermissionDecision:"allow". With:*form the prefix matches,TW5returns true,iLreturns false, and the gate is bypassed. So users with["git", "gh", "rm", ...]see this regression on practically every git/gh/rm call, while users with["git:*", "gh:*", "rm:*", ...]see it only on the long tail of non-excluded commands invoked withdangerouslyDisableSandbox:true(e.g., REPL sandbox-violation auto-retries).The settings doc examples for
excludedCommandsuse bare names — they probably should be updated toname:*form, orIw8should treat bare names as prefix-by-default. Either change would significantly reduce the visible blast radius of the gate regression while it gets fixed.One more observation while reading the surrounding code in v2.1.118 — this regression isn't hook-specific, it's symmetric across every non-rule decision producer. The gate just bites hook-based policies most because of how those tend to interact with
dangerouslyDisableSandbox.ok7accepts only"rule"and"subcommandResults"(recursive). The fulldecisionReason.typeenum from the binary is:So the gate fires identically for:
type:"hook"— third-party PreToolUse hookstype:"classifier"— auto-mode classifier decisionstype:"mode"— auto-mode fast-paths (fastPath:"allowlist",fastPath:"acceptEdits")type:"permissionPromptTool"— MCP permission prompt toolstype:"asyncAgent"— async-agent decisionsIn other words: every approval source other than literal settings rules is downgraded to
askwhendangerouslyDisableSandbox: trueand the command is sandbox-eligible.Auto-mode users mostly don't notice this regression because auto-mode doesn't have an analog to a hook proactively setting
dangerouslyDisableSandbox: true. The model in auto-mode runs Bash sandboxed by default,autoAllowBashIfSandboxedsilences it, and the gate's first precondition (H.dangerouslyDisableSandbox) is never true. The bug is invisible from the auto-mode telemetry path. Hook-based policies hit it constantly because they often deliberately route certain command families unsandboxed (viaexcludedCommands+dangerouslyDisableSandbox: true) so that operations needing network/filesystem outside the sandbox read scope can run at all.Notice the gate condition already trusts
q.behavior === "deny"from any source unconditionally:Asymmetric:
denyfrom a hook/classifier/mode is final, butallowfrom the same sources is overridden. The minimal fix is makingok7accept the matching set of trusted approval sources — at minimum"hook"(since hooks are explicitly user-configured policy), and arguably"classifier"and"mode"(since the user opted into auto-mode and accepted its judgment). That parallels the existingdenysymmetry.Additional forensic evidence: this regression is an activation of dormant code, not a semantic change to an existing gate.
Binary-diffing 2.1.112 (last known working version for my setup) against 2.1.119 surfaces a structural change that I haven't seen mentioned in release notes or prior comments on this issue.
Finding 1 — the sandboxOverride whitelist function doesn't exist in 2.1.112
Signature pattern
function <name>(H){if(H?.type==="rule")return!0;if(H?.type==="subcommandResults")return[...H.reasons.values()].every((_)=>name(_.decisionReason));return!1}:| Version | Hits | Minified name |
|---|---|---|
| 2.1.112 | 0 | (function does not exist) |
| 2.1.118 | 2 |
ok7|| 2.1.119 | 2 |
Tu7|Body byte-identical across 2.1.116/117/118/119. The function was introduced between 2.1.112 and 2.1.116.
Finding 2 — no code in 2.1.112 emits
decisionReason.type: "sandboxOverride"The string
"sandboxOverride"exists in 2.1.112, but only in:decisionReason.typeenum declaration.Ol7at offset 83028401:case "sandboxOverride": return "Requires permission to bypass sandbox";oU5at 83497003).I can't find a single code path in 2.1.112 that actually returns
{behavior: ..., decisionReason: {type: "sandboxOverride", ...}}. Greppingtype:"sandboxOverride"yields zero emission sites.String-count deltas support this:
| Token | 2.1.112 | 2.1.119 |
|---|---|---|
|
"sandboxOverride"| 8 | 18 ||
sandboxOverride(no quotes) | 9 | 25 |The emission sites, the caller of the whitelist, and the whitelist itself all appear to have been added post-2.1.112.
Why this reframes the issue
The current framing — "hook-emitted
permissionDecision: allowis silently demoted toaskfordangerouslyDisableSandbox: truecommands, contrary to the documented hook contract" — is correct but incomplete. The more precise framing is:This materially narrows the public hook contract (docs still state
permissionDecision: "allow""skips the permission prompt" with no sandbox carve-out) without release-note acknowledgment. If the intent was to add a security invariant — "third-party hooks must not silently authorize sandbox escape without user consent" — that's a defensible position, but it should:sandboxOverride,asyncAgent,classifier, etc.) and their precedence interactions with hook output are load-bearing for anyone building hook-based permission systems.allowtoaskmasks the hook author's intent from the user.Minimal fixes, in ascending invasiveness
permissionDecision: "allow"from a hook is demoted to"ask"whentool_input.dangerouslyDisableSandbox === true, and enumerate thedecisionReason.typewhitelist used by the sandbox-override gate."hook"in the gate — extendok7/Tu7to accepttype === "hook"alongside"rule"and"subcommandResults". Preserves the security posture againstasyncAgent/classifier/etc. while honoring the documented hook contract. This is also the smallest code change.Reproduction
macOS ARM64 binaries, Bun-compiled Mach-O:
~/.local/share/claude/versions/2.1.119, 213,404,000 bytes.The signature regex
function [A-Za-z_$][A-Za-z0-9_$]*\(H\)\{if\(H\?\.type==="rule"\)return!0is name-agnostic across minifier renames and should locate the function on any version that has it.2.1.121 is still having this behavior
The parser is renamed (hn6 → Xl_/I08, Iw8 → YU5) but the semantics are identical:
Confirmed on 2.1.117. The regression specifically affects hooks +
dangerouslyDisableSandbox: true-permissionDecision: "allow"from aPreToolUsehook still works for regular Bash calls without sandbox bypass.Workaround: migrate affected patterns to
permissions.allowin.claude/settings.json(e.g.,"Bash(your-cmd:*)") - sidesteps the hook decision path entirely and survives the regression.Worth checking whether the 2.1.116 sandbox change moved permission gating to after hook evaluation in the execution path - that would explain why the hook's allow decision gets ignored specifically when
dangerouslyDisableSandbox: true.Not fixed in 2.1.122.
AU7 (the gate's allow-list discriminator) in 2.1.122:
Only type:"rule" (settings allow-rule) bypasses the override gate. type:"hook" still returns false, so the gate fires and the user gets the "Run outside of the sandbox" prompt despite hook approval.
The excludedCommands parser (hn6) is also unchanged: bare "cmd" still becomes {type:"exact"}, only "cmd:*" becomes {type:"prefix"}.
AU7is one line from being fixed - addingH?.type === "hook"to the discriminator would close it. The fact this has shipped through 5+ releases unchanged does make you wonder if it's intentional (hooks as a weaker trust boundary than explicit rules), but the lack of any release note or doc change arguing for that interpretation leans me toward oversight.permissions.allowin.claude/settings.jsonstill sidesteps it since those producetype:"rule"and passAU7cleanly.still not fixed in 2.1.123:
Prefix/exact parser in 2.1.123 unchanged: bare excludedCommands entries still parse as {type:"exact"}, only name:* parses as {type:"prefix"}.
still not fixed in 2.1.126:
10 releases in and
yg7is still a two-liner fix away - addingH?.type === "hook"covers the hook trust gap, and the excludedCommands parser just needs a fallthrough to{type:"prefix"}for bare entries (not justname:*ones). The two bugs compound each other: even ifyg7got patched, users relying on bareexcludedCommandsentries for wildcard matching would still see exact-only behavior. Until both land,permissions.allowwith explicitBash(cmd:*)syntax in.claude/settings.jsonremains the only reliable workaround since those producetype:"rule"and pass the gate cleanly.not fixed 2.1.128
Ten releases, same two-liner fix outstanding - at this point the rename cadence (
nB7→AU7→$U7→yg7→ed7) almost looks like minifier churn obscuring a deliberate non-fix, but still no release note rationalizing hook as a weaker trust boundary thanrule. The compound effect you're tracking is the real blocker: even ifed7getsH?.type === "hook", anyone relying on bareexcludedCommandsentries expecting wildcard behavior would still hit exact-match-only fromTi_. Explicitpermissions.allowwithBash(cmd:*)syntax remains the only path that producestype:"rule"and actually passesed7cleanly - worth flagging this as a docs gap too, since nothing in the settings reference warns thatexcludedCommandsbare entries don't behave like the allow-listname:*syntax.not fixed 2.1.129
renamed ed7 → Gc7, otherwise byte identical
ed7 → Gc7- sixth rename, zero diff. At this point the minifier churn is doing a better job of documenting the non-fix than any changelog entry. The logic is still byte-identical:type === "rule"passes,type === "hook"falls through to the sandbox override prompt. UntilH?.type === "hook"gets added as a branch,permissions.allowwith explicitBash(cmd:*)in.claude/settings.jsonremains the only reliable path around it.not fixed 2.1.132
Discriminator renamed Gc7 → _n7
not fixed 2.1.136
_n7 → ao7; otherwise nothing changed.
not fixed 2.1.138
Discriminator still ao7
not fixed 2.1.139
not fixed 2.1.141
ao7 → G6K, that's it
not fixed 2.1.144
not fixed 2.1.145
G6K → QqK
Same regression appears to affect static allowlist rules in
settings.json, not just hook-basedpermissionDecision: "allow"— suggesting a shared code path for the unsandboxed-Bash gate.Repro
~/.claude/settings.json:Tool call (after a sandboxed attempt failed with
error connecting to api.github.com, model retries per the documented escape hatch):Expected (per sandbox docs):
"Regular permission flow" should consult the allowlist; the matching
Bash(gh project item-list:*)rule should auto-approve.Actual (Claude Code 2.1.x, macOS,
allowUnsandboxedCommands: true):"Bash command (unsandboxed) — Do you want to proceed?" prompt fires every time, regardless of allowlist match.
The unsandboxed gate appears to ignore the static allowlist the same way it ignores hook
allowdecisions in the original report.not fixed 2.1.152
QqK → NPK, body byte-for-byte identical
not fixed 2.1.153
NPK → g2K
not fixed 2.1.157
@ww2283 this regression is a good example of why policy-hook decisions and final execution gates need a single auditable decision boundary.
If
PreToolUsereturnsallowbut a later unsandboxed Bash gate can still override it, the system probably needs to surface that as a distinct decision state rather than making the hook appear ignored. Something like:That would let local policy engines stay useful without hiding the fact that a higher-risk execution gate overrode them. We have been exploring this exact decision/audit split in Agent_Sudo: https://github.com/Kisyntra/Agent_Sudo
not fixed 2.1.161
not fixed 2.1.165
Re-verified with a clean isolated repro (temp project, no other hooks; only a PreToolUse
Bashallow hook via--settings; default permission mode; Sonnet 4.6):dangerouslyDisableSandbox: true.{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}(confirmed in the hook's own log).HTTP 200).So
allowis honored for execution but does not suppress the dialog — the regression is unchanged.Worth noting for repro design: in headless
claude -p(no dialog UI) the allow decision is honored and the command runs without blocking, while a control with no allow is blocked. So the bug is specific to the interactive prompt path — a headless test cannot observe it and will look "fixed." The interactive dialog is the only faithful repro.Closing for now — inactive for too long. Please open a new issue if this is still relevant.