Skip to main content
Back to blog

Allow Is a Hope. Deny Is a Rule.

5 min read

I run a worker called sandbox-builder that scaffolds small apps with a local model and tries to compile them, unattended. When that loop gives up with the build still red, a new Phase 4 fires: a headless Claude session opens inside the generated repo and works the build until xcodebuild says BUILD SUCCEEDED. Then comes the rule I care about most — my worker runs the build again itself. Claude reporting success counts for nothing. Only my own compiler's word upgrades the task, and if the build is still red, the task stays flagged with concerns instead of pretending.

That was the easy part. The hard part was deciding what an unattended AI session, running in the middle of the night with nobody watching, is allowed to do on my machine.

The first draft had every key I own

The first wiring used bypassPermissions — the same seam another of my workers already ran unattended, so it barely registered as a decision. Then I put the diff through adversarial review, and it registered. One HIGH: the app name arrives in the dispatch payload, and a crafted one could path-traverse out of the apps directory — into the tree the worker deletes, and into the working directory of that anything-goes session. Fixed in the same PR with a strict single-segment gate. And one LOW that turned out to be the whole story: the app spec's free-text description gets interpolated verbatim into the prompt of a session that can do anything I can. A prompt-injection channel, wired straight into the most permissive process in my house.

The allowlist wasn't a wall

The fix looked obvious: swap bypass for an allowlist. Read, edit, write, the search tools, xcodegen, xcodebuild — what a build-fix loop needs, nothing more.

Then the doc-verified discovery that changed the design: --allowedTools is not default-deny. Permission rules merge across scopes, so my seven careful entries union with the ambient allow rules already sitting in my settings file — roughly five hundred and forty of them. python3. git. gh api. Whole WebFetch domains. A read rule covering my entire home directory. scp. An injected description wouldn't need to defeat my allowlist at all. It could ride every yes I'd ever told my own machine.

What actually holds is the other direction: deny wins over allow, from every scope. As the docs put it, "Deny rules and explicit ask rules apply in every mode, including bypassPermissions." So the shipped shape is a small allowlist plus a roughly forty-entry deny backstop that does the real work: network egress, every interpreter bare and path-prefixed — including swift — git, credential and keychain tooling, ~/.ssh, ~/.aws, ~/.claude, my env file. I also looked at --bare, the hermetic option, and rejected it: it skips the keychain OAuth read, which breaks the exact headless seam this whole pipeline runs on.

An allowlist describes what you hope happens. A deny list is the only sentence the permission system reads as final.

The build system is a shell with extra steps

Even fenced, one primitive was left on the table: xcodebuild executes run-script build phases. It is arbitrary code execution by design. A session that can edit project.yml can plant a script phase, and then my verify build — the one whose green I trust — runs the payload with my own hands. So before the verify build, the worker rewrites project.yml from its own template. Whatever the session did to the project definition, the build that counts runs mine. The untrusted description got fenced too: it sits between markers keyed with a per-run random nonce, so an injected end-of-spec marker can't spoof the boundary.

While I was cutting keys, the chain was lying to me

Same day, different lesson. Headless claude -p OAuth had been dead machine-wide since before the 5 AM chain — every call returning 401. The chain scored those tasks COMPLETED, about fourteen seconds each. A false green. Nothing woke me up, because nothing thought anything was wrong.

Two defects, and neither was a permissions problem. The keychain token had gone invalid — no fence I'd built that day touches that. And the gstack worker returned a plain dict on a non-zero exit, so the base class recorded COMPLETED. The chain wasn't broken. It was fluent.

Fixed the same day: re-auth brought headless Claude back, a live probe of the deny backstop then passed — read denied, cat denied, ssh denied, and the run kept going instead of aborting — and PR #298 made failed runs actually score FAILED. That PR also carried the deny-backstop pattern to the gstack worker, the last one running blanket bypass. It keeps bypass, because its skills bail without it; the backstop binds anyway, because deny binds in every mode.

What's proven and what isn't

The unit suites are green — hundreds of tests, including new ones locking the deny-list contents, the flag ordering, the project.yml reset, and the nonce fence. The deny probe ran live and held. But as I write this, no real failing scaffold has been through the full Phase 4 finalize under the new deny set. The fence is tested; the machine it fences is still unproven live. And the deny list is a category backstop, not a hermetic sandbox — OS-level sandboxing is the stronger option, and the code says so out loud.

Here's what the week actually taught me. The permission work decides what an unattended AI can touch. The honesty work — re-running the build myself, refusing to let a 401 score as COMPLETED — decides whether I can believe a single thing it tells me. The failure that really happened wasn't an attacker riding my five hundred ambient yeses. It was my own pipeline calling a dead service a success, fourteen seconds at a time.

A fluent green is not a true one.