Quickstart | Concepts | API | Verify | Coverage | Teams | Anchors | Recovery | Human approval in CI | Verifiable audit log | Unattended runs | Approval when unattended | Hooks vs deny
Claude Code hooks vs permissions.deny: what each one stops
Both can stop a tool call, and people use them as if they were the same. They are not. A permissions.deny rule is a fixed pattern checked by Claude Code. A PreToolUse hook is a program you run that decides each call. This guide compares them and says where each one fails. Checked against Claude Code 2.1.310 and the official pages Permissions and Hooks.
At a glance
| permissions.deny | PreToolUse hook | |
|---|---|---|
| What it is | a pattern in settings | a command that gets the call as JSON |
| Decides by | matching text of the call | any logic you write |
| Answers | block | allow, deny, ask, defer |
| If it breaks | rule keeps working | exit codes other than 0 and 2 let the call through |
| Can wait for a person | no | yes, within its timeout |
What permissions.deny does
Rules are evaluated "deny, then ask, then allow. The first match in that order determines the outcome" (Permissions page). A deny rule wins over an allow rule ("An allow rule can't carve an exception out of a deny rule") and over a hook that says allow: "PreToolUse hook decisions don't bypass permission rules. Claude Code evaluates deny and ask rules regardless of what a PreToolUse hook returns" (Permissions page).
{"permissions": {"deny": ["Bash(curl *)", "Bash(ssh *)", "Read(.env)", "Read(.env.*)", "Edit(.claude/**)"]}}
Strengths: nothing to install, nothing to crash, the same in interactive and claude -p runs. Limits: a Bash rule "matches the command text Claude writes ... and isn't a security boundary around the program". The page's own examples: Bash(curl *) does not stop /usr/bin/curl https://example.com or sh -c 'curl https://example.com'. File rules have a trap too: Write(path) rules never match; use Edit(path), which covers the Write tool. It also cannot look at context: it cannot say "allow git push only to this remote" or "ask a human first".
What a PreToolUse hook does
"PreToolUse hooks run before the permission prompt" (Permissions page); the hook receives the tool name and input and can return a decision: "deny" "prevents the tool call", "ask" "prompts the user to confirm", "allow" "skips the permission prompt" (Hooks page). It can parse a command, check a path after resolving it, call a service, or hold the call until someone answers. A minimal blocking hook prints:
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Needs a human"}}
When we ran this with claude -p on 2.1.310, with --allowedTools "Write" on the same run, no file was written and the result listed the call under permission_denials.
The part that surprises people: hooks fail open
The Hooks page: exit 0 is success, exit 2 blocks, "any other exit code" is a non-blocking error and "the action goes ahead". So a hook with a wrong path, a crash or a timeout lets every call through (on a timeout, "a timed-out command, http, or mcp_tool hook doesn't block the tool call"). A deny rule cannot do that. To make a hook fail closed:
- Print a JSON
denyyourself on every error you can catch. - Run it as
your-hook || exit 2, so a missing binary becomes a block. - Add
"onFailure": "block"(Hooks page: "Requires Claude Code v2.1.295 or later"; in our check of the published 2.1.294 binary the key does not exist, so an older version ignores it without an error).
Also check the matcher. A hook with "matcher": "Bash" never sees Read, Edit, PowerShell or MCP calls. Cover the tools you care about, or use a rule for them.
Where each one applies
claude -pruns the hooks in the project's.claude/settings.jsonwithout a trust dialog, unless you pass--bare, which skips hooks (Headless page). In bare mode, load the settings you rely on, deny rules included, with--settings, the flag the Headless page lists for that.- Order of strength: a deny rule beats a hook's
allow. A hook'sdenybeats an allow rule. A hook cannot loosen a deny rule. We ran both cases withclaude -pon 2.1.310: hookallowagainstEdit(out.txt)in deny, and hookdenyagainst--allowedTools "Write"; neither wrote the file. The Permissions page names one exception: with neither managed settings nor a Team or Enterprise/login, an installed mod that handlestool.check"can approve a call that a deny rule refuses or aPreToolUsehook blocked". - Neither one watches what a process does after it starts. A shell command that Claude Code allowed can read and write anything the OS lets it. For that, use the sandbox: Safe way to run Claude Code unattended.
Which to use
- Use
denyfor things that must never happen and can be named as a pattern: network tools, secret files, your own settings. Cheap and robust, but bypassable by rewriting the command. - Use a hook when the answer depends on the call: its arguments, the destination, the time, or a person. Make it fail closed.
- Use both. Deny as the first fence, the hook as the decision layer, a sandbox as the wall.
With Deus Witness
Deus Witness is a hook (deus-witness-hook) that sends each call to a service, compares it with a mandate you signed with a passkey, and holds anything the mandate does not cover until you approve it; the hook checks a signed receipt before it prints allow. deus-witness-hook --doctor "$PWD" reports whether your install is fail-closed (all three layers above, and the matcher covering the file tools). It is a decision layer for tool calls, not a sandbox. Setup for unattended runs: Require approval before Claude Code runs a command unattended. Every decision is written to a log you can verify offline: Audit log for AI agent actions you can verify offline.