{"slug": "your-permission-list-can-t-see-taint", "title": "Your Permission List Can't See Taint", "summary": "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.", "body_md": "# Your Permission List Can't See Taint\n\nOpen your Claude Code `settings.json`\n\nand look at the `permissions`\n\nblock. If you’ve\nlived in it a while, it’s a pile: `Bash(npm run test:*)`\n\n, `Edit(src/**)`\n\n,\n`WebFetch(domain:…)`\n\n, an `mcp__…`\n\nline you don’t remember adding. It grows, nobody\nprunes it, and no one can read it top to bottom and say what the agent can actually do.\n\nThat’s the visible problem. Here’s the one underneath it: **the pile is blind to where\na request came from.** `Bash(curl:*)`\n\nsays “curl is allowed.” It cannot say “curl is\nallowed *unless the agent just read a web page that told it to run this one*.” A flat\nallowlist has no notion of provenance — and provenance is the whole game in prompt\ninjection.\n\nThere’s a name for the thing the pile can’t see. It’s *taint*.\n\n## Taint, and why it isn’t new\n\nThe idea is older than agents. Its most familiar home is the Linux kernel: load a\nproprietary driver and the kernel marks itself **tainted** — and it stays tainted even\nafter you unload the driver, because the point was never the driver, it’s that the\nkernel’s trustworthiness is already spent\n([kernel docs](https://docs.kernel.org/admin-guide/tainted-kernels.html)). Perl shipped\nthe same reflex as *taint mode* in 1989: data from outside the program is marked, the\nmark spreads to anything derived from it, and tainted data isn’t allowed to do dangerous\nthings until you’ve explicitly checked it.\n\nPoint that reflex at an agent’s tool calls and you get a safety floor: anything that\ncame from an untrusted source is tainted, taint only ever spreads (it never washes back\nout), and once a session is tainted, nothing that reaches the outside world — a web\nrequest, a file write — is allowed out. That floor is what stops a\n[poisoned memory](/blog/the-zombieagent-threat/) from turning into an exfiltration.\n\nNow look back at the `settings.json`\n\npile. It has nowhere to *put* a taint mark. It\nmatches on the shape of a call — the tool, the argument pattern — and never on the\ncall’s history. That isn’t a missing feature you could bolt on. It’s the wrong kind of\nobject.\n\n## Govern the actor, not the file\n\nThe reframe is small and it changes everything: you’re not editing a tool’s config\nfile, you’re **describing the world a particular actor is allowed to act in.** The actor\nis Claude. The description is a manifest — one reviewable artifact that says which\nactions exist, what each one may touch, and how trust flows through them.\n\nTwo things a manifest can express that a pile can’t:\n\n**Absence, not refusal.** A dangerous tool isn’t on a deny-list the model can argue with; it simply*doesn’t exist*in the agent’s world. We’ve written about[why that distinction matters](/blog/why-deny-is-dangerous/)—`ABSENT`\n\nis not`DENY`\n\n.**Taint.** The manifest carries the floor above. The same request is allowed with a clean history and denied with a tainted one.\n\nWhich raises the obvious question: your host already *has* a permission system. Can a\nmanifest actually replace it, or only nag from the sidelines?\n\n## The trick that makes it possible: “allow” doesn’t mean allow\n\nClaude Code lets you run a **PreToolUse hook** — a small program that sees every tool\ncall before it runs and returns a verdict. Here’s the part almost everyone gets wrong,\nand it’s the crux of the whole thing. A hook has three ways to *not* block, and two of\nthem look identical until they don’t:\n\n**Defer**— the hook exits quietly with no verdict. The call falls through to Claude Code’s*normal*permission flow, which may still show you an Allow/Deny prompt.**Grant**— the hook explicitly answers`\"allow\"`\n\n. Claude Code**skips the prompt** and runs the call. It doesn’t defer; it*authorizes*.**Escalate**— the hook answers`\"ask\"`\n\nand forces the prompt.\n\n“Allow” in plain English sounds like “don’t block.” In the hook contract it means\n*grant* — silence the prompt, wave it through. The gap between those two readings is a\ngenuine footgun: a security hook that returns `\"allow\"`\n\nas its default “nothing to see\nhere” doesn’t hand control back to the permission system, it **replaces** the permission\nsystem, silently, for every call that passes its check. That behavior has been\nsurprising enough to file about, more than once\n([1](https://github.com/anthropics/claude-code/issues/28812),\n[2](https://github.com/anthropics/claude-code/issues/52822),\n[3](https://github.com/anthropics/claude-code/issues/18312),\n[4](https://github.com/anthropics/claude-code/issues/39344)).\n\nRead as a footgun it’s a hazard. Read as a hinge, it’s exactly the door we want: if a\nhook can *grant*, then a manifest driving that hook can be the whole policy — not an\noverlay on top of the pile, a replacement for it.\n\n## Replacing the pile\n\nSo we empty the `settings.json`\n\npermission block down to nothing and let the manifest do\nthe deciding. In our harness that’s a hook running in “grant” mode against a world\nmanifest: it answers `allow`\n\nfor what the manifest permits, blocks what it doesn’t, and\ncarries the taint floor underneath.\n\nHere is the same handful of actions, judged by the old pile and by the manifest:\n\n```\nwhat the agent tries                     the pile        the manifest\na clean file read                        allow           allow  (granted, no prompt)\ncurl to a web page                       allow           allow  (granted — session now tainted)\nthe same kind of curl, right after       allow           DENY   ← the pile can't see the difference\nrm -rf on a path                         a match, maybe  ask\na tool you never declared                absent = ¯\\_(ツ)_/¯  ABSENT — it doesn't exist for the agent\n```\n\nRow three is the whole post in one line. Same command, opposite answer — because between\nthe two the session touched the network, and the manifest can see that. The pile matches\nboth `curl`\n\ncalls against the same rule and lets both through. No amount of pruning your\nallowlist fixes that, because the allowlist is asking the wrong question.\n\n## The honest caveats\n\nThis is a real mechanism, not a magic wand, and the boundaries matter.\n\n**It governs** A hook can deny or grant a call, but it can’t make a tool vanish from the model’s menu. Full*calls*, not the*surface*.`ABSENT`\n\n— the tool genuinely not existing — still needs the host to prune its tool list. The manifest becomes the source of truth for*decisions*; making tools disappear is a separate lever.**Empty the baseline, or it fights you.** A native`deny`\n\n/`ask`\n\nrule still fires even when the hook grants. Leave rules in`settings.json`\n\nand they’ll quietly override the manifest. Replace mode wants the pile actually*empty*.**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.\n\n## Try it where it can’t hurt you\n\nOne flag in this setup — the one that makes undeclared tools `ABSENT`\n\n— will lock the\nagent out of anything your manifest forgot to list. That’s the *point* when you’re\ntesting a policy, and a disaster if you do it to the Claude you’re currently working in.\nSo don’t. Run the experiment against a **throwaway, containerized Claude** — a disposable\ninstance with the manifest mounted in and the internet fenced off behind an\nallowlist, exactly the [sandbox we’ve written up before](/blog/running-claude-safely/).\nLock that agent out and the fix is `docker rm`\n\n, not a ruined afternoon.\n\nThe container is also the honest home for the emptied `settings.json`\n\n: the stripped-down\nconfig lives *in the container only*, overlaid at runtime, while your real project file\nnever changes. Separate the config you develop with from the config that governs the\nruntime — and get to be reckless in the one place recklessness is free.\n\n## The takeaway\n\nA 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.\n\nA manifest can hold it. Give the actor a world instead of the tool a list, put the taint\nfloor underneath, and let the hook *grant* instead of defer — and the same `curl`\n\nthat\nwas fine a moment ago is refused now, for the only reason that ever mattered: not what it\ndoes, but where it came from.", "url": "https://wpnews.pro/news/your-permission-list-can-t-see-taint", "canonical_source": "https://ai2rules.dev/blog/permission-list-cant-see-taint/", "published_at": "2026-07-21 00:00:00+00:00", "updated_at": "2026-08-15 07:42:53.117926+00:00", "lang": "en", "topics": ["ai-safety", "ai-agents", "ai-policy"], "entities": ["Claude Code", "Anthropic", "Linux kernel", "Perl"], "alternates": {"html": "https://wpnews.pro/news/your-permission-list-can-t-see-taint", "markdown": "https://wpnews.pro/news/your-permission-list-can-t-see-taint.md", "text": "https://wpnews.pro/news/your-permission-list-can-t-see-taint.txt", "jsonld": "https://wpnews.pro/news/your-permission-list-can-t-see-taint.jsonld"}}