Feature request: PreToolUse hooks need a silent-deny permission decision for non-interactive / dontAsk sessions
Summary
PreToolUse hooks currently have three outcomes: silent allow (exit 0, no output), ask (exit 0 + permissionDecision: "ask" JSON), and hard error (exit 2). There is no way to silently deny a tool call from a hook without either blocking for user input or raising an error. This creates a deadlock in scheduled / autonomous sessions running under dontAsk mode.
---
Use case
Scheduled agents run fully autonomously on a cron or on-demand basis, with no user present. The typical harness for such an agent uses:
{
"permissions": {
"defaultMode": "dontAsk",
"allow": ["mcp__some_tool__read_channel", ...]
}
}
dontAsk correctly silences permission prompts for tools not in allow — the harness denies them and returns an error message to the model, which can then adapt. This is the right behaviour for unattended runs: fail fast and continue, never block.
Now suppose you want parameter-level access control for a listed tool. Example: read_channel is allowed, but only for a specific set of channel IDs. You write a PreToolUse hook that inspects the channel_id argument:
data = json.load(sys.stdin)
channel_id = data["tool_input"].get("channel_id", "")
if channel_id in ALLOWED_CHANNELS:
sys.exit(0) # silent allow
# channel not in allowlist — what now?
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "ask",
"permissionDecisionReason": f"Channel '{channel_id}' is not in the allowlist."
}
}))
sys.exit(0)
The hook returns "ask" because there is no "deny" option. In an interactive session this is fine — the user sees a prompt, can approve the new channel, and the run continues. But in an autonomous scheduled session, "ask" overrides dontAsk and the entire run blocks indefinitely, waiting for a user who is not present.
---
The gap
| Desired outcome | Currently available? |
|---|---|
| Allow the call silently | ✅ exit 0, no output |
| Prompt the user | ✅ exit 0 + permissionDecision: "ask" |
| Hard error (aborts the tool call with an error string) | ✅ exit 2 |
| Silently deny (same behaviour as dontAsk for unlisted tools) | ❌ not available |
Exit 2 is a partial workaround but it produces a confusing error rather than a clean "access denied" message, and signals a hook malfunction rather than a deliberate policy decision.
---
Proposed solution
Add permissionDecision: "deny" as a valid hook output:
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": f"Channel '{channel_id}' is not in the allowlist."
}
}))
sys.exit(0)
Behaviour should mirror what dontAsk does for unlisted tools: the model receives a structured "access denied" tool result (including the permissionDecisionReason as context), the session is not blocked, and the model can adapt and continue.
---
Alternative / complementary solution
Respect the session's defaultMode when resolving hook "ask" decisions. If the session is running under dontAsk, treat a hook-returned "ask" as "deny" instead of blocking for user input. This would make the hook's escalation mode context-aware, so the same hook works correctly in both interactive and non-interactive sessions.
---
Impact
Without this, any PreToolUse hook that does parameter-level validation (allowlisting specific resource IDs, file paths, API scopes, etc.) is incompatible with fully autonomous scheduled sessions. The only safe options today are:
- Return
"ask"and accept that autonomous runs can stall indefinitely. - Return exit 2 and accept noisy errors.
- Remove the hook entirely and lose parameter-level access control.
A "deny" decision (or dontAsk-aware "ask" resolution) would make hooks composable with autonomous agents, which is an increasingly common deployment pattern.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