A question cannot change your file

REX makes one safety promise: ASK cannot write, ACT edits a copy, and only Approve touches your file. Three times it broke without a sound — a line gone under "Applied to 0 file(s)", a committed file deleted under a notice saying it was put back, a copy removed while the agent was reading it — and what an allowlist, four SDKs and a repository's own settings file taught me about where a boundary actually is.

On 2026-08-25 at 22 I selected a margin note in a Markdown document, set the switch to ACT, and typed “REmove this”. The run took 7.4 seconds and cost $0.328. The trace shows the agent reading the file, calling Edit, and answering “Removed the LUKAS margin note … Docs-only change; nothing to run.”

Four things were then true at once:

Where What it said
the file on disk the line was gone
the document view the line was still there
the thread Applied to 0 file(s).
apply_run status='applied', diff='', files_json='[]'

No diff was shown. Nothing was accepted, because there was nothing to accept. The change stayed.

The cause was one line of git status --porcelain, which REX diffed before and after the run to learn what the agent had touched. The document lived in an untracked docs/ tree, and for that git prints ?? docs/ — the same line before the edit and after it. So the run found nothing, drew nothing, and git checkout -- had nothing to restore from. For the whole class of files git does not track, REX had no way back at all, and it did not say so.

The promise, and why it can only fail quietly

REX makes exactly one safety claim. ASK runs an agent that cannot write, by any route. ACT runs one that edits a copy, shows you the change, and writes your file only when you press Approve.

A promise like that has no loud failure mode. When it breaks, every sentence on screen is true — Applied to 0 file(s), the agent changed no files, REX put it back — and the file underneath says otherwise. This post is the record of the three times that happened, and of the boundary that grew around each one.

The working copy

The first fix kept one copy per run: the agent edited the real file, and REX moved the change aside and put the original back afterwards. I rejected it. It left a window in which my file held a change I had not seen, and it forced every change to be taken or dropped one run at a time — “not like that, do it again” had nowhere to happen.

Version 2 turned the copy round. The agent never touches the file. REX forks a working copy under ~/.rex/work/<documentId>/, every ACT run edits that, and the copy survives across runs, so a change is a conversation rather than a verdict. Git stopped being load-bearing: REX compares its own two files, so an untracked document is no different from a committed one.

And REX does not merge. If the file on disk changed since the fork, Approve refuses, because:

A three-way merge that silently resolves a conflict inside a document under review is the same class of failure as an anchor that resolves to the wrong place: it succeeds, it reports success, and it is wrong.

One naming trap, reported the next day: the copies were first called base.md and current.md, so the agent answered “the joke at current.md:31 and I read it as some other file entirely. The files now carry the document’s own name.

It failed again, twice

The put-back that deleted. When an agent writes outside the files it was given, REX restores the original from a “before-set” — everything git status listed as dirty or untracked before the run, plus the run’s targets. A tracked, clean file is in neither list, and the restore for a path with no before-bytes was rmSync. So an agent that edited a committed file outside the comment’s documents had that file deleted, under a notice saying REX had put it back. The bytes survived in a stash and git still had the file, but nothing said so. The fix was a second source, git ls-files --error-unmatch, and a pure classifyStray function holding the whole keep-or-delete decision in one testable place. The same spec caught a quieter cousin — an agent writing a 9,511-byte file into REX’s own store because the prompt said “inside this workspace” and the only absolute paths it showed were there — and a write into the store is now reported as misplaced instead of silently skipped.

The copy that vanished under the agent. Thread f54f2c9a, 2026-09-02:

Time (UTC) What happened
12:11 an earlier working copy of the document is approved; its directory is deleted
12:25 I press ACT. REX forks a fresh copy and names it in the prompt
12:25 the agent reads that path: File does not exist — eight times over forty seconds
12:25 the agent gives up and edits my real file in place
12:28 the run ends; REX puts the file back and adopts the agent’s bytes as a copy

The run recovered, and spent forty seconds and part of $1.33 on a file REX had named and then removed. A fork with no revisions leaves no trace when deleted, so the only remaining cause was a button — Approve, Discard or Undo — pressed while the run held the copy.

The first fix proposed was a Document: line on every turn, telling the agent where the document is, again, each time. I said no:

I don’t want to have some workarounds or half-working solutions. I want to think about it, think it through, understand what I am trying to achieve, and propose a proper fix, a proper systematic fix that would work always without these hacks.

The reframe: a document has an identity and a location, and REX kept moving the location — forked on the first ACT, deleted on approve, on discard, by an end-of-run sweep and by a start-up sweep, each a moment after which an agent’s memory was wrong. So the earlier rule — a working copy exists only while it differs from the file — was reversed. The copy exists from the first time an agent is pointed at a document, at one path, and is never deleted. “Exists” became “pending”, a hash comparison; forkWorkingCopy and both sweeps were deleted, not deprecated. Approve, Discard and Undo now refuse a document a run is holding, and each writes an event into the thread — The reviewer discarded the change — so a resumed agent learns what happened while it was out of the room.

The gate for ASK

ACT’s promise is kept after the tool runs, by the diff. ASK’s is kept before it, by a gate that sees every tool call. Write, Edit and NotebookEdit are refused outright; the interesting half is Bash.

The gate first bit me by refusing something harmless. Thread a1d3793c, two of eighteen steps denied:

