Nine Commits

Commit 6 of 9
v6-four-operations

Four Operations

List, read, write, edit. Four filesystem primitives finished all five tasks, fifty runs out of fifty. So did a fifth — it cost eighty-eight tokens in the first request and changed nothing else I could measure at fifty runs.

Title card for commit 06 of Nine Commits, Four Operations, with the opening line of a recorded run printed beneath it

The thesis printed at the top of this page is not the one that was on the index when I started. That one said four primitives are enough and adding a fifth makes the agent worse. The second half is gone, because a hundred and fifty runs did not support it, and the rule in this series is that the index is a claim the commits have to earn.

What is left is the half that held, and one number that survived the other half’s funeral.

But first: until this commit the agent could only read. A bad run cost a wrong answer. This commit gives it a write, in a public repository with five published posts in it, and that is the part I want to talk about before any of the measuring.

The thing that had to be right before anything else

The agent’s tools have resolved paths against a project root since v2. Here is what that check was:

const rel = relative(PROJECT_ROOT, target)
if (rel.startsWith('..') || isAbsolute(rel)) return { error: '...' }

It is fine for a read and it is not enough for a write, in two ways I could name and at least one I could not.

It is a lexical check. .. and absolute paths are the traversals that are written down in the path. A symlink is a traversal that is not: sandbox/out can be a junction to anywhere on the disk, and every string reaching through it is, lexically, inside the sandbox. And rel.startsWith('..') is a string prefix test, which also rejects a file called ..gitkeep and — the direction that matters — would accept /a/bc as being inside /a/b if the comparison ran the other way.

So the guard moved into its own file, and every path the model writes goes through one function:

export function resolveInside(root: string, candidate: unknown): string {
  // ... rejects a NUL byte and anything that is not a string
  const target = resolve(root, path)
  if (!contains(root, target)) {
    throw new SandboxEscape(candidate, 'resolves outside the sandbox')
  }
  const anchor = deepestPresent(target)          // lstat, so it does not follow
  if (lstatSync(anchor).isSymbolicLink()) {
    throw new SandboxEscape(candidate, 'passes through a link')
  }
  if (!contains(realpathSync(root), realpathSync(anchor))) {
    throw new SandboxEscape(candidate, 'follows a link out of the sandbox')
  }
  return target
}

deepestPresent is the part I did get wrong, and the section below about what broke is mostly about it. realpathSync throws on a path that does not exist, and a write tool is mostly asked for paths that do not exist yet, so the link check has to run against the nearest ancestor that is really there. Walk up with the wrong fs call and the whole check is skippable.

It throws, and every path a caller hands a tool comes out of it — which is four of the five tools, and is not the same as every path this program opens. search_files takes a pattern and no path at all, so it never calls the guard; what keeps it inside the root is a different mechanism, and it is worth naming because it is the one doing the work in the wider blast radius. walk asks each Dirent whether it is a directory or a file. A link is neither — both predicates are false for a junction — so a planted link is skipped rather than followed, and nothing under it is ever opened. Two mechanisms, and a test for each, is better than one sentence covering both badly.

sandbox.test.ts asserts the throw for .., for a path whose every segment is inside the sandbox until the last two normalise it out, for an absolute path, for a junction pointing at another directory — including for a file under that junction that does not exist yet, and including a junction whose target does not exist at all — and, on Windows, for a UNC path, an extended-length path and the drive-relative C:foo.txt, which is the one form that looks absolute and is not. Every branch of it fails closed: an lstat or realpath that raises becomes a refusal, because “could not check” is not “checked”. That sentence was not true when I first wrote it either, and the section below says so.

Where the agent is allowed to write

A guard that confines writes to a root is only as good as the root. So the root is checked too, with two rules that are worth different amounts:

export function assertWritableRoot(root: string): void {
  if (contains(realpathSync(REPO_ROOT), real)) throw new UnsafeRoot(...)
  if (!contains(SCRATCH_ROOT, real)) throw new UnsafeRoot(...)
}

Never inside this checkout, and that one has no switch: REPO_ROOT is computed from import.meta.url, this file’s own location on disk, so no environment variable, prompt, task file or shell loop can move it. It is the rule that protects the thing actually at risk.

Only inside scratch space, and that one is not unsettable, which an earlier draft of this paragraph claimed it was. SCRATCH_ROOT is os.tmpdir(), and os.tmpdir() is whatever TMP, TEMP or TMPDIR said. Start the process with TMP pointing at a home directory and the home directory is scratch space. Snapshotting it at module load, which is what SCRATCH_ROOT is, buys exactly one thing: a root cannot be scratch space when it is checked and somewhere else when it is written to. It does not buy the sentence I had written.

