# Claude Code's Deny List Can Be Bypassed. Here's What a Real Enforcement Boundary Looks Like.

Deny rules match command strings inside the client — compound commands, substitution, and one documented flag all route around them. Where client-side permissions honestly stop, and what enforcement outside the agent process looks like.

The short version: **Claude Code's deny rules are string matches evaluated inside the client, and both halves of that sentence are bypassable.** String matches miss commands rewritten to not match. The client can be told to skip enforcement entirely. Neither is a secret — Anthropic's own docs caution against treating Bash deny rules as a security boundary, and the write-ups documenting the bypasses rank on the first page when you search for them.

What's mostly missing from those write-ups is the second half: what enforcement that *doesn't* have this property looks like. That's this post. And since we ship a product whose install registers a Claude Code hook, we're in the blast radius of our own argument — so we'll be precise about which of our own layers share the weakness.

## The bypass classes, plainly

You don't need an adversarial model for any of these. A helpful agent trying to complete its task will wander into them.

**1. The deny rule matches the string, not the effect.** A rule like `Bash(rm:*)` matches commands that *start with* `rm`. The same deletion reaches the filesystem as a compound command (`cd /tmp && rm -rf data`), via command substitution (`bash -c "$(echo cm0gLXJmIGRhdGE= | base64 -d)"`), through an interpreter (`python3 -c "import shutil; shutil.rmtree('data')"`), or by the agent writing a three-line script and running it — which is not a trick, it's what coding agents *do all day*. The effects of a shell command are not recoverable from its prefix. Any prefix-matched deny list on a Turing-complete tool is a speed bump.

**2. The permission prompt gates the call, not the capability.** Approving `Bash(make build)` approves whatever the Makefile does — including reading `.env` and posting it somewhere, if the Makefile (or a dependency's install script) says so. The approval UI shows you the string; the string doesn't contain the behavior.

**3. The client can opt out of its own enforcement.** *Corrected 2026-08-28: an earlier version of this section said `--dangerously-skip-permissions` disables PreToolUse/PostToolUse hooks. It doesn't — hooks fire in every permission mode, and a hook `deny` still blocks. [The full correction is here](/blog/claude-code-dangerously-skip-permissions).* The real form of this bypass class sits one level down, in the config rather than in a runtime flag: `"disableAllHooks": true` in `settings.json`, editing or deleting the hook entry, or running a build with no hook configured. In any of those, ACP's hook doesn't fire, the audit log is empty for the session, and there is no server-side signal that a hook *didn't* run. Every hook-based tool — ours, anyone's — shares this property, because the hook only exists inside the process that agreed to run it. What it takes is local write access to the config, which for a fleet is a very different question than for one laptop.

Add these up and the structural statement is: **a check that runs inside the agent's process, on the agent's terms, enforces nothing against that process.** It filters accidents. Accidents are most incidents, so client-side rules are worth having — but they're a convenience layer, not a boundary.

## What makes a boundary real

The criteria aren't new; they're the reference-monitor requirements, settled in 1972 and [worth restating for agents](/blog/when-to-use-an-agentic-control-plane): the enforcement point must be **non-bypassable** (on every path the effect can take), **tamper-proof** (not modifiable by the thing it constrains), and small enough to verify. Claude Code's deny list fails the first two by construction — not because it's badly built, but because it lives in the client.

So the fix isn't a better regex. It's moving the decision to a place the agent's process can't reach:

**Gateway-executed tools.** When a tool call runs *through* a gateway — MCP tools fronted by a control plane, typed tools the gateway executes — the tool's effect *is* the gateway's action. A denied tool isn't blocked; it's absent from `tools/list`. There is no client-side flag that makes a tool the server doesn't expose come back.

**The model proxy.** Route the agent's model traffic through a proxy and denied `tool_calls` are stripped from the response before the harness ever sees them. The harness can't execute a call it never received. This also can't be disabled by a client flag — a client's escape hatches turn off things inside the client, not its need to reach a model. (This is the layer `claude-acp` adds on top of the hook; plain `claude` keeps the hook layer and skips the proxy.)

**The sandbox, for the shell.** Here's the honest limit on our side of the argument: for a raw `Bash` tool, even a gateway sees a string. If the threat you care about is a determined exfiltrator with shell access to live secrets, the control that holds is an OS-level boundary — container, VM, file permissions — because it constrains *effects*, not command text. We've [said this before](/blog/when-to-use-an-agentic-control-plane) and it doesn't stop being true because we sell the other layer.

The composition that actually works: sandbox the shell, gateway the typed tools, proxy the model — and keep the client-side rules as the fast local filter they are. Enforcement layered from outside in, with the deny list demoted from "security boundary" to "first line."

## What this looks like in practice

One command sets up the outside layers for a coding agent:

```bash
curl -sf https://agenticcontrolplane.com/install.sh | bash
```

That registers the hook (the convenience layer — and yes, anyone with write access to `settings.json` can remove it; that's why it's not the whole story), sets up the model proxy (the layer the flag *doesn't* disable), and captures the agent's declared tool surface — all 76 tools of a live Claude Code session, indexed in the [Tool Surface Index](/tool-surfaces) — so your allow/flag/deny decisions are made against what the agent can actually do, [priced and visible per action](/for-coding-agents).

If you take one thing from this post, though, take the criterion, not the product: for every control you rely on, ask *where does it run?* If the answer is "inside the process it's supposed to constrain," you have a filter. Useful, worth keeping — and not the thing between your agent and the incident. The thing between your agent and the incident has to live where the agent can't edit it.
