[BUG] PreToolUse command hook permissionDecision: "allow" never suppresses the Bash prompt; if field never matches

Open 💬 0 comments Opened Jul 13, 2026 by ddyer-kml

What's Wrong?

A PreToolUse hook (type: "command", matcher "Bash") that returns hookSpecificOutput.permissionDecision: "allow" does not suppress the interactive permission prompt for the matching Bash call. The tool call is still denied/prompted exactly as if no hook were configured at all, even with zero competing deny/ask rules from any settings source.

Per the docs (https://code.claude.com/docs/en/hooks-guide.md), this is the documented contract, quoted verbatim:

"allow": skip the interactive permission prompt. Deny and ask rules, including enterprise managed deny lists, still apply
Returning "allow" skips the interactive prompt but doesn't override permission rules... This means deny rules from any settings scope, including managed settings, always take precedence over hook approvals.

There is no documented precondition on permission mode or any required opt-in setting for "allow" to take effect. I tested in a clean sideload with no other settings files contributing deny/ask rules, so there was nothing to "take precedence" — "allow" should have worked per the docs above, and did not.

Separately (may be a related defect, may be distinct — see below): the hook's if field never matches, in a way that also contradicts the documented contract at https://code.claude.com/docs/en/hooks-guide.md#filter-by-tool-name-and-arguments-with-the-if-field:

The if field requires Claude Code v2.1.85 or later. Earlier versions ignore it and run the hook on every matched call. The filter also fails open, running your hook regardless of pattern, when the Bash command can't be parsed.

I'm on 2.1.207 (well past the 2.1.85 gate), and the test command (docker images -a) is a plain literal with no $()/backticks/$VAR — trivially parseable, structurally identical to the docs' own worked example (Bash(git *) matching git push). Instead of matching (or, worst case, "failing open" per the documented fallback), the hook simply never fires — behaving as if the entire hook entry were silently dropped.

What Should Happen?

  1. A PreToolUse hook returning permissionDecision: "allow" should skip the interactive prompt and let the Bash call proceed, when no deny/ask rule from any settings scope matches.
  2. A hook's if: "Bash(docker images *)" should match docker images -a (wildcard) and if: "Bash(docker images -a)" should match the identical literal command (exact), per the documented permission-rule syntax — or at minimum fail open per the documented fallback, not silently never fire.

Steps to Reproduce

  1. Create a plugin directory with a minimal .claude-plugin/plugin.json and this hooks/hooks.json:
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "echo '{\"hookSpecificOutput\": {\"hookEventName\": \"PreToolUse\", \"permissionDecision\": \"allow\", \"permissionDecisionReason\": \"test\"}}'"
          }
        ]
      }
    ]
  }
}
  1. Run, from any directory with no other conflicting settings.json allow/deny/ask rules:
claude -p --permission-mode manual --output-format json --plugin-dir /path/to/plugin-dir "Use the Bash tool to run exactly: docker images -a"
  1. Inspect the JSON result's permission_denials array and result text.

Expected: permission_denials is empty; the command runs.
Actual: permission_denials contains the docker images -a call; result says permission wasn't granted — identical to running with no hook/plugin at all.

Ruling out other explanations

  • Hook execution itself works: swapping only permissionDecision to "deny" in the exact same hook reliably blocks the call and surfaces the custom permissionDecisionReason in the result text. So hook dispatch, stdout JSON parsing, and "deny" handling all work correctly — only "allow" fails.
  • Not a plugin-name collision: reproduces identically whether the sideloaded plugin's name in plugin.json collides with an already-installed marketplace plugin or is renamed to something unique.
  • Not a script/stdin-parsing issue: reproduces with a static inline echo (no external script file, no cat/stdin read involved).
  • Not a --permission-mode interaction: reproduces identically with --permission-mode manual explicitly set and with the flag omitted entirely (session default).
  • if behavior: adding "if": "Bash(docker images *)" (wildcard) or "if": "Bash(docker images -a)" (exact) to the same hook causes it to never fire at all for docker images -a, rather than matching or failing open per the documented fallback.

Error Messages/Logs

No error surfaces — the failure is silent. --debug hooks produced no additional hook-loading/evaluation output in -p (headless) mode that would explain the mismatch.

Representative JSON result (allow case, denied anyway):

{
  "result": "It looks like the permission prompt for this Bash command wasn't approved. Let me know if you'd like to grant permission and try again.",
  "permission_denials": [
    {
      "tool_name": "Bash",
      "tool_use_id": "toolu_01CHsRXhf8b2dBJQmREaqzrR",
      "tool_input": { "command": "docker images -a", "description": "List all Docker images" }
    }
  ]
}

Claude Model

Sonnet (default) — confirmed via the API usage in each test's JSON output (claude-sonnet-5).

Is this a regression?

I don't know — I only tested on the current latest version and did not bisect against older releases.

Claude Code Version

2.1.207 (Claude Code) — confirmed latest via claude update: "Claude Code is up to date (2.1.207)"

Platform

Anthropic API (no CLAUDE_CODE_USE_BEDROCK/CLAUDE_CODE_USE_VERTEX set, no direct ANTHROPIC_API_KEY — standard OAuth login)

Operating System

macOS (26.4, Darwin 25.4.0)

Terminal/Shell

iTerm2, zsh — though the repro itself runs via claude -p (non-interactive), invoked from within a separate parent Claude Code session running in iTerm2.

Additional Information

  • This blocks the documented use case (plugins docs, hooks-guide) of a plugin shipping a PreToolUse hook to reduce permission-prompt friction for known-safe commands (e.g. read-only git status, docker images, aws sts get-caller-identity) without requiring every installer to hand-edit their own settings.json. As observed, hooks can currently only ever narrow permissions (deny), never relax them (allow), which contradicts the documented "allow" contract quoted above.
  • Possibly related, filed separately since the repro shape differs (this one is type: "command" and 100% deterministic rather than intermittent, and doesn't involve an agent-dispatch subagent): #75686 (type: "agent" hooks: if: nondeterminism + ALLOW→deny conversion).
  • Also possibly relevant as a broader tracking issue: #30519 (permission-matching bugs generally).
  • Full sideload testing was done against a real application repo to confirm this wasn't an artifact of a synthetic test environment; happy to provide the full transcript/JSON outputs for each step above on request.

View original on GitHub ↗