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 …",makeandinstallall 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.