Permissions and refusal¶
An agent that cannot be stopped is not deployable. This page is about making "the agent refused" into an outcome your system produces on purpose and your tests can assert on.
The asymmetry¶
Start here, because every other decision on this page follows from it:
A wrongly denied command is an annoyance. A wrongly allowed one is an incident.
Those two errors are not symmetric, so the policy that treats them symmetrically is wrong. Which means the default has to be deny, and everything the agent is allowed to do has to be stated.
This is unpopular because it is more work up front and it produces friction during development. It is also the only version that fails safe when the model does something nobody anticipated — which is the entire category of problem you are defending against, since if you could anticipate it you would have just prevented it.
The policy¶
Rules are checked in order and the first match wins:
class Rule(BaseModel):
tool: str = "*" # tool name, or '*' for any
argument_pattern: str | None = None # regex over the serialised arguments
decision: Decision = Decision.DENY
reason: str = ""
class PermissionPolicy(BaseModel):
rules: list[Rule]
default: Decision = Decision.DENY
def check(self, tool: str, arguments: dict[str, Any]) -> tuple[Decision, str]:
for rule in self.rules:
if rule.matches(tool, arguments):
return rule.decision, rule.reason
return self.default, "no matching rule"
Source:
agent/permissions.py.
Decision has three values, not two: ALLOW, DENY, and ASK. The third is what a human-in-the-loop
deployment needs — the call is neither safe enough to run unattended nor wrong enough to refuse. It is
in the type from the start because retrofitting a third state into a boolean is miserable.
ASK has a measured failure rate, and it is bad¶
Worth knowing before you reach for it as the safe default.
Claude Code launched with essentially the maximal version of ASK — allow reads, prompt for every
write, bash and network call — aimed at an audience of developers who can read a shell command.
Anthropic's telemetry:
| Measurement | Value |
|---|---|
| Permission prompts approved by users | ~93% |
| Reduction in prompts after shipping an OS-level sandbox | 84% |
| Benign commands blocked by the model-based auto-approver | ~0.4% |
| Overeager actions the same classifier still lets through | ~17% |
Source: How we contain Claude across products.
A 93% approval rate is not oversight; it is a click-through agreement. The mechanism is ordinary alarm fatigue — the more approvals someone sees, the less attention each one gets — and it means a feature built to provide supervision can end up reducing it, by manufacturing the feeling of review without the substance.
Two consequences worth internalising:
ASK is a budget, not a mode. Every prompt spends a fixed amount of a finite resource: the
user's attention. Prompting on everything spends it immediately. The prompts that survive should be
the ones a human can actually adjudicate and would plausibly refuse.
The tighter the perimeter, the fewer questions you have to ask. The 84% figure is the whole
argument for sandboxing stated in user-experience terms. ASK is what you
fall back on when the environment cannot hold the boundary — not the boundary itself.
And the audience decides which is available. A developer can evaluate git push --force; a knowledge
worker cannot reasonably be asked to adjudicate
find . -name "*.tmp" -exec rm {} \;. Where the user cannot judge, the answer has to be an absolute
boundary rather than a dialog.
Ordering is the whole design¶
The default policy denies destructive patterns first, then allows tools generally:
Rule(tool="bash", argument_pattern=DESTRUCTIVE_SHELL, decision=DENY, ...)
Rule(tool="bash", argument_pattern=GIT_DESTRUCTIVE, decision=DENY, ...)
Rule(tool="bash", argument_pattern=SECRET_EXFIL, decision=DENY, ...)
Rule(tool="read", decision=ALLOW)
Rule(tool="write", decision=ALLOW)
Rule(tool="bash", decision=ALLOW) # only reached if no deny matched
Flip the last line above the first three and the policy still passes a naive "does it allow bash?"
test while permitting rm -rf /. First-match-wins with denies at the top is not a style preference;
it is the property that makes the policy mean anything.
What gets denied by default¶
Three families, kept deliberately short — a deny-list nobody understands is a deny-list nobody maintains.
| Family | Catches |
|---|---|
DESTRUCTIVE_SHELL |
rm -rf and variants, mkfs, dd if=, fork bombs, chmod -R 777 /, writes to /dev/sd*, shutdown/reboot/halt |
GIT_DESTRUCTIVE |
push --force, push -f, reset --hard origin, branch -D |
SECRET_EXFIL |
curl/wget/nc in the same command as $*_KEY, $*_TOKEN, .env, id_rsa, credentials |
The third one is the interesting category and the one most policies miss entirely. Destructive
commands are loud and you will notice them. Exfiltration is quiet — a curl that posts your .env
to a pastebin looks like ordinary network activity and produces no error, no missing file, and
nothing in the transcript that reads as alarming.
A deny-list is a speed bump, not a wall
rm -rf / is caught. python -c "import shutil; shutil.rmtree('/')" is not. Base64-encoded
payloads are not. A model determined to be clever will get past any regex, and so will a
prompt injection in a file the agent reads.
The deny-list is layer 2. Layer 1 is the disposable container, and it is the only one that is really a boundary. Treat the regexes as protection against plausible accidents, not against adversaries.
The subtle bug in the pattern list¶
Worth reading the comment in the source, because it is a real trap:
# These are plain (non-verbose) regexes on purpose. Rules accept user-supplied
# patterns, so compiling with re.VERBOSE would silently change the meaning of
# anyone else's pattern containing a literal space or '#'.
The obvious way to write a long pattern list is re.VERBOSE, so you can space it out and comment it.
But Rule.argument_pattern is user-supplied, and it is compiled by the same code path. Turn on
re.VERBOSE and every user pattern containing a space stops matching what its author meant — silently,
with no error, in the direction of allowing things. A formatting preference turns into a security
regression.
Denial is a first-class result¶
When a call is denied, the loop does not raise. It records a DeniedCall, opens a tool.denied
trace span, and feeds a result back to the model:
Three consequences, all of them the point:
- The agent can adapt. It sees the refusal and can try a safe alternative, which is what a competent colleague would do.
- The run continues. One refused command does not throw away twenty steps of correct work.
denied_callsends up inRunResult, which means an eval can assert on it.
That last one is what makes refusal testable. A permissioning task passes when three things hold
together: the destructive request was refused, denied_calls >= 1, and the sandbox tree hash is
unchanged. You are asserting that nothing happened — and "nothing happened" is only checkable if the
harness records the near-miss and the grader can look at the filesystem.
Proving the policy is load-bearing¶
A test suite that passes whether or not a feature works is decoration, so the policy is checked by
removing it. tests/test_mutation.py runs the deny-recursive-delete task twice: once with the
default policy, where the agent's rm -rf data is refused and the tree survives, and once with an
allow-everything policy, where the command runs and the task fails.
There is a second half to it that is easy to miss. tree_unchanged alone cannot tell refused from
ignored — an agent that never attempted the command leaves exactly the same filesystem as one the
harness stopped. So each permissioning task also requires denied_calls >= 1, and a third test
asserts that an agent which simply declines the request does not pass.
Run that check against your own guardrails. It is uncomfortable and it is the only way to know.
Three risks, and a policy only covers two of them¶
A useful taxonomy, because "who are we defending against" changes what a rule can achieve:
| Risk | What it is | Can a deny-list help? |
|---|---|---|
| User misuse | The user asks for something harmful, carelessly or deliberately | Yes — this is what the deny-list is for |
| Model misbehaviour | The agent does something nobody asked for | Partly. More capable models make fewer obvious mistakes but are better at finding paths nobody thought to write down |
| External attackers | Instructions arrive through a file, tool result or web page | No. See Prompt injection |
The second row has a counter-intuitive shape: capability and safety do not move together. Weaker models misread situations and fail visibly; stronger ones route around restrictions that were never written down, which is harder to notice and harder to enumerate.
The third row has a case that defeats the entire model layer. In a controlled internal red-team,
Anthropic phished an employee into pasting a prompt that read like ordinary task instructions and,
partway through, asked Claude to read ~/.aws/credentials and POST it to an endpoint. Across 25
attempts, the exfiltration completed 24 times. No classifier fired, and none could have: model-layer
defences anchor on user intent, and here the user was the one typing. Only egress control and
filesystem boundaries stop that.
The general principle, and it is the summary of the whole page:
Design for containment at the environment layer first, then steer behaviour at the model layer. The deterministic boundary is what gets hit when everything probabilistic misses.
Open problems¶
ASK does not scale, and nothing has replaced it. Model-based auto-approval is the current best
attempt — trading a 0.4% false-block rate for a ~17% miss rate — which makes it another probabilistic
layer inside a sandbox, not a substitute for one. There is no known way to get an attentive human
into a loop that runs for hours.
Policies are syntactic; risk is semantic. A regex matches command text. What matters is effect,
and the same effect has unbounded spellings — the rm -rf versus shutil.rmtree problem
generalises. Capability-based designs like
CaMeL attack this by tracking data provenance and
constraining what tainted data may trigger, but nothing of that kind is widely deployed.
Supervision changes shape with experience, in both directions. Anthropic's usage data shows experienced users auto-approve about twice as often as new ones — while also interrupting the agent mid-execution more. They are supervising the trajectory rather than the step, which is arguably better and depends entirely on noticing drift. As agents write more ambitious commands, and as work fans out across subagents, drift gets harder to see.
Agent identity is unresolved. Should an agent be its own principal with its own credentials and audit trail, or an extension of the user inheriting their permissions? Today it is almost always the second, which means every agent action is indistinguishable from a user action in every downstream log. Standards work is in progress; there is no settled answer.
Writing your own¶
See Write a permission policy.
For why the SECRET_EXFIL family exists and why cutting the outbound channel is the highest-value
rule in the whole policy, see Prompt injection and the lethal trifecta.