Reviewed Plugin Code Shouldn’t Ask Permission

Illustration generated specifically for this blog post.

What building a blueprint-manifest auto-allow taught me — the wall that reviewed code keeps hitting, the provenance fix that clears it, and the measurement that took my design apart three times and left it better.

Every skill in my Claude Code setup opens the same way. It writes a little state file — a run directory, a PR number, a report path — into $TMPDIR, then reads it back a step later, because the Bash tool hands you a fresh shell on every call and forgets everything in between. The read looks like this:

VAR=$(cat "${TMPDIR:-/tmp}/run-dir-${CSID}")

I wrote that line. I reviewed it. It lives in a versioned plugin file I ship to a marketplace. And Claude Code stops to ask my permission before running it — every skill, every session, dozens of times a day. Approving it teaches the tool nothing, because the string is never the same twice: the temp path and the session ID rotate on every run, so there’s no prefix to remember, and no box to tick that makes it stop.

The paradigm I actually want is one sentence. Code written verbatim in reviewed, versioned plugin files should run without asking; anything the model adjusts on the fly stays gated. Pre-canned runs, custom prompts. That’s the whole policy.

This post is about why that one sentence turned out to be hard to express to a permission system, the fix that finally expressed it — trusting where a command came from instead of what it looks like — and the part I didn’t plan for, where I instrumented the whole thing to check my work, and it contradicted three of my confident conclusions in a row.

A note on scope. Everything here is a snapshot of one plugin suite — foundry, oss, develop, research, codemap, about 890 executable bash blocks — against Claude Code’s permission model as of August 2026. The design, its generator, its runtime hook, and a two-pass adversarial review have shipped, and the live pilot that counts prompts eliminated in real sessions is running as I write this. I’ll be explicit about which numbers came from measuring the code on disk and which are early signal from a pilot still in flight, because the gap between those two is where most of the honesty in this post lives.

Why reviewed code looks suspicious

The prompts aren’t a nuisance bolted onto an otherwise clean setup. They’re structural, and they trace straight back to one harness fact: the Bash tool doesn’t keep state across calls. Each call is a fresh shell. So any skill that needs to carry a value from one step to the next has to round-trip it through a file — write echo "$X" > "${TMPDIR:-/tmp}/name-${CSID}", read it back later. That single read is the most repeated line in the whole suite.

Once state lives in files, the rest follows. Run directories get timestamped names, so $(date -u +%Y-%m-%dT%H-%M-%SZ)shows up everywhere. Rule stubs resolve their versioned cache path at read time through $(ls -td ~/.claude/plugins/cache/…), because the path changes every release and can't be hardcoded. Report filenames embed $(git branch –show-current). The blueprint code is full of command substitution because the harness leaves it no other way to work.

And command substitution is exactly what Claude Code’s permission matcher refuses to allow-list. Two blunt rules stand in the way:

  • “Contains expansion.” Any command containing $(…) makes prefix allow-rules fail closed. The prompt fires no matter what the allow list says. No entry can ever cover VAR=$(cat file).
  • First-token prefix matching. The matcher inspects only the command’s first token. A leading assignment — IFS= read -r VAR < file — presents IFS= as the first token, which matches no allow entry, ever.

My first instinct was the obvious one: rewrite $(cat …) into the substitution-free IFS= read -r VAR < …, and dodge the "Contains expansion" rule entirely. It half-worked, and then it landed squarely on the other rule — the IFS= first token that matches nothing. You can't out-idiom a first-token matcher. The two rules cover each other's blind spots, and reviewed code falls into the seam.

The first fix, and the ceiling it hit

The first working mitigation was a PreToolUse hook that thinks in shapes. It auto-allows a command only when every segment starts with a read-only whitelisted token, and its only substitutions are known-safe idioms — the sentinel read, the timestamp stamp. Anything else drops silently through to the normal prompt.

I did not trust it on my own say-so. I ran it through a two-pass adversarial review with Codex as the attacker, which found four distinct bypass classes across nineteen proofs of concept. Every PoC became a regression test. The hardening stripped writer tokens like mkdir and find from the safe set, rejected path traversal and write-redirects, and blocked loader-poisoning assignments. What came out the far side has a property worth stating plainly: its allows are provably non-mutating. By construction, it can only ever green-light a read.

That’s a good hook. It also has a ceiling, and the ceiling is the point. Shape analysis works when a command’s safety is visible in its structure. It has nothing to say about $(python some_script.py) — the largest single class, 189 of them — because "capture whatever this program prints to stdout" has no safe shape. No pattern proves it. Same story for eval "$(…)", for mkdir compounds, for the git-branch capture in every report name. The commands I most needed to stop prompting on were precisely the ones living above the shape ceiling.

Provenance, not shape

So I stopped asking the question the hook was built to answer. Not does this command look safe? — but is this the exact text of a command I already reviewed and committed?

That reframe is the whole design. Trust attaches to provenance, not appearance. A command is allowed because its bytes exist, verbatim, in a reviewed plugin file — not because its structure passed a heuristic.

