PreToolUse hook's empty stdout forces an interactive prompt, ignoring an existing allow rule (VS Code extension)
PreToolUse hook's empty/non-JSON stdout forces an interactive prompt, ignoring an existing allow rule (VS Code extension)
Environment
- Extension:
anthropic.claude-code2.1.210 (darwin-arm64) - VS Code: 1.128.1
- OS: macOS 15 (Darwin 25.5.0), arm64
- Project: multi-root workspace (
Nav Bar.code-workspace→ folderfasthosts-showroom)
Summary
A PreToolUse hook that abstains on a given tool call (exits 0, empty stdout, the documented no-op pattern for "only gate specific commands") forces an interactive permission prompt, even when the command already matches an existing allow rule in settings.json. Expected behaviour: a hook producing no output should be treated as "no opinion", falling through to the normal allow/deny/ask rule evaluation, not forcing an interactive prompt.
Setup
~/.claude/settings.json registers a PreToolUse hook on the Bash matcher:
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "bash /Users/<user>/.claude/hooks/send-gate.sh" }
]
}
]
send-gate.sh gates git commit/git push behind a marker file, and is a no-op for every other Bash command:
if not is_commit_or_push(cmd):
sys.exit(0) # not a gated command — let it through untouched
For any non-git-commit/push command (e.g. touch ~/.claude/.commit-authorised), this exits 0 with empty stdout, since the hook has nothing to say about that command.
.claude/settings.json (project-level) already contains explicit allow rules for the exact commands being tested, added by a prior interactive approval in the same session:
"Bash(touch ~/.claude/.commit-authorised)"
"Bash(git push origin feature/1866-ai-receptionist-topnav *)"
Steps to reproduce
- Register any PreToolUse hook on the
Bashmatcher that exits 0 with no stdout for commands it doesn't care about (this is the standard/documented no-op pattern). - Approve a Bash command matching that hook's matcher via the interactive dialog, choosing "Allow for this project" (or any option that writes an
allowrule to.claude/settings.json/settings.local.json). - Confirm the rule is written correctly to disk (it is — verified in
.claude/settings.json). - Run the exact same command again, later in the same running session (no window reload, no extension restart).
Expected
Step 4 runs without a prompt: the command matches an existing allow rule, and the PreToolUse hook has no opinion (empty output), so nothing should override the allow rule.
Actual
Step 4 re-prompts with the interactive "Allow this bash command?" dialog every time, as if no allow rule existed. Confirmed twice in the same session, ~16 minutes apart, both times re-writing an identical (already-present) rule to .claude/settings.json via addRules.
Extension log (Claude VSCode.log, debug level) shows the mechanism:
[DEBUG] executePermissionRequestHooks called for tool: Bash
[DEBUG] Hook output does not start with {, treating as plain text
repeated 3x per invocation, immediately before the forced prompt fires. This confirms the hook's empty/non-JSON stdout is being misinterpreted as a reason to force interactive approval, rather than as "hook abstains, defer to existing permission rules."
Why this matters / severity
Any project using a PreToolUse hook that intentionally no-ops (the standard pattern for "only gate specific commands, ignore the rest") loses the benefit of persisted allow rules for every command that hook's matcher covers. In our case this affects all Bash(*) calls project-wide, since the hook matcher is Bash, forcing a manual approval on commands already explicitly allowed, every single time, indefinitely. This makes settings.json allow rules effectively decorative for any Bash call once a broad-matcher hook is registered.
Ruled out (in case it helps narrow it)
- Hook allow/deny logic itself — simulated independently, correct.
- A stale
permissions.askrule in globalsettings.jsonpredating the hook, which per docs always wins over a hook'sallowdecision — found and removed; fixed thegit commitstep specifically but nottouch/git push. - Any settings.json persistence failure — the rule is confirmed present and correct on disk in all three possible locations (global
settings.json, projectsettings.json, projectsettings.local.json). - VS Code workspace state (
state.vscdb) andglobalStorageholding a stray permission/trust key — checked, neither contains anything beyond UI view state.
Two concrete asks for the platform
- Treat a PreToolUse hook's empty/non-JSON stdout + exit 0 as "hook abstains" and fall through to normal
allow/deny/askrule evaluation, not a forced prompt. - Surface this distinction in the hook-development docs (currently silent on what empty output means for a
Bash-matcher hook), since "no-op for commands I don't care about" is the standard pattern for narrowly-scoped hooks on a broad matcher.
Notes
Happy to share the full extension log excerpt and hook script on request.