DEV Community

Marco Belini
Marco Belini

Posted on Originally published at tapproval.thoughtfulsteward.org

Your Claude Code hook can say "allow" and be ignored, without a single error

You wrote a PermissionRequest hook. It decides, it prints "allow", your log says "allowed", and Claude Code shows the ordinary prompt anyway. No error, no warning. It looks exactly like a hook that chose to leave the question to you.

We shipped that bug. Every approval tapped on a remote device was thrown away, on every install, for weeks. Here is the one-line cause, and two habits that would have caught it.

The cause: a string where Claude Code wants an object

Older examples print the decision as a string:

"decision": "allow"
Enter fullscreen mode Exit fullscreen mode

Claude Code validates a hook's output. A string fails validation, and a hook that fails validation is treated as non-blocking: the permission flow carries on as if your hook had never answered. The CLI's own validator says what it wants:

PermissionRequest decision must be {"behavior": "allow"} or {"behavior": "deny", "message": "..."}
Enter fullscreen mode Exit fullscreen mode

When we checked, the published hook docs still showed the string form. Trust the validator.

What to print instead

Standard output carries the answer and nothing else. Logs go to standard error.

Allow:

{"hookSpecificOutput": {"hookEventName": "PermissionRequest",
  "decision": {"behavior": "allow"}}}
Enter fullscreen mode Exit fullscreen mode

Deny, with a reason Claude reads and acts on:

{"hookSpecificOutput": {"hookEventName": "PermissionRequest",
  "decision": {"behavior": "deny", "message": "Not on main. Push to a branch."}}}
Enter fullscreen mode Exit fullscreen mode

Leave it to the person by sending no decision at all:

{"hookSpecificOutput": {"hookEventName": "PermissionRequest"}}
Enter fullscreen mode Exit fullscreen mode

Two habits that would have caught it

  1. One function builds the output, and a test checks the exact bytes it prints. Assert that decision is an object with behavior, and that a deny carries a message. Test what you print, not what you meant to print.
  2. Prove one real allow, end to end. Create a marker file, ask a session to delete it, approve the delete through your hook, and check that the file is gone. If it is still there and the ordinary prompt appeared, your answer was ignored.

Wiring, briefly

In ~/.claude/settings.json, or in a plugin's hooks/hooks.json:

{"hooks": {"PermissionRequest": [{"hooks": [
  {"type": "command", "command": "python3 /path/to/your-hook.py", "timeout": 86460}]}]}}
Enter fullscreen mode Exit fullscreen mode

The timeout is in seconds. A hook that waits for a person needs a long one, or the question disappears while they are still reading it. The event arrives as JSON on standard input, with tool_name, tool_input, cwd, and permission_suggestions when the prompt would offer "don't ask again".

Three more traps

  • Fail closed. If the hook can't parse the event, or crashes partway, print the "leave it to the person" block. Never allow on a guess.
  • Headless runs skip the hook. A claude -p run has no prompt to show, so it never reaches a PermissionRequest hook. Use --permission-prompt-tool with an MCP tool there.
  • A lingering prompt isn't your bug. The Claude app on a phone can keep showing a prompt your hook already answered (claude-code#32493). The command ran; the marker test settles it.

Checked against Claude Code 2.1.280, September 2026.


I build Tapproval, an open-source PermissionRequest hook in standard-library Python, with an Apple Watch app that answers these prompts from the wrist. Its tests check the exact JSON, because we learned this the hard way.

Top comments (1)

Collapse
 
fable7 profile image
fable7 •

Same failure class bit me from the other side, and it cost the same hours. I ran five calls against OpenCode v1 and v2 servers: three threw, one returned a success-shaped response that did nothing at all, one actually worked. The three that threw were cheap, because there was a stack trace to grep. The one that "succeeded" silently was the expensive one, since nothing in the output told me it had not done the work.

So the marker-file test is the part I would copy: prove one real allow end to end, not just assert the bytes you meant to print. And the detail that the published hook docs still showed the string form while the validator wanted an object is worth pinning somewhere: trust the validator, not the docs.