← Back to BlogSecurity

Claude Code Permissions: What Deny Rules Miss

Claude Code permissions are a policy layer, not a security boundary. What settings.json deny rules block, what slips past, and what to use instead.

A Claude Code deny rule of Bash(rm *) does not stop /bin/rm -rf build/, and the official docs use that exact example. Permission rules are a policy layer, not a security boundary: they match the command text Claude usually writes. If you need a restriction that holds no matter how a command is spelled, use the OS-level sandbox, not permission rules.

Sources: Claude Code docs: Configure permissions

Several users have filed bug reports after finding their deny list did less than they believed. Two settings many people rely on do nothing at all: a .claudeignore file "has no effect," and path rules written for Write are never consulted. Below: each rule type checked against the docs, then a minimal settings.json using only documented syntax.

Sources: Claude Code docs: Read and Edit

A disclosure before the specifics: we build TLM Forge, a process layer for Claude Code, so weigh the product section near the end accordingly. Everything before that section is drawn from the Claude Code documentation and public GitHub issues, linked inline so you can check each claim.

How Claude Code permissions evaluate allow, ask, and deny

The first matching rule in the order deny, ask, allow decides the outcome, and the docs state that "rule specificity doesn't change the order." A broad deny such as Bash(aws *) blocks aws s3 ls even when you also have a narrower allow rule for Bash(aws s3 ls). In the docs' words, "An allow rule can't carve an exception out of a deny rule." The same holds between ask and allow: a matching ask rule prompts even when a more specific allow rule also matches.

Sources: Claude Code docs: Manage permissions

Scope does not change this either. Deny rules from every scope are evaluated before any allow rule, so a user-level deny blocks a project-level allow and vice versa. Nothing, including command line flags, overrides a managed deny.

Sources: Claude Code docs: Settings precedence

A bare tool name in the deny list works differently: Bash alone removes the tool from Claude's context entirely, so Claude never sees it. A scoped rule such as Bash(rm *) leaves the tool available and blocks matching calls when Claude attempts them.

Sources: Claude Code docs: Manage permissions

Insight

Permission rules are enforced by Claude Code, not by the model. A line in CLAUDE.md that says "never read .env" shapes what Claude tries to do. It does not change what Claude Code allows. Only rules, permission modes, hooks, mods, and the sandbox do that.

Bash rules match command text, not programs

A Bash rule matches the command string Claude writes, after Claude Code splits compound commands and strips a fixed list of wrappers. The docs describe it as covering "the invocation Claude usually produces" and say it "isn't a security boundary around the program." Their own examples: Bash(rm *) stops rm -rf build/ but not /bin/rm -rf build/ or bash -c 'rm -rf build/'. Bash(git push *) stops git push origin main but not git -C . push origin main, git -c push.default=current push origin main, or git 'push' origin main. Bash(curl *) misses /usr/bin/curl and sh -c 'curl ...'.

Sources: Claude Code docs: What a Bash rule doesn't match

The matcher does understand shell operators: a deny or ask rule fires when any subcommand matches, even one inside a subshell or command substitution. A deny rule also matches past a leading environment assignment, so Bash(rm *) still catches FOO=bar rm -rf tmp/. Wrappers such as timeout and nohup are stripped first, but runners like npx, docker exec, and devbox run are not, so Bash(devbox run *) also matches devbox run rm -rf ..

Sources: Claude Code docs: Compound commands and wrappers

File rules: use Edit and Read, nothing else

