cd /news/ai-safety/your-permission-list-can-t-see-taint · home topics ai-safety article
[ARTICLE · art-97750] src=ai2rules.dev ↗ pub= topic=ai-safety verified=true sentiment=· neutral

Your Permission List Can't See Taint

Anthropic's Claude Code permission system is vulnerable to prompt injection because its flat allowlist cannot track the provenance of tool calls, a concept known as 'taint' that has existed since Perl's 1989 taint mode and the Linux kernel's tainted flag. The article argues that replacing the permission pile with a manifest that describes the actor's world and supports taint, combined with correctly using PreToolUse hooks where 'allow' means grant rather than defer, provides a safety floor against poisoned memory exfiltration.

read7 min views2 publishedJul 21, 2026
Your Permission List Can't See Taint
Image: Ai2Rules (auto-discovered)

Open your Claude Code settings.json

and look at the permissions

block. If you’ve lived in it a while, it’s a pile: Bash(npm run test:*)

, Edit(src/**)

, WebFetch(domain:…)

, an mcp__…

line you don’t remember adding. It grows, nobody prunes it, and no one can read it top to bottom and say what the agent can actually do.

That’s the visible problem. Here’s the one underneath it: the pile is blind to where a request came from. Bash(curl:*)

says “curl is allowed.” It cannot say “curl is allowed unless the agent just read a web page that told it to run this one.” A flat allowlist has no notion of provenance — and provenance is the whole game in prompt injection.

There’s a name for the thing the pile can’t see. It’s taint.

Taint, and why it isn’t new #

The idea is older than agents. Its most familiar home is the Linux kernel: load a proprietary driver and the kernel marks itself tainted — and it stays tainted even after you unload the driver, because the point was never the driver, it’s that the kernel’s trustworthiness is already spent (kernel docs). Perl shipped the same reflex as taint mode in 1989: data from outside the program is marked, the mark spreads to anything derived from it, and tainted data isn’t allowed to do dangerous things until you’ve explicitly checked it.

Point that reflex at an agent’s tool calls and you get a safety floor: anything that came from an untrusted source is tainted, taint only ever spreads (it never washes back out), and once a session is tainted, nothing that reaches the outside world — a web request, a file write — is allowed out. That floor is what stops a poisoned memory from turning into an exfiltration.

Now look back at the settings.json

pile. It has nowhere to put a taint mark. It matches on the shape of a call — the tool, the argument pattern — and never on the call’s history. That isn’t a missing feature you could bolt on. It’s the wrong kind of object.

Govern the actor, not the file #

The reframe is small and it changes everything: you’re not editing a tool’s config file, you’re describing the world a particular actor is allowed to act in. The actor is Claude. The description is a manifest — one reviewable artifact that says which actions exist, what each one may touch, and how trust flows through them.

Two things a manifest can express that a pile can’t:

Absence, not refusal. A dangerous tool isn’t on a deny-list the model can argue with; it simplydoesn’t existin the agent’s world. We’ve written aboutwhy that distinction mattersABSENT

is notDENY

.Taint. The manifest carries the floor above. The same request is allowed with a clean history and denied with a tainted one.

Which raises the obvious question: your host already has a permission system. Can a manifest actually replace it, or only nag from the sidelines?

The trick that makes it possible: “allow” doesn’t mean allow #

Claude Code lets you run a PreToolUse hook — a small program that sees every tool call before it runs and returns a verdict. Here’s the part almost everyone gets wrong, and it’s the crux of the whole thing. A hook has three ways to not block, and two of them look identical until they don’t:

Defer— the hook exits quietly with no verdict. The call falls through to Claude Code’snormalpermission flow, which may still show you an Allow/Deny prompt.Grant— the hook explicitly answers"allow"

. Claude Codeskips the prompt and runs the call. It doesn’t defer; itauthorizes.Escalate— the hook answers"ask"

and forces the prompt.

“Allow” in plain English sounds like “don’t block.” In the hook contract it means grant — silence the prompt, wave it through. The gap between those two readings is a genuine footgun: a security hook that returns "allow"

as its default “nothing to see here” doesn’t hand control back to the permission system, it replaces the permission system, silently, for every call that passes its check. That behavior has been surprising enough to file about, more than once (1, 2, 3, 4).

Read as a footgun it’s a hazard. Read as a hinge, it’s exactly the door we want: if a hook can grant, then a manifest driving that hook can be the whole policy — not an overlay on top of the pile, a replacement for it.

Replacing the pile #

So we empty the settings.json

permission block down to nothing and let the manifest do the deciding. In our harness that’s a hook running in “grant” mode against a world manifest: it answers allow

for what the manifest permits, blocks what it doesn’t, and carries the taint floor underneath.

Here is the same handful of actions, judged by the old pile and by the manifest:

what the agent tries                     the pile        the manifest
a clean file read                        allow           allow  (granted, no prompt)
curl to a web page                       allow           allow  (granted — session now tainted)
the same kind of curl, right after       allow           DENY   ← the pile can't see the difference
rm -rf on a path                         a match, maybe  ask
a tool you never declared                absent = ¯\_(ツ)_/¯  ABSENT — it doesn't exist for the agent

Row three is the whole post in one line. Same command, opposite answer — because between the two the session touched the network, and the manifest can see that. The pile matches both curl

calls against the same rule and lets both through. No amount of pruning your allowlist fixes that, because the allowlist is asking the wrong question.

The honest caveats #

This is a real mechanism, not a magic wand, and the boundaries matter.

It governs A hook can deny or grant a call, but it can’t make a tool vanish from the model’s menu. Fullcalls, not thesurface.ABSENT

— the tool genuinely not existing — still needs the host to prune its tool list. The manifest becomes the source of truth fordecisions; making tools disappear is a separate lever.Empty the baseline, or it fights you. A nativedeny

/ask

rule still fires even when the hook grants. Leave rules insettings.json

and they’ll quietly override the manifest. Replace mode wants the pile actuallyempty.Some prompts can’t be silenced, by design — an org-mandated approval, or a tool flagged as needing a human, still asks no matter what the hook says. Good.Pin your version and check. The grant behavior has shifted across releases (that’s what those issues are about). Verify it does what you expect before you rely on it.

Try it where it can’t hurt you #

One flag in this setup — the one that makes undeclared tools ABSENT

— will lock the agent out of anything your manifest forgot to list. That’s the point when you’re testing a policy, and a disaster if you do it to the Claude you’re currently working in. So don’t. Run the experiment against a throwaway, containerized Claude — a disposable instance with the manifest mounted in and the internet fenced off behind an allowlist, exactly the sandbox we’ve written up before. Lock that agent out and the fix is docker rm

, not a ruined afternoon.

The container is also the honest home for the emptied settings.json

: the stripped-down config lives in the container only, overlaid at runtime, while your real project file never changes. Separate the config you develop with from the config that governs the runtime — and get to be reckless in the one place recklessness is free.

The takeaway #

A permission list is a fine thing for what it is: a set of shapes you’ve decided to wave through. But it will never know that the shape it’s waving through was suggested, thirty seconds ago, by a document the agent doesn’t trust. That’s not a rule you can add. It’s a question the format can’t hold.

A manifest can hold it. Give the actor a world instead of the tool a list, put the taint floor underneath, and let the hook grant instead of defer — and the same curl

that was fine a moment ago is refused now, for the only reason that ever mattered: not what it does, but where it came from.

── more in #ai-safety 4 stories · sorted by recency
── more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/your-permission-list…] indexed:0 read:7min 2026-07-21 ·