Skip to content

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

@argszero

Measured this cycle (cyc20260923-210134, this host, evolution task emrg, tier read-only — forced by the structural dirty-tree downgrade). Filed because the next step of the bash-tool-v2 programme (P7: delete emrg/tools/bash_tool.py, host-authorized 2026-09-23) is not a deletion as scoped: the file is still the read-only gate for the write and edit tools, 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:

call result
printf probe > ~/.emrg/evolution/.emrg/memory/.probe-bash-write.txt (bash) Operation not permitted
printf probe > .probe-ws-write.txt (bash, inside the workspace) Operation not permitted
rm -f ~/.emrg/evolution/.emrg/memory/_index_hygiene_20260923.py (bash) Operation not permitted
uv run … (its own cache init) Failed to initialize cache at '/Users/argszero/.cache/uv' … Operation not permitted
write tool → ~/.emrg/evolution/.emrg/memory/cycle-20260923-210134.md written, 13,879 bytes (the cycle record itself)

Under read-only bash has no writable root at all, while the write/edit tools still write outside the workspace.

That is deliberate today, and the code says why. emrg/tools/write_tool.py:70-71 and emrg/tools/edit_tool.py:85-86 call the legacy predicate emrg/tools/bash_tool.py:4944 check_read_only_file_write, whose docstring ends:

Writes OUTSIDE the workspace (memory dir, logs, OS temp) stay allowed so a read-only cycle can still record state and write its own artifacts — the guard protects the host's uncommitted work, not the agent's own scratch space. Mirrors the bash tool's read-only semantics for file tools.

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_roots returns [] for every mode other than workspace-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

grep over the tree this cycle, product code first:

symbol imported from the frozen module importers outside it
BashTool emrg/server/daemon.py:144 — the config.bash_tool_v2 = false fallback at daemon.py:297-298
SANDBOX_MODES emrg/server/scheduler.py:28 (used at :415, :2376, :2435)
check_read_only_file_write emrg/tools/write_tool.py:10,71 · emrg/tools/edit_tool.py:10,86
check_workspace_write emrg/tools/write_tool.py:10,83 · emrg/tools/edit_tool.py:10,96

Everything else the module exports is test-only (_check_sandbox in 18 files, _extract_write_targets in 11, _tokenize_command in 2, …). 44 test files import the frozen module directly; those disappear with it.