Claude Code checks file permissions against Edit(path) and Read(path) rules only. A path rule written for Write, NotebookEdit, Glob, or the legacy MultiEdit tool is accepted and never consulted, and Claude Code warns about it at startup. Use Edit(docs/**) in place of Write(docs/**). A Read deny rule also blocks the Edit and Write tools on the same path, including creating a new file there, but NotebookEdit is not covered, so add an Edit deny for any path no tool may change.

Sources: Claude Code docs: Read and Edit

If your project has a .claudeignore file, the docs say directly that "it has no effect." Move its entries into Read deny rules.

Sources: Claude Code docs: Read and Edit

Path anchoring is the other common trap. File rules use gitignore syntax: //path is absolute, ~/path starts at your home directory, /path is relative to the settings source, and path or ./path is relative to the current directory. A rule like Read(/secrets/**) in user settings blocks ~/.claude/secrets/**, not a secrets folder in your project. For a rule in user settings that applies in every project, use // or ~/.

Sources: Claude Code docs: Read and Edit path patterns

Coverage is the biggest gap. 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 redirect targets like > file and < file. They do not apply to a command that reads files without naming them, such as grep -r pattern . run from the folder that holds the file, or to "a Python or Node script that opens files itself." For enforcement that blocks every process, the docs point to the sandbox.

Sources: Claude Code docs: Read and Edit deny rule coverage

Eight rules and files that do less than you think

Every row comes from the permissions, sandboxing, and hooks docs. The last column names the layer that actually does the job.

Sources: Claude Code docs: Configure permissions, Claude Code docs: Sandboxing

Rule or fileWhat people think it doesWhat it actually doesWhat to use instead
Bash(rm *) in denyClaude can never delete files with rmBlocks rm ... and FOO=bar rm ...; misses /bin/rm and bash -c 'rm ...'Sandbox filesystem limits, plus a PreToolUse hook for your own policy checks
Bash(git push *) in denyNo pushes leave the machineMisses git -C . push, git -c ... push, and git 'push'Keep the remote host off the sandbox network allowlist; protect branches on the remote
Narrow allow under a broad denyThe more specific rule winsDeny is checked first; an allow cannot carve an exception out of itNarrow the deny itself, or allow the tool and block specific commands with a hook
Write(docs/**)Limits where Claude can writeAccepted but never consulted; startup warningEdit(docs/**)
.claudeignoreHides files from ClaudeNo effectRead deny rules
Read(**/.env) in denyThe secret is unreadableCovers built-in tools, recognized Bash file commands, and redirects; not grep -r or a script that opens the fileSandbox credentials or filesystem.denyRead
Read(/secrets/**) in user settingsBlocks the project's secrets folderBlocks ~/.claude/secrets/** only// absolute or ~/ home-relative paths
CLAUDE.md "never touch .env"An enforced ruleGuidance the model may follow; nothing is enforcedPermission rules, hooks, or the sandbox

Why deny rules seem not to work: four GitHub reports

In issue #13121, opened in December 2025, a user who had denied Bash(cat:*), Bash(grep:*), and Bash(pgrep:*) in user settings reported that Claude ran cat freely when the commands also appeared in an allow list. The reporter wrote: "Users cannot reliably restrict Claude's access to sensitive commands." A bot closed it as a duplicate. The documented rule is the one the reporter expected: a deny from any scope beats an allow.

Sources: GitHub issue #13121

In issue #25621, from February 2026, a user denied Bash(*gcloud*), reported that Claude still ran gcloud -h and gcloud, and called it "a security issue" because it allowed "bypassing user-configured safety restrictions for dangerous commands." A bot closed that one as a duplicate too.

Sources: GitHub issue #25621

Issue #39987, from March 2026 on v2.1.84, is the clearest case. Path deny rules worked for Read, Glob, and Grep, but Claude then read the denied directory with grep and head through the Bash tool. The reporter wrote that users relying on settings.json for this "have a false sense of security" and asked for rules to either cover Bash or be documented as file-tool only. A bot closed it as not planned after inactivity.

Sources: GitHub issue #39987

In issue #61268, from May 2026, a user put Read(/**/*.key) in ~/.claude/settings.json and found Claude could still read a key file in the project. A maintainer explained that a single leading slash anchors at the settings file's directory, so the rule was "working as documented," and // fixed it. They also confirmed a second problem: on macOS, a sandboxed command could still read the file where the sandbox allowed reads of that area, reproduced on v2.1.233 with perl and python one-liners. A fix was described as in progress when a bot closed the issue in September.

Sources: GitHub issue #61268

Pro Tip

The four reports do not share one cause. In #39987 and #61268, a rule did not cover what the reporter assumed it covered: Bash file reads, and a single-slash path. In #13121, the reported behavior contradicted the documented order, and #61268 also exposed a sandbox read gap. Either way, when a restriction has to survive a creatively spelled command, it belongs in a layer that does not read the command at all. A deny rule filters what Claude writes. The sandbox limits what the process can do.

