[BUG] PreToolUse command hook permissionDecision: "allow" never suppresses the Bash prompt; if field never matches
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?
- A
PreToolUsehook returningpermissionDecision: "allow"should skip the interactive prompt and let theBashcall proceed, when nodeny/askrule from any settings scope matches. - A hook's
if: "Bash(docker images *)"should matchdocker images -a(wildcard) andif: "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
- Create a plugin directory with a minimal
.claude-plugin/plugin.jsonand thishooks/hooks.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "echo '{\"hookSpecificOutput\": {\"hookEventName\": \"PreToolUse\", \"permissionDecision\": \"allow\", \"permissionDecisionReason\": \"test\"}}'"
}
]
}
]
}
}
- Run, from any directory with no other conflicting
settings.jsonallow/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"
- Inspect the JSON result's
permission_denialsarray andresulttext.
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
permissionDecisionto"deny"in the exact same hook reliably blocks the call and surfaces the custompermissionDecisionReasonin 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
nameinplugin.jsoncollides 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, nocat/stdin read involved). - Not a
--permission-modeinteraction: reproduces identically with--permission-mode manualexplicitly set and with the flag omitted entirely (session default). ifbehavior: adding"if": "Bash(docker images *)"(wildcard) or"if": "Bash(docker images -a)"(exact) to the same hook causes it to never fire at all fordocker 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
PreToolUsehook to reduce permission-prompt friction for known-safe commands (e.g. read-onlygit status,docker images,aws sts get-caller-identity) without requiring every installer to hand-edit their ownsettings.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.