The line turned up in the egress log on a Friday afternoon, in among two hundred unremarkable ones:
DENY job-4711 → collect.some-analytics.io:443
An agent run had installed a dependency, and somewhere in that dependency’s install script sat a call to the outside world. No attack, no compromised registry. A library reporting usage data at setup time, the way it has done for years. The interesting part was not that it tried. The interesting part was that nobody had asked it to, that the connection never happened, and that on Monday we could read that it had tried.
We tell that story often, because it gets at what agent security is actually about. The question is never whether the model means well. The question is what can happen when anything in the chain — the model, a package, a script in a repository, a snippet of text in a ticket — does something nobody planned for.
Trust is not a control
The usual first instinct is to make it a question of trust: can we trust the model? Is it reliable enough to let loose on a repository unsupervised?
It sounds like a sensible question and it points the wrong way, because it has no checkable answer. An agent that did nothing surprising across a thousand runs guarantees nothing about the thousand-and-first — not least because its behaviour depends on inputs that come off the internet. The text in an issue is input. A dependency’s README is input. A build tool’s error message is input.
Trust does not scale. Boundaries do. You draw a boundary once and it holds for every run, every model, every version of every dependency, without anyone re-assessing it. It is also the only thing you can say to a client that is not a variation on “it has worked well for us so far”.
Four boundaries that actually change something
A container with no Docker socket. The agent runs in a container — easy to say, and usually where the thinking stops. What matters is what you hand into that container. A mounted Docker socket turns the isolation into decoration: anyone who can talk to the socket can start a new container with the host filesystem inside it, and is therefore outside. The same goes for mounted host directories, for privileged mode, and for network access to the control plane. A run needs a working directory, a runtime, and nothing else. When it ends the container is thrown away, and with it whatever a script left behind in there.
An allowlist instead of open egress. Outbound traffic is the boundary most often missing, because leaving it open never hurts. An agent run needs the package registry, the git host, the model provider’s API. That is a short list, and everything outside it is a request nobody ordered. That is exactly how the line above came to exist. Let everything out and police only what comes in, and if a secret ever does leak you have no second lock on the door.
Secrets per repository. One set of credentials for every run is convenient. It also means the blast radius of any mistake is the sum of all your projects. A token scoped to exactly one repository, valid for the length of one run, allowed to write branches and nothing more, turns a potential write-off into a contained incident. The cost is configuration, once. The difference when it matters is the whole distance between deleting a branch and spending a weekend rotating every key you own.
A log of refusals that somebody reads. A refused connection that shows up nowhere is information you threw away. A log nobody opens is the same thing with extra steps. For us it is a short weekly pass: which destinations were denied, and which of those are new? Mostly it is telemetry, occasionally a CDN that a dependency only pulls from at runtime. Both are worth knowing before somebody else finds out. The only real requirement is that the list stays short enough to actually read — which is why it pays to mark known, harmless destinations explicitly rather than let them run along in the noise.
The strongest boundary is the one you never implement
The point that almost always goes missing from lists like that one is also the most effective: the safest dangerous operation is the one that does not exist.
We learned it where an agent works with git. You can hand it a shell and explain in the instructions that it must never force-push to main and never delete a branch. That works surprisingly often. It does not work always, and above all it does not work verifiably — an instruction in a prompt is a request, not a mechanism. The alternative is a narrow interface: a function that creates a branch and commits to it, a function that opens a pull request. No delete, no rewriting history, because those functions are simply not in the toolbox.
After that, whether the model could be talked into deleting something stops being a question. Code that cannot express deletion cannot be talked into it. No prompt, no cleverly phrased line in an issue, no misunderstanding about the state of the repository produces an operation there is no call for.
The same reasoning carries further than you would expect. An agent allowed to deploy rarely needs more than the ability to trigger a pipeline that knows its own rules. An agent that reads data rarely needs a connection that can also write. Every time you leave a capability out instead of regulating it, you save yourself a rule somebody would otherwise have to audit.
What this means in a client conversation
“How do you make sure your agent does not break anything?” can be answered with confidence or with architecture. Confidence usually sounds better in the room and survives no follow-up question.
The answer that holds is a list of things that are technically impossible. The run cannot see the host. It cannot reach an address that is not on the list. Its token is good for this one repository and for the next hour. It cannot rewrite history, because there is no command for it in the toolbox. And when something hits one of those boundaries, it lands in a log that a person reads on Monday.
None of it depends on the model behaving itself. That is the whole point.