The sandbox is the boundary for shell commands

The sandbox is enforced by the operating system on the running process, so the docs say it "holds regardless of what the model chose to run and even if an allowed command does more than its name suggests." It uses Seatbelt on macOS and bubblewrap on Linux and WSL2, and it covers Bash, PowerShell, and Monitor commands plus every process they start. That is exactly the gap Bash rules leave open: /bin/rm, bash -c, and a Python script that opens a file all run inside it.

Sources: Claude Code docs: How sandboxing relates to permissions, Claude Code docs: OS-level enforcement

It has limits. It is off by default, and sandboxed commands can read most of the machine, including ~/.ssh and ~/.aws/credentials, until you protect them. If the sandbox cannot start, Claude Code runs commands unsandboxed unless you set failIfUnavailable, and it does not run on native Windows. Claude can retry a failed command outside the sandbox unless you set allowUnsandboxedCommands to false. Built-in file tools, hooks, and MCP servers run outside it entirely, which is why Read and Edit rules still matter. The docs themselves say sandboxing "is not a complete isolation boundary."

Sources: Claude Code docs: What the sandbox restricts, Claude Code docs: Sandbox limitations

For the broader question of which risks belong to runtime containment and which to review of the code itself, see runtime vs code-time guardrails. Scoping the credentials an agent can reach is a separate control again, covered in least-privilege access for AI coding agents.

Hooks: a stricter policy layer, with three catches

A PreToolUse hook that exits with code 2 blocks the tool call before permission rules are evaluated, so the block applies even when an allow rule would have let the call through. The docs suggest this for a permissive setup: allow Bash, then register a PreToolUse hook that rejects the specific commands you want stopped. The precedence runs one way, though: a hook that returns "allow" does not bypass a matching deny or ask rule.

Sources: Claude Code docs: Extend permissions with hooks, Claude Code docs: Hooks, exit code 2

The first catch is the exit code. Exit 1 is a non-blocking error for a PreToolUse hook unless you configure the hook to block on failure, and the docs warn: "If your hook is meant to enforce a policy, use exit 2." A mistyped script path also fails open as a non-blocking error. The second: a hook reading tool_input.command sees the same text a Bash rule sees, so a string-matching hook is bypassable by an odd spelling too. The third: hooks run outside the sandbox with your full access, so keep their scripts small and reviewed. For a worked example that protects test files with deny rules and a hook, see how to stop Claude Code changing tests to pass.

Sources: Claude Code docs: Hooks, other exit codes, Claude Code docs: What runs outside the sandbox

Mods are an exception to deny-first precedence. A mod that handles tool.check answers after rules and hooks, and outside plan mode its answer can replace theirs. On a machine with managed settings, or when you are signed in with a Team or Enterprise plan, deny rules hold over it by default, and your organization can change that. Anywhere else, the docs say "the mod can approve a call that a deny rule refuses." Read what a mod does before installing it; our post on Claude Code mods covers why.

Sources: Claude Code docs: Precedence among rules, hooks, and mods

A minimal settings.json: deny rules, sandbox and a hook

The configuration below combines three top-level blocks, each written only in syntax the docs show. Permissions shape what Claude asks to do, the sandbox contains what shell commands can reach, and a hook adds checks of your own. Put the permissions in the project's .claude/settings.json. Put the sandbox block in ~/.claude/settings.json for yourself, or in managed settings for a team, because a false for allowUnsandboxedCommands set there holds even when a project sets it to true.

Sources: Claude Code docs: Turn off the retry with strict sandbox mode

  • In .claude/settings.json: { "permissions": { "allow": ["Bash(npm run *)", "Bash(git commit *)"], "ask": ["Bash(git push *)"], "deny": ["Read(**/.env)", "Read(secrets/**)", "Bash(curl *)", "Bash(wget *)"] }, "hooks": { "PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/bash-policy.sh" }] }] } }, where the hook script reads the JSON on stdin, inspects tool_input.command with jq, prints a reason to stderr, and exits 2 to block
  • In ~/.claude/settings.json or managed settings: { "sandbox": { "enabled": true, "failIfUnavailable": true, "allowUnsandboxedCommands": false, "credentials": { "files": [{ "path": "~/.aws/credentials", "mode": "deny" }, { "path": "~/.ssh", "mode": "deny" }] }, "network": { "allowedDomains": ["registry.npmjs.org"] } } }

