I gave twelve agents a shell and a web browser

10–15 minutes
measured on my own machine
Two dark green rings overlapping on a near-black field, the lens-shaped area where they intersect filled in amber.

Until the middle of August, twelve of my nightly agents were allowed to run any command on my machine. Six of those twelve could also read any page on the open internet.

Either of those on its own is ordinary. Together they are a short path from a sentence somebody writes on a web page to code running on my computer, at two in the morning, with nobody watching.

Closing it took an afternoon. What the closing exposed took longer to understand, and it is the reason this is worth writing down.

Why the permission was wide

Each of my agents gets an explicit list of what it may do. Read files. Search. Write. Fetch a web page. The lists are per agent and mostly tight.

Twelve of them carried a permission to run a particular program with any arguments at all. That program is a script interpreter. Given the right argument, it executes whatever text you hand it. So the permission was not “may run that tool”, it was “may run anything”, spelled in a way that looks specific.

I did not write it that way out of carelessness, exactly. I wrote it that way because writing it narrowly requires knowing which scripts each agent actually calls, and nobody had ever made that list. The wide version worked on the first try, which is the most expensive property a configuration line can have.

a page on the web written by anyone the nightly agent may fetch the page may run any command a shell on my machine 02:00, nobody watching Six agents held both permissions. Twelve held the second one. Neither permission is dangerous. The pair is the chain. today: 0 of 26 agents hold the pair
Figure 1. The chain, and why neither link looks like a problem. Counted from the agent configuration on 6 September 2026. The twelve and the six are the figures recorded when the permission was narrowed on 16 August.

Nothing had happened, which was the problem

I want to be accurate about the harm here, because overstating it would be the same sin the site is against. Nobody attacked me. No hostile page was fetched. As far as I can tell, the chain was never used.

That is not reassurance. It is the actual complaint. With a permission that wide, a run that did something unexpected and a run that behaved perfectly produce the same log. There was no line anywhere that would have looked different if the thing had been exploited.

The morning after

The narrowing was simple in principle. Read each agent’s instructions, list the scripts it genuinely calls, and permit those scripts by name. The interpreter with arbitrary arguments falls away, and with it the ability to execute text.

The next morning a run failed. It had tried to call a script that was not on its list, and the script was not one of mine. The model had written it.

I went back this morning to photograph that directory for this article. The files are gone, cleaned up in some deploy since. So I cannot show you the listing, and I am not going to reproduce a count from memory and call it a measurement. What I can tell you is the shape, because the shape is the point and it did not depend on how many there were.

Why a well-behaved model builds its own tools

The cause turned out to be almost boring, and that is why I trust it.

The scripts in question read their configuration from whatever directory they are started in. So the instructions said: change into that directory, then run the script. Two commands joined together.

That joined form is exactly the shape that triggers a confirmation prompt. In an interactive session you press yes. At two in the morning there is nobody to press anything, so the command does not run.

the prescribed route → blocked by a confirmation nobody can answer doing nothing → reporting failure, which the agent avoids a third route → write a small script that does it another way

Given a blocked route and a job to finish, the model chose a third route and built it. Every one of those wrapper scripts was a small, competent, entirely reasonable piece of work. None of them should have existed.

And this ran for months without costing anything or breaking anything, which is precisely why nobody saw it. The wide permission did not cause the workarounds. It made them invisible.

The fix is not to allow the workaround

The tempting response is to permit the improvised route, because it works. That is the hole, restated as a feature.

The right response is to make the correct route actually walkable and then name it in the instructions, so the model has no reason to invent one. In my case that meant permitting the specific scripts by name, which removes both the confirmation prompt and the need to work around it.

There is now a check that runs with the test suite, and it asserts two things rather than one. First, that no agent has the wide permission back. Second, that every script an agent’s instructions actually mention is on its permitted list.

The second assertion is the one I would not have thought to write a year ago. A permission that is too narrow does not fail loudly. It fails in the middle of the night, after the expensive part is done.