The second rule exists because “outside the repository” would permit a typo pointing at a home directory — and an environment already pointing TMP there defeats it, which is precisely why the first rule is not expressed in terms of the second. With TMP set to this repository, both rules disagree and the repository check still wins. There is a test for that, and it re-imports the module in process, with vi.resetModules(), so SCRATCH_ROOT is recomputed under the hostile environment — not a child process, which is what I wrote here first. The first version of the test did spawn a child, and with TMP pointed at the checkout node and tsx wrote their cache directories into this repository: a test for the commit about the agent not writing into this repository, writing into this repository. The re-import is the better technique and it is also the one that is actually there.

It is enforced where the tools are built, which means the strongest thing I can say about it is a unit test rather than a promise:

expect(() => buildTools({ roster: 'four' })).toThrow(UnsafeRoot)
expect(() => buildTools({ root: PROJECT_ROOT, roster: 'five-append' })).toThrow(UnsafeRoot)

There is no argument to buildTools that returns a write tool pointed at this checkout. The read-only pair from commits 2 to 5 still builds against the repository, and asking that object for writeFile raises rather than returning a refusal, because a read-only root should not have a write tool to call at all.

One decision inside that I would defend and you might not. resolveInside throws; the tool layer catches it, hands the model { ok: false }, prints [sandbox] refused "…" to the terminal and counts it on the run. A throw that escapes execute would kill the run instead, and a killed run is a run missing from the tally — which is an exclusion, and this series does not have a mechanism for those. The block is absolute either way. What is negotiable is whether the experiment gets to see it, and I think it has to.

What the agent can no longer see

The corpus stopped being this repository, and that is a consequence of the safety rule rather than a solution to anything. The agent works in a copy of a small invented project — eight files, a config, a changelog, a server — made fresh in scratch space for every run and deleted afterwards.

Which quietly fixes the thing posts 2, 3, 4 and 5 each paid for. In that workspace the timeout is 4500 in exactly one file and the port is 8137 in exactly one file; there is no README paraphrasing them, no post about them, no plan document naming them. tasks.test.ts asserts that each graded value appears in exactly one file, so if that ever stops being true the suite says so.

I did not earn this. It arrived because the agent is not allowed near the repository any more.

The experiment

Four tools: list_files, read_file, write_file, edit_file. read_file returns bytes verbatim — untrimmed, unnumbered — because edit_file matches the text it is given against the bytes on disk, and a read whose output cannot be pasted into an edit quietly makes the edit tool unusable. edit_file replaces exactly one occurrence and refuses zero or two: zero means the model is editing a file it has not read, two means it does not know which line it is changing, and picking the first would be the tool deciding that for it.

Then a fifth, and the word doing the work is plausible.

I picked append_file, and the reason is that it adds no capability at all. Everything it does, write_file and edit_file can already do. That is the claim under test: not do five capabilities beat four, which is a different and much less interesting question, but does a redundant fifth degrade the choice among the four. A fifth that can do something the four cannot is not a test of “four are enough”.

It is also a tool people ship. Appending a line to a changelog through a read, a string concatenation and a whole-file write is worse code than appending it, and the failure mode of the long way round is that you rewrite a file from memory of it. And it is built to win rather than to lose: it creates the file if it is missing and inserts the newline the caller forgot, so appending to a file that does not end in one produces a new last line instead of a corrupted one. Its description is written to post 2’s precise standard, same as the other four, and descriptions.test.ts asserts it is not materially shorter than the shortest of them and that it says what it cannot do. A fifth tool described worse than its neighbours would be measuring post 2’s variable and calling the result this post’s.

There is a second five-tool condition, five-search, which adds back the search_files this agent carried from commit 2 to commit 5. That one does add a capability, so it answers a different question, and it is reported separately rather than folded in. It is here because one choice of fifth tool would have made the result a fact about that choice.

Five tasks, and why those

Ground truth had to be checkable by code, and post 4 is the reason: a task whose success you can only judge by reading prose is a task this series has already shown you cannot grade. So four of the five are graded on the filesystem — the tree before, the tree after, and nothing else.

task what it exercises how it is graded
find-timeout read the answer names the file and the value
write-note write notes/ports.md exists, carries the port and the source file, and nothing else changed
edit-timeout edit in place the JSON still parses, timeoutMs is 9000, every other key is byte-identical, nothing else changed
append-changelog addition at the end the old changelog is an exact prefix of the new one, the added text carries the value
add-setting genuinely ambiguous the JSON still parses, maxBatch is 250, every other key survives

