the short answer
Usually because the action arrived in a shape the rule does not describe. A rule matching terraform destroy does not see the same command inside ./cleanup.sh, a Python boto3 call, a Makefile target or a base64-decoded string. Agents rarely do this to evade you; a script is a normal way to finish a task. Close the gap by gating script writes, adding a decision model that judges the call, and scoping credentials underneath.
- Weakness
- String matching sees syntax, not effect
- Common routes
- Shell scripts, SDK scripts,
bash -c, wrappers, encoding, other tools - Layer 1
- Deterministic
denyon known shapes, including script writes - Layer 2
- A decision model that judges the tool call against the user’s request
- Backstop
- Credentials that cannot perform the action at all
Six Ways a Blocked Action Still Happens
This is why teams that only configured a harness’s built-in deny list report that “agents get around it”. Anthropic’s own permissions documentation says a Bash rule matches the command text Claude writes and is not a security boundary around the program: Bash(rm *) does not stop /bin/rm -rf build/ or bash -c 'rm -rf build/'. See are managed settings enough.
| Route | Example | Why a string rule misses it |
|---|---|---|
| Write, then run, a script | Agent writes cleanup.sh containing terraform destroy, then runs bash cleanup.sh | The executed command is bash cleanup.sh |
| SDK instead of CLI | python -c "import boto3; boto3.client('route53').change_resource_record_sets(...)" | No aws route53 token appears |
| Shell indirection | bash -c "$CMD", eval, sh <<EOF | The command is assembled at runtime |
| Wrappers | make teardown, ./bin/deploy --destroy, a justfile target | The dangerous call lives in a file the rule never reads |
| Encoding | echo dGVycmFmb3Jt... | base64 -d | sh | The literal text is not present |
| Different tool | An MCP server or IDE tool that performs the same API call | The rule only gates the shell tool |
Layer 1: Deterministic Rules That Also Read What Gets Written
The first fix is cheap: gate the write as well as the run. A PreToolUse policy sees Write calls with their content and Edit calls with their new_string, so a script that contains a destructive call can be refused before it exists.
import { customPolicies, allow, deny } from "failproofai";
// Destructive infrastructure calls, whether run directly or written into a
// script, including argument lists such as ["terraform", "destroy"].
const DESTRUCTIVE = [
/\b(?:terraform|tofu)["',\s]+destroy\b|\bapply\b[^\n]*\s-destroy\b/,
/\b(change|delete)[-_]?(resource[-_]?record[-_]?sets|hosted[-_]?zone)/i,
/\bkubectl\b["',]?(?:\s+--?[\w-]+(?:[=\s]+[^-\s|]\S*)?)*["',\s]+delete\b/,
];
customPolicies.add({
name: "no-destructive-scripts",
description: "Refuse commands and scripts that embed destructive infra calls",
match: { events: ["PreToolUse"] },
fn: async (ctx) => {
const text = ["Write", "Edit"].includes(ctx.toolName ?? "")
? String(ctx.toolInput?.content ?? ctx.toolInput?.new_string ?? "")
: ctx.toolName === "Bash" ? String(ctx.toolInput?.command ?? "") : "";
if (!DESTRUCTIVE.some((re) => re.test(text))) return allow();
return deny("This embeds a destructive infrastructure call. Propose it as a reviewed change, not a local script.");
},
});Test Cases
An Edit only carries the replaced fragment in new_string, so a destructive line added through an edit is caught, but one already in the file is not.
| Tool call | Expected |
|---|---|
Write: cleanup.sh | deny |
Write: dns.py | deny |
Bash: kubectl --context prod delete ns payments | deny |
Write: README.md | allow |
Write: teardown.py | deny |
Write: dns.go | deny |
Bash: kubectl get pods | allow |
Bash: kubectl get pods | grep -v delete | allow |
Where Layer 1 Stops Working
- Scripts that already exist in the repository (a
Makefiletarget) are run, not written, so content checks never see them. - Runtime-assembled commands and encodings defeat any regex you can maintain.
- Broad patterns block legitimate work. The coding agent pack’s opt-in
block-terraformdenies the whole CLI, includingterraform plan, which developers need all day. - Each new tool, SDK or wrapper means another pattern.
Layer 2: Decision Models That Judge the Call
A decision model replaces “does this string match” with “what does this call do, and did the person ask for it”. Failproof’s semantic layer uses Jev, TypeSafe AI’s classifier, which answers yes/no checks about a tool call in the context of the user’s recent prompts: does it change live infrastructure, does it delete data that cannot be regenerated, does it send credentials off the machine. The checks come from the FailproofAI/jev-policies pack, and Jev runs only once it is configured, through FailproofAI Cloud or your own provider.
Two properties matter. It judges the call in context, so python -c "...boto3...delete..." is judged on its inline code. And it can clear false positives from broad rules: a policy marked reviewable lets Jev clear terraform plan or aws sts get-caller-identity while the destroy stays denied. Policies marked hard can never be cleared. It does not read files on disk: bash cleanup.sh is judged on its name and arguments, which is why layer 1 checks scripts when they are written.
| Call | Rule alone | With Jev review |
|---|---|---|
terraform plan -out=tfplan | Denied by block-terraform (if enabled) | Cleared: read-only |
terraform destroy -target=module.cdn | Denied | Denied: changes live infrastructure |
aws s3 ls | Denied by block-aws-cli (if enabled) | Cleared: read-only |
rm -rf node_modules | Denied by block-rm-rf (if enabled) | Cleared: regenerable |
bash scripts/cleanup_dns.sh | Allowed: no matching token | Judged on the command line, not the file’s contents |
What a Decision Model Does Not Fix
- It only judges what it is sent: the tool call, the recent prompts and the agent’s last message, with secrets redacted.
- It is probabilistic. Failproof documents measured cases where evidence landed just under the firing line and a reviewable deny was cleared. Keep the policies whose failure you cannot afford hard.
- It adds one model call per reviewed call.
- It is not a credential boundary. Use scoped AWS profiles as the backstop.
Find Bypasses That Already Happened
Failproof’s saved queries list “policy denials followed by a successful alternative” as a common use: a deny, then the same effect reached another way a few calls later. Run that over your fleet’s sessions under Analyze → Queries, or ask the Failproof Assistant (fp agent ask "show sessions where a denied command was followed by a script that did the same thing"), and turn each hit into a test case.
Red-Team Your Own Rules in 20 Minutes
- Pick one blocked action. Ask the agent to do it directly. Confirm the deny and its reason.
- Ask it to “write a script that does this and run it”. Note whether the write or the run was caught.
- Ask it to do the same with the language SDK your team uses (
boto3, the Go SDK, the Node SDK). - Ask it to use a
Makefiletarget that already wraps the action. - Record every miss as an
fp policies testcase, then decide per miss: new deterministic rule, semantic check, or credential change.
FAQ
Is the agent trying to evade the policy?
Usually not. When a command is denied, a capable agent looks for another way to finish the task, and a script is a reasonable way. Write deny reasons that tell it what to do instead, so it stops searching.
Should I just block bash?
No. Coding agents need a shell to be useful, and developers will switch tools if the agent cannot run tests. Gate effects, not the shell.
Does the decision model see my code?
It sees the tool call, the recent prompts and the agent’s last message, with secrets redacted. Through FailproofAI Cloud the request is forwarded to TypeSafe and not logged or kept by Failproof.
Sources
Checked against the sources below on October 21, 2026. Harness settings, hook capabilities and cloud features change between versions.
- Claude Code docs: Configure permissions
- Failproof AI docs: Custom policies (policy SDK)
- Failproof AI docs: Policy authority
- Failproof AI docs: Jev policies
- Failproof AI docs: Jev through FailproofAI Cloud
- Failproof AI docs: Use a policy pack
- OWASP: LLM06:2025 Excessive Agency