Three of the four dependencies are already arranged:

  • SANDBOX_MODES has a v2 home with the same three values — emrg/sandbox/policy.py:18-25 — and its comment states it is "pinned equal by tests/test_bash_v2_policy.py so a drift cannot hide (the old definition disappears with its file at P7)". Confirmed: tests/test_bash_v2_policy.py:82 asserts SANDBOX_MODES == bash_tool.SANDBOX_MODES. P7 moves scheduler.py's import.
  • BashTool is the thing being removed, together with the bash_tool_v2 config flag and its branch.
  • check_read_only_file_write / check_workspace_write have 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-11 already 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 from writable_roots, "so 'the write tool cannot write /tmp but bash can' asymmetries cannot arise between them". (emrg/sandbox/fence.py does 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 the write tool could no longer write the memory dir, the logs, or the OS temp area — a read-only cycle 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:

  1. writable_roots as-is — strict symmetry with bash: read-only denies 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 a read-only cycle writes no record (and the index/record contract in evolution_prompt.md §6 has to say so).
  2. A named exception for the agent's own state roots (<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

  1. D9 first (the prompt memory root: complete in the working tree, blocked from landing by the tier — see the cycle record).
  2. P7, as one change: delete emrg/tools/bash_tool.py and its 44 test files; move scheduler.py's SANDBOX_MODES import to emrg/sandbox/policy.py; drop config.bash_tool_v2 and daemon.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.
  3. P5 (session approval channel) — independent of this.

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.

Activity

  1. argszero commented on Sep 23, 2026

    @argszero
    OwnerAuthor

    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_write is not affected — it realpaths first.) — cycle cyc20260924-013134

  2. argszero commented on Sep 23, 2026

    @argszero
    OwnerAuthor

    Measured addendum (cycle cyc20260924-013134): the read-only predicate is not "the agent's own scratch space", it is the complement of the workspace

    This cycle re-measured §1's asymmetry with the predicate called directly (pure calls — realpath only, 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 (what write/edit call) _check_sandbox (bash's own scan, same tier)
    <workspace>/emrg/tools/write_tool.py BLOCK BLOCK
    ~/.emrg/config.toml (protected) BLOCK BLOCK
    ~/.emrg/emrgd.token (protected) BLOCK BLOCK
    ~/scm/github.com/argszero/aitokenpool/Cargo.toml — another project's tracked file ALLOW BLOCK
    ~/scm/github.com/argszero/aitokenpool/.git/hooks/pre-commit — a hook the other repo will run ALLOW BLOCK
    ~/.emrg/install/version.txt — the auto-upgrade chain's own state, not in _PROTECTED_FILES ALLOW BLOCK
    ~/.zshrc ALLOW BLOCK
    ~/Library/LaunchAgents/com.evil.plist ALLOW BLOCK
    /etc/hosts ALLOW 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-only the 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 — and write/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_roots as-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:

    1. The predicates are opposite shapes, so "symmetry" needs to say which one is the model. check_read_only_file_write is a 2-entry deny-list; check_workspace_write is an allow-list (workspace ∪ _trusted_write_zones() ∪ _temp_write_roots()), and the trusted zone is still present in bash_tool.py:5169 for the workspace-write leg even though emrg/sandbox/roots.py:58-64 records its deletion as D5. Re-homing must pick one shape; today's two tools disagree about which.
    2. check_workspace_write is not total: it raises rather than answering. Called with an unusable OS temp area (this tier), it raises FileNotFoundError from _temp_write_roots() → tempfile.gettempdir() on every input above, and so does writable_roots() on its workspace-write branch. Latent, not observed in the tool path: write_tool.py:82 only reaches it when sandbox == "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, a TMPDIR pointing somewhere unwritable) would fail closed with an exception rather than a reason. Worth knowing when P7 moves the predicate behind that fence.
  3. argszero commented on Sep 23, 2026

    @argszero
    OwnerAuthor

    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 /tmp

    Evaluated 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 /tmp while the v2 fence grants it. v1's set is realpath(gettempdir()) plus the Windows Temp-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 /tmp explicitly (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:

    1. The direction of the change is wider, and it is live. P6 made v2 the default, so /tmp became writable under workspace-write at that commit. If P7 re-homes the file tools onto writable_roots, they inherit /tmp too — 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.
    2. 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 of tempfile.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) both check_workspace_write and writable_roots fail 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.

  4. argszero commented on Sep 23, 2026

    @argszero
    OwnerAuthor

    One cost of the asymmetry, measured this cycle: the repo own index-maintenance instrument cannot run at read-only

    Same turn, same tier, same directory (the canonical memory dir this issue already names):

    writer result
    the in-process edit/write tools (check_read_only_file_write) two files written — this cycle record and its MEMORY.md row
    python3 scripts/archive-memory-index.py <index> --cap 50, run through bash refused: [Errno 1] Operation not permitted: .../cycle-archive-20260924.md.tmp-9424

    The archiver is the one tool built for the cap/archive move, and its docstring exists precisely because hand-written moves failed twice (a sed that deleted every line; an off-by-one that archived the newest row). That tool needs a temp file — and at read-only bash has 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 archive on 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

  5. argszero commented on Sep 23, 2026

    @argszero
    OwnerAuthor

    Where a read-only cycle can actually write, measured — one directory, and it is outside the workspace

    Cycle cyc20260924-035923 (this turn, tier read-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 directory refusal 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 maintain refusal same
    ~/.emrg/evolution/.emrg/memory/<file> — the evolution root, a sibling of the repo written 6,098 bytes

    realpath on 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:

    1. 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.
    2. A P7 fence derived from writable_roots alone would take that directory away. writable_roots(read-only) == [], and the workspace-write set 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.

  6. argszero commented on Sep 24, 2026

    @argszero
    OwnerAuthor

    Three measurements for the decision this issue asks for, taken on base e5b69c87 with 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-2903 injects it as workspace for write/edit exactly as it injects workdir for bash); _trusted_write_zones() = ~/.emrg/evolution/.emrg; _protected_paths() is five daemon state files and nothing else.

    target read-only workspace-write
    the 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
    ~/.zshrc allow BLOCK
    /etc/hosts allow 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_write returns a reason when the target is inside the workspace, and None otherwise (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. ~/.zshrc and /etc/hosts are allowed at read-only by the in-process tools, while at workspace-write both are blocked. At the same tier the bash tool is confined by the v2 fence and can write nothing (emrg/sandbox/roots.py:48 returns [] for any mode but workspace-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 in roots.py:1-11 says 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; at workspace-write the 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-only ALLOW 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.

  7. argszero commented on Sep 25, 2026

    @argszero
    OwnerAuthor

    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-write comments at both call sites in write_tool.py and edit_tool.py (surviving files), plus the two test narratives in tests/test_write_tool.py / tests/test_edit_tool.py that repeat the same overstatement.

    #1607 replaces 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, so writable_roots(read-only) == [] is its complement rather than a tightening of it).

  8. argszero commented on Sep 28, 2026

    @argszero
    OwnerAuthor

    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:

    Prose half already landed (#1607, 956efe94): the predicate no longer claims to mirror bash. What remains here is the decision, not the wording.

  9. argszero commented on Sep 28, 2026

    @argszero
    OwnerAuthor

    Decision taken by the host, 2026-09-28: (A) — one policy for both tool families.

    The in-process write/edit tools stop asking their own predicate and ask the sandbox policy instead. A tier then means one thing for every tool:

    The sub-question this decision creates, measured before it is answered. check_workspace_write allows 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.md the emrg evolution cycle yes — 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:55 not 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.

  10. pm25coder commented on Sep 28, 2026

    @pm25coder
    Collaborator

    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, workspace C:\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.

    1. 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.md there: 2026-09-23T05:45:01. 230 files / 1.31 MB in total, nothing newer.

    2. The only 2026-09-28 write to that root is a 12-byte fixture payload. cycle-20260901-000000.md contains the literal text cycle 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_memory builds its target from os.path.expanduser("~/.emrg/evolution/.emrg") and writes it through the real WriteTool.

      A/B on this host, mtime of that file: 20:47:00 before uv run pytest tests/test_write_tool.py, 21:38:00 after (15 passed); with -k evolution_memory alone, 21:38:00 -> 21:38:16. One test in that file writes outside the workspace; the other 14 do not.

    3. The fixture's pin does not hold on Windows, so the write lands on the host. It sets HOME only, and os.path.expanduser reads USERPROFILE here -- the target resolves against the real home while the test believes it is in tmp_path. Windows-only: with HOME set, POSIX expanduser honours 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.

    4. 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-write allow-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.

  11. pm25coder commented on Sep 28, 2026

    @pm25coder
    Collaborator

    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-write allow-list. Their verdicts then come from emrg/sandbox/policy.py + roots.writable_roots alone, 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 since cycle-20260924-225336.md at 22:59:15.
    • tests/test_write_tool.py::test_write_workspace_write_allows_evolution_memory inverts: 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.

  12. argszero commented on Sep 28, 2026

    @argszero
    OwnerAuthor

    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 is emrg/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/edit both 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):

    1. At read-only the 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-only grants 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), and read-only is 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 to DEFAULT_MODE = danger-full-access and stays unconfined, exactly as before.
    2. At workspace-write the 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.
    3. The five protected daemon state files (~/.emrg/config.toml and 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 in tests/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.py pins 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 patched writable_roots becomes 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 over writable_roots.

    One stale sentence this change leaves behind, and it is not in this issue's scope: the delivered evolution_prompt.md still offers check_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.

  13. argszero commented on Sep 28, 2026

    @argszero
    OwnerAuthor

    Both halves are now implemented — the decision and the deletion

    Head 2456e72d on #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 is emrg/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.py keeps 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.py held 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 in tests/test_file_policy_has_one_home.py asserts 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.

  14. argszero commented on Sep 28, 2026

    @argszero
    OwnerAuthor

    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/edit tools no longer answer for themselves at all. emrg/sandbox/fence.py::file_refusal takes its allow-list from emrg/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-only the 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 to danger-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_root in 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions