[BUG] PreToolUse permissionDecision: "allow" no longer suppresses prompt for Bash with dangerouslyDisableSandbox: true (2.1.116+ regression)

Resolved 💬 31 comments Opened Apr 22, 2026 by ww2283 Closed Jul 11, 2026

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 with dangerouslyDisableSandbox: true. All ran silently.
  • 2026-04-21 (2.1.117): same hook, same decisions, every dangerouslyDisableSandbox: true call 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: autoAllowBashIfSandboxed bypassed 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.

View original on GitHub ↗

31 Comments

github-actions[bot] · 2 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/36059
  2. https://github.com/anthropics/claude-code/issues/49525
  3. https://github.com/anthropics/claude-code/issues/37745

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

ww2283 · 2 months ago

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 PreToolUse hook decisions are merged. From the 2.1.118 binary:

// applied after hooks have produced `q.behavior` and `q.decisionReason`
if (H.dangerouslyDisableSandbox
    && q.behavior !== "deny"
    && q.behavior !== "ask"
    && !ok7(q.decisionReason)
    && !iL(H)
    && iL({ ...H, dangerouslyDisableSandbox: !1 }))
  return {
    behavior: "ask",
    decisionReason: { type: "sandboxOverride", reason: "dangerouslyDisableSandbox" },
    message: "Run outside of the sandbox"
  };
return q;

And ok7:

function ok7(H) {
  if (H?.type === "rule") return true;
  if (H?.type === "subcommandResults")
    return [...H.reasons.values()].every(_ => ok7(_.decisionReason));
  return false;
}

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:

  1. Migrate the policy decision into \permissions.allow\ rules (loses dynamic context, transcript-aware judging, etc.).
  2. Set \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.

ww2283 · 2 months ago

Follow-up while investigating workarounds for this regression: the excludedCommands parsing 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:

Iw8 = Cm_;
function Cm_(H){
  let _ = hn6(H);
  if (_ !== null) return { type: "prefix", prefix: _ };
  if ($A1(H)) return { type: "wildcard", pattern: H };
  return { type: "exact", command: H };
}
function hn6(H){ return H.match(/^(.+):\*$/)?.[1] ?? null }

So excludedCommands: ["git"] is parsed as type:"exact" and only matches the literal string git (a bare invocation with no arguments). Any actual usage like git status --short does not match. The user must write ["git:*"] to get a type:"prefix" rule that matches git 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 returned permissionDecision:"allow". With :* form the prefix matches, TW5 returns true, iL returns 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 with dangerouslyDisableSandbox:true (e.g., REPL sandbox-violation auto-retries).

The settings doc examples for excludedCommands use bare names — they probably should be updated to name:* form, or Iw8 should treat bare names as prefix-by-default. Either change would significantly reduce the visible blast radius of the gate regression while it gets fixed.

ww2283 · 2 months ago

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.

ok7 accepts only "rule" and "subcommandResults" (recursive). The full decisionReason.type enum from the binary is:

mX6 = ["rule","mode","subcommandResults","permissionPromptTool",
       "hook","asyncAgent","sandboxOverride","workingDir","safetyCheck",
       "classifier","other"]

So the gate fires identically for:

  • type:"hook" — third-party PreToolUse hooks
  • type:"classifier" — auto-mode classifier decisions
  • type:"mode" — auto-mode fast-paths (fastPath:"allowlist", fastPath:"acceptEdits")
  • type:"permissionPromptTool" — MCP permission prompt tools
  • type:"asyncAgent" — async-agent decisions

In other words: every approval source other than literal settings rules is downgraded to ask when dangerouslyDisableSandbox: true and 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, autoAllowBashIfSandboxed silences 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 (via excludedCommands + 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:

if (H.dangerouslyDisableSandbox
    && q.behavior !== "deny"   // any source's deny is honored
    && q.behavior !== "ask"
    && !ok7(q.decisionReason)  // but only "rule" allow is honored
    && !iL(H)
    && iL({...H, dangerouslyDisableSandbox:!1}))

Asymmetric: deny from a hook/classifier/mode is final, but allow from the same sources is overridden. The minimal fix is making ok7 accept 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 existing deny symmetry.

ww2283 · 2 months ago

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:

  • The decisionReason.type enum declaration.
  • UI render switch/case branches (Ol7 at offset 83028401: case "sandboxOverride": return "Requires permission to bypass sandbox"; oU5 at 83497003).

I can't find a single code path in 2.1.112 that actually returns {behavior: ..., decisionReason: {type: "sandboxOverride", ...}}. Grepping type:"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: allow is silently demoted to ask for dangerouslyDisableSandbox: true commands, contrary to the documented hook contract" — is correct but incomplete. The more precise framing is:

In 2.1.112, this demotion did not occur because the demotion code path did not exist. The sandboxOverride decision type was declared in the enum and handled by display code, but was never produced by the decision pipeline. In 2.1.116+, an emission path and a whitelist function (ok7/Tu7) that excludes type: "hook" were both introduced, activating what was previously dormant.

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:

  1. Be documented. The undocumented decision-reason types (sandboxOverride, asyncAgent, classifier, etc.) and their precedence interactions with hook output are load-bearing for anyone building hook-based permission systems.
  2. Be opt-in or deprecation-pathed rather than activated silently.
  3. Ideally surface a distinct decision or UI affordance — silently rewriting allow to ask masks the hook author's intent from the user.

Minimal fixes, in ascending invasiveness

  1. Document the current behavior. At minimum: update hook docs to state that permissionDecision: "allow" from a hook is demoted to "ask" when tool_input.dangerouslyDisableSandbox === true, and enumerate the decisionReason.type whitelist used by the sandbox-override gate.
  2. Whitelist "hook" in the gate — extend ok7/Tu7 to accept type === "hook" alongside "rule" and "subcommandResults". Preserves the security posture against asyncAgent/classifier/etc. while honoring the documented hook contract. This is also the smallest code change.
  3. Revert the activation and keep the decision type reserved for a future opt-in mechanism.

Reproduction

macOS ARM64 binaries, Bun-compiled Mach-O:

  • 2.1.112: 203,956,832 bytes.
  • 2.1.119: ~/.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!0 is name-agnostic across minifier renames and should locate the function on any version that has it.

ww2283 · 2 months ago

2.1.121 is still having this behavior

The parser is renamed (hn6 → Xl_/I08, Iw8 → YU5) but the semantics are identical:

function Xl_(H){                             // entry parser
  let _=r_8(H);                              // r_8 = match(/^(.+):\*$/)
  if(_!==null) return {type:"prefix",prefix:_};
  if(lN1(H))  return {type:"wildcard",pattern:H};
  return {type:"exact",command:H};
}

function YU5(H){                             // matcher
  ...
  case "exact": if (D === w.command) return true;   // still exact-only on bare entries
}
desiorac · 2 months ago

Confirmed on 2.1.117. The regression specifically affects hooks + dangerouslyDisableSandbox: true - permissionDecision: "allow" from a PreToolUse hook still works for regular Bash calls without sandbox bypass.

Workaround: migrate affected patterns to permissions.allow in .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.

ww2283 · 2 months ago

Not fixed in 2.1.122.

AU7 (the gate's allow-list discriminator) in 2.1.122:

function AU7(H) {
  if (H?.type === "rule") return true;
  if (H?.type === "subcommandResults")
    return [...H.reasons.values()].every(_ => AU7(_.decisionReason));
  return false;
}

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"}.

desiorac · 2 months ago

AU7 is one line from being fixed - adding H?.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.allow in .claude/settings.json still sidesteps it since those produce type:"rule" and pass AU7 cleanly.

ww2283 · 2 months ago

still not fixed in 2.1.123:

if(H.dangerouslyDisableSandbox && q.behavior!=="deny" && q.behavior!=="ask"
   && !$U7(q.decisionReason) && !fN(H) && fN({...H,dangerouslyDisableSandbox:!1}))
  return {behavior:"ask", decisionReason:{type:"sandboxOverride",...}, message:"Run outside of the sandbox"}

Prefix/exact parser in 2.1.123 unchanged: bare excludedCommands entries still parse as {type:"exact"}, only name:* parses as {type:"prefix"}.

ww2283 · 2 months ago

still not fixed in 2.1.126:

  1. No type:"hook" branch:
function yg7(H){
  if(H?.type==="rule")return!0;
  if(H?.type==="subcommandResults")return[...H.reasons.values()].every((_)=>yg7(_.decisionReason));
  return!1}
  1. The excludedCommands prefix/exact parser is unchanged — match(/^(.+):\*$/) still drives prefix vs. exact, so bare entries remain exact-match-only.
desiorac · 2 months ago

10 releases in and yg7 is still a two-liner fix away - adding H?.type === "hook" covers the hook trust gap, and the excludedCommands parser just needs a fallthrough to {type:"prefix"} for bare entries (not just name:* ones). The two bugs compound each other: even if yg7 got patched, users relying on bare excludedCommands entries for wildcard matching would still see exact-only behavior. Until both land, permissions.allow with explicit Bash(cmd:*) syntax in .claude/settings.json remains the only reliable workaround since those produce type:"rule" and pass the gate cleanly.

ww2283 · 2 months ago

not fixed 2.1.128

  1. Discriminator (now ed7, was nB7/AU7/$U7) — still only whitelists type==="rule" (and recursive subcommandResults). Hook-allow decisions still fall through to the override prompt.
function ed7(H){
  if(H?.type==="rule") return true;
  if(H?.type==="subcommandResults") return [...H.reasons.values()].every(_=>ed7(_.decisionReason));
  return false;
}
  1. excludedCommands parser (now Ti_, was Iw8/Cm_) — bare "git" still parses as {type:"exact"}, only "git:*" parses as {type:"prefix"}.
desiorac · 2 months ago

Ten releases, same two-liner fix outstanding - at this point the rename cadence (nB7AU7$U7yg7ed7) almost looks like minifier churn obscuring a deliberate non-fix, but still no release note rationalizing hook as a weaker trust boundary than rule. The compound effect you're tracking is the real blocker: even if ed7 gets H?.type === "hook", anyone relying on bare excludedCommands entries expecting wildcard behavior would still hit exact-match-only from Ti_. Explicit permissions.allow with Bash(cmd:*) syntax remains the only path that produces type:"rule" and actually passes ed7 cleanly - worth flagging this as a docs gap too, since nothing in the settings reference warns that excludedCommands bare entries don't behave like the allow-list name:* syntax.

ww2283 · 2 months ago

not fixed 2.1.129

renamed ed7 → Gc7, otherwise byte identical

desiorac · 2 months ago

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. Until H?.type === "hook" gets added as a branch, permissions.allow with explicit Bash(cmd:*) in .claude/settings.json remains the only reliable path around it.

ww2283 · 2 months ago

not fixed 2.1.132

Discriminator renamed Gc7 → _n7

ww2283 · 2 months ago

not fixed 2.1.136

_n7 → ao7; otherwise nothing changed.

ww2283 · 2 months ago

not fixed 2.1.138

Discriminator still ao7

ww2283 · 2 months ago

not fixed 2.1.139

ww2283 · 2 months ago

not fixed 2.1.141

ao7 → G6K, that's it

ww2283 · 1 month ago

not fixed 2.1.144

ww2283 · 1 month ago

not fixed 2.1.145

G6K → QqK

lambcode-unified · 1 month ago

Same regression appears to affect static allowlist rules in settings.json, not just hook-based permissionDecision: "allow" — suggesting a shared code path for the unsandboxed-Bash gate.

Repro

~/.claude/settings.json:

{
  "permissions": {
    "allow": ["Bash(gh project item-list:*)"]
  }
}

Tool call (after a sandboxed attempt failed with error connecting to api.github.com, model retries per the documented escape hatch):

{"name": "Bash", "input": {"command": "gh project item-list 1 --owner \"@me\" --limit 1", "dangerouslyDisableSandbox": true}}

Expected (per sandbox docs):

The retried command runs outside the sandbox, so it goes through the regular permission flow and requires your approval.

"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 allow decisions in the original report.

ww2283 · 1 month ago

not fixed 2.1.152

QqK → NPK, body byte-for-byte identical

ww2283 · 1 month ago

not fixed 2.1.153

NPK → g2K

ww2283 · 1 month ago

not fixed 2.1.157

Ram9199 · 1 month ago

@ww2283 this regression is a good example of why policy-hook decisions and final execution gates need a single auditable decision boundary.

If PreToolUse returns allow but 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:

  • hook_decision: allow
  • final_gate: ask_unsandboxed
  • final_decision: approval_required
  • reason: unsandboxed execution requires explicit confirmation

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

ww2283 · 1 month ago

not fixed 2.1.161

ww2283 · 1 month ago

not fixed 2.1.165

Re-verified with a clean isolated repro (temp project, no other hooks; only a PreToolUse Bash allow hook via --settings; default permission mode; Sonnet 4.6):

  • The model issued a Bash call with dangerouslyDisableSandbox: true.
  • The PreToolUse hook fired and returned {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}} (confirmed in the hook's own log).
  • The "Bash command (unsandboxed) — Do you want to proceed?" dialog still rendered anyway.
  • Approving it ran the command fine (HTTP 200).

So allow is 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.

github-actions[bot] · 4 days ago

Closing for now — inactive for too long. Please open a new issue if this is still relevant.