Playbook

Read-Only Access for AI Agents: The Guardrails That Made Production Possible

Everyone's first instinct for keeping an agent safe is to put "do not modify anything" in the prompt. I did it the other way. I gave my agents hands that physically cannot write, and that one habit is the reason I could point them at production at all.

The situation

The agents I run don't live in a sandbox. A support-triage agent looks up real customer account state on a schedule. Diagnostic tooling fans out across several internal services. Another agent reasons about internal staff permissions. Some runs I kick off myself. Increasingly, a scheduler in the cloud kicks them off at an hour when nobody is looking at a screen.

And the text these agents consume is not friendly input. Support tickets are written by whoever files them, which means every unattended run processes content I don't control.

The constraint

Models follow instructions well. "Well" is not a security posture.

The realistic failure isn't a malicious model. It's a helpful one: an agent that notices something it could fix and, absent a hard boundary, tries to fix it. Or an instruction that degrades over a long context. Or a prompt-injection attempt buried in a ticket that says, in effect, ignore your rules. If the only thing standing between any of that and a production write is a sentence I wrote in a system prompt, then every unattended run is a bet on instruction-following. I wasn't willing to make that bet hourly, forever, against production data.

So the safety property had to move somewhere instructions can't reach.

What I built

Four layers, from the inside out.

A client that can't POST. Every internal service call these agents make goes through a single HTTP client factory. The allowed-methods list is frozen and contains exactly one verb. The host allowlist is checked when the client is constructed and again on every request. The object handed to the agent's tool code exposes only a get(). The underlying HTTP instance stays private, and the whole thing is frozen so nothing can bolt a write method on after the fact. The write path doesn't exist to be found, which matters because agents are extremely good at finding things.

A tripwire that speaks. Tests assert the exact method list and the exact host list, and a separate check scans the source for write-verb call sites. If anyone adds a POST (including me, including a future agent editing its own tooling), the suite fails on a test whose name says what just happened: you are granting write access. The point isn't that the guard is unbreakable. The point is that breaking it can't be an accident. Loosening it means editing code and tests that announce the grant in plain words, in front of a reviewer.

A gateway with a scoped key. For a long time the weakest link was auth. The agent borrowed my personal, hourly-expiring token, with far broader access than it needed. Fine for a supervised prototype, disqualifying for anything unattended. We now front agent access with a purpose-built internal API gateway. The agent authenticates with an API key bound to an allowlist of the specific endpoints and methods this one consumer is permitted. Every write rejected, every out-of-scope path rejected, no key or a bad key rejected outright. It's defense in depth against my own first layer: even if the in-code guard were somehow bypassed, the far side of the wire enforces the same contract.

One identity per thing, and jobs that end at a comment. Each deployed piece runs as its own scoped service account. Secrets are granted per-secret to the one runtime that needs them, not project-wide. Every request identifies itself as agent traffic with a named operator, so in monitoring this traffic is attributable, not mystery load. And there's one recurring ticket type where the fix itself is dangerous: it requires a privileged edit to a hand-maintained list inside a backend service, and a wrong entry there quietly corrupts what customers see. There, the agent diagnoses, explains, names the exact fix, and posts a comment. It doesn't get the write. Knowing the answer and having the capability are different grants, issued separately.

Attack your own allowlist

An allowlist you haven't attacked is a hypothesis. Before any agent traffic went through the gateway, I built a harness that runs the full matrix both ways: every call the agent legitimately makes must be allowed, and then the interesting half. Writes must be denied. Missing and malformed keys must be denied. Out-of-scope services, paths, and path-traversal tricks must go nowhere.

It paid for itself before launch. An early configuration turned out to be almost exactly inverted: nearly every parameterized read the agent actually needed was blocked, while one bare endpoint answered with far more data than any legitimate lookup should return. The deny side held. The allow side was wrong in both directions. The harness caught it before a single agent run, and the over-broad endpoint went back to my pod for lockdown.

The positive tests tell you the agent works. Only the negative tests tell you what you've actually exposed. You do not know what a gate denies until you've tried to walk through it wrong.

What the constraints bought

The surprise wasn't that the guardrails made the agents safer. It's that they made the agents possible.

The questionSafety in the promptCapability boundaries
Can it run unattended, on a schedule?Every run bets on instruction-followingWorst case of a bad run is a wasted read
What can a hijacked run do?Whatever the borrowed credentials allowIssue a GET, to an allowlisted endpoint
What does loosening look like?An invisible prompt editA failing test that names the grant
What does a leaked credential cost?A person's full accessOne scoped key, rotated in minutes
Can I explain the risk to someone?"The model is usually good about this"A one-paragraph contract, enforced twice

That last row is the organizational one. "The model is usually good about this" is not a sentence a director can stand behind when something breaks. "It is not capable of writing, here is the code and the test and the gateway rule" is. And least privilege turned out to be an enabler, not a tax: the scoped gateway key is what finally replaced the hand-pasted personal token, which is exactly what unblocked running the agent in the cloud at all. The safest design and the most operable design were the same design.

There's no dollar figure to put on this one; prevented incidents don't leave receipts. The measurable result is capability: agents that run unattended against production, on a schedule, with a risk story I can explain in one paragraph.

What I'd do differently

  • Start with a scoped key, even for a prototype. The borrowed personal token was supposed to be temporary and lingered for months, and it was the single thing blocking unattended runs. A scoped key on day one would have cost an afternoon.
  • Write the deny tests before the gate goes live. I wrote the gateway config first and built the test harness after, and the config turned out to be nearly inverted. In the other order, the config would have been written against a harness that failed until the config was right, and the inversion could never have shipped. This is nothing new; it's TDD, still a gold standard, applied to a security config instead of code. Write the tests that must fail, then write the gate until they pass. That ordering applies to any gate, not just this gateway: prove what it blocks before you trust what it allows.
  • Automate secret scanning on agent output from the start. The one time a key surfaced in a session transcript, it got rotated because I happened to be reading. Agents generate a lot of text that gets pasted, summarized, and stored. Detection shouldn't depend on my eyeballs.
  • Put the "no raw HTTP outside the guarded client" rule behind tooling sooner. Today that line is held by convention, a source scan, and review. It's real, but it's the softest wall in the stack, and I've known it longer than I've acted on it.

Take what helps

Every capability my agents have sits on top of a stack of things they can't do, and the ordering matters. I built the can't-do layer first. It's the least glamorous and most load-bearing engineering in the whole program.

If you take one thing: decide what the system must never do, then put that decision in code, behind a test, on the far side of the wire. The prompt goes back to being what it's good at (instructions) instead of load-bearing safety equipment. Then hand the agent the keys and notice that you're comfortable. Not because you trust the model, but because you counted the keys.

Your services and risk tolerance will vary. Take what helps, skip what doesn't.

Get new playbooks by email

One email when a new playbook or guide goes up, nothing else. Unsubscribe anytime.