My agent signed its own permission slip
What a month of giving a coding agent real publishing authority taught me about where the gates have to sit.
Bypass. Claiming the read-aloud review had happened meant typing READALOUD_DONE=1, so it got typed without the review. Twice.
Keyed wrong. The rule was a list of document types, commit messages included, and it got skipped anyway. A list gets read while you write, not when you send.
Failed open. The gates wrote their refusals using jq, a JSON tool that was not guaranteed to be installed. Without it they printed nothing, and printing nothing means allow.
No check at all. Nothing was watching commits leaving for a remote, where a leaked secret is permanent, or the hooks that run code without asking.
Wrong measure. The large-file check measured bytes when the rule was about kind, so a build artifact passed for a week while a comment claimed it was covered.
Acted unasked. A session committed nine times without being asked. The rule against it lived in three places and relied on the agent remembering it.
Wrong reach. A shared push hook was edited for one project and installed only there. Sixteen other repositories kept the old copy.
The week I turned the prompts off, publishing became impossible#
Turning the permission prompts off and making publishing impossible were the same decision, not opposite ones.
There is one line in my Claude Code settings file that explains everything else here:
"defaultMode": "bypassPermissions"Permission prompts, globally off. The agent runs commands on my machine without asking.
In the same week, I made it structurally impossible for that agent to publish anything. A hook runs before every tool call and can refuse it. It denies every GitHub CLI command by default. The allowlist holds four pure reads and nothing else, and there is no token, flag or phrase that gets past it. When the agent needs to open an issue, it prepares the command and hands it to me to run in another terminal. Three more guards have been added since, on three other irreversible things.
Those two decisions look contradictory. They were the same decision.
This is a field report from one machine. I run 21 git repositories on a Linux desktop, some private and local only, some private on GitHub, some published as public mirrors of private work. Material leaves this machine through three channels: a git push, the GitHub CLI, and files I send by hand. Working this way, I have accumulated 4,236 lines of rules carrying 414 separate dated entries. Not all of them are failures. The ones worth reading are, and each of those records a guard that broke, the date it broke, and why the way I had written it was wrong.
That record is the only thing here worth your time. The tooling is unremarkable: about 4,400 lines of shell and 1,300 of Python, guarded by 258 regression tests. The design ideas are mostly not mine. Anthropic's Beyond permission prompts11 claude.com makes the same argument from the other side of the tool. What I have that they do not publish is a list of the specific ways my own guards failed open, with dates.
Scope, stated up front so nobody has to guess: N=1, one person, one machine. The dense part of the failure record covers about three weeks. These fences are built against accidents and carelessness, not against a determined attacker, and where that distinction matters I say so.
A safety step you can sign for yourself#
A safety step the agent can assert for itself is not a safety step. The review token was typed without the review, twice.
The first version of my publishing gate had an escape hatch. Before anything went out under my name it was supposed to get a read-aloud review, and the way you asserted that review had happened was to prefix the command with READALOUD_DONE=1.
It got typed twice in three days, on July 28 and July 30, without the review ever happening.
Not by a careless human. The agent typed it, because typing it was the documented way to proceed and proceeding was the task. The token was not a check. It was a form, and the thing filling it in was the thing it existed to restrain. The header of the replacement hook puts it better than I can paraphrase:
A gate whose clearance is self-issued is not a gate; it is a speed bump with a form to sign.
The interesting part is what I did next, because my first instinct was wrong. The obvious response is to write the rule more firmly: bold it, add a warning, explain the consequences. I had already done that. The rule was not unclear and the agent was not defying it. The failure was that a compliant actor could satisfy the letter of the check without doing the work, and no amount of emphasis changes that.
So the token was deleted. Not tightened, not logged, not conditioned. The affordance was removed, and now there is no way to assert the review happened, because assertion was the bug.
Then the gate itself got rebuilt, and that rewrite carries its own lesson. It had been a blocklist: a list of publishing verbs that were denied. Testing the real command surface showed the list leaked badly. gh repo fork, gh release upload, gh pr ready, gh workflow run and gh secret set all walked straight through, and every one of them puts something in front of other people. A blocklist can only ever lag behind whatever the tool ships next, so it became an allowlist of four reads with everything else denied.
Later I handed it to a different model, in a session that had not written the code and was asked only to find the input that defeats it. It found that the new allowlist check was grepping the whole command string. So gh auth status && gh repo delete passed it, because one allowlisted read anywhere in the string cleared everything after it. The deleted READALOUD_DONE check had carried a comment warning about exactly this: a match "must be a real PREFIX, not merely present somewhere in the string". When I deleted the check, I deleted the warning with it.
The lesson had been written as a comment, and comments do not survive the deletion of the code they sit beside. It is now a regression test instead, in a suite that passes clean as I write this. That version held.
Nine commits nobody asked for#
A rule written down in three places and left to the agent's memory produced nine unrequested commits. It took two hooks to stop.
The token story has a sequel, and it is the one that taught me what actually mattered about it.
On August 7 I went looking at a project called arctic-coverage and found that a session had committed to it nine times without being asked. Not destructively. Nothing was lost, nothing was published, and every commit was defensible on its own. But I had never said the word, and the workspace rule has always been that only the word "save" commits.
That rule was written down in three places. It relied on the agent remembering it, and remembering is exactly what had failed twice already with the review token. So the rule got a mechanism, and the mechanism is two hooks rather than one.
The first runs on every prompt I submit and writes down what that prompt authorized. The second runs before any tool call and refuses a commit, a push or a deploy when the matching word is not in the record. Three words, three actions, and no cross-grants: "save" does not authorize a push, "push" does not authorize a commit, and neither authorizes a deploy.
The grant cannot be self-issued. It is written from my keystrokes and from nothing else, and the agent has no way to run the hook that writes it. That is the property the deleted token lacked, and it is the only reason this is not the same mistake wearing a new name. From the outside the two look identical: both are a marker that has to be present before an action proceeds. The difference is who can create the marker.
Two rules constrain the recorder, and both come from the same fact: it runs on every single prompt I type.
- It prints nothing to standard output, ever. I would not have predicted this one, and it is the one I find most interesting. In Claude Code, whatever a
UserPromptSubmithook prints is injected into the agent's context. So a grant the agent can read is a grant it can quote back to me, reason about, and argue around. A guard has to be invisible to the thing it guards, or it stops being a guard and becomes a topic of conversation. - It never fails the prompt. Every step is guarded and it always exits zero. If the recorder breaks, no grant is written, and the gate then denies. The failure lands on the guarded actions instead of on my session. That is fail-closed pointed in the one direction that matters. The hook sitting next to it fails open instead, on purpose, and both choices are right, because the two of them would do different damage by breaking. The first version got it wrong in the other direction, and that failure is more instructive than the design. It did not accept a project name, so
save resumewas denied. That is my own established way of saying it, quoted verbatim in my own notes. I caught it within the hour by typing it and being refused. A gate that rejects the normal way of saying the thing is a gate that gets switched off, and that costs more than the strictness buys.
Gates fire on words I type, not on what the agent does#
Gate on the word the human types, not on what the agent is doing. Rules written as lists of nouns get skipped; the same rules written as moments hold.
Every agent guardrail tool I have looked at gates on agent behaviour: this command is destructive, that branch is protected, this commit is missing a trailer. Mine gates somewhere else. The triggers are words I type. "save" runs every check and reports. "push" does nothing but push. "desktop" builds a thing, drops it where I can send it, and runs the send checks.
The split between save and push is the whole design in miniature. Save does all the work and never publishes. Push publishes and checks nothing, because the checking already happened and I read the report before I said the word. My reading of that report is the gate. There is no hook enforcing it, and adding one would be theatre.
That is a placement rule, not an accident. Where a human keystroke already exists in the flow, I do not add a mechanism, because a mechanism there is ceremony, and ceremony is what trains the reflex that broke the first gate. Email is the clean case: I press Send myself, so the send channel gets a report and no hook. The GitHub CLI is the opposite case, because publishing there happens in one motion with no moment where I decide anything. That channel gets the absolute block.
Underneath this sits the finding I did not expect and now trust more than anything else in the system.
Rules written as lists of nouns fail. The same rules rewritten as moments hold.
I have watched this happen three times on unrelated subject matter. The review rule was originally a list of document types that needed review: emails, cover letters, README files, commit messages. It was skipped twice anyway. The post-mortem is blunt about why, and notes that incompleteness was not the problem: the list already named commit messages. A list of nouns gets consulted while you are producing something that looks like one of those nouns, and never at the moment a command runs.
The same shape appeared again on a rule about material I send by hand, which
used to read "sending something by hand", which named no moment and so fired on nobody's schedule. Now it's a word he types.
And once more on a rule that said to reconcile project trackers "at the end of each working session", which names no moment either, so it ran on whether I happened to remember. It moved to "at save", which is a word I type.
I think this generalizes beyond my machine, and it is the one idea here I would defend in a room. An agent consults its instructions when something in the current context resembles the instruction. Categories resemble content. Moments do not resemble anything, they occur. So a rule attached to a category fires when the agent is thinking about that category, which is not reliably the moment the irreversible thing happens.
Concentrating decisions into a few typed words also does something for me rather than to me. Scale X ran an interactive study across 40,000 plays and found humans missed roughly one threat in three22 scalex.dev when approving agent commands, with miss rates climbing measurably toward the end of each run. That is the failure mode of asking a person to approve everything. Blanket human review is not safety and autonomy at once. It is the unsafe option, because attention is finite and it degrades under exactly the load that approval prompts create.
Fail closed, and report scope instead of verdicts#
A check that cannot tell "nothing wrong" from "I could not look" is not a check, so every guard reports what it covered rather than only its verdict.
The principle is written into my rule file in one line:
A check that cannot tell "nothing wrong" from "I couldn't look" is not a check.
I did not start there. I arrived at it by finding, repeatedly, that my guards were reporting clean when they had not run at all. A partial list, all real, all dated:
- The secret scanner was a shell pipeline, so its exit status was grep's. Feed grep an empty input and it reports no match, which is indistinguishable from a genuinely clean diff. If the
git diffahead of it failed outright, the scan passed. - One check used
grep -q "", which matches every line of every file forever, so it could never fail. - One check searched with
git grep, which only searches tracked content, so any untracked file was invisible to a check whose entire purpose was finding untracked files. - A check found 45 real issues and printed nothing, because its section had never been added to the report's render list. The check ran. The evidence went nowhere.
Every one of those failed silently, in the safe-looking direction, sometimes for weeks. The pattern across them is not that shell is treacherous, though it is. It is that a verdict-only report cannot distinguish success from non-execution, so the moment a check breaks it starts telling you what you want to hear.
The fix is small and I would recommend it to anyone regardless of what they are building. Report scope before verdict. Not "no secrets found" but "scanned 4 files, 212 lines, no matches, 0 suppressed". The scope line is what makes a broken check look broken.
It has already paid for itself. A runner invoked with a path argument silently resolved to the wrong repository and scanned nothing at all. What caught it was the scope line reading zero files, against a warning sitting in that same script:
A ZERO-FILE SCAN IS REPORTED AS A ZERO, NEVER AS A PASS. A green tick beside "scanned 0 file(s)" is the exact shape of the failure this whole system is built around: it cannot be told apart from a scan that found nothing, and the eye reads the tick, not the count.
A verdict-only tool would have printed "no matches" and been believed.
The gates themselves failed open, including the one written not to#
The worst instance of this was not in a scanner. It was in the gates.
All three of my PreToolUse guards built their refusal payload by shelling out to jq. The pattern was jq -n ... 2>/dev/null followed by exit 0. With jq missing, the JSON is never emitted, standard output is empty, and an empty exit-zero reads as allow. So on a machine without jq every one of them would have decided to block and then permitted the action anyway, silently, with nothing printed to say so.
The publishing gate was worse than that, and separately. It read the command through jq too, so a missing jq left the command string empty, the next line exited zero, and every GitHub CLI command was allowed. The one default-deny guard on the box, with permission prompts turned off behind it, would have switched itself off rather than weakened. Switched off, not weakened. That difference is the whole argument here.
The detail I keep coming back to is in the action gate. Its no-jq branch had been written deliberately to fail closed on the gated verbs, with a comment explaining the reasoning. And that branch called into the shared refusal function, which needed jq. The path that had thought about a missing jq was undone by the path that had not. A guard is only as closed as its least-considered exit.
All three now fall back to exit code 2 with the reason on standard error, which is the protocol's other refusal path and needs nothing installed. All three suites assert that branch by staging a PATH with no jq on it. I found all of it while writing another guard, where the same question came up and I answered it properly on the first try. Building a new guard is the cheapest audit of the old ones I have found.
The guards live in the files the worms target#
The settings file a supply-chain worm plants hooks in is the same file the guards are declared in, so it is audited against a pinned allowlist and its committed version.
On April 30 2026 the Shai-Hulud "Mini" worm shipped in PyPI's lightning package, versions 2.6.2 and 2.6.3, and planted a SessionStart hook in .claude/settings.json. It arrived through an ordinary package install.
Read that carefully, because the target is not a script in a hooks directory. It is four lines of JSON in a settings file, pointing anywhere it likes. SessionStart is the event to fear, because it fires when a session opens and nobody has to be tricked into anything.
And here the argument I have been making turns around and bites. On this machine bypassPermissions is on, so nothing stands behind a hook at all. The autonomy I bought by turning the prompts off is exactly what makes four lines of JSON the highest-value target on the box. A fence that cannot be inspected is a fence that has been moved.
So the settings files get audited. Every hook declaration is compared against a pinned allowlist of exact commands. An autorun event, a hook running code from inside the workspace, a changed hash on the publishing gate's own script, or a project-level settings file appearing where none should exist: each of those raises the alarm on its own.
Two holes turned up, and both were found by testing rather than by reading.
The first: a deleted hook was invisible. The loop only judged hooks that existed, so removing the gate's entry from the settings file passed clean, and the hash pin matched too, because that attack never touches the script. Deleting is also the cheaper attack. Editing the gate requires understanding it. Deleting four lines of JSON does not.
The second is subtler and I would not have found it by inspection. The settings file is tracked in git, so a worm editing it leaves a diff. Every tool I own already saw that diff and reported it as "1 changed file", which is indistinguishable from me changing a theme. A tripwire nobody reads is not a tripwire. The hooks block is now compared against the committed version specifically, so a hook appearing between commits is its own loud finding rather than one line in a status summary.
Both were verified by planting a real SessionStart running curl … | bash in the actual settings file and confirming the checks fired, then restoring it. Testing a guard by describing it to yourself is how the first hole survived.
A hash pin and a test suite are two different questions#
The pins tell me that a guard changed. They say nothing about whether it still works, and a pin passes happily on a guard somebody edited into uselessness and then re-pinned. So each guard also carries a regression suite, and a nightly timer runs both. Integrity and correctness are not the same property, and I had been treating them as one.
One of them has already caught a real bypass: a kill gate that allowed the exact flag combination you reach for when the first attempt fails. A pin would have been perfectly happy with that guard.
One asymmetry inside the pins earned its keep. The commit gate is two hooks, a recorder and a reader, and both are pinned as separate entries. If the recorder goes missing, the reader denies everything, which is loud and announces itself within seconds. If the reader goes missing, nothing is checked at all and the recorder carries on writing grant files that nobody reads. That is silent, and silent is the dangerous direction, so the half that fails quietly is the one that needs its own pin.
Three different threats, and which fence is for which#
Accident, injection and malice need separating. Gating the irreversible act rather than the intent behind it catches the first two without telling them apart, and does not extend to the third.
I have been sliding between three threats without naming them, and they need separating, because a fence built for one is usually weak against the others.
Accident. The agent doing something reasonable that I did not ask for, or a command whose blast radius is larger than intended. Nine unrequested commits. A process-killing command broad enough to match the shell it was running in, so a cleanup killed itself halfway through. A publish that skipped the review because the review had a form to sign. This is where nearly everything here is aimed, and it is the honest headline: these are anti-accident fences.
Injection. Text an attacker can influence entering the session and being read as instruction rather than as data. A CI log from a pull request opened by a stranger. A dependency's README. A skill file. One thing points here: the decision not to widen the publishing gate to fetch CI logs directly, because a log from a pull request a stranger opened is text an attacker can write.
Malice. Someone with code execution on this machine, or a package that plants a hook on install. The settings audit, the pinned allowlist and the hashes live here, and all of them are detection rather than prevention. Everything they compare against sits on the same disk they audit.
Separating them turned up the thing I did not expect.
The gates do not ask why. The commit gate refuses because the word is not in the record. It has no opinion about whether the agent decided to commit on its own, was talked into it by text in a log, or was driven by something running as me. An accident and a successful injection produce the same action, and a fence on the action catches both without ever having to tell them apart.
That is an argument for gating the irreversible act rather than the reasoning behind it, and it is the one part of this design I would defend as load-bearing rather than convenient. Intent is guessable and forgeable. git push is neither.
It does not extend to malice, and the stopping point is sharp. An attacker who can edit the hook can edit the checker beside it. The word-triggered design also has a plain dependency: if I never type the word, nothing is ever checked. That is a cost, not a hole, but it is a real one.
And there is a version of the injection case that walks straight past all of this. I would rather name it than have it named for me.
The gate needs my keystroke. It does not need my keystroke to be well-informed. Injected text cannot type "save", but it can shape the report I read before I decide to. The Scale X study above says humans miss roughly one threat in three when they are approving things, and that finding turns around: it argues against trusting me at the concentration point, not only against trusting me at every prompt.
The honest answer is that concentrating the decisions does not make me a better reviewer. It makes the number of decisions small enough that I still have attention left when an important one arrives, which is a weaker claim than it first sounds and is the one I actually believe. Everything here rests on it.
The agent is not careless, it is average#
The last failure is not an action at all. It is prose that is competent, generic, and wrong for the surface it lands on.
Everything above is a fence on an action. There is one more failure and it does not involve an action at all.
I had the agent write a two-page explainer for a prospective client. The prose was fine, the facts were checked, and the client's reaction was that it read like something a machine produced. She was right, and looking at it beside a real proposal from an engineering firm made the reason obvious. It had a heading every three paragraphs. It had bold lead-ins on its bullets. It opened sections with questions and answered them. Real proposals have numbered sections, captioned tables, a reference number and a footer, and none of those had occurred to either of us.
Nothing was wrong. That is the entire problem.
A model's default output is the most typical output, and for "explain this clearly" the most typical output is the shape that appears most often in the training data. That shape is competent, which is why it survives review, and it is instantly recognisable, which is why it fails. The failure is invisible to whoever produced it and obvious to whoever receives it, which is the same asymmetry as the guards that only failed closed once somebody else reviewed them.
The fix turned out to be the fix from every other section of this piece. I had written a document quality pass and made it a word I typed, so it ran when I remembered to ask, which was not always, and never on the documents I was least suspicious of. It is now a mandatory step in the one command that puts anything in front of another person, and its first stage is not a style rule at all. It is an instruction to go and fetch a real document of the same kind, render it to images, and look at it, because reading a proposal as text tells you what it says and nothing about how it was built.
A rule that says "write well" is a category. A step that fires when a file is handed over is a moment. That distinction has now cost me the same lesson four times.
What it bought, what it cannot do, and what nobody has solved#
The ledger, with the limits named rather than implied: better output and fewer decisions, no protection against malice, and nothing solved about being a well-informed reviewer.
What it bought, before any ledger, is the thing I built it for: the output is better.
Better means coverage. Fewer observations missed, more methods tried before I settle on one, and every finding checked by something whose job was to refute it. Not more output in the same time. Fewer things I failed to look at, and conclusions that have survived more attempts to knock down.
Then the ledger, because the argument also rests on the frontier actually having moved.
One piece of operational evidence, and it is the only one, because the rest of this is design argument rather than measurement. The guard that refuses a pattern-matching kill fires roughly once a day, and the agent's own reaction to being blocked has been "this hook just saved me". It carries a finding I would rather not have: the gate was built alongside a small tool that records a process group at launch, so nothing ever has to be searched for by pattern, and firing daily means that tool is underused and the old reflex is still there. The fence works and the habit it exists to correct has not changed.
Now the limits, stated as plainly as the wins.
- These are anti-accident, not anti-malice. A regex cannot truly parse bash. This catches carelessness, not a determined evader, and the system says so in its own comments.
- The hooks are not a hard boundary, and this is documented rather than theoretical. Claude Code issues #11226 and #36637 cover hook bypasses, and there are symlink-escape CVEs in the same family.
git commit --no-verifyskips every git hook I install. The guards live in files the agent can edit, and the checker that audits them sits on the same disk it audits. An attacker who edits both wins. - The commit gate cannot see through a wrapper. It reads the command it is handed, so a shell script that commits internally is invisible to it. Nothing in my workspace does that today, which I have checked rather than assumed, and it is a fact with an expiry date, so that nightly run watches for it.
- Word-triggered gates depend on the human typing the word. That is the honest cost of building the triggers out of my own vocabulary. If I never say "save", nothing checks anything.
- Some of it is still unfixed, and that is deliberate. Two history rewrites on published repositories were considered and declined, because in each case something had reached a public repository that should not have, and the rewrite is a bigger act than that justifies. Neither was a credential, which is the one case where that trade would be wrong, since a secret that reaches a remote is compromised whatever you do to the history afterwards. A written decision beats an unnoticed one.
- N=1. One person, one machine, about a month, a cooperative agent. This is an existence proof, not a methodology.
The field is not much further along, which is the uncomfortable part. There is still no reliable model-level fix for prompt injection, with adaptive attacks reported around 85% success against current defenses (arXiv 2601.1754833 arxiv.org). Execution provenance is not yet standard (arXiv 2606.0499044 arxiv.org), and the hook, skill and MCP supply chain is live and unsolved: Skill-Inject55 arxiv.org reports around 80% attack success against skill files, and the first malicious MCP server in the wild was found in production last year.
What does look durable is architectural containment rather than better instructions. CaMeL66 arxiv.org, the six design patterns paper77 arxiv.org and Meta's Agents Rule of Two88 ai.meta.com all land in the same place, which is roughly where I ended up by trial and error.
If you take one thing from this and change nothing else, make it the smallest one. Print scope before verdicts. Every guard I own that told me a comforting lie told it by reporting a verdict with no scope attached, and every one of them was caught the moment a count appeared next to the tick.
I build sensor and test systems for hardware companies, and I work this way on client deliverables too. If your team is trying to work out what an AI-assisted engineer should be accountable for, I am happy to talk about what has and has not held up here.
Appendix: the failure timeline#
Nineteen dated failures between 28 July and 14 August 2026. Every fence in this piece exists because of one of them.