0agents with the wide right
26agents configured
13that read the web
5assertions in the guard

What I found in the rest of the file

When I first published this, the last section said the guard was green, that this was a sentence about agents, and that I was going through the rest of the configuration file. That took one evening, and it found something.

My configuration file has a list of agents, and next to that list, at the same level, one more entry: the chat window I use to talk to an agent about a single card. It carried the wide permission. It carried the web permission too. It was the same pair, one key over from the list the guard reads.

The guard had been green for three weeks, and every word of what it asserted was true.

The part that stings

This exact blind spot had already been found. A different guard in the same project, checking a different thing, had once run over the same list of agents and missed the same chat entry sitting beside it. Somebody wrote that down, fixed that guard, and moved on.

The lesson stayed where it was found. The guard one file over kept reading the same short list for another three weeks.

Three places, not one

Removing it from the configuration would have changed nothing. There is a fallback in the code that supplies the same permission list whenever a card is not assigned to an agent, and that is the normal case for my own cards. So the same wide permission sat in two places, and taking it out of one would have left the system behaving exactly as before.

The third place was the guard itself. It now walks every carrier of a permission list in that file instead of the one list it knew by name, and it checks the fallback in code as well. It also has a small test asserting that it finds more than the agent list, because otherwise “nothing has the wide permission” and “I looked in the wrong place” produce the same green tick.

Then I put the wide permission back, twice, once in each place, to watch the guard go red. It did, each time at its own assertion, and nothing else moved.

Two more, found on the way

Narrowing a permission means naming the scripts it may run, and the names in my configuration contain a placeholder for a directory that differs between my two machines. The nightly runner substitutes it. The chat did not. So the chat had been passing the placeholder through literally, and every one of those permissions had been dead there for weeks. The same shape as the hole, with the sign reversed: not a permission too wide, but one too narrow. A permission that is too narrow does not fail loudly, it fails in the middle of a run, after the expensive part.

And I introduced one myself while fixing that. I moved the substitution into a shared function, gave it an optional second argument, and called it through a list transform. A list transform passes the position of each item as a second argument. My optional argument received the number zero, and the whole thing died. A default value only applies when an argument is missing, and mine was not missing, it was wrong. A test caught that within a minute. I would have caught it at two in the morning, three weeks later, in a log nobody reads.

Measured, not assumed

Production runs a deployed copy, not my working directory. The fix was correct and committed for several hours while the machine that actually runs the agents still had the old file. I checked, and it did.

After deploying: the wide permission is gone there, twenty-five named permissions in its place, the guard green on the machine itself, the chat starts, the service stable. Fixed in the repository is not fixed in production, and the only way to tell them apart is to go and look.

If you take one thing from this: go and look at what your agents are permitted to do, and ask of each permission not “is this reasonable” but “what is the widest thing this allows”. Mine named a program and meant anything. It had been true for four months, it had cost nothing, and I found it by reading my own configuration on a quiet afternoon.

Measured from the repository on 6 September 2026: 26 agents configured, 0 holding the unrestricted interpreter permission, 13 holding a web-reading permission, and 5 assertions in the guard that enforces this, run directly to confirm they pass. The twelve agents and the six with both permissions are the figures recorded in that guard when it was written on 16 August 2026, and are not a fresh measurement. The wrapper scripts were checked for again on the production machine on 6 September and no longer exist, so no count of them is given. The closing section was added on 7 September 2026 after the chat entry was found, narrowed in both places, and deployed: the guard now carries 7 assertions and was run on the production machine itself, where the permission list went from 2 entries, one of them unrestricted, to 25 named ones. Both deliberate reintroductions of the wide permission were confirmed to turn the guard red. The full suite stood at 2,967 tests with none failing.

Georg Singer

Georg Singer

I automate my own projects in public and write down what each one cost. Physics degree, doctorate in computer science, nine systems in production on hardware I own, all of it built in my own time.

More from the log