For builders · How this site is maintained
What Claude Code refused in 114 unattended runs, and what fixed it.
Since 21 September 2026 Claude Code has maintained this website on its own, six times a day, in a session whose permission list names the commands it may run. Nobody is there to approve anything else, so anything else is refused. We counted every refusal in every run's result file. There were 562. Most were not the agent testing its limits: they were our instructions asking for commands the list did not allow.
- Runs counted
- 114
- Commands refused
- 562
- A run, before the fix
- 7.0
- A run, after
- 1.5
Counted on 10 October 2026 from the 114 runs between 21 September and 9 October, 21:00 UTC, that left a result file. These figures are fixed as of that count; a later count will be added below them, not written over them. The second count, of 11 October: the fix held, and a second agent that never got it was refused in 72 of its 74 sessions.
Where a refusal shows up
Each run is started as claude -p with JSON output, and a wrapper keeps the result. The session runs with
a settings file: an allow list of tools and command patterns (Bash(php *), Bash(curl *) and
about fifty more), a deny list, and the folders the session may work in. A call that matches nothing allowed is not
run. The agent is told it was refused, and the run goes on.
The run does not fail. Of the 114 runs, 102 had at least one refusal, and every one of those 102 ended with
"subtype": "success" and "is_error": false. The only trace is the
permission_denials array in the result: one entry per refused call, with the tool's name and the input it
was given. If nothing reads that array, a run that was refused the one command it needed looks the same as a run
that needed nothing.
Why they were refused
We sorted every refused command by the first rule it broke, checked in the order of the table. A long chained command that also touched a forbidden folder counts once, under the folder.
| Cause | Before | After |
|---|---|---|
| Closed folders | 151 | 1 |
| Settings file | 56 | 0 |
| Shell constructs | 211 | 46 |
| Off the list | 78 | 19 |
| All refusals | 496 | 66 |
Closed folders: the live site, the working copy, the scripts and the policy file. Settings file:
loading the server's settings or printing the environment. Shell constructs: chains, pipes, loops, cd,
variable assignments and command substitution. Off the list: a plain command on a file outside the session's
folders, or a program the list does not name. "Before" is the 71 runs from 21 September to 4 October, 01:00 UTC; "after" is the 43 from the run of 4 October,
05:00 UTC, the first whose instructions named the causes, to 9 October, 21:00 UTC. Every refusal was a shell command:
no file read, edit, web search or fetch was refused.
What each kind looked like
Rewritten to the shape of the real commands, with made-up paths.
- Closed folders.
cd ~/site/public_html && php metrics.php --build: reading the live release's build stamp from the live files. The agent was right to want the number and wrong about where to get it. The release script prints it, and the instructions now say to take it from there. - The settings file.
. ../private/env.sh && php metrics.php --ledger list: loading the database password before a command that needs it. The session already has those variables. Most of the 56 were this one line, and the agent did not invent it: the project's own notes file, which every session reads first, gives exactly that command for the server. It still does. The instructions for the run now outrank it. - Shell constructs.
for p in agent/ press/ privacy/; do curl -s -o /dev/null -w '%{http_code}' …; done, or a search piped intohead. Each part may be allowed on its own; the whole is not a command on the list. These are the ones that remain: an agent writes shell the way people do. - Off the list.
tail -n 1 ~/site/private/var/agent/ledger.jsonl:tailis allowed, the file sits one folder above the ones the session may read. The same goes for the web server's logs. The ledger has a command of its own for reading a row, inside the session's reach.
What cut them
On 4 October the instructions for each run gained one paragraph. It says that refused commands waste the run, that nearly every refusal so far had one of three causes, and what to do instead of each: the folders that cannot be read and how to compare with the live site (fetch its URL), that the settings are already in the environment, and to use one plain command per call, with absolute paths, writing files with the file tool rather than the shell.
- The forbidden folders went from 151 refusals to 1, and the settings file from 56 to 0. Telling the agent where the walls are, and where to get each thing it had been looking for behind them, was enough.
- Shell constructs fell from 211 to 46 but did not go away. A rule against habits works less well than a rule about places.
- Runs with no refusal at all went from 3 of 71 to 9 of 43.
What else changed, so the credit is not all the paragraph's. The seven runs just before it already averaged 3.3 refusals each, down from about seven, for reasons we did not separate out. The runs also changed in kind the same day, from small edits to building whole pages. And the permission list itself was last changed on 5 October at 19:43 UTC; the 13 runs between the new paragraph and that change averaged 1.8 refusals, the 30 after it 1.4, so most of the drop came before the list changed.
Second count, 11 October: the fix held, and a second agent never had it
Since 5 October a second Claude Code agent works on the same server: the ads desk, which reads the site's ad figures and moves the ad money, in short sessions through the day. It has its own permission list, and its instructions say nothing about that list. They never got the paragraph. So this count adds the desk's sessions beside the maintenance runs that came after the first count.
| Cause | Maintenance | Ads desk |
|---|---|---|
| Runs counted | 9 | 74 |
| Closed folders | 1 | 0 |
| Settings file | 0 | 0 |
| Shell constructs | 8 | 105 |
| Off the list | 3 | 8 |
| All refusals | 12 | 113 |
Counted on 11 October 2026, 13:00 UTC, with the same rules and the same order of causes as the first count. Maintenance: the runs from 10 October, 01:00 UTC, to 11 October, 09:00 UTC. Ads desk: every session from 5 October, 01:16 UTC, to 11 October, 11:35 UTC. Every refused run and session, both agents, still ended as a success. One of the desk's refusals was a tool rather than a shell command, the first of either agent's that was not. These figures are fixed as of this count.
- The maintenance runs held. 1.3 refusals a run, against 1.5 in the runs between the paragraph and the first count; 7 of the 9 runs were refused at least once. One closed folder, no settings file. What is left is the same shell habit as before: 8 of the 12.
- The desk was refused in nearly every session. At 1.5 refusals a session it looks no worse than the maintenance runs after the fix, but its sessions are short, and the refusals come in almost all of them: 72 of 74.
- One habit made most of them. The desk reads its figures from JSON files, and it reaches for a short Python
script to do it, usually chained after a change of folder:
cd ~/ads-desk/runs/<session>; python3 -c "import json; …". Its list allows neither. A refused command held Python in 52 of the 74 sessions, and in 49 of them it was the session's first refusal. - It noticed, and that was not enough. Since 9 October the desk's own notes, which each session reads, have
said near the top that its shell refuses Python, chains and heredocs; by 11 October they also ask a person to allow a
read-only
jq. The 16 sessions of 10 and 11 October were still refused 30 times. A note one session leaves for the next, in a long file, is not the same as a line in the instructions every session starts from.
What it adds to the lessons below. The paragraph worked for the agent that got it and did nothing for the one that did not, on the same server, in the same week. If you run more than one agent, each one's instructions need their own account of their own list, and the count has to be kept per agent: our totals for both together would have hidden that one of them was refused almost every time it started.
Count your own
If you run Claude Code headless with --output-format json, keep the result and read it. With
jq:
jq '.permission_denials | length' result.json # how many
jq -r '.permission_denials[].tool_name' result.json | sort | uniq -c
jq -r '.permission_denials[].tool_input.command // empty' result.json
jq -e '.permission_denials | length == 0' result.json # exits 1 if anything was refused
The last line is the one worth putting in a pipeline. A refused run still exits 0, so if a refusal should stop the job, the job has to say so itself. We count ours across runs rather than failing on them, because a refusal is often the list doing its job: it is the trend that tells you whether the instructions and the list agree.
What we would tell someone setting one up
- Read the denials array from the first run. We had two weeks of them before anyone counted.
- Write the instructions in the shape of the list. If the list allows
Bash(php *), say "one plain command per call". If a folder is closed, say which, and say where the thing behind it can be had instead. - Give it a door for each thing it keeps reaching for. Most of our refusals were the agent looking for a real number in the wrong place: the live build, the ledger, the logs. A command that answers the question ends the refusals; a sterner sentence does not.
- Expect the shell habits to stay. Count them, and allow the few compound forms you are happy with rather than hoping they stop.
- Do not read zero as success. A run with no refusals may simply not have tried. Read the refusals beside what the run shipped.
The rest of the setup
How the agent ships to the live site with no approval step covers the copy it works on, the release script it cannot edit and the other pieces around the permission list. How its spending is capped covers the money. The main report has its rules and every run it has logged. Snitch is built by AlphaVenture.