The unchanged-keys clause is what makes an edit task an edit task. An agent that rewrites a file it half-remembers and an agent that changes one value in it produce the same timeoutMs, and differ only in the bytes they did not touch.

append-changelog is in the set on purpose, and it is the task append_file is the right answer for. A question set with no task the extra tool is good at would make the fifth makes it worse true by construction. add-setting is the other side of the same coin: “add a setting” reads like an addition, and at the end of a JSON object an addition is a syntax error. Neither is a trap invented for this post — both are ordinary things to ask.

Ten runs per cell, three conditions, five tasks, one hundred and fifty runs. The number was fixed in tools/run-roster.ts before the first call was made. Post 2’s phrase is that one run per condition is a coin flipped once, and five tasks by two rosters is ten coins. The conditions are interleaved — every task under every roster runs once before any of them runs twice — so a provider drifting over an hour drifts across all three rather than into one. Nothing is retried; a run that throws is written down as a run that threw.

It goes red

A grader that cannot fail is not a grader, and this one returned pass a hundred and fifty times, which is exactly the shape of one that cannot.

So tasks.test.ts is mostly failures. Each task’s checker is handed the end state a plausible bad run would leave behind and asserted to say no: a config/settings.json rewritten with only timeoutMs in it — the whole-file write that loses everything else — fails on the keys it dropped. The same file with "maxBatch": 250 appended after the closing brace fails because it is no longer JSON. A changelog rewritten rather than added to fails because the old content is not a prefix. A note written to ports.md instead of notes/ports.md fails on the path. A correct edit that also rewrote README.md fails on the file it did not need to touch. And a run that did nothing at all fails every task in the set, which is the assertion that stops the whole thing from being a rubber stamp.

What broke

The guard had a hole, and it was in the clever part

Review found it after everything above had been written, by attacking the guard rather than by reading it, which is the only way this one was ever going to turn up.

The link check walked up the path with existsSync to find the nearest ancestor that was really there. existsSync follows links. So a link whose target does not exist reports false — and the walk climbed straight past the link, kept going to the sandbox root, and checked that against the sandbox root, which is trivially inside it. The link was never inspected at all.

resolveInside(root, 'dangling/escaped.txt')
  →  <root>\dangling\escaped.txt     [approved]

That is not the TOCTOU I had already disclosed at the bottom of this page. TOCTOU is checked, then swapped. This is the guard never looking.

Two things are true about it and both matter. The agent could not reach it: every sandbox is a fresh mkdtemp holding eight plain files, and no tool in any roster creates a link, so no run in the hundred and fifty could have used it and the zero in the refused-paths row stands. And the post above it claimed the general property anyway — nothing that calls it can get a path out of it any other way — which was false when I wrote it. People copy guards out of posts. That sentence was the defect, more than the two lines of code were.

The fix is lstatSync, which does not follow, so a dangling link counts as present and the walk stops on it; and then any link at all is refused rather than resolved, because a link with no target cannot be resolved to ask where it goes, and one rule has no second case to get wrong. Nothing in this program has a legitimate reason to reach through a link.

The test is beside the junction test that was already there, and I checked it the only way worth checking a regression test — by putting the old walk back and watching it go red, with the other sixteen still green.

Then the fix broke the sentence the fix had just written

The commit that added every branch of it fails closed put one branch outside the try that does the failing closed. realpathSync(root), the last line of the check, sat after the catch. So a sandbox that had been deleted — not attacked, just gone — raised a bare ENOENT that left the guard, left the tool, and killed the run. Measured, with the root removed after createFs:

list_files:  THREW Error: ENOENT: no such file or directory, lstat 'C:\Users\...
read_file:   THREW Error: ENOENT ...
write_file:  THREW Error: ENOENT ...
escapes recorded: 0

Those four lines are a transcript, not an artifact. They came off a terminal running code that the next section deletes, against a root I removed by hand, so nothing in this repository can produce them again and no test asserts them. The fixed state is the opposite: agent/src/tools/write.test.ts deletes the root under a live tool set, calls all five tools, asserts each one returns ok: false rather than throwing, and asserts the escape counter holds one entry per call. So the broken state is my word and the fixed state is a test, which is the wrong way round from how it reads on the page. Post 7 marks the equivalent case — four lines from a batch it threw away — and this one went unmarked until a reader checking the page asked what was behind it.

