Repository navigation
sandbox: P7 would delete the file that still gates the write/edit tools, and the gate's stated reason (that it mirrors bash) is no longer true #1553
Description
Activity
Related measurement, filed as #1558: the two predicates named here as the last gate standing also exempt every relative path before resolving anything (
check_workspace_write,bash_tool.py:5305-5308), and because the in-process tools resolve against the daemon's cwd rather than the session workspace, a relative..path is allowed by the gate and lands elsewhere. It sharpens this issue's P7 question rather than duplicating it: whatever the fence does about the file tools' semantics, the predicate currently answers about a base the write does not use. (check_read_only_file_writeis not affected — it realpaths first.) — cycle cyc20260924-013134Measured addendum (cycle
cyc20260924-013134): the read-only predicate is not "the agent's own scratch space", it is the complement of the workspaceThis cycle re-measured §1's asymmetry with the predicate called directly (pure calls —
realpathonly, nothing executed, no file touched) and with a target set chosen so each row is a different kind of thing. Same tier, same workspace argument (/Users/argszero/.emrg/evolution/emrg):target check_read_only_file_write(whatwrite/editcall)_check_sandbox(bash's own scan, same tier)<workspace>/emrg/tools/write_tool.pyBLOCK BLOCK ~/.emrg/config.toml(protected)BLOCK BLOCK ~/.emrg/emrgd.token(protected)BLOCK BLOCK ~/scm/github.com/argszero/aitokenpool/Cargo.toml— another project's tracked fileALLOW BLOCK ~/scm/github.com/argszero/aitokenpool/.git/hooks/pre-commit— a hook the other repo will runALLOW BLOCK ~/.emrg/install/version.txt— the auto-upgrade chain's own state, not in_PROTECTED_FILESALLOW BLOCK ~/.zshrcALLOW BLOCK ~/Library/LaunchAgents/com.evil.plistALLOW BLOCK /etc/hostsALLOW BLOCK and the confinement underneath the bash leg is not a text scan at all:
writable_roots(resolve_policy(mode="read-only", workspace_root=<repo>)) == []So at
read-onlythe agent has two file-write channels with opposite verdicts on the same path: bash, whose granted roots are the empty set, refuses to write anything outside the workspace because it has nowhere to write — andwrite/edit, which allow everything outside the workspace except the five_PROTECTED_FILES.Why this changes §3's decision, not just its evidence
§3 frames the P7 choice as strict symmetry (
writable_rootsas-is → the agent's own state dirs are denied too) versus a named exception for the agent's own state roots. Read against today's behaviour, both candidates are narrower than the status quo:- today =
¬(inside workspace) ∧ ¬(one of five files)— unbounded in every other direction; - candidate 1 =
[]— narrower; - candidate 2 =
[]+ the agent's state roots — narrower, and the only one of the three that matches what the docstring claims ("the memory dir, logs, OS temp").
The docstring's enumeration is the policy that was intended; the predicate is a deny-list of two entries and implements a much wider one. That is the same defect class as the stale "mirrors the bash tool" sentence next to it — a stated policy the code does not implement — and it means "reintroduce
_trusted_write_zones()" is the wrong description of candidate 2: relative to the current gate, candidate 2 is a restriction, not a restoration of D5's deletion.Two side measurements, stated with their limits:
- The predicates are opposite shapes, so "symmetry" needs to say which one is the model.
check_read_only_file_writeis a 2-entry deny-list;check_workspace_writeis an allow-list (workspace ∪_trusted_write_zones()∪_temp_write_roots()), and the trusted zone is still present inbash_tool.py:5169for the workspace-write leg even thoughemrg/sandbox/roots.py:58-64records its deletion as D5. Re-homing must pick one shape; today's two tools disagree about which. check_workspace_writeis not total: it raises rather than answering. Called with an unusable OS temp area (this tier), it raisesFileNotFoundErrorfrom_temp_write_roots() → tempfile.gettempdir()on every input above, and so doeswritable_roots()on itsworkspace-writebranch. Latent, not observed in the tool path:write_tool.py:82only reaches it whensandbox == "workspace-write", which is exactly the mode that grants the temp area, so the raise cannot currently fire from a tool call — but a fence that derives its allow-list before the temp area exists (a fresh container, aTMPDIRpointing somewhere unwritable) would fail closed with an exception rather than a reason. Worth knowing when P7 moves the predicate behind that fence.
- today =
A measured asymmetry between the two predicates, on one path and one tier — the row §1 of this issue argues in the abstract, and it lands on a decision P7 has to take.
The two predicates do not agree about
/tmpEvaluated in process, pure calls, nothing executed (the temp reading pinned to this host's value so the call does not raise — see the addendum below):
v1 bash_tool.py::_temp_write_roots() -> ['/private/var/folders/s6/.../T']
v2 sandbox/roots.py::writable_roots(workspace-write) -> ['', '/private/tmp', '/private/var/folders/s6/.../T']so under workspace-write the old tool refuses a write under
/tmpwhile the v2 fence grants it. v1's set isrealpath(gettempdir())plus the WindowsTemp-parent spelling and nothing else (bash_tool.py:5194-5217), and the refusal is the one at :5333 —workspace-write sandbox: blocked write outside workspace {file!r}. v2 adds/tmpexplicitly (roots.py:74, spellings = workspace_root, "/tmp", gettempdir()), which its docstring attributes to the blueprint (roots.ts:52-55).This is not a claim about which is right — it is the input to the choice §3 says P7 must make. Two consequences worth having in the issue when it is made:
- The direction of the change is wider, and it is live. P6 made v2 the default, so
/tmpbecame writable under workspace-write at that commit. If P7 re-homes the file tools ontowritable_roots, they inherit/tmptoo — which is the concrete answer to §3's 'the agent's own state dirs' question for one root nobody has named yet: the file tools would gain a shared, world-writable directory with predictable names, sourced from the blueprint rather than from a decision EMRG made. - Consumers have already adapted to the narrower rule. A sibling task's own memory (OfficeCLI,
.emrg/memory/emrg-opensource-verification-discipline.md) records the measurement in v1's words —mv /tmp/officecli-501/CoreFxPipe_…→ blocked write outside workspace — and the workaround it derived: put scratch files under<repo>/.emrg/instead of/tmp, because that path is inside the workspace and excluded by.git/info/exclude. So the refusal is a rule at least one task built around, and it is now false on any task running ≥ 0.3.0. The site that states it is neither tool's docstring; nothing in the tree says the old set was narrower.
Addendum, and it is the same defect as §2's
_temp_write_roots()raises rather than answering when the process has no usable OS temp area —FileNotFoundError: No usable temporary directory found in [...], straight out oftempfile.gettempdir()(bash_tool.py:5205). That is §2's 'the predicate is not total' on the other predicate: at this tier (read-only, #1552/#1465) bothcheck_workspace_writeandwritable_rootsfail closed with an exception instead of a reason, which is why the v1 reading above had to pin the temp value rather than call it. The two arms share one cause — the allow-list is derived before it is asked anything — and if P7 moves both predicates behind one fence, that is one place to fix it, or one place to leave it.No vote here (this is an issue), and the reading is source plus pure predicate calls only — this cycle is read-only, so nothing was written and nothing was executed.
- The direction of the change is wider, and it is live. P6 made v2 the default, so
One cost of the asymmetry, measured this cycle: the repo own index-maintenance instrument cannot run at
read-onlySame turn, same tier, same directory (the canonical memory dir this issue already names):
writer result the in-process edit/writetools (check_read_only_file_write)two files written — this cycle record and its MEMORY.mdrowpython3 scripts/archive-memory-index.py <index> --cap 50, run through bashrefused: [Errno 1] Operation not permitted: .../cycle-archive-20260924.md.tmp-9424The archiver is the one tool built for the cap/archive move, and its docstring exists precisely because hand-written moves failed twice (a
sedthat deleted every line; an off-by-one that archived the newest row). That tool needs a temp file — and atread-onlybashhas no writable root, so the safe instrument is unusable in exactly the state where the index needs maintenance, while the unsafe-by-comparison in-process writer is the one that works. The index cap had to be honoured this cycle by doing the move by hand through the in-process tool, with a conservation check afterwards (53 rows across index + archive before and after, the moved row occurring exactly once) — the operation the tool was written to make unnecessary.Two smaller things the failure path shows, both worth a line if anyone touches it:
- the plan is printed after the failure (
move: cycle-20260920-141503.md), so the operator sees an error before the plan it belongs to; - the recovery messages fire even when nothing was written —
could not restore the index/could not restore the archiveon a run whose first write (the temp file) was the one that failed. The failure mode is safe ("nothing happened", verified: index unchanged, no stray*.tmp-*left), but the message names a restore that was not needed. That is the same shape as the rule this repo already mechanised — a status must distinguish nothing changed from could not repair.
This is offered as evidence for the decision this issue raises, not as a request to special-case the archiver: if the file tools keep a named exception for the agent own state roots (
write_tool.py, the memory dir here), then a bash-run script cannot use it, and the exception is only half an exception. Whichever way that goes, the asymmetry is what makes a repository tool tier-dependent.— cycle
cyc20260924-015321- the plan is printed after the failure (
Where a
read-onlycycle can actually write, measured — one directory, and it is outside the workspaceCycle
cyc20260924-035923(this turn, tierread-only) attempted the two locations the standing rules name and one they do not. Both refusals are the file tools own text, not the OS profile message:target verdict text <workspace>/.emrg/sessions/<sid>/tmp/<file>— the Temp File Rules mandated scratch directoryrefusal read-only sandbox: blocked file write inside workspace ... (dirty-tree guard, community issue #979)<workspace>/.emrg/sessions/<sid>/memory/<file>— the session memory the prompt tells a cycle to maintainrefusal same ~/.emrg/evolution/.emrg/memory/<file>— the evolution root, a sibling of the repowritten 6,098 bytes realpathon the three in-repo state roots (.emrg/sessions/<sid>/tmp,.emrg/sessions/<sid>/memory,.emrg/memory) puts all three inside/Users/argszero/.emrg/evolution/emrg; the one that succeeds is outside it.Two consequences for the decision this issue asks P7 to take, both from that single measurement:
- The two locations the rules name are the two the gate refuses, so rule and tier contradict each other and the contradiction is quiet — bash denies everything here, so the file tools look permissive, but their permissiveness now stops exactly at the workspace boundary, which contains every state root a cycle is told to write. The one directory that works is one no rule names.
- A P7 fence derived from
writable_rootsalone would take that directory away.writable_roots(read-only) == [], and theworkspace-writeset is{workspace, /tmp, gettempdir()}(emrg/sandbox/roots.py) — neither contains~/.emrg/evolution/.emrg, so the strict-symmetry candidate in the P7 discussion would deny the path this cycle record was written to: a read-only cycle could not record why it was read-only.
So whichever candidate wins, the exception has a precise membership worth writing down: the agent own state roots that live outside the workspace (today the evolution memory root is the only one; the session roots are inside and are refused anyway).
No claim here about what the fence should do — only that the three rows above are what a cycle actually has at this tier.
Three measurements for the decision this issue asks for, taken on base
e5b69c87with pure predicates only (nothing spawned, no write attempted) — so each row is the gate's own verdict, not a simulation of it.What the two legacy file-write gates actually decide, per tier
Workspace = the session cwd (
daemon.py:2901-2903injects it asworkspaceforwrite/editexactly as it injectsworkdirforbash);_trusted_write_zones()=~/.emrg/evolution/.emrg;_protected_paths()is five daemon state files and nothing else.target read-onlyworkspace-writethe source tree ( …/emrg/emrg/tools/bash_tool.py)BLOCK allow {{ source_dir }}/.emrg/memory/cycle-x.md(the root D9's prompts point at)BLOCK allow ~/.emrg/evolution/.emrg/memory/cycle-x.md(this cycle's own record root)allow allow (via _trusted_write_zones()){{ source_dir }}/.emrg/sessions/<sid>/tmp/x.py(the path the Temp File Rules mandate)BLOCK allow ~/.zshrcallow BLOCK /etc/hostsallow BLOCK The three facts I would want in front of whoever decides
1. Read-only here is a workspace-denial rule, not a grant rule — the opposite shape from
writable_roots.check_read_only_file_writereturns a reason when the target is inside the workspace, andNoneotherwise (protected paths aside). It does not enumerate anywhere the tools may write; it names one place they may not. That is why a mechanical port is not available:writable_roots(read-only) == [](the "strict symmetry" branch this issue offers) is not the same rule tightened, it is the complement of it.2. Outside the workspace, read-only grants the world — measurably, including the host's home.
~/.zshrcand/etc/hostsare allowed atread-onlyby the in-process tools, while atworkspace-writeboth are blocked. At the same tier the bash tool is confined by the v2 fence and can write nothing (emrg/sandbox/roots.py:48returns[]for any mode butworkspace-write). So the two tools do not simply disagree in strength; they disagree in direction about the same file at the same tier — the asymmetry the P7 end state inroots.py:1-11says must not be able to arise.3. The two paths a read-only cycle is instructed to use are the two the predicate blocks, and the one it needs most is the one it happens to allow for the wrong reason. Blocks:
{source_dir}/.emrg/memory/(the memory root D9 moved the prompts onto) and{cwd}/.emrg/sessions/{sid}/tmp/(the Temp File Rules' mandated scratch path — both are inside the workspace). Allows: this cycle's record root, which is outside the workspace, so the read-only predicate lets it through only because it denies the workspace rather than granting zones; atworkspace-writethe same root is allowed by name, through_trusted_write_zones().One dependency, and one correction I would apply to the issue's own premise
The base a relative target is joined onto is the daemon's cwd on this base, and PR #1569 (open) changes it to the declared workspace — so any decision written here should be phrased against the post-#1569 shape. Before it, the containment question could be asked about a path the caller never designated: the same file answered
read-onlyALLOW when spelled relatively and BLOCK absolutely (measured), and the write followed the same base out of the declared workspace. Worth knowing because it means "what the predicate did at read-only" was partly a property of the process's cwd, i.e. not a stable thing to port either.Nothing here decides the question — it says that whatever is decided has to be written as an explicit grant list (or an explicit named exception), because the legacy read-only gate has no grant list to inherit, and the two roots a read-only cycle cannot do without are outside the set
writable_roots(read-only) == []would permit.The sentence this issue quotes as load-bearing now has a PR correcting it — and nothing else changes. Measured this cycle (
cyc20260925-110214) with pure predicates on the current master, then opened as #1607.The tables above already record the divergence; what was missing was that the code stated the opposite. Three sites claimed a mirror the mechanism does not have:
check_read_only_file_write's docstring — "Mirrors the bash tool's read-only semantics for file tools.", the sentence §1 of this issue singles out as the only statement of what the file tools are supposed to do at a tier;check_workspace_write's docstring — its stated rule omitted_trusted_write_zones()altogether, so it understated what it allows while claiming dsh's workspace + temp list;- the
read-only/workspace-writecomments at both call sites inwrite_tool.pyandedit_tool.py(surviving files), plus the two test narratives intests/test_write_tool.py/tests/test_edit_tool.pythat repeat the same overstatement.
#1607replaces each with the measured relation and cites this issue for the decision. Behaviour, tests and this issue's open question are untouched — a mechanical port is still unavailable for the reason measured above (the read-only rule is a workspace denial, not a grant list, sowritable_roots(read-only) == []is its complement rather than a tightening of it).The plan of record for this issue's subject now has its own issue: #1675 — P7: the legacy bash tool must end with zero users — four importers, one of them with no v2-side home (host directive 2026-09-28).
Division of labour between the two:
- P7: the legacy bash tool must end with zero users — four importers, one of them with no v2-side home #1675 owns the end state (nothing imports
emrg/tools/bash_tool.py, the file and thebash_tool_v2switch are gone) and carries the implementation once the policy decision below is taken. - this issue owns the decision P7: the legacy bash tool must end with zero users — four importers, one of them with no v2-side home #1675 cannot take on its own: what the in-process file tools answer at each tier, given that the v2 fence grants nothing at
read-onlyand that the file tools'workspace-writeallow-list carries one root D5 deleted from the v2 side (~/.emrg/evolution/.emrg/, in use today — that MEMORY.md was written at 2026-09-28 09:55).
Prose half already landed (#1607,
956efe94): the predicate no longer claims to mirror bash. What remains here is the decision, not the wording.- P7: the legacy bash tool must end with zero users — four importers, one of them with no v2-side home #1675 owns the end state (nothing imports
Decision taken by the host, 2026-09-28: (A) — one policy for both tool families.
The in-process
write/edittools stop asking their own predicate and ask the sandbox policy instead. A tier then means one thing for every tool:- the allow-list is
emrg/sandbox/policy.py+roots.writable_roots; - the three legacy functions (
check_read_only_file_write,check_workspace_write,resolve_file_target) move out ofemrg/tools/bash_tool.pyand are re-expressed against that policy — not carried over as a parallel answer; - P7: the legacy bash tool must end with zero users — four importers, one of them with no v2-side home #1675 carries the implementation and the end state (nothing imports the legacy module; the module and its switch are deleted).
The sub-question this decision creates, measured before it is answered.
check_workspace_writeallows one root the v2 policy does not have —_trusted_write_zones()→~/.emrg/evolution/.emrg/(the #1093 self-regression fix), which host decision D5 deleted from the v2 side. Dropping it re-opens #1093 unless whatever needs it moves inside its own workspace. What I measured today:path who writes it inside the writer's workspace? ~/.emrg/evolution/emrg/.emrg/memory/cycle-20260928-094648.mdthe emrgevolution cycleyes — the workspace is that repo /Users/argszero/scm/github.com/argszero/aitokenpool/.emrg/memory/MEMORY.md(67 entries, 2026-09-28 07:46)the aitokenpool open-source task yes — workspace is that checkout ~/.emrg/evolution/.emrg/memory/MEMORY.md+ a codex entry, both 2026-09-28 09:55not attributed — this is the open item the path is outside the repo whose cycle records I can place Both the installed and the master templates tell a task to write under
{{ source_dir }}/.emrg/memory/, and for the two tasks above that is inside their own workspace — so the third row is either a session whose cwd is the evolution root itself, or a pre-D9 leftover. Whoever implements #1675 should settle it by measuring the writer, not by keeping the root: the two admissible outcomes are (i) drop the root, having moved any live writer inside its workspace, or (ii) re-authorize it as a named fourth source in the policy, written down and pinned by tests — never a silent third answer.Given the host's decision that the old tool ends with zero users, (i) is the direction of record unless the implementing cycle measures a writer that cannot move.
- the allow-list is
The open item is measured: nothing writes that root any more
#1675 delegates this to whoever implements it -- "Whoever implements #1675 should settle it by measuring the writer, not by keeping the root" -- and the measurement decides between (i) drop the root and (ii) re-authorize it as a named source. Measured this cycle on
aac28fb, Windows host, workspaceC:\Users\Administrator\.emrg\evolution\emrg.The root has no task writer. Its only live writer is a pytest fixture.
~/.emrg/evolution/.emrg/is a real path on this host, so the third row of the table in comment 5862806352 resolves here rather than needing a mac host to be observable.-
Nothing legitimate has written it since 2026-09-24 22:59. Newest cycle record there:
cycle-20260924-225336.md, mtime 2026-09-24T22:59:15.MEMORY.mdthere: 2026-09-23T05:45:01. 230 files / 1.31 MB in total, nothing newer. -
The only 2026-09-28 write to that root is a 12-byte fixture payload.
cycle-20260901-000000.mdcontains the literal textcycle record-- not a record. It is rewritten on every suite run, and it is one test:tests/test_write_tool.py::test_write_workspace_write_allows_evolution_memorybuilds its target fromos.path.expanduser("~/.emrg/evolution/.emrg")and writes it through the realWriteTool.A/B on this host, mtime of that file:
20:47:00beforeuv run pytest tests/test_write_tool.py,21:38:00after (15 passed); with-k evolution_memoryalone,21:38:00 -> 21:38:16. One test in that file writes outside the workspace; the other 14 do not. -
The fixture's pin does not hold on Windows, so the write lands on the host. It sets
HOMEonly, andos.path.expanduserreadsUSERPROFILEhere -- the target resolves against the real home while the test believes it is intmp_path. Windows-only: withHOMEset, POSIXexpanduserhonours it, so the fixture behaves as written on the CI legs and leaks only on this platform. This is the fixture home is pinned with one spelling, so the suite writes into the host real evolution root #1709 / emrg: the fixture home is pinned in both spellings, so the suite stops writing into the host root #1710. -
The premise its docstring states has been stale since D9. The test's reason is "the evolution module writes its cycle records to
~/.emrg/evolution/.emrg/memory/". D9 (emrg: the prompt's memory root is the root the daemon loads #1555,65aa473, 2026-09-24T01:10:54+08) moved the prompt's memory root to<source_dir>/.emrg/memory/. The in-repo root is live (MEMORY.md, 2026-09-28T21:30:49); the out-of-repo one is not.
What this answers
The third row of the decision's table --
~/.emrg/evolution/.emrg/memory/MEMORY.md, recorded as "not attributed" -- is a fixture, not a writer. No task has its cwd at the evolution root, and no live writer there cannot move.So outcome (i) is available: drop
_trusted_write_zones() -> ~/.emrg/evolution/.emrg/from the file tools'workspace-writeallow-list, and the #1093 self-regression does not re-open, because the writer the exception was added for no longer exists -- D9 removed it, and the fixture is the residue that made the root look live.The drop has to be accompanied by exactly one textual change: the test above stops asserting the exception and starts asserting its absence. Its premise is the thing D9 invalidated. #1710 pins its home; the inverted assertion is the other half, and it is the one that keeps the root unwritten rather than merely un-pinned.
Not measured
Whether a host outside this one still has a live writer there. On this host the reading is one-directional, and the exception's only remaining consumers are the fixture and the predicate call site itself. Zero cycle records have landed there in the four days since D9 -- if another host's cycles still are, this reading does not see it.
-
The host rules on this question: drop the root, do not chase the writer
Reading this thread's open item, the host ruled today (2026-09-28):
「"到底谁在写工作区外那个 memory 根" 不用管啊,删除就是了」
(Do not care who writes that out-of-workspace memory root — just delete it.)
That settles the sub-question this issue opened against #1675's decision (A). The (i)/(ii) fork measured in #5871039382 resolves to (i): the root is dropped, not re-authorized as a named fourth source, and the writer census stops being a precondition for implementation.
What this means for #1675's remaining steps
_trusted_write_zones()->~/.emrg/evolution/.emrg/leaves the file tools'workspace-writeallow-list. Their verdicts then come fromemrg/sandbox/policy.py+roots.writable_rootsalone, which is what (A) asked for.- The emrg: workspace-write sandbox blocks the evolution module's own memory writes (self-regression) #1093 self-regression concern retires with it: measured this cycle, the writer that exception was added for no longer exists — D9 (emrg: the prompt's memory root is the root the daemon loads #1555,
65aa473) moved the cycle records inside the workspace on 2026-09-24, and nothing legitimate has written the old root sincecycle-20260924-225336.mdat 22:59:15. tests/test_write_tool.py::test_write_workspace_write_allows_evolution_memoryinverts: it asserts the refusal instead of the grant. Its docstring's premise ("the evolution module writes its cycle records to~/.emrg/evolution/.emrg/memory/") is the statement D9 invalidated.- The 230 files / 1.31 MB already under that root are history and stay where they are. The change is to the permission, not to the archive: nothing writes there afterwards.
No new issue is filed for this (host standing rule, 2026-09-28 — no new issues until the open ones are finished): the ruling lands here and in #1675's implementation.
Ruled by the host, in session, 2026-09-28T21:50 — the question this issue was filed to hold:
read-only 时文件工具与 bash 共用策略、还是各自独立? 答:共用
("at
read-only, do the file tools share one policy with bash, or answer independently? — Share.")So this issue's decision half is answered with option 1 in §3: the file tools take the policy the kernel-enforced dialects read. Implemented on #1708 (draft, P7's long-lived branch) as
emrg/sandbox/fence.py:fence.file_refusal(path, policy)— the in-process fence. Its only input isemrg/sandbox/roots.writable_roots, the same derivation the Seatbelt/bwrap profile is built from, so the set of paths a file tool may touch is the set a spawned command may write. One derivation, two readers.write/editboth ask it, with the policy resolved the way the shell tools resolve theirs (resolve_policy(mode=arguments["sandbox"], workspace_root=…)).- The two gates that used to answer for the file tools —
check_read_only_file_write,check_workspace_write— are deleted rather than moved a third time, together with the extra deployer root they granted aside (host decision D5; the root itself was archived by emrg: the instance-root index stops being a subject, and the review queue reads one root #1703).
The consequences, stated rather than left to be discovered (they are in the fence's docstring, which is where the tier's meaning now lives):
- At
read-onlythe file tools write nowhere, including the memory root a read-only cycle would write its cycle record into. That is what "share bash's policy" means —read-onlygrants nothing by definition — and it is survivable only because of when a cycle runs at that tier: the daemon's dirty-tree guard now pins and recovers the tree itself (scheduler._recover_dirty_tree_sync), andread-onlyis the residue of a recovery that errored, not the ordinary state of a dirty tree. The exit from it is a host turn: a call carrying no tier resolves toDEFAULT_MODE=danger-full-accessand stays unconfined, exactly as before. - At
workspace-writethe file tools grant the workspace root and the platform temp areas and nothing else — the deployer root §3 option 2 would have re-named is not re-introduced. - The five protected daemon state files (
~/.emrg/config.tomland neighbours) are refused by containment in every deployment this repo runs; the fence also names them, which makes it stricter than the profile there and never looser. Asserted both ways intests/test_file_policy_has_one_home.py.
What is measured, and what is left. Green on the branch head: full suite 4428 passed / 32 skipped, import + CLI checks, and the rewritten
tests/test_file_policy_has_one_home.pypins the sharing as an identity rather than a behaviour — both tools bound to the fence's own function object; the fence's single input proven by injection (a scratch root granted by a patchedwritable_rootsbecomes writable, and the policy's own workspace root stops being writable); the two tools' refusals byte-equal for one target; and the never-looser invariant overwritable_roots.One stale sentence this change leaves behind, and it is not in this issue's scope: the delivered
evolution_prompt.mdstill offerscheck_workspace_write(now deleted) as an example of a pure predicate, and_check_sandbox— its companion in the same parenthetical — is deleted with the legacy tool at P7 step 5. The prompt is a stable template and normal evolution may not edit it; recorded here so the sentence is not mistaken for a live call path.- added a commit that references this issue
on Sep 28, 2026 Both halves are now implemented — the decision and the deletion
Head
2456e72don #1708, CI running. This issue's two halves are the host ruling and the file that contradicts it; both are closed on this branch.The decision (was the open half). Host ruling 2026-09-28T21:50: the file tools and the shell share one policy rather than answering independently. Implemented by asking
emrg/sandbox/fence.file_refusal, whose only input isemrg/sandbox/roots.writable_roots— the same derivation the Seatbelt/bwrap profile is built from. The two gates that used to disagree with the fence (check_read_only_file_write,check_workspace_write) are deleted, not parked:emrg/tools/file_policy.pykeeps only the mechanics (where a relative spelling resolves, what "inside" means, the host's protected files).The file that made the decision ambiguous is now deleted.
emrg/tools/bash_tool.pyheld the third spelling of the same policy — the static command scan (_check_sandbox) and its own copies of those mechanics — and it had zero production importers. With it gone there is one home for the file-effect policy, and the stateable form of this issue's complaint ("a gate whose stated reason is no longer true") no longer has a carrier: the surviving refusal texts name the fence, and the negative guard intests/test_file_policy_has_one_home.pyasserts the legacy module cannot come back.What the tests pin, so a later change cannot quietly re-split the policy:
- the fence has one input — an injected derivation moves the verdict in both directions (
test_the_fence_has_one_input_the_shared_derivation); - both file tools are bound to the fence's own function object, and an injected refusal reaches them (
test_the_file_tools_are_bound_to_the_fence,test_an_independent_answer_reaches_the_tool); - one tier, two tools, one verdict, byte-equal — in both directions (
test_the_two_tools_state_the_same_answer); - the fence is never looser than the derivation, the host's protected daemon state being the one deliberate exception and the safe direction (
test_the_fence_is_never_looser_than_the_derivation); - silence keeps its meaning: a call carrying no tier is unconfined, for the file tools as for the shell.
Full suite 3508 passed / 12 skipped on this host; the guest-host and Windows legs are CI's. Remaining for P7: the Windows leg on this head, then review and merge.
- the fence has one input — an injected derivation moves the verdict in both directions (
The decision this issue records is taken, and handled by #1708 — the implementation is the same change that closes #1675.
The host ruling (2026-09-28T21:50) answered the §3 question in the shared direction:
read-only 时文件工具与 bash 共用策略、还是各自独立?答:共用
and #1708 makes it structural: the
write/edittools no longer answer for themselves at all.emrg/sandbox/fence.py::file_refusaltakes its allow-list fromemrg/sandbox/roots.writable_roots— the same derivation the Seatbelt/bwrap profile is built from — and the predicates this issue named (check_read_only_file_write,check_workspace_write) are deleted, not re-homed a third time. The stale sentence in §1 is gone with the file that carried it: the fence states what the file tools answer at each tier, in the fence.The §3 candidates resolved as the strict one, with its cost written down rather than discovered:
- at
read-onlythe fence refuses everything, including the memory root a read-only cycle writes its record into. That tier is reached only when the daemon dirty-tree recovery errored (scheduler.TaskHandler._resolve_sandbox), and its exit is a host turn — which carries no tier and resolves todanger-full-access, so a host session stays unconfined exactly as before. - option 2 (a named exception for the agent state roots) was not taken: the extra deployer root stays deleted by host decision D5, and
test_write_workspace_write_refuses_the_retired_deployer_rootin emrg: the in-process file policy moves out of the file P7 deletes #1708 pins its retirement so a quiet reintroduction is red.
Both directions of the §1 asymmetry are pinned by tests, and a mutation arm that gives the write tool an independent answer kills 6 of them.
- at
- added a commit that references this issue
on Sep 28, 2026
Measured this cycle (
cyc20260923-210134, this host, evolution taskemrg, tierread-only— forced by the structural dirty-tree downgrade). Filed because the next step of the bash-tool-v2 programme (P7: deleteemrg/tools/bash_tool.py, host-authorized 2026-09-23) is not a deletion as scoped: the file is still the read-only gate for thewriteandedittools, and the reason that gate gives for itself has gone stale.1. Two tools, one tier, opposite answers
In the same cycle, at the same tier:
printf probe > ~/.emrg/evolution/.emrg/memory/.probe-bash-write.txt(bash)Operation not permittedprintf probe > .probe-ws-write.txt(bash, inside the workspace)Operation not permittedrm -f ~/.emrg/evolution/.emrg/memory/_index_hygiene_20260923.py(bash)Operation not permitteduv run …(its own cache init)Failed to initialize cache at '/Users/argszero/.cache/uv' … Operation not permittedwritetool →~/.emrg/evolution/.emrg/memory/cycle-20260923-210134.mdUnder
read-onlybash has no writable root at all, while thewrite/edittools still write outside the workspace.That is deliberate today, and the code says why.
emrg/tools/write_tool.py:70-71andemrg/tools/edit_tool.py:85-86call the legacy predicateemrg/tools/bash_tool.py:4944 check_read_only_file_write, whose docstring ends:The final sentence is no longer true. Since P6 (#1545) the process-boundary tool is the default, and the v2 fence grants nothing under
read-only(emrg/sandbox/roots.py:48 writable_rootsreturns[]for every mode other thanworkspace-write). A justification that names a tool whose semantics have since changed is the stale-reading defect class this repo keeps finding — but here it is load-bearing: that sentence is the only statement anywhere of what the file tools are supposed to do at this tier.2. Why deleting the file is not enough: a symbol census
grepover the tree this cycle, product code first:BashToolemrg/server/daemon.py:144— theconfig.bash_tool_v2 = falsefallback atdaemon.py:297-298SANDBOX_MODESemrg/server/scheduler.py:28(used at:415,:2376,:2435)check_read_only_file_writeemrg/tools/write_tool.py:10,71·emrg/tools/edit_tool.py:10,86check_workspace_writeemrg/tools/write_tool.py:10,83·emrg/tools/edit_tool.py:10,96Everything else the module exports is test-only (
_check_sandboxin 18 files,_extract_write_targetsin 11,_tokenize_commandin 2, …). 44 test files import the frozen module directly; those disappear with it.Three of the four dependencies are already arranged:
SANDBOX_MODEShas a v2 home with the same three values —emrg/sandbox/policy.py:18-25— and its comment states it is "pinned equal bytests/test_bash_v2_policy.pyso a drift cannot hide (the old definition disappears with its file at P7)". Confirmed:tests/test_bash_v2_policy.py:82assertsSANDBOX_MODES == bash_tool.SANDBOX_MODES. P7 movesscheduler.py's import.BashToolis the thing being removed, together with thebash_tool_v2config flag and its branch.check_read_only_file_write/check_workspace_writehave no v2 counterpart. They are the one unarranged dependency — and they sit on the only path a file write takes when it does not go through bash.3. Re-homing them is a decision, not a copy
emrg/sandbox/roots.py:1-11already anticipates the re-home and states the intended end state: the Seatbelt profile and "from P7, the in-process filesystem fence (emrg/sandbox/fence.py)" both derive their allow-list fromwritable_roots, "so 'the write tool cannot write /tmp but bash can' asymmetries cannot arise between them". (emrg/sandbox/fence.pydoes not exist yet — P7 creates it.)Read literally, that end state removes the asymmetry above in the opposite direction from today:
writable_roots(read-only)is[], so at P7 thewritetool could no longer write the memory dir, the logs, or the OS temp area — aread-onlycycle could not write its own cycle record (this cycle's was written by that tool, §1), and #1552's "no test, gate or tempfile can run at this tier" would extend to the artifacts as well.So P7 has to decide what the in-process file tools may write under
read-only. The two candidates visible in the code, neither chosen yet:writable_rootsas-is — strict symmetry with bash:read-onlydenies the agent its own state dirs too. Then the record and notes need a channel that is not tier-gated, or the project accepts that aread-onlycycle writes no record (and the index/record contract inevolution_prompt.md§6 has to say so).<session cwd>/.emrg/…, in-process tools only) — close in effect to the deleted_trusted_write_zones()(host decision D5), which is exactly why it would have to be stated as such rather than reintroduced quietly.4. Suggested sequence
emrg/tools/bash_tool.pyand its 44 test files; movescheduler.py'sSANDBOX_MODESimport toemrg/sandbox/policy.py; dropconfig.bash_tool_v2anddaemon.py:297-298; re-home the two predicates onto the v2 fence with the §3 decision written down at the fence, plus mutation arms that ask the same question of both tools at every tier.Filed as a scope/decision record rather than a defect: the current behaviour is intentional, and the defect is only that its stated reason has gone stale while the dependency it guards is still unarranged and unowned.