Designing allow, deny and hooks is becoming the new code review
Agents produce more than anyone can read. The docs draw a hard line between instruction and enforcement, and what sits on each side catches more than reading the diff after.
Contents
On a team that uses coding agents daily, the highest-leverage thing to review shifts from the code itself to the design of allow, deny and hooks. Reading diffs afterwards does not go away. But a layer appears in front of it, and that layer stops more incidents than the reading does.
The argument rests on a line the tools' own documentation draws, not on a hunch.
KEY POINT
The claim
- The docs separate instruction from enforcement, and CLAUDE.md sits on the instruction side
- Permission rules and hooks are the executable form of the review checklist items a machine can decide
- But permissions never review correctness — this is a layer added in front of review, not a replacement
The docs draw the line explicitly
The Claude Code memory page settles the question about organization-wide CLAUDE.md in one sentence:
Settings rules are enforced by the client regardless of what Claude decides to do. CLAUDE.md instructions shape Claude's behavior but are not a hard enforcement layer.
The same page names where to go when you need certainty:
Claude treats them as context, not enforced configuration. To block an action regardless of what Claude decides, use a PreToolUse hook instead.
A document holding your project's rules is something that gets read, and followed on a best-effort basis. Its content arrives as a user message after the system prompt, not as part of it. No amount of work sharpening its prose changes which side of the line it sits on.
That line maps directly onto review. Writing "please don't force push" in a review comment and writing it into permissions.deny differ in strength. The first may need writing again next week; the second is finished when you write it.
Permission design is the review checklist made executable
A review checklist mixes two kinds of item: the ones a person must read to judge — design intent, naming, whether the abstraction holds — and the ones a machine can decide, such as "did this touch production config" or "did a credential reach the logs."
The second kind can be written as a rule. And when you do, the docs guarantee different strengths.
| Where it lives | What the docs say | Strength |
|---|---|---|
permissions.deny | "Blocks before the classifier is consulted. Neither the classifier nor user intent can override it" | Before the classifier; intent cannot reach it |
permissions.ask | "Always prompts for a command that matches a content-scoped rule like the recipe above. The classifier cannot auto-approve a matching action" | Always reaches a human |
| Critical-path removals | "Recognized rm and rmdir critical-path removals, which no allow rule or PreToolUse hook \"allow\" approves" | Neither a rule nor a hook can approve it |
| PreToolUse hook | "To block an action regardless of what Claude decides" | Runs without going through Claude's judgement |
| CLAUDE.md | "not a hard enforcement layer" | Instruction |
The top four rows are checklist items you can express as configuration instead of comments. For anything in deny, the probability of missing it in review is not reduced — it leaves the realm of probability.
For the mechanics, see Claude Code permissions in settings.json and settings.local.json and Claude Code permissions by example. For the hook side and where it stops being reliable, see Block rm -rf with a Claude Code PreToolUse hook, and know its limits.
There are three layers to design
"Designing permissions" covers three distinct things.
- Per-tool allow, ask and deny. In Claude Code that is
permissions; in Codex it is named profiles,[permissions.<name>], withfilesystem,workspace_rootsand thenetworkkeys beneath them. - Lifecycle hooks. PreToolUse and its siblings run without passing through the model's judgement. Only what you put here is in the "always" category.
- The execution environment itself — the sandbox and the reach of the working directory. Codex ships
:read-only,:workspaceand:danger-full-accessas built-in profiles, whichextendscan narrow. For how approvals and sandboxing pair up, see Codex approval_policy versus sandbox_mode.
Three layers exist because one leaks. Claude Code's auto mode suspends broad rules the moment you enter it:
By default, narrow Bash and PowerShell allow rules such as
Bash(npm test)stay in effect in auto mode. Claude Code resolves them before the classifier runs, unless the command carries per-command allowed domains. Claude Code suspends only the broad rules that grant arbitrary code execution, such asBash(*)or wildcarded interpreters, together with every rule that namesMonitor, because Monitor commands run through the shell.
So a blanket Bash(*) buys nothing in auto mode. Keeping the grain fine is not style advice; it is a precondition for the rule working at all. For how the modes differ, see Claude Code starts in auto mode by default.
Objections and limits
Four conditions make this claim fail.
Permissions never review correctness. This is the hard limit. deny stops writes to places that are off-limits; it says nothing about whether the logic written in an allowed place is right. Algorithm choice, boundary conditions, naming and design coherence stay with human readers. Permission design is not a substitute for review — it carves out the part review no longer has to carry.
It does not pay off where nothing is irreversible. On a local library with no deploys and no outbound calls, the design cost is pure prepayment. The argument holds where production, an external service, or credentials are within reach.
Rules cannot express intent. "Don't touch this directory, except during a migration" resists being written as a rule. Claude Code's classifier does let user intent override blocks, but within tight bounds:
hard_denyrules block unconditionally. User intent andallowexceptions don't apply.
Intent only reaches soft_deny, and even there the required specificity is stated: "General requests don't count as explicit intent. Asking Claude to 'clean up the repo' doesn't authorize force-pushing, but asking Claude to 'force-push this branch' does." Judgement that a rule cannot capture goes back to an in-the-moment prompt.
The configuration itself becomes something to review. A permissions file grows and turns into another thing that is hard to read. Review moves; it does not disappear.
Three things to decide first
Given those limits, the list that actually earns its place is short.
- Put irreversible operations in
deny. Force push, production deploys, and the paths anrm -rfcould reach. That is enough for the layer intent cannot override. - Put anything that leaves the machine in
ask. Pushes, writes to external APIs, reads of credentials. Not blocked — guaranteed to reach a person. - Keep the rest narrow. When you want to write
Bash(*), the question is not what to allow but which permission mode you are in.
Together that is the exercise of pulling out of your review checklist the items you keep writing every week. Whatever you pull out is reading time returned to design and correctness.
Summary
- The docs separate instruction from enforcement, and CLAUDE.md is not on the enforcement side
permissions.deny,ask, critical paths and PreToolUse hooks are the executable form of review items- Three layers matter: rules, hooks and the execution environment. Overly broad allow rules are suspended in auto mode
- Permissions never review correctness; this is a layer in front of code review, not a replacement
- Start with deny for irreversible operations and ask for anything that leaves the machine
FAQ
- Does good permission design make code review unnecessary?
- No. Permissions govern what may happen, not whether the code that was written is correct. Nothing is removed from review; a layer is added in front of it.
- Why isn't writing the prohibition in CLAUDE.md enough?
- Because the docs say it isn't: "Settings rules are enforced by the client regardless of what Claude decides to do. CLAUDE.md instructions shape Claude's behavior but are not a hard enforcement layer." Anything that must hold belongs in settings or a hook.
- Is this worth doing on a small team?
- It depends on how many irreversible operations you have, not team size. Even three deny entries — force push, production deploys, and reading credentials out — earn their keep.
Questions & answers
Q. My CLAUDE.md says to edit only with Edit and Write and never with python or sed, yet subagents rewrite files with heredocs and python scripts. How do I actually stop it?
CLAUDE.md cannot stop it, and the docs say why in one sentence:
Permission rules are enforced by Claude Code, not by the model. Instructions in your prompt or
CLAUDE.mdshape what Claude tries to do, but they don't change what Claude Code allows.
To make it hold, move the rule into permissions or a hook. Both reach subagents. On hooks: "Hooks from settings files, managed policy settings, and plugins also run inside subagents. When a subagent calls a tool, tool events such as PreToolUse and PostToolUse fire the same configured hooks as in the main conversation."
{
"permissions": {
"deny": [
"Bash(python*)",
"Bash(python3*)",
"Bash(perl*)",
"Bash(sed -i*)",
"Bash(awk*)"
]
}
}
Deny rules reach inside compound commands: "Deny and ask rules apply when any subcommand matches them, including a command nested inside a subshell, a command substitution, or a control-flow body such as a for loop." They also match past a leading assignment.
The space before a trailing * changes the meaning. The docs' example: "Bash(ls *) requires a space after ls, so lsof doesn't match. Bash(ls*) has no space, so it matches lsof too." Drop the space if you want python3 caught as well.
For heredocs, what gets checked is the redirect target of cat > file <<'EOF': "Read and Edit deny rules apply to Claude's built-in file tools, to file commands Claude Code recognizes in Bash, such as cat, head, tail, sed, and tee, and to the targets of Bash redirections such as > file and < file." So an Edit deny rule protects the path.
A python script that opens files itself is out of reach
The same paragraph states the limit: "They don't apply to a command that reads files without naming them, such as grep -r pattern . run from the directory that holds the file, or to arbitrary subprocesses that read or write files indirectly, like a Python or Node script that opens files itself. For OS-level enforcement that blocks all processes from accessing a path, enable the sandbox." A python3 - <<'EOF' block that opens files in its own code is outside what rules can see; sandboxing is the documented enforcement.
Bash rules match command text, so they miss /usr/bin/python3 and bash -c 'python3 …'. The docs put it plainly: "It doesn't match the same program invoked in a different form", and such a rule "isn't a security boundary around the program".
You can also remove the tool from the subagent outright. Frontmatter tools is an allowlist and disallowedTools a denylist — but a disallowedTools entry with a specifier such as Bash(git push *) removes the whole tool, not just matching commands. To keep Bash and block specific commands, use the permissions.deny rules above, which apply to the main conversation and to subagents alike.
For an edit-only subagent, simply not granting Bash is the most reliable shape:
---
name: implementer
description: Implements features. Changes files only with Edit and Write.
tools: Read, Grep, Glob, Edit, Write
---
For where the line between instruction and enforcement sits, see Designing allow, deny and hooks is becoming the new code review.
Primary sources
This article was drafted by AI from official documentation and reviewed by the site operator before publishing. Found a mistake? Let us know via the contact page.