Nobody’s file was at risk; no path outside the root is ever returned. What broke is two promises this page makes about itself. The sentence three screens up said every branch fails closed, and one did not. And SandboxEscape’s own doc comment says a throw out of execute kills a run, a killed run is a run missing from the tally, and this design does not do that — while producing exactly that. The ENOENT also carried the absolute path of the sandbox, which is the string every other error message in that module is written to avoid.

The fix is moving one line inside the try. Now:

list_files: returned {"ok":false,"error":"refused \".\": could not be checked"}
escapes recorded: 5

The same mistake had a second address. assertWritableRoot called realpathSync on the root before any of its own checks, so AGENT_ROOT with a typo in it produced an ENOENT stack trace out of buildTools — from the one function whose entire job is to give clear answers about roots. It now refuses in its own vocabulary, and the CLI prints that one sentence rather than an Error object.

There is a pattern here I am going to name rather than dress up. The round that fixed the guard introduced two defects, and one of them was in the sentence the round existed to make true. Both were found by someone attacking the code again, not by me re-reading it.

The guard’s first defect was refusing a legitimate call

The first version of resolveInside rejected the empty string. A pilot run of three showed what that costs: the model calls list_files with {"directory": ""}, meaning here — which is what list_files has meant since v2 — got a refusal, asked again, and the four-tool run reached the ten-step cap.

That is not a guard being strict. It is a guard joining the measurement, on the side of the condition that lists most. It was fixed before the hundred and fifty runs, the three pilot runs were discarded and are not in the tally, and the discard is here rather than nowhere because it is the only run in this post that was thrown away. An empty path now means the root, which is what it meant before I touched it. You can see the call it was refusing in the second frame of the right-hand trace below.

Two things in this repository predicted this commit and both fired

Post 4 ended a section with a line about its own eval:

A tool result whose shape the eval does not recognise raises rather than returning nothing. … Post 6 changes the tool set; this is the line that will notice.

Post 5 said the same of the judge’s renderer. The first v6 trace put in front of both of them:

eval observationsOf: UnreadableResult: frame 8: read_file returned a result shape this eval does not understand
judge renderTrace:   UnrenderableFrame: frame 8: a tool result shape this renderer does not understand

That is what those lines were for. The alternative — returning an empty list — would have graded every v6 run as a run that observed nothing, with every claim in every answer unsupported, and the suite would have looked most rigorous at the exact moment it had stopped reading.

Both now know two more shapes. A read contributes one observation per line, with real line numbers, because a run that read config/settings.json and a run that searched and got line 3 of it back have both seen line 3, and counting only one of them would report a difference between the tool sets that belonged to the grader. A write contributes nothing — and is recognised anyway, because the difference between no observations because the tool returns none and no observations because the reader stopped reading is the whole reason the error exists.

The substring grader inverted

Post 4’s complaint was that the recorder’s substring check over the final sentence is over-generous: it gives full marks to a run that guessed. Every run here was graded both ways — the old sentence check, and the new check over the files — and they disagree four times in a hundred and fifty.

All four disagreements run the other way.

run sentence grade the files
five-append edit-timeout #1 failure timeout changed, nothing else touched
five-append edit-timeout #9 failure timeout changed, nothing else touched
four append-changelog #8 failure line added, changelog intact
five-search find-timeout #5 partial both facts named correctly

The two edit-timeout runs hit the ten-step cap with no final sentence at all, so the sentence grader had nothing to read and called it a failure. Post 3 described that firing and said the failure belongs to the harness rather than to the model. It could not show that. Here the file proves it: the work was finished and the pen was taken away.

The append-changelog run said Done — I added the line to the end of docs/CHANGELOG.md, which is true, complete, and does not contain the number it was graded on. And the find-timeout run answered perfectly but had one tool call come back failed, which caps the recorder’s grade at partial by a rule written at v2 for an agent whose only product was a sentence.

So the sentence check is not generous or strict. It is measuring a different thing, and the moment the product is a file it is measuring the wrong one. That is post 4’s finding with the polarity flipped, which I did not expect and which is the most useful thing here.

A whole-file rewrite passed seven times

On append-changelog, the four-tool agent reached for write_file in 7 of its 10 runs — rewriting the whole changelog rather than editing it — and produced a byte-exact prefix of the original every time. It passed. It passed because the file is ten lines long. That is a property of the workspace and not of the agent, and it is the single biggest reason not to read this post’s pass rates as a claim about real repositories.