Each piece covers a gap in another. The Read deny rules stop the built-in file tools, which the sandbox does not wrap. The credentials entries stop sandboxed commands, which Read rules do not fully cover, from reading cloud and SSH keys. Denying curl and wget catches the usual spellings. The docs pair that with the sandbox network allowlist "when the restriction must hold." With your Git host off that list, a push in any spelling hits the network layer. HTTPS pushes go through the sandbox proxy, which prompts for an unlisted host in the Manual permission mode; in bypassPermissions mode the connection is allowed unless strictAllowlist or allowManagedDomainsOnly is on. SSH pushes are tunneled through the same proxy on Linux and WSL2, and on macOS they fail inside the sandbox even to an allowed host.

Sources: Claude Code docs: Network isolation, Claude Code docs: Hosts outside your allowed domains, Claude Code docs: Protect credentials

In the default auto-allow mode of the sandbox, sandboxed commands run without a prompt, but a command a Bash deny rule matches is still blocked and content-scoped ask rules such as Bash(git push *) still prompt.

Sources: Claude Code docs: Sandbox modes

Pro Tip

To confirm the sandbox is actually active, ask Claude to run touch ~/sandbox-probe. Inside the sandbox it fails with "Operation not permitted" on macOS or "Read-only file system" on Linux.

Where TLM Forge fits, and where it does not

Nothing above needs TLM Forge, and it replaces none of it: it is not a sandbox and does not make code secure. It answers a different question, whether the change Claude made got reviewed before it left your machine. On Medium and Deep tasks, a final review wave runs independent reviewer agents: code-reviewer and phase-auditor on Medium, plus tester, red-team-reviewer, and architect-reviewer on Deep, and ux-reviewer when UI changes. CRITICAL and HIGH findings are fixed test-first or brought to you, and MEDIUM and LOW go on a written list.

A commit hook blocks git commit, git push, and gh pr merge when the code has changed since the final review. It works by matching the command text of those three commands, so the caveat above about string-matching hooks applies to it too. It is on for everyone, and TLMFORGE_HOOKS=0 turns hooks off, so treat it as a process gate you control, not a containment layer.

Frequently asked questions

01Does deny override allow in Claude Code?

Yes. Claude Code checks deny rules first, then ask, then allow, and the first match decides. Specificity does not matter, so a narrow allow rule cannot carve an exception out of a broad deny. A deny in any settings scope also beats an allow in any other scope.

02Can a Claude Code deny rule block rm completely?

No. Bash(rm *) matches the command text Claude writes, so it blocks rm -rf build/ but not /bin/rm -rf build/ or bash -c 'rm -rf build/'. The docs say Bash rules are not a security boundary. Use the sandbox to restrict what shell commands can write.

03Does Claude Code respect .claudeignore?

No. The permissions documentation says a .claudeignore file has no effect. Move its entries into Read deny rules in settings.json, such as Read(**/.env) or Read(secrets/**).

04How do I stop Claude Code from reading my .env file?

Add a Read deny rule such as Read(**/.env). That covers built-in file tools and recognized Bash file commands like cat, but not a script that opens the file itself. To cover scripts and other processes too, also enable the sandbox and deny the path with filesystem.denyRead.

05Why is my Claude Code deny rule not working?

Usually one of four reasons: the command was spelled differently from the rule, such as /bin/rm or bash -c; the rule names Write instead of Edit; a single leading slash in user settings anchored it under ~/.claude; or the file was read by grep -r or a script, which Read deny rules do not cover.

Ship AI-written code you can trust

TLM Forge is the missing process layer for Claude Code: a spec audit, independent multi-agent review, enforced TDD, and an adversarial red-team gate.

Get TLM Forge