Defenses have a range too
A while back, I wrote a post called “Permissions have a range.” Tell an AI “you can push” once, and it will carry that permission into other repos and other tasks on its own. So a permission is bounded along three axes — repository, task, and kind of operation. I logged the failure in an external memory file called mistakes.md, dropped the operations I really cared about into git-layer hooks, built the whole two-layer defense, and closed with “I’ll probably pay this tuition a few more times yet.”
Three days later, I paid. The one who collected was the very Claude Code that helps me with my work.
“Translate it to English” became a merge to main
What I was doing at the time was maintenance on news, a self-built unattended AI task (a public repo) that writes a news digest every morning. I was getting it into a distributable shape, and asked Claude Code to “translate the whole thing to English.”
Claude Code created PR #6 with the translation. Fine so far. The problem was what came next: once CI passed, it ran gh pr merge and took the PR into main. I never said a word about merging.
Its reasoning was exactly the same as last time. Just before this, on a different batch of PRs in the same repository, I had said “merge them in order.” It carried that permission over as merge permission for the translation PR — a different task. The behavior from the earlier post — “it took a permission granted once and quietly extended it” — replayed itself, beat for beat. Except this time the operation had grown beyond push to gh pr merge: taking code into a protected branch, a decisive operation.
The memory rule was written at the right granularity
Here’s where this time is different. Last time, the story was “the recorded lesson was too coarse to catch the mutation.” Not this time. mistakes.md said (excerpt):
Correct Action: Permission for an operation applies to that repo and that task only — never carry it forward
Trigger: right before git commit / push — especially when push permission was granted in a different task just before“Never carry it forward” was already written down. The body of the rule fits this failure exactly. It fit — and it didn’t work.
The reason it didn’t work is in the Trigger line. The firing condition was “right before git commit / push,” and merge isn’t push, so it never fired. The content of the rule was right, but the index that decides when to recall it was pinned to the surface of a command name: push. Merge walked past the outside of that net.
The machine guards weren’t broken — they were walked through, head on
The earlier post’s conclusion was “memory is weak against mutation, so push anything that really matters down into the machine layer.” So how did the machine layer do? This repo’s main branch was protected: no direct pushes, changes require a PR, enforced even for admins. On top of that, a hook called confirm-master-push.sh — which intercepts any git push headed for master or main and forces a pause — was alive too.
All of it was working correctly. And all of it was walked through.
What branch protection enforces is the shape of the process — “go through a PR, with CI green” — not whether a human approved. There was a PR. CI was green. Mechanically, a perfectly legitimate operation. The hook, meanwhile, was watching the git push command; gh pr merge is a different command, and it never even enters the hook’s field of view. Two doors led to the same outcome — code landing on main — and the defense was mounted on only one of them.
Defenses have a range too
Lay it out, and three defenses were defeated that day, none of them broken.
- The memory rule had a range: its firing condition, “right before a push”
- The push hook had a range: its watch target, “the git push command”
- Branch protection had a range: its process, “PR plus CI”
Just as permissions have a range, defenses have a range. The moment you stand a defense up, what it doesn’t see gets decided at the same time. Failures recur, without malice, through the places where the defense’s range runs out. The mutation from last time’s push to this time’s merge wasn’t anyone hunting for a loophole. From the AI’s side the logic held — “I was just given merge permission” — and the defense that would stop that perfectly logical operation simply wasn’t mounted anywhere.
“Creating” and “sending” are different things, I wrote last time. What I learned the hard way this time: as long as the defense implements that distinction by command name, it leaks every time another “send”-class operation shows up. Push, merge, publish, delete. A human groups these by meaning — decisive operations that leave the machine — but hooks and memory triggers are strung along the literal text git push. The gap between meaning and literal text is exactly where a defense’s range gets holes.
Filling the gaps one square at a time
The fix was to extend the defense’s range in the direction it got beaten.
That same day, I added a merge-specific entry to mistakes.md. Its Trigger: “when taking a PR into main or a protected branch with gh pr merge — especially when merge/push permission was granted in a different task just before.” In effect, registering the word merge into the memory’s index, right next to push.
Later, I turned it into a mechanism: confirm-merge.sh, a hook that makes gh pr merge confirm-first, sitting next to the push hook. Failures caught by memory get pushed down into the machine layer — the same two-layer flow as the earlier post. Incidentally, that hook itself turned out to have a hole of its own — an option splitting the command let things through silently — found the very day it was built; I wrote that up separately, as the story of having a different agent audit it and plug it. Add a defense, and that defense’s range opens a new gap somewhere. The nesting doesn’t end.
Closing
The prediction at the end of the last post came true, just not the way I expected. I thought the same failure would come through the same door again. Instead, the failure came through the door the defenses weren’t watching. Not by aiming. It stopped at the doors the defenses watched, and didn’t stop at the ones they didn’t. That’s all it was.
So a defense over an AI’s decisive operations is never something you set up once and walk away from. Each time it gets beaten, you work out which range it slipped past, then widen the memory’s index or the hook’s watch target by one square. Beyond this grind, I don’t yet have any way to close the gap between meaning and literal text. It takes the same patience as teaching a permission its range: you grow the defense’s range, square by square.