The demo

The ambiguous task, the same run number, both conditions. These two were picked by a rule fixed before I looked at any of them — add-setting, run 1 — rather than for what they show.

Two runs

The same task with a fifth tool in hand. 'Add a setting' is an addition, append_file was available, and the five-tool run reached for edit_file anyway — as all ten of its runs on this task did.

Recorded run

Add a maxBatch setting of 250 to this project’s settings.

Recorded from gpt-5-mini at commit v6-four-operations. The run finished the task.

Frame 1 of 8

397 tokens in context, against this run's peak of 863.

  1. You

    Add a maxBatch setting of 250 to this project’s settings.

Both list, read and edit. Neither appends. The only difference is the last call: the five-tool run read the file back afterwards, and the four-tool run did not.

Which post 4’s grader has an opinion about. The four-tool run’s closing sentence — Updated config/settings.json — added “maxBatch”: 250 — states a value that appears in nothing any tool returned to it. The read happened before the edit, so 250 was never in a result:

06-four-operations/trace-a-four.json
  tools        list_files > read_file > edit_file
  observations 15
  answer       success (recorded: success)
  unread       value
  provenance   unevidenced (0 ground truth, 0 copies)

06-four-operations/trace-b-five.json
  tools        list_files > read_file > edit_file > read_file
  observations 23
  answer       success (recorded: success)
  unread       (none)
  provenance   grounded (1 ground truth, 0 copies)

One extra call turns an unread claim into a read one. Both files on disk are identical and both runs pass; the difference is entirely in what the trace can prove. That is one run against one run, which this series has spent two posts learning not to read as an effect — the tally below has the tool sequences for all thirty runs of this task and the extra read is not a property of the roster.

Both runs also used edit_file with the entire file as the span to replace, which is write_file with a precondition attached. Two of the four primitives collapsed into one, in both conditions, on the task designed to make the choice hard.

The numbers

All hundred and fifty runs are in runs.tsv, one row per run, with the tool sequence, the step count, what stopped it, the verdict and why, the files it changed, the token counts and the final answer. Every figure below is recomputed from that file by tally.test.ts, so if the prose and the file drift apart, npm test says so.

four five-append five-search
tasks completed 50 of 50 50 of 50 50 of 50
runs that changed a file they should not have 0 0 0
paths the sandbox refused 0 0 0
runs that called no tool 0 0 0
runs stopped by the cap 0 2 0
steps per run 5.1 5.3 4.7
context before the first action 404.4 492.4 473.4
total input tokens 175,549 211,980 172,490

Every one of the hundred and fifty runs changed exactly the file its task named and nothing else. The read-only task changed nothing, thirty times out of thirty.

The fifth tool was not misused once. append_file was called in 10 of the 50 five-append runs, and all ten are append-changelog — the one task it is right for. On the other forty it was not called at all.

Read those two numbers as a pair rather than as one number and its absence, because 40 of 40 declines on its own would be consistent with a tool nobody could find a use for. The pair that matters is 10 of 10 on append-changelog against 0 of 10 on add-setting — a task where the verb in the prompt is add, the tool is called append, and appending would have broken the file. Reached for where it belonged, declined where it was bait. That is discrimination, not abstention, and it is also the answer to whether the fifth was plausible: a strawman gets mis-selected.

search_files was called in 24 of 50 — ten of the edit-timeout runs, nine find-timeout, three write-note, two add-setting — and that condition took the fewest steps and the fewest tokens of the three while passing everything.

The one cost that is real

A tool definition is part of every request for the whole life of a run. Before either agent has done anything at all, the four-tool run is carrying a mean of 404.4 tokens of context and the five-append run 492.4 — a standing charge of 88 tokens in the first request, for a tool that was called on one task in five and changed no outcome anywhere.

In the first request, and I mean it narrowly. That figure is tokens[0] from every run, which is the one measurement in this experiment that is clean: same system instruction, same task string, one more tool definition, nothing else in the context yet. It is not a per-request constant afterwards. In the two recorded runs below, the second request differs by 80 rather than 88, once each side is carrying a tool result as well. The charge is real and it is paid every time; its exact size after the first request is confounded with whatever the run has read.

Post 2 measured the same meter for two sentences of description and said the hundred tokens a turn bought a sixfold better chance of the right tool, which is a trade it would take. This one bought nothing measurable. On append-changelog, the task append_file exists for, the five-append condition took 4.0 steps and 2,924 input tokens against four tools’ 3.9 and 2,656 — the right tool for the job, correctly chosen ten times out of ten, and slightly more expensive than not having it.