cd /…/docs/architecture && grep -n -i "mcp|adapter api" components.md | head -60
  → 'cd' is not on the allowlist.

cd cannot write a file even in principle, and it was not one missing entry: the same session would have been refused sed -n, sort, jq, tree and 2>/dev/null. A missing entry fails invisibly — an agent that cannot search the way it wanted does not stop, it answers from the prose it already has.

The fix was not a denylist, and the reason is written at the top of src/main/agent/gate.ts:

REX uses an allowlist anyway, because a denylist states the rule without being able to keep it: to allow everything except known writers, REX would have to know every program that writes. xsltproc -o out.xml, sqlite3 db "delete …", make and install all write, none is famous for it, and each would ship as a hole. Under an allowlist “cannot write” is a fact; under a denylist it is a hope.

So the list grows under one admission test: a binary joins only when there is a decidable test for “this invocation writes”. ls never does; find has its action flags; git has its subcommand. python, sh, node, xargs and make are absent and always will be.

Applying that test found what the old list had let through. rg --pre hands every file to an arbitrary program. awk '{print > "f"}' survives the shell parser as one quoted word, redirect and all. git branch -a lists branches and git tag -a writes an annotated tag — one letter, two verbs. gh auth token is out because it prints a credential into a saved transcript. And curl joined late, in its reading forms only, because an agent that cannot follow a redirect cannot check a citation.

The refusal text changed too. “‘foo’ is not on the allowlist” names REX’s list; the honest sentence is “REX cannot tell whether ‘foo’ writes, so a read session does not run it.” REX admitting it cannot tell, not REX calling the command dangerous.

Four agents, four barriers

The gate lives in TypeScript and is asked over a pipe by the Python child that runs the agent SDKs; an unanswered question is denied after thirty seconds, because a bug in main must never become a write. Where the “no” lands differs:

SDK Where “no” is enforced What leaked before it held
Claude Agent SDK a PreToolUse hook returns deny a repository’s own settings file (below)
Codex the OS sandbox, read_only — there is no pre-execution veto, so the policy only makes the attempt visible cwd and /tmp are writable by default, so the first proof, staged under /tmp, proved nothing
OpenCode a permission ruleset, plus sandbox-exec around the server the default ruleset is allow everything; a default session wrote a file with no permission event at all
Deep Agents a filesystem rule, plus Path.resolve() of ten path spellings, one got through: a symlink inside the writable root, and PWNED reached my real file

One detail from two of those rows. OpenCode’s deny told the model nothing, so it retried — 22 wasted calls in the measured run — whereas a rejection through ask carries a sentence, and the model stops. Deep Agents matched it: a bare deny gave five attempts and a GraphRecursionError; a refusal in REX’s own words gave three attempts and “I am in a read-only environment”. The boundary and the thing that makes the model stop are two layers, and neither replaces the other.

The subtlest hole was in the gate itself. gateDecision() allows any tool name it does not know — right for Claude, whose disallowed_tools plus the write-tool denials are the whole read guarantee, and a hole for every other SDK, whose tools are called edit, write, fileChange. So each adapter maps its tools into a closed common vocabulary, and for every SDK but Claude an unmapped name is refused by name. The fall-through direction is the whole difference between a gate and a decoration.

The repository under review turned on a sandbox

One run: nineteen steps, three minutes ten, $4.03, and three failed gh calls the debug report summarised as 0 denied · 3 failed. The gate had refused nothing. REX allowed all three and they still did not run.

The reviewed repository’s .claude/settings.json said sandbox.enabled: true, and REX loads project settings so that CLAUDE.md, skills and agents shape the run. The sandbox came in with them, its network allowlist was empty, and api.github.com:443 was denied. The model retried with dangerouslyDisableSandbox, which raised a permission prompt — and a headless run has nobody to answer it. REX’s hook allow does not count there: the bundled CLI accepts a prior approval only when it is type === "rule". Not a crash and not a denial; is_error = 1, denied = 0, and every surface drew a successful run that had quietly lost three steps.

The fix is two options on the Claude adapter: pin sandbox={"enabled": False} so REX’s value outranks the repository’s, and answer the CLI’s own prompt with the same policy the hook uses. The rule I wrote down:

A reviewed repository may shape the agent’s context. It may not set REX’s boundary.

The honest limit

An ACT run may now edit any Markdown or HTML file under the workspace, not only the document you commented on. REX cannot fork a copy for a file it does not yet know the agent will touch, so for those files the agent edits in place and REX puts your bytes back when the run ends, holding the agent’s version as a working copy. That is the write-then-revert window version 2 was written to close, reopened for one case. FORMATS.md says it plainly: a REX killed in that moment leaves the file so, and git is the way back. The document you commented on is never in that state.

What generalises

A safety promise is only as good as the sentence the UI prints when it fails. Every incident above was found by a human noticing that a true sentence and a file disagreed, and every fix added a sentence — misplaced, held, The reviewer discarded the change, REX cannot tell whether ‘foo’ writes — where before there was silence.

The DEX post on the Agent SDK was the Claude-only version of this; the four-row table is what it turned into. Word and PowerPoint go one step further, because for a .docx there is no diff to show — the agent writes a plan, and REX checks it. And for why any of this is worth building, start here.