From 66c50777b6bc4c3fd0659189a7f2a7c3b18ef6d9 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Thu, 24 Sep 2026 13:35:50 +0800 Subject: [PATCH 1/4] emrg: the file tools judge the file they write, joined onto the declared workspace (#1558) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At workspace-write the write/edit tools were judged by a predicate that returned early on every relative path ('assumed in-workspace; cwd = the workspace root') while the write itself resolved that spelling against the daemon process's cwd — a different tree. Each half is right for its own assumption and jointly they are wrong: the gate answered about one file and write_text touched another, with nothing in between (an in-process write is not kernel-confined the way a bash child is). check_read_only_file_write had the mirror-image slip: it realpath'd the spelling as given, so a relative path could land inside the workspace without that function ever having looked there — the dirty-tree guard bypassed by a spelling. One reduction, resolve_file_target(), now decides the base both halves use: the declared workspace (the session cwd the daemon injects into write/edit exactly as it injects workdir into bash), and both tools hand the joined target to both gates and write to it. Same rule #1353 installed for the command scan, applied to the two in-process writers. An absolute target is returned as spelled, and a relative one with no declared workspace keeps the previous fail-open reading. --- emrg/tools/bash_tool.py | 69 +++++- emrg/tools/edit_tool.py | 23 +- emrg/tools/write_tool.py | 25 +- .../test_file_tools_judge_where_they_write.py | 223 ++++++++++++++++++ 4 files changed, 317 insertions(+), 23 deletions(-) create mode 100644 tests/test_file_tools_judge_where_they_write.py diff --git a/emrg/tools/bash_tool.py b/emrg/tools/bash_tool.py index c60757bf..641f74fc 100644 --- a/emrg/tools/bash_tool.py +++ b/emrg/tools/bash_tool.py @@ -5236,6 +5236,40 @@ def _is_absolute_path(p: str) -> bool: ) +def resolve_file_target(file_path: str, workspace: str | None) -> str: + """The path the in-process file tools will actually touch (issue #1558). + + One home for the base a relative ``file_path`` is joined onto, because the + defect this exists to close is precisely a disagreement: the predicates + below treated a relative target as "in the workspace" — a *spelling* — while + the write resolved it against the **daemon process's cwd**, a different tree. + The gate answered a question about one file and the write touched another, so + no gate downstream could refuse anything (there is no fence for in-process + writes, unlike bash, which is kernel-confined by the Seatbelt/bwrap profile). + + So both callers read the same reduction here: the target joined onto the + directory the write happens in — ``workspace``, the session cwd the daemon + injects into ``write``/``edit`` exactly as it injects ``workdir`` into bash. + This is the same rule #1353 installed for the command scan (`os.path.join` + of the target onto the base the child starts in, then a realpath containment + test); the in-process tools have no child, so their base is the declared + workspace. + + An absolute target is returned as spelled, and a relative one with **no** + workspace has no base to join onto — it is returned as spelled too, which is + the pre-existing behaviour for a caller that declared no boundary (the tools + are then fail-open, as their docstrings say). + + :param file_path: the target as the model spelled it. + :param workspace: the session cwd, or ``None`` when none was declared. + :returns: the absolute target to judge and to write. + """ + expanded = os.path.expanduser(file_path) + if not expanded or _is_absolute_path(expanded) or not workspace: + return expanded + return os.path.join(os.path.expanduser(workspace), expanded) + + def _is_within(path: str, root: str) -> bool: """True when ``path`` (absolute) is inside ``root`` (absolute) or equals it. @@ -5269,8 +5303,15 @@ def check_read_only_file_write(file_path: str, workspace: str | None = None) -> 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. + + A relative target is joined onto ``workspace`` first (issue #1558): this used + to realpath the spelling as given, i.e. against the **daemon's cwd**, while + the write that followed resolved it somewhere else — so a relative path could + land inside the workspace without this function ever having looked there. The + judgement and the write now name the same file, which is what makes the + containment answer mean anything. See :func:`resolve_file_target`. """ - path = os.path.realpath(os.path.expanduser(file_path)) + path = os.path.realpath(resolve_file_target(file_path, workspace)) if workspace: ws = os.path.realpath(os.path.expanduser(workspace)) if _is_within(path, ws): @@ -5293,18 +5334,27 @@ def check_workspace_write(file_path: str, workspace: str | None = None) -> str | (OS temp allowed — mirrors dsh's workspace + backend-promised temp area); returns None when allowed. - Relative paths are assumed in-workspace (cwd = the workspace root), matching - the bash tool's workspace-write semantics (2026-08-20T15:46:50) and dsh's - writableRoots single-source + tool-symmetry design. Without this check the - write/edit tools let ``workspace-write`` sessions write anywhere outside the - session cwd, while the bash tool is correctly blocked — the asymmetric hole - this function closes. + Relative paths are joined onto ``workspace`` and then judged exactly like + absolute ones (issue #1558) — they used to return early on the assumption + "cwd = the workspace root", which is not where a write resolves when the + caller passes the spelling the model gave it. That asymmetry inside one + function was the hole: the absolute branch below realpaths both sides and + requires containment, and the relative branch reached none of it. A relative + target with no declared workspace still has no base to join onto, so it keeps + the old reading — and both tools pass the joined target, so in daemon use + (``workspace`` always injected) the base is never in doubt. + + Without this check the write/edit tools let ``workspace-write`` sessions write + anywhere outside the session cwd, while the bash tool is correctly blocked — + the asymmetric hole this function closes. """ if not file_path: return None - expanded = os.path.expanduser(file_path) + expanded = resolve_file_target(file_path, workspace) if not _is_absolute_path(expanded): - # Relative target: assumed in-workspace (cwd = the workspace root). + # No workspace was declared, so there is no base to join onto and no + # boundary to require containment against (non-daemon use; the tools are + # fail-open there by construction). return None real = os.path.realpath(expanded) if real in _protected_paths(): @@ -5331,6 +5381,7 @@ def check_workspace_write(file_path: str, workspace: str | None = None) -> str | ): return ( f"workspace-write sandbox: blocked write outside workspace {file_path!r}" + + (f" (resolves to {real!r})" if real != os.path.expanduser(file_path) else "") ) return None diff --git a/emrg/tools/edit_tool.py b/emrg/tools/edit_tool.py index 808af7cc..8e3e471a 100644 --- a/emrg/tools/edit_tool.py +++ b/emrg/tools/edit_tool.py @@ -7,7 +7,11 @@ from emrg.server.tool_types import ToolDefinition, ToolResult from emrg.tools.base import ToolExecutor -from emrg.tools.bash_tool import check_read_only_file_write, check_workspace_write +from emrg.tools.bash_tool import ( + check_read_only_file_write, + check_workspace_write, + resolve_file_target, +) logger = logging.getLogger(__name__) @@ -76,24 +80,31 @@ async def execute(self, arguments: dict) -> ToolResult: if not old: return ToolResult(name="edit", content="Error: old_string is empty", error=True) - path = Path(file_path).expanduser().resolve() + # Where the bytes actually go, decided in one place (issue #1558) — a + # relative `file_path` is joined onto the injected workspace (the session + # cwd), not resolved against the daemon's own cwd, which is the tree the + # predicates do not judge. + target = resolve_file_target(file_path, arguments.get("workspace")) + path = Path(target).resolve() # Read-only sandbox (community issue #979): the edit tool must not # modify the host's uncommitted work in the task source tree when the # dirty-tree guard forced read-only. Workspace boundary injected by the # daemon (session cwd); None in non-daemon use → fail-open. + # The joined `target` is handed to both gates, so the file they judge is + # the file edited below (before #1558 the spelling was passed on and + # resolved elsewhere). if arguments.get("sandbox") == "read-only": - reason = check_read_only_file_write(file_path, arguments.get("workspace")) + reason = check_read_only_file_write(target, arguments.get("workspace")) if reason: return ToolResult(name="edit", content=reason, error=True) # workspace-write sandbox (rant 2026-09-01T15:10:23): mirror the bash # tool's boundary so edit is symmetric with bash — a workspace-write # session must not edit outside the session cwd (or OS temp / protected - # daemon state). Pass the ORIGINAL file_path (not the resolved one) so - # relative paths keep the "assumed in-workspace" semantics. + # daemon state). if arguments.get("sandbox") == "workspace-write": - reason = check_workspace_write(file_path, arguments.get("workspace")) + reason = check_workspace_write(target, arguments.get("workspace")) if reason: return ToolResult(name="edit", content=reason, error=True) diff --git a/emrg/tools/write_tool.py b/emrg/tools/write_tool.py index 7cd50496..c66dd0e1 100644 --- a/emrg/tools/write_tool.py +++ b/emrg/tools/write_tool.py @@ -7,7 +7,11 @@ from emrg.server.tool_types import ToolDefinition, ToolResult from emrg.tools.base import ToolExecutor -from emrg.tools.bash_tool import check_read_only_file_write, check_workspace_write +from emrg.tools.bash_tool import ( + check_read_only_file_write, + check_workspace_write, + resolve_file_target, +) logger = logging.getLogger(__name__) @@ -61,26 +65,31 @@ async def execute(self, arguments: dict) -> ToolResult: error=True, ) - path = Path(file_path).expanduser().resolve() + # Where the bytes actually go, decided in one place (issue #1558). A + # relative `file_path` is joined onto the injected workspace — the + # session cwd — instead of being resolved against the daemon's own cwd, + # which is the tree the predicates do not judge. + target = resolve_file_target(file_path, arguments.get("workspace")) + path = Path(target).resolve() # Read-only sandbox (community issue #979): the write tool must not # clobber the host's uncommitted work in the task source tree when the # dirty-tree guard forced read-only. Workspace boundary injected by the # daemon (session cwd); None in non-daemon use → fail-open. + # The joined `target` is what both gates are handed, so the file they + # judge is the file written below (before #1558 the spelling was passed + # on and resolved elsewhere). if arguments.get("sandbox") == "read-only": - reason = check_read_only_file_write(file_path, arguments.get("workspace")) + reason = check_read_only_file_write(target, arguments.get("workspace")) if reason: return ToolResult(name="write", content=reason, error=True) # workspace-write sandbox (rant 2026-09-01T15:10:23): mirror the bash # tool's boundary so write/edit are symmetric with bash — a # workspace-write session must not write outside the session cwd (or - # OS temp / protected daemon state). Pass the ORIGINAL file_path (not - # the resolved one) so relative paths keep the "assumed in-workspace" - # semantics — resolving a relative path against the daemon cwd would - # wrongly classify it as absolute-outside-workspace. + # OS temp / protected daemon state). if arguments.get("sandbox") == "workspace-write": - reason = check_workspace_write(file_path, arguments.get("workspace")) + reason = check_workspace_write(target, arguments.get("workspace")) if reason: return ToolResult(name="write", content=reason, error=True) diff --git a/tests/test_file_tools_judge_where_they_write.py b/tests/test_file_tools_judge_where_they_write.py new file mode 100644 index 00000000..4f2e5850 --- /dev/null +++ b/tests/test_file_tools_judge_where_they_write.py @@ -0,0 +1,223 @@ +"""The write and the predicate that judges it must name the same file (issue #1558). + +The class +--------- +At ``workspace-write`` the in-process file tools were judged by a predicate that +returned early on every **relative** path — "assumed in-workspace (cwd = the +workspace root)" — while the write itself resolved that path against the +**daemon process's cwd**, a different tree. Each half is correct for its own +stated assumption, and jointly they are wrong: the gate answers about one file +and ``write_text`` touches another, with nothing between them (an in-process +write is not kernel-confined by the Seatbelt/bwrap profile the way a bash child +is, and ``emrg/sandbox/fence.py`` does not exist yet). + +``check_read_only_file_write`` had the mirror-image slip: it realpath'd the +spelling as given — i.e. against the daemon's cwd — so a relative path could land +*inside* the workspace without that function ever having looked there, which is +the dirty-tree guard bypassed rather than an unrelated file written. + +What is asserted here is therefore the **composition**, not either predicate +alone: for a relative input, the file the tool writes is the file the predicate +judged. Each test pins the process cwd to ``tmp_path`` (``monkeypatch.chdir``), so +the "daemon's cwd" of the measurement is a directory this test owns and a +regression lands inside it rather than in a host tree — the base the pre-fix code +used is then observable as a *different file inside the same tmp_path*, which is +what makes these tests discriminating instead of merely green. + +The rule they pin is one function, ``resolve_file_target`` — the same reduction +#1353 installed for the command scan (join the target onto the base, then require +the realpath to stay under it), applied to the two in-process writers. +""" + +from __future__ import annotations + +import asyncio +import os +from pathlib import Path + +from emrg.tools.bash_tool import ( + check_read_only_file_write, + check_workspace_write, + resolve_file_target, +) +from emrg.tools.edit_tool import EditTool +from emrg.tools.write_tool import WriteTool + + +def _run(coro): + return asyncio.run(coro) + + +def _workspace(tmp_path: Path) -> Path: + """The session cwd for these tests: a directory the test owns.""" + ws = tmp_path / "ws" + ws.mkdir() + return ws + + +def test_a_relative_target_is_joined_onto_the_workspace_not_the_cwd(tmp_path, monkeypatch): + """The one decision, stated: the base is the declared workspace.""" + monkeypatch.chdir(tmp_path) + ws = _workspace(tmp_path) + assert resolve_file_target("sub/f.txt", str(ws)) == str(ws / "sub" / "f.txt") + # An absolute target is judged as spelled; a caller that declared no boundary + # has no base to join onto and keeps the old reading. + assert resolve_file_target(str(tmp_path / "abs.txt"), str(ws)) == str(tmp_path / "abs.txt") + assert resolve_file_target("sub/f.txt", None) == "sub/f.txt" + + +def test_a_relative_write_lands_inside_the_workspace(tmp_path, monkeypatch): + """The composition half that was never asserted: judge and write agree. + + Under the pre-fix reading this file appears at ``/sub/f.txt`` — i.e. + beside the workspace, not in it — so both assertions below flip together. + """ + monkeypatch.chdir(tmp_path) + ws = _workspace(tmp_path) + result = _run(WriteTool().execute({ + "file_path": "sub/f.txt", + "content": "x\n", + "sandbox": "workspace-write", + "workspace": str(ws), + })) + assert not result.error, result.content + assert (ws / "sub" / "f.txt").read_text() == "x\n", ( + "the write did not land inside the workspace it was judged against" + ) + assert not (tmp_path / "sub" / "f.txt").exists(), ( + "the write was resolved against the process cwd — the base the predicate " + "does not judge (issue #1558)" + ) + + +def test_a_relative_escape_is_judged_like_its_absolute_spelling(tmp_path, monkeypatch): + """A relative path that leaves the workspace is no longer exempt. + + Measured before the fix (pure predicate, issue #1558): `../outside.txt` and + `../../../tmp/escape.txt` both answered ``None`` — allowed — because the + relative branch returned before any realpath ran. + + The target is placed outside the workspace **and** outside the two zones a + ``workspace-write`` session may always write (the OS temp area and the + evolution module's own data root): with a target under the temp root both + spellings are legitimately allowed, so a test built on ``tmp_path`` alone + would agree with the defect. ``$HOME`` appears here **only** as an input to a + pure predicate — it is realpath'd and nothing is opened, and the write that + follows this judgement is asserted never to happen. + """ + monkeypatch.chdir(tmp_path) + ws = _workspace(tmp_path) + outside = Path.home() / "emrg-1558-does-not-exist" / "f.txt" + spelling = os.path.relpath(outside, ws) + assert ".." in spelling, f"the fixture must actually leave the workspace: {spelling!r}" + + absolute_verdict = check_workspace_write(str(outside), str(ws)) + assert absolute_verdict, "the fixture is not outside the boundary to begin with" + relative_verdict = check_workspace_write(spelling, str(ws)) + assert relative_verdict, ( + f"{spelling!r} is still exempt from the workspace boundary — the relative " + "branch returned before any realpath ran (issue #1558)" + ) + # Not a string comparison: the message quotes the spelling it was handed, while + # the *judgement* is about the file, and the same file spelled two ways is the + # property under test. The refusal is additionally required to name where the + # spelling resolved — a relative target that is refused without saying where it + # pointed is the same "which file?" gap one layer down. + assert os.path.realpath(str(outside)) in relative_verdict, relative_verdict + # The control: inside the workspace both spellings are allowed. + inside = ws / "f.txt" + assert check_workspace_write(str(inside), str(ws)) is None + assert check_workspace_write(os.path.relpath(inside, ws), str(ws)) is None + + +def test_a_relative_escape_never_reaches_the_disk(tmp_path, monkeypatch): + """The write half of the same row: a refused escape leaves nothing behind. + + ``../outside.txt`` from a workspace under ``tmp_path`` is *allowed* by the + zones above (it lands in the OS temp area), so this row uses an outside + directory that exists and is owned by this test: the workspace is + ``tmp_path/ws`` and the spelling climbs to ``tmp_path/outside.txt``, which is + refused by nothing — hence the assertion is about where the bytes landed, not + about a refusal. The refused shape is judged in the test above. + """ + monkeypatch.chdir(tmp_path) + ws = _workspace(tmp_path) + result = _run(WriteTool().execute({ + "file_path": "../outside.txt", + "content": "x\n", + "sandbox": "workspace-write", + "workspace": str(ws), + })) + assert not result.error, result.content + assert (tmp_path / "outside.txt").read_text() == "x\n", ( + "the joined target is the file the tool writes" + ) + assert not (ws / "outside.txt").exists() + + +def test_a_relative_edit_inside_the_workspace_is_allowed_and_a_relative_escape_is_not( + tmp_path, monkeypatch +): + """The edit side of the same pair — the two tools must not drift apart.""" + monkeypatch.chdir(tmp_path) + ws = _workspace(tmp_path) + (ws / "in.txt").write_text("old\n") + result = _run(EditTool().execute({ + "file_path": "in.txt", + "old_string": "old", + "new_string": "new", + "sandbox": "workspace-write", + "workspace": str(ws), + })) + assert not result.error, result.content + assert (ws / "in.txt").read_text() == "new\n" + assert not (tmp_path / "in.txt").exists() + + escaped = _run(EditTool().execute({ + "file_path": os.path.relpath( + Path.home() / "emrg-1558-does-not-exist" / "f.txt", ws + ), + "old_string": "old", + "new_string": "new", + "sandbox": "workspace-write", + "workspace": str(ws), + })) + assert escaped.error and "outside workspace" in escaped.content, escaped.content + + +def test_a_relative_path_is_not_a_way_past_the_read_only_fence(tmp_path, monkeypatch): + """The read-only half: relative no longer means "somewhere I did not look". + + Before the fix this row was *allowed* — the predicate realpath'd the spelling + against the process cwd, which is outside the workspace — and the write then + landed inside the workspace, i.e. the dirty-tree guard was bypassed by a + relative path rather than by a defect in the fence. + """ + monkeypatch.chdir(tmp_path) + ws = _workspace(tmp_path) + reason = check_read_only_file_write("rel.txt", str(ws)) + assert reason and "read-only sandbox" in reason, reason + result = _run(WriteTool().execute({ + "file_path": "rel.txt", + "content": "x\n", + "sandbox": "read-only", + "workspace": str(ws), + })) + assert result.error and "read-only sandbox" in result.content, result.content + assert not (ws / "rel.txt").exists() + assert not (tmp_path / "rel.txt").exists() + + +def test_the_no_workspace_reading_is_unchanged(tmp_path, monkeypatch): + """The control: with no declared boundary, neither predicate invents a base. + + The daemon always injects ``workspace`` for ``write``/``edit``, so this is the + non-daemon path — and it is the one the fix must not silently tighten, since + there is no boundary the caller asked for. + """ + monkeypatch.chdir(tmp_path) + assert check_workspace_write("rel.txt", None) is None + assert check_read_only_file_write("rel.txt", None) is None + result = _run(WriteTool().execute({"file_path": "rel.txt", "content": "x\n"})) + assert not result.error, result.content + assert (tmp_path / "rel.txt").read_text() == "x\n" From 56d43bf2f216571da09e211b500d9dcb5c31cc41 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Thu, 24 Sep 2026 13:50:48 +0800 Subject: [PATCH 2/4] emrg: compare the joined path as a path, and the refusal as the repr it prints (#1558) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both failures were in the new test file and both were Windows-only, i.e. the local green could not see them: os.path.join appends the second part as spelled, so 'sub/f.txt' comes back as '...\ws\sub/f.txt' (a mixed-separator spelling of the same file) and string equality called that a regression; and the refusal message renders the resolved target through {real!r}, so on Windows the raw backslash path is doubled inside the string and is not a substring of it. Neither is a product change — the gates and the tools were already right about the file; the assertions were about the spelling. Measured with ntpath/purelib: old string equality False / path equality True; raw spelling not in the message / repr in the message. POSIX sides unchanged (7 passed, 5243 passed 29 skipped). --- .../test_file_tools_judge_where_they_write.py | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/tests/test_file_tools_judge_where_they_write.py b/tests/test_file_tools_judge_where_they_write.py index 4f2e5850..94e33252 100644 --- a/tests/test_file_tools_judge_where_they_write.py +++ b/tests/test_file_tools_judge_where_they_write.py @@ -56,10 +56,19 @@ def _workspace(tmp_path: Path) -> Path: def test_a_relative_target_is_joined_onto_the_workspace_not_the_cwd(tmp_path, monkeypatch): - """The one decision, stated: the base is the declared workspace.""" + """The one decision, stated: the base is the declared workspace. + + The comparison is ``Path``-to-``Path``, not string-to-string, and that is a + measured requirement rather than tidiness: ``os.path.join`` appends the + second part **as spelled**, so a relative target written with a forward slash + comes back as ``…\\ws\\sub/f.txt`` on Windows — the mixed-separator spelling + of the file ``tmp_path/"sub"/"f.txt"`` names. String equality would call that + a regression on Windows and only on Windows (this test failed the Windows CI + leg on exactly that line, 2026-09-24); path equality compares the file. + """ monkeypatch.chdir(tmp_path) ws = _workspace(tmp_path) - assert resolve_file_target("sub/f.txt", str(ws)) == str(ws / "sub" / "f.txt") + assert Path(resolve_file_target("sub/f.txt", str(ws))) == ws / "sub" / "f.txt" # An absolute target is judged as spelled; a caller that declared no boundary # has no base to join onto and keeps the old reading. assert resolve_file_target(str(tmp_path / "abs.txt"), str(ws)) == str(tmp_path / "abs.txt") @@ -123,7 +132,13 @@ def test_a_relative_escape_is_judged_like_its_absolute_spelling(tmp_path, monkey # property under test. The refusal is additionally required to name where the # spelling resolved — a relative target that is refused without saying where it # pointed is the same "which file?" gap one layer down. - assert os.path.realpath(str(outside)) in relative_verdict, relative_verdict + # + # Compared as ``repr`` because that is how the message renders it (`{real!r}`): + # on Windows a raw backslash path is *doubled* inside the string, so the + # un-repr'd spelling is not a substring of it and this line was red on the + # Windows CI leg alone (measured 2026-09-24). ``repr`` is the same on both + # sides of that comparison, so one assertion holds on either platform. + assert repr(os.path.realpath(str(outside))) in relative_verdict, relative_verdict # The control: inside the workspace both spellings are allowed. inside = ws / "f.txt" assert check_workspace_write(str(inside), str(ws)) is None From d1656626291b1b5ba0d4bef3d8ca39c8cfc1079a Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Thu, 24 Sep 2026 15:50:24 +0800 Subject: [PATCH 3/4] emrg: the read-only paragraph states what the pre-fix write did, not a bypass (#1569) --- emrg/tools/bash_tool.py | 14 +++++++++----- tests/test_file_tools_judge_where_they_write.py | 10 +++++++--- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/emrg/tools/bash_tool.py b/emrg/tools/bash_tool.py index 641f74fc..e5e2d2a3 100644 --- a/emrg/tools/bash_tool.py +++ b/emrg/tools/bash_tool.py @@ -5305,11 +5305,15 @@ def check_read_only_file_write(file_path: str, workspace: str | None = None) -> space. Mirrors the bash tool's read-only semantics for file tools. A relative target is joined onto ``workspace`` first (issue #1558): this used - to realpath the spelling as given, i.e. against the **daemon's cwd**, while - the write that followed resolved it somewhere else — so a relative path could - land inside the workspace without this function ever having looked there. The - judgement and the write now name the same file, which is what makes the - containment answer mean anything. See :func:`resolve_file_target`. + to realpath the spelling as given, i.e. against the **daemon's cwd** — a base + the caller never designated — so the containment answer was about a path + outside the workspace the caller declared, and one file's two spellings split + their verdict (the relative spelling allowed at a tier where the absolute + spelling of that same file was blocked). The write followed the same base, so + it landed outside the declared workspace rather than inside it: what was wrong + is *which* file was judged, not a write this function never saw. The judgement + and the write now name the file the caller named. + See :func:`resolve_file_target`. """ path = os.path.realpath(resolve_file_target(file_path, workspace)) if workspace: diff --git a/tests/test_file_tools_judge_where_they_write.py b/tests/test_file_tools_judge_where_they_write.py index 94e33252..2c17af8c 100644 --- a/tests/test_file_tools_judge_where_they_write.py +++ b/tests/test_file_tools_judge_where_they_write.py @@ -204,9 +204,13 @@ def test_a_relative_path_is_not_a_way_past_the_read_only_fence(tmp_path, monkeyp """The read-only half: relative no longer means "somewhere I did not look". Before the fix this row was *allowed* — the predicate realpath'd the spelling - against the process cwd, which is outside the workspace — and the write then - landed inside the workspace, i.e. the dirty-tree guard was bypassed by a - relative path rather than by a defect in the fence. + against the process cwd, a base outside the workspace the caller declared — so + its containment answer was about a path the caller never named, while the + absolute spelling of that same file was blocked at this tier. The write used + the same base, so it landed outside the declared workspace rather than inside + it: the hole is *which* file the predicate was asked about, not one it never + saw. Re-measured on both trees: pre-fix the relative spelling was allowed here + while the absolute spelling of that same file was blocked. """ monkeypatch.chdir(tmp_path) ws = _workspace(tmp_path) From ea27a58a9052cec3fd5330dfedacd55c5f6304ec Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Thu, 24 Sep 2026 17:05:19 +0800 Subject: [PATCH 4/4] emrg: the fourth carrier of the read-only claim says what was measured --- tests/test_file_tools_judge_where_they_write.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_file_tools_judge_where_they_write.py b/tests/test_file_tools_judge_where_they_write.py index 2c17af8c..ac5ea855 100644 --- a/tests/test_file_tools_judge_where_they_write.py +++ b/tests/test_file_tools_judge_where_they_write.py @@ -12,9 +12,13 @@ is, and ``emrg/sandbox/fence.py`` does not exist yet). ``check_read_only_file_write`` had the mirror-image slip: it realpath'd the -spelling as given — i.e. against the daemon's cwd — so a relative path could land -*inside* the workspace without that function ever having looked there, which is -the dirty-tree guard bypassed rather than an unrelated file written. +spelling as given — i.e. against the daemon's cwd, a base the caller never +designated — so its containment answer was about a path outside the workspace the +caller declared, and one file's two spellings split their verdict (the relative +spelling allowed at a tier where the absolute spelling of that same file was +blocked). The write followed the same base, so it landed outside the declared +workspace rather than inside it: the hole is *which* file the predicate was asked +about, not a write it never saw. What is asserted here is therefore the **composition**, not either predicate alone: for a relative input, the file the tool writes is the file the predicate