five-search paid a 69-token charge and came out ahead anyway: 172,490 input tokens against four tools’ 175,549, and 235 steps against 253. A fifth tool that adds a capability can pay for its own description. A fifth that adds none cannot, and that is the whole of what these runs found against it.

What it cost

560,019 input tokens and 57,082 output tokens across the hundred and fifty runs, both counted per run in the tally. At the list price I used — $0.25 per million input and $2 per million output — that is about 25 cents, plus three pilot runs that are not in the file. The arithmetic is in tally.ts rather than only in this sentence, because the price is the one number here guaranteed to be wrong by the time you read it.

Yours will differ. That is the premise:

npm run roster                          # the whole thing, 150 runs
npm run roster -- --runs 1 --task add-setting
npm run roster:tally                    # the committed file, recounted

The thesis was wrong and the index has changed

series.ts said adding a fifth makes the agent worse. Fifty runs with four tools and a hundred with five say otherwise, on every axis I wrote down before running them: same completion, same discipline about files, no misuse of the extra tool, and a step count that moved by two tenths in one direction and four tenths in the other.

So the line on the front page now says what the runs say. Post 3 corrected its thesis because the diff contradicted it; this one is corrected because the tally does.

I want to be exact about the size of the claim I am now not making, because there is an overclaim available in the other direction too.

This experiment has a ceiling and it hit it. All three conditions scored 100%, which means the tasks have no resolution left to measure with. Fifty runs with no failures put the 95% upper bound on a condition’s failure rate at about 5.8% — so what these runs can tell you is that a fifth tool did not cost more than roughly six points of completion on tasks this size. They cannot tell you it costs nothing, and they say nothing at all about a fifteen-tool agent, a larger codebase, or a task whose file does not fit in a read.

They also say nothing about the middle of the spectrum, and that is where I would look next. My two fifths sit at the ends: one that adds no capability, one that adds a whole one. Neither is the shape that would plausibly degrade selection — a fifth whose contract partly overlaps one of the four, with different semantics and a different failure mode. patch_file(path, unified_diff) sitting beside edit_file(path, old, new) is the example: both are the obvious tool for change this line, they are not interchangeable, and they fail in different ways. append_file was never going to be confused with edit_file, and a tool that cannot be confused is a weak test of confusion. That is a real limit on the correction at the top of this page, and it is the experiment I did not run.

The task set was fixed before the first run and not adjusted afterwards, including after the first forty came back at 100%. Making them harder once the ceiling was visible would have been choosing the difficulty that produced a result, and I would rather publish a ceiling.

What this does not fix

The sandbox was never tested in anger. Zero refusals in a hundred and fifty runs. The model never tried to leave the directory it was given, so everything I can tell you about the guard comes from the test file rather than from the experiment — which is the same standing as post 1’s leak gate before it had seen a real trace, and post 1’s leak gate was broken. This one had a hole too, and what found it was somebody attacking it on purpose rather than any of the hundred and fifty runs. A guard with no adversary is a guard with no evidence.

The check is not atomic. resolveInside resolves, and then the write happens. A concurrent process could replace a directory with a link in between. Nothing else writes in these sandboxes, and a real deployment would need something better than that sentence.

A hard link would defeat it. realpath resolves symlinks and junctions; a hard link to a file outside the sandbox is the file, and writing through it changes the original. The agent has no tool that creates one, which is an argument about the roster rather than about the guard.

And the thing that has to be said about a write tool: every safety property above is a property of this program, not of the model. The model asked for a path outside its sandbox zero times in a hundred and fifty runs, and that is a fact about a well-behaved run of a small task. Post 8 is the shell, where the number of things a badly-behaved run can reach stops being a list I can enumerate.

The commit

The agent has four hands now, and it used them well enough that the interesting column in the tally is not the outcome. Again.

That has happened at every commit since the third, and it keeps meaning something slightly different. At v3 it meant the loop worked. At v4 it meant the grade was worthless. Here it means the experiment I designed could not separate its conditions, and the honest response was to change the claim rather than the tasks.

What did separate them is a number post 2 already knew how to measure and I had not expected to be the whole finding: eighty-eight tokens, in every request, for the whole life of every run, for a tool that was reached for correctly and made no difference. Nothing in these runs says a fifth tool makes an agent worse. What they say is that it is never free, and that the bill arrives before the first action.

Which is the next commit’s problem, because the bill compounds. The context is the constraint.