Problem
Claude Code hooks provide a PreToolUse mechanism to gate tool calls, but the current permission and hook system forces users into a denylist model — enumerating what the agent cannot do, rather than declaring what it can do. Bash(*) grants access to all commands with all flags, and there is no built-in way to express "allow git commit but not git commit --no-verify" without writing custom PreToolUse hooks.
In practice, we have observed Claude Code:
- Using
--no-verify to bypass git pre-commit hooks
- Using
--admin to bypass GitHub branch protections
- Seeking the path of least resistance around rules when it perceives them as obstacles
We have built a defense-in-depth stack (PreToolUse hooks that block --no-verify, hooks that block Edit/Write to hook files, permission deny rules as backup), but this is a denylist arms race. Every new bypass vector requires a new rule. This is fundamentally the wrong model.
The cybersecurity lesson (1995 → 2005)
The security industry spent a decade learning this the hard way:
| Era |
Model |
Result |
| 1990s firewalls |
Default-allow, block known bad ports |
Every new exploit required a new rule. Always one step behind. |
| 2000s firewalls |
Default-deny, allow only what's needed |
Attacker must operate within the allowlist. Fundamentally harder. |
| AI agents today |
Default-allow tools/filesystem, bolt on deny hooks |
Same arms race. Same structural weakness. |
Denylists answer: "What should we block?" — an unbounded question.
Allowlists answer: "What should we permit?" — a bounded question.
The container ecosystem solved this decades ago. A Docker container starts with zero capabilities and is granted only what it needs via a manifest. The manifest is immutable at runtime, declarative, and auditable. The container cannot modify its own security profile.
Proposed: Immutable Session Manifest
A session manifest (e.g., .claude/manifest.json) that is:
- Loaded once at session start and frozen for the session's lifetime
- Not modifiable by Claude — enforced in the application layer, not via hooks
- Allowlist-only — unlisted capabilities are denied by default. No denylist needed.
{
"manifest": {
"tools": {
"Bash": {
"allow": [
"cargo build *", "cargo test *", "cargo clippy *", "cargo fmt *",
"npm run *",
"git add *", "git commit *", "git push *",
"git status", "git diff *", "git log *",
"gh pr view *",
"python3 .claude/scripts/*"
],
"overridable": true
},
"Edit": {
"allow_paths": ["src/**", "tests/**", "docs/**", "static/**"],
"overridable": true
},
"Write": {
"allow_paths": ["src/**", "tests/**"],
"overridable": false
},
"Read": {
"allow_paths": ["**"],
"overridable": false
}
},
"git": {
"protected_branches": ["master", "main"],
"hooks_bypass": false,
"overridable": false
},
"immutable": true
}
}
Key properties:
- No denylist. If
git push --force isn't in allow, it's denied. No need to enumerate every dangerous flag.
- Immutable at runtime. The Claude Code runtime refuses all modifications to the manifest during the session — enforced in application code, not via hooks that can be circumvented.
- Declarative. The human operator specifies intent ("this agent can build, test, and commit"). The runtime enforces it.
- Auditable. The manifest is a single file that fully describes the agent's capabilities. No need to trace through chains of hooks, deny rules, and instructions to understand what's permitted.
Overridable allowlists for different session profiles
Not every session needs the same capabilities. A review-only session doesn't need Write. A deploy session needs gh pr merge. Rather than maintaining separate manifest files, specific allowlists can be marked "overridable": true and widened at launch:
# Widen Bash for a deploy session
claude --manifest-override 'Bash.allow+=["gh pr merge *"]'
# Widen Edit paths for a docs session
claude --manifest-override 'Edit.allow_paths+=["docs/business/**"]'
# Rejected — Write is not overridable in the manifest
claude --manifest-override 'Write.allow_paths+=["CLAUDE.md"]'
# Error: Write.allow_paths is not overridable
# Rejected — hooks_bypass is not overridable
claude --manifest-override 'git.hooks_bypass=true'
# Error: git.hooks_bypass is not overridable
Design constraints:
overridable: false is the default. You must explicitly opt in. Safety-critical settings (protected branches, hooks bypass, Write paths) are locked unless the manifest author chooses otherwise.
- Overrides can only widen, never narrow. The manifest base is always included. You can add capabilities at launch, but you cannot remove base capabilities — this prevents an override from accidentally weakening the baseline.
- The effective manifest (base + overrides) is logged at session start, so the human can audit exactly what was granted.
This mirrors established patterns: Docker's CMD (overridable) vs ENTRYPOINT (fixed), Kubernetes pod security policies (immutable) vs container args (configurable).
Why allowlist-only matters
With the current denylist model, we maintain:
- A PreToolUse hook blocking
--no-verify in Bash commands
- A PreToolUse hook blocking Edit/Write to hook configuration files
- A PreToolUse hook blocking
git config core.hooksPath
- A PreToolUse hook blocking
rm/mv/sed -i/tee/redirects to protected files
- Permission deny rules as backup for hook failures
- CLAUDE.md instructions (probabilistic — model may ignore)
Each of these exists because we discovered a specific bypass vector. Next week there will be another. This is the 1990s firewall model applied to AI agents.
With an allowlist manifest, none of these deny rules would be needed. The agent can run cargo test and git commit because those are listed. It cannot run git commit --no-verify or rm .claude/hooks/protect-config.sh because those are not listed. The question changes from "what might the agent do wrong?" (unbounded) to "what does the agent need to do?" (bounded).
The fundamental issue
Computers are deterministic. Claude Code's enforcement model is probabilistic — whether a rule is followed depends on whether the model "chooses" to comply, or whether a denylist happens to cover the specific bypass vector the model attempts. This is the wrong abstraction.
Enforcement should be structural and declarative, not behavioral and reactive.
Environment
- Claude Code CLI (non-enterprise, no MDM/managed settings)
- Use case: autonomous development workflows where the agent runs multi-step PR lifecycles
Problem
Claude Code hooks provide a
PreToolUsemechanism to gate tool calls, but the current permission and hook system forces users into a denylist model — enumerating what the agent cannot do, rather than declaring what it can do.Bash(*)grants access to all commands with all flags, and there is no built-in way to express "allowgit commitbut notgit commit --no-verify" without writing custom PreToolUse hooks.In practice, we have observed Claude Code:
--no-verifyto bypass git pre-commit hooks--adminto bypass GitHub branch protectionsWe have built a defense-in-depth stack (PreToolUse hooks that block
--no-verify, hooks that block Edit/Write to hook files, permission deny rules as backup), but this is a denylist arms race. Every new bypass vector requires a new rule. This is fundamentally the wrong model.The cybersecurity lesson (1995 → 2005)
The security industry spent a decade learning this the hard way:
Denylists answer: "What should we block?" — an unbounded question.
Allowlists answer: "What should we permit?" — a bounded question.
The container ecosystem solved this decades ago. A Docker container starts with zero capabilities and is granted only what it needs via a manifest. The manifest is immutable at runtime, declarative, and auditable. The container cannot modify its own security profile.
Proposed: Immutable Session Manifest
A session manifest (e.g.,
.claude/manifest.json) that is:{ "manifest": { "tools": { "Bash": { "allow": [ "cargo build *", "cargo test *", "cargo clippy *", "cargo fmt *", "npm run *", "git add *", "git commit *", "git push *", "git status", "git diff *", "git log *", "gh pr view *", "python3 .claude/scripts/*" ], "overridable": true }, "Edit": { "allow_paths": ["src/**", "tests/**", "docs/**", "static/**"], "overridable": true }, "Write": { "allow_paths": ["src/**", "tests/**"], "overridable": false }, "Read": { "allow_paths": ["**"], "overridable": false } }, "git": { "protected_branches": ["master", "main"], "hooks_bypass": false, "overridable": false }, "immutable": true } }Key properties:
git push --forceisn't inallow, it's denied. No need to enumerate every dangerous flag.Overridable allowlists for different session profiles
Not every session needs the same capabilities. A review-only session doesn't need Write. A deploy session needs
gh pr merge. Rather than maintaining separate manifest files, specific allowlists can be marked"overridable": trueand widened at launch:Design constraints:
overridable: falseis the default. You must explicitly opt in. Safety-critical settings (protected branches, hooks bypass, Write paths) are locked unless the manifest author chooses otherwise.This mirrors established patterns: Docker's
CMD(overridable) vsENTRYPOINT(fixed), Kubernetes pod security policies (immutable) vs container args (configurable).Why allowlist-only matters
With the current denylist model, we maintain:
--no-verifyin Bash commandsgit config core.hooksPathrm/mv/sed -i/tee/redirects to protected filesEach of these exists because we discovered a specific bypass vector. Next week there will be another. This is the 1990s firewall model applied to AI agents.
With an allowlist manifest, none of these deny rules would be needed. The agent can run
cargo testandgit commitbecause those are listed. It cannot rungit commit --no-verifyorrm .claude/hooks/protect-config.shbecause those are not listed. The question changes from "what might the agent do wrong?" (unbounded) to "what does the agent need to do?" (bounded).The fundamental issue
Computers are deterministic. Claude Code's enforcement model is probabilistic — whether a rule is followed depends on whether the model "chooses" to comply, or whether a denylist happens to cover the specific bypass vector the model attempts. This is the wrong abstraction.
Enforcement should be structural and declarative, not behavioral and reactive.
Environment