The mechanism is deliberately dull. A generator walks each plugin’s skills, agents, and rules, pulls every fenced bash block, normalizes it conservatively, and hashes each whole block and each whole logical command into a per-plugin manifest:

{
"schema": 1,
"plugin": "cc_foundry@0.4.0",
"entries": {
"<sha256>": { "src": "skills/review/SKILL.md:L120", "kind": "block" }
}
}

At runtime, a second hook normalizes the incoming command with the same steps, hashes it, and looks for an exact match. Match → allow, with a reason that names the source file and line. Miss anywhere → silent passthrough to the normal prompt. A pre-commit drift gate regenerates every manifest and fails the commit if one is stale, so editing a skill’s bash without rebuilding the manifest can’t slip through.

That last piece is what makes it a standing guarantee rather than a patch for today’s idioms. The classes shape analysis could never touch — $(python …) output capture, eval "$(…)", $(git branch …), mkdir run-dir compounds — are all covered the moment they're committed, because the match is on text, not shape. And it doesn't stop at the blocks that exist now: the drift gate enrolls every future blueprint block at commit time, so from here on, any expansion the harness forces on me is covered as blueprint automatically, with no per-idiom hook work ever again. I fixed a category, not a list.

The elegant part is what falls out for free. An adjusted command — the model interpolated a real PR number, resolved a concrete path, spliced in an argument — no longer matches the committed text, so it prompts. The “custom code stays gated” half of my one-sentence policy isn’t a feature I built. It’s the default behavior of exact match. I got it by not doing anything.

And it’s easier to trust than the shape hook, which is the counter-intuitive bit. The shape hook took two adversarial rounds to reach a state I’d trust — the second came back clean — but it parses attacker-influenceable structure, so every future extension would reopen that review from scratch. The manifest parses nothing untrusted. Its trust statement fits in a sentence you can audit in one read: only text from reviewed, versioned files ever auto-runs. There’s nothing to bypass. There’s only something to replay.

The danger filter

Which is the real residual, and worth naming. Provenance proves where a command came from, not whether it’s the right moment to run it. A blueprinted cleanup step — git worktree remove –force … — is real, reviewed text. A prompt-injected agent could replay it out of order and do damage with a command that legitimately lives in the manifest.

So a manifest match is necessary but not sufficient. Any command carrying a danger token — rm, git push, git commit, git reset, a –force on a git subcommand, dd, chmod — keeps its prompt regardless of provenance. Provenance handles the boring, high-frequency 97%. A human still stands in front of the sharp edges. The deny rules and the commit guard sit behind that as the outer backstop, unchanged.

Illustration generated specifically for this blog post.

Then I measured it, and lost three arguments

Everything above was designed by reasoning, and reasoning feels more reliable than it is. So before I trusted it in the wild, I instrumented a copy of the shape hook to report exactly why it rejected each command, ran both hooks against every bash block on disk, and started building the extensions the data seemed to be asking for. Three arguments I was sure of lost to that measurement — and the design came out smaller and better for each loss. None of what follows is the system failing. It’s my confidence failing, which is the cheap kind to catch, and each finding below is a belief I held, then what the data did to it.

Belief: the rejection histogram is a prioritized backlog

Breaks — and it’s good news. Instrumenting the hook produced a clean ranked list of rejection reasons — comments 107, write-redirect 46, path-traversal 27 — and I read it the way anyone would: the top of the list is where the wins are. A first-reason histogram counts what fails first, though, and blockers stack, so fixing the top entry only exposes the next one down. Building each fix and re-running told the real story: “skip whole-line comments” was billed at 107 and added 28; “permit the sentinel-write redirect” was billed at 46 and added 1. Small gains — and that’s the good part, not a weak fix. They’re small because the manifest had already swept up nearly everything worth catching, so the histogram was ranking a backlog that mostly no longer existed.

The sharpest version was the token everyone reaches for first. sed and awk had been pulled from the safe set during hardening, at a coverage cost that felt real at the time. Add them back, and they unblock nothing — every block that would reach them is already stopped upstream. The safety trade I'd braced to pay for turned out to be free, and I only knew that because I built the variant and ran it.

Belief: the low coverage number means expand the shape hook

Breaks — and it’s good for us. Provenance came in at 34.4%, which read as one instruction: the shape hook is carrying real unique load, so grow it. Wrong. Every plugin’s blocks had been routed through one plugin’s hook, and since each plugin ships its own manifest, cross-plugin blocks could never match. Routed per plugin, coverage jumped to 92.9%, and the shape hook’s unique contribution fell to zero. The recommendation flipped from “expand it” to “leave it alone” — the manifest had been doing far more all along than the broken number let it show, which is the better world to be in.

The part I’m keeping in the post on purpose: the number that falsified my headline was already in my own output. A control sample had shown one plugin’s hook allowing 143 of its own 166 blocks — impossible to square with 32% across the corpus. I’d run the control and read straight past it. A control you already ran can kill your conclusion while you’re busy admiring the conclusion.

Belief: zero unique coverage makes the shape hook dead weight

