Deus Witness

Quickstart | Concepts | API | Verify | Coverage | Teams | Anchors | Recovery | Human approval in CI | Verifiable audit log | Unattended runs | Approval when unattended | Hooks vs deny | Skip permissions | PreToolUse hook | Deny not working | Headless permissions | MCP human-in-the-loop

Claude Code permissions.deny not working? What deny rules match and what slips past

A permissions.deny rule matches only the tool and the text it names, as written. When one "does not work", the cause is almost always one of these: a rule for Write(path), which never matches anything (use Edit(path)); a path anchored somewhere other than you think (/x is not the filesystem root); a file or entry that Claude Code skipped; a command written in a form your pattern does not cover (git -C . push); or another tool doing the same job (a script, an MCP server). Checked against Claude Code 2.1.312, the official Permissions and Settings pages, and runs we made with claude -p.

How deny rules are evaluated

"Rules are evaluated in order: deny, then ask, then allow. The first match in that order determines the outcome, and rule specificity doesn't change the order" (Permissions page). So Bash(aws *) in deny blocks aws s3 ls even if Bash(aws s3 ls) is in allow: "An allow rule can't carve an exception out of a deny rule."

Lists from all settings files are combined, and "If a tool is denied at any level, no other level can allow it": a deny in user settings blocks a project allow, and a managed deny "can't be overridden by --allowedTools". Deny rules also hold in every permission mode, bypassPermissions included.

A bare tool name works differently from a pattern: Bash in deny "removes the tool from Claude's context entirely", while Bash(rm *) leaves the tool and blocks matching calls.

Why your deny rule did not fire

1. You wrote Write(...). "If you write a path rule for Write, NotebookEdit, Glob, or legacy MultiEdit, Claude Code accepts the rule but never matches a read or edit against it." We ran claude -p with Write(out.txt) in deny and --allowedTools "Write": the file was written and permission_denials was empty. Use Edit(out.txt), which covers every built-in tool that edits files, and Read(...) for reads. A Read deny also blocks Edit and Write on the same path, but not NotebookEdit.

2. The path is anchored somewhere else. Path rules use gitignore syntax with four anchors: //path is the filesystem root, ~/path your home, /path "relative to the settings source", and path the current directory. The page's warning: "A pattern like /Users/alice/file isn't an absolute path." In user settings, Read(/secrets/**) blocks ~/.claude/secrets/**, not your project's secrets. For a rule that should hold in every project, use // or ~/.

3. The Bash command has another shape. "A Bash rule matches the command text Claude writes ... and isn't a security boundary around the program." The page lists forms a rule does not stop, such as git -C . push origin main for Bash(git push *). We reproduced that: Bash(git rm *) in deny, Bash(git *) in allow, and git -C . rm -f victim.txt deleted the file. Two other forms from the same table, /bin/rm and bash -c 'rm ...', were blocked by Bash(rm *) in our runs on 2.1.312. Treat that as current behaviour, not a promise: the documentation still says they are not stopped.

Two details that help: a deny rule "matches past any leading assignment" (FOO=bar rm -rf tmp/), and it applies to every part of a compound command, $(...) included.

4. The file reads without naming it. Read and Edit rules cover the built-in tools, file commands Claude Code recognizes in Bash (cat, sed, tee...) and redirections. They "don't apply to a command that reads files without naming them, such as grep -r pattern .", or to "a Python or Node script that opens files itself".

5. Another tool does it. A deny for Bash(curl *) does not touch WebFetch or an MCP tool that fetches URLs. MCP tools need their own rules, mcp__<server>__<tool> or mcp__<server>__*. And a deny rule with parentheses on an MCP tool is skipped in settings files: "When Claude Code loads a settings file, it skips any mcp__ rule that has parentheses"; pass such a rule with --disallowedTools instead.

6. The rule never loaded. Settings files are strict JSON: "a // comment or a trailing comma is a syntax error". In an interactive session you get a Settings Error dialog; "A -p run shows no dialog", and Claude Code "skips the broken file or values and continues with the rest". A single malformed rule is a Settings Warning and is dropped while the rest of the file applies. The tool name must be the canonical one: a rule written against a label such as Stop Task does not match TaskStop.

7. You expected deny to stop a mod. The Permissions page names one exception to "deny wins": with neither managed settings nor a Team or Enterprise login, an installed mod that handles tool.check "can approve a call that a deny rule refuses or a PreToolUse hook blocked".

One thing that is not a cause: workspace trust. Allow rules from a project's .claude/settings.json wait for the trust dialog, but "deny and ask rules aren't affected, since they only restrict".

How to check what is in force

When a deny rule is the wrong tool

A deny rule is a cheap fence for what you can name: secret files, .claude/**, network commands, your own settings. It reads text, so a determined or injected agent can rewrite the command. For "this process may not touch that path or that host", turn on the sandbox, which "restricts shell commands' filesystem and network access" at the OS level. For decisions that depend on context (the branch, the arguments after parsing, a person), use a PreToolUse hook: examples and the "hook error", and how hooks and deny rules combine.

For a team, put the rules in managed settings, which "user and project settings can't override". allowManagedPermissionRulesOnly makes managed settings the only source of permission rules, so a project cannot add allow rules at all. In CI, the same rules work from --settings or --disallowedTools; see Claude Code headless mode: permissions and CI.

Where Deus Witness fits

Deus Witness does not replace deny rules; keep them. Its PreToolUse hook (deus-witness-hook) sends each matched call to the witness service, which judges it against a mandate you signed with a passkey and holds anything outside it until you approve. The hook checks the signed receipt before it answers allow and denies on any error. deus-witness doctor reads your Claude Code settings and lists the built-in tools that are neither covered by the hook nor in permissions.deny. It is a decision layer for tool calls, not a sandbox. Setup: Require approval before Claude Code runs a command unattended.