Breaks — and it’s good I checked, because deleting it would have hurt. On verbatim text, the shape hook catches nothing the manifest misses, so the tidy move is to drop it. The mutation test said keep it. Take a blueprinted command and vary it the way a different run legitimately would — rename the sentinel file, rename the session variable, leave the structure intact. Exact-match provenance misses every one, 0 of 57. The shape hook catches all 17 of 17. Neither works on the other’s ground: the manifest owns committed text, the shape hook owns the model’s honest variations, and the design needs both.

One caveat cost me a whole pass, and it’s the useful kind. “The way a different run legitimately would” is load-bearing. My first mutation swapped ${TMPDIR:-/tmp} for a resolved literal path, which deletes the very anchor the hook keys on, scored both hooks at zero, and proved nothing except that I'd written a bad test. When a test scores every arm zero, suspect the test before the arms.

Two smaller beliefs, checked the same way

The 7% neither hook covers is a coverage hole — breaks, good for us. Fifty-six of the ~790 blocks are allowed by neither hook, which looks like a hole until you see 51 of them carry a danger token and are gated on purpose. The real residual is 5 blocks, 0.6%. Most of a gap can be the policy doing its job; check before you optimize it.

The on-disk corpus can measure real coverage —breaks— and it’s what sent me to the pilot. A sample of real session transcripts scored almost nothing, and meant almost nothing: those were plugin-development sessions, all ad-hoc git and grep and pytest, not skill runs. The population these hooks exist to serve is deviation traffic — the commands the model assembles when it steps off the blueprint — and no file on disk holds it. Only live sessions do, which is exactly what the pilot now exercises, and so far it runs clean.

What I would take from it

Beyond the specific mechanism, five things I now build by. Each cost me real time.

  1. A ranked list from static analysis is an upper bound, not a backlog. Measure marginal gain by building each fix and re-running — or you’ll spend a day earning the 1 block you were promised 46 of.
  2. Instrument the thing, then prove the instrument agrees with the real decision before you trust one number it reports. A drifted copy produces a confident, wrong histogram, and it looks exactly like a right one.
  3. The scariest-looking safety trade can cost nothing. Before you pay for hardening in coverage, build the unsafe variant and measure what admitting it would actually have bought. Sometimes it’s zero.
  4. Two mechanisms can be fully redundant on the corpus you can measure and non-substitutable in production. Pick the test that separates them — here, fair mutation — or you’ll delete the one you needed.
  5. Self-correction is the story, not the part you quietly delete. The routing bug, the unfair mutation, and the 34.4% headline all shipped as findings. They’re the most useful thing in the writeup.

Where it stands

What shipped is the whole spine — generator, per-plugin manifest, runtime hook, drift gate, docs — and it earns its place by what it takes out of the day: the reviewed idioms that used to fire a permission prompt on every skill run now execute silently, and the drift gate means I never hand-maintain that coverage as the plugins grow. The two-pass adversarial review matters more than a changelog line makes it sound. A hook that auto-allows anything is a trust surface, and the review — clean on the second pass — is what earns the right to run reviewed code without asking; without it, the whole idea is just a convenient way to get burned. The only two shape-hook extensions the data justified — skipping whole-line comments, matching .. as a real path component rather than a bare substring — shipped with a negative-test suite that still leaks nothing, test count 44 to 80. Everything the data didn't justify stayed out, which is the restraint the measurement was for in the first place.

The pilot is rolled out now, and in live use it holds. The reviewed idioms that used to interrupt every skill run go through quietly, and I haven’t seen a surprise auto-allow. The manifest covers 92.9% of the blueprint text on disk, and the live behavior matches what that predicted — the daily friction is simply gone. One caveat I’ll state plainly: the pilot numbers were collected right at the edge of the rollout, before it had fully settled, so treat them as noisy rather than final — a first read, not a booked result. What I can say without a caveat is that real usage feels clean. I haven’t frozen a single before/after percentage yet, because I’d rather let it run wider than freeze a noisy one, so take the target — ninety-plus percent of blueprint prompts gone, zero unexpected allows — as the target the rollout is bearing out. If you run it, I’d like your before/after: repost your numbers and I’ll fold the field data in.

The design that survived is smaller than the one I started with. Provenance owns the committed text. Shape owns the honest variations. A danger filter owns the sharp edges. A human owns anything none of them can vouch for. I trust it more than the version I was certain about before I measured it — and the reason I trust it more is that I stopped being certain.

The through-line isn’t really Claude Code permissions. It’s that a ranked list of reasons, generated by a tool I wrote myself, talked me out of the right answer three times — and each time the thing that caught it was building the fix and running it against the real corpus. The system that came out the other side is smaller than the one I planned, running live now, and I trust it more precisely because it survived being measured. So the question I now ask before trusting any backlog a static analyzer hands me, and the one I’ll leave you with: what’s the cheapest experiment that would tell me this ordering is wrong?

Better yet, run the rig against your own setup and see where it bends. If you’ve got a permission model that doesn’t punish reviewed code, or before/after numbers that beat or dent mine, repost them — the issues tab is open: github.com/Borda/AI-Rig.


Reviewed Plugin Code Shouldn’t Ask Permission was originally published in Level Up Coding on Medium, where people are continuing the conversation by highlighting and responding to this story.

Similar Posts

Leave a Reply