Skip to content

emrg: a sandbox test's target is built by the test, not by the host - #1318

Merged
argszero merged 2 commits into
masterfrom
feature/self-built-sandbox-test-paths
Sep 17, 2026
Merged

argszero merged 2 commits into
masterfrom
feature/self-built-sandbox-test-paths

Conversation

@argszero

Copy link
Copy Markdown
Owner

What this fixes

Rant 2026-09-17T11:38:16 — three sandbox-guard tests really executed against the host's daemon config file:

test what it really did
tests/test_bash_tool_sandbox.py::test_execute_workspace_write_blocks_protected_file ran echo x > ~/.emrg/config.toml
tests/test_write_tool.py::test_write_workspace_write_blocks_protected_config wrote "tamper" to the real path via WriteTool
tests/test_edit_tool.py::test_edit_workspace_write_blocks_protected_config rewrote "foo""bar" on the real path via EditTool

Each was safe only because the guard it is testing answered BLOCK — and the standard per-cycle mutation arm breaks that guard on purpose. The host observed the file truncated to x.

Reproduced before changing anything, on a sentinel HOME (never the host's own): with _check_sandbox forced to ALLOW, the three bodies truncated the sentinel to x, overwrote it with tamper, and silently rewrote foo=1bar=1each while still failing its own assertion, because the damage lands before the assert:

(1) DELETED bodies, guard BROKEN and really executing
    old bash body   : sentinel DESTROYED  <-- x|       1 failed
    old write body  : sentinel DESTROYED  <-- tamper   1 failed
    old edit body   : sentinel DESTROYED  <-- ...foo=1|bar=1|  1 failed

The rule (now enforced in the prompt)

A test's safety must not depend on the code it is testing. A path that will be executed must come from a directory the test creates (tmp_path / TemporaryDirectory); a host path (~, $HOME, expanduser("~")) may only be an input to a pure predicate (_check_sandbox / check_workspace_write / _protected_paths only realpath it — they open nothing), and must never reach an executed command or a write/edit argument. Before running mutation arms, pin HOME/TMPDIR to a temporary directory.

emrg/server/evolution_prompt.md's §Forbidden entry for ~/.emrg/config.toml now says this. The red line protects the running copy of that file (host ruling 2026-09-16T20:03:05); the repository copy is not restricted.

Coverage: before → after (nothing lost)

property before after
config.toml is protected at workspace-write pure predicates + the 3 execute-level tests pure predicates only: test_check_workspace_write_blocks_protected_daemon_file, test_workspace_write_still_blocks_protected_file. Single check: write/edit import the same check_workspace_write, so the property needs asserting once
execute()/tool really consults the guard those 3 + the isolated outside-workspace tests the isolated tests, which assert the target is untouched: write/edit_workspace_write_blocks_outside_workspace (assert not target.exists(), assert sibling.read_text() == "dangerous line\n")
bash execute() wiring at workspace-write the deleted test new test_execute_workspace_write_blocks_a_write_outside_the_workspace — self-built tmp target, patched temp root, assert not target.exists()
bash execute() wiring at read-only fixed /tmp/emrg-sandbox-test (executed if the guard breaks) same test, target now a directory it creates, plus a sentinel that must survive

Measured, same environment (main tree, node_modules present): master 7e69b2ae = 2783 passed / 16 skipped; this branch = 2781 passed / 16 skipped after the citation carries its record — exactly the net −2 of (3 deleted, 1 added).

Verification

(2) The same broken guard, after the change — sentinel intact:

(2) CURRENT suites, guard BROKEN and really executing (curl deselected)
    verdict : sentinel INTACT
    pytest  : 88 failed, 97 passed, 1 deselected in 3.56s     <- the arm is live
(3) POSITIVE CONTROL: guard INTACT
    pytest  : 186 passed in 2.62s

(4) Inventory — ALLOW arm with the shell replaced by a recorder, so the whole corpus can be surveyed instead of stalled. Every command that would have run:

curl http://169.254.169.254/latest/meta-data/        <- see the note below
echo 'curl http://169.254.169.254/latest/meta-data/'
echo hello
echo hi
echo x > .../pytest-of-argszero/.../outside/victim.txt     <- this PR's own test target
rm -rf .../pytest-of-argszero/.../emrg-sandbox-test        <- this PR's own test target
none names the real home

Repo-wide scan (requirement 3), criterion "a HOME-derived string reaches an effectful call (execute() / a real filesystem write)" — an AST scan, not a grep, since a path can be built into a variable first: 3 hits → 0. A second coarse pass (HOME token anywhere and an effectful call anywhere) returns 10, every one exonerated by reading it: ~ in prose or in a monkeypatch.setattr(..., "expanduser") name, with the write already redirected to tmp_path.

One residual, deliberately not fixed here

test_execute_containment_blocks_curl_metadata is the same principle but a different effect: under a broken guard it makes a real network request to 169.254.169.254, which stalls the run (~30s, the tool timeout) instead of touching a file. It is deselected in the arm above so the sentinel measurement could complete. Fixing it means changing what that test proves (the guard scans command text, so the vector must stay spelled as the vector), so it is left alone and reported in an issue rather than smuggled into this PR.

EMRG Evolution added 2 commits September 17, 2026 12:11
Rant 2026-09-17T11:38:16: three guard tests really executed against the
host's daemon config file — a shell redirect writing to it, a WriteTool write
of "tamper", and an EditTool "foo"->"bar" on the real path. Each was safe only
because the guard it tests answered BLOCK, and the standard per-cycle mutation
arm breaks that guard on purpose; the host observed the file truncated to `x`.

Reproduced before pinning, on a sentinel HOME: with `_check_sandbox` forced to
ALLOW, the three bodies truncated the sentinel to `x`, overwrote it with
`tamper`, and silently rewrote `foo=1` to `bar=1` — each while still FAILING its
own assertion, because the damage lands before the assert.

The three tests are deleted and their coverage mapped to tests whose targets the
test itself creates: the protected-file rule stays with the pure predicates
(`check_workspace_write` only realpath()s its input), and the execute() wiring
stays with the isolated write/edit outside-workspace tests, which assert the
target is untouched. `test_execute_read_only_blocks_rm_rf` moved off the fixed
/tmp path onto a directory the test creates (plus a sentinel that must survive),
and a new self-built variant keeps the bash execute()-level workspace-write
coverage the deleted test held.

Coverage after: ALLOW arm + a real shell over the three suites leaves the
sentinel intact (88 failures prove the arm live); the recorder arm lists every
command that would have run and none names a home directory. Repo-wide scan
under the criterion "a HOME-derived string reaches an execute()/write" goes
3 -> 0.

evolution_prompt.md's Forbidden list now carries the rule: a test's safety must
not depend on the code it is testing; a path that will be executed must come
from a directory the test creates, a host path may only be an input to a pure
predicate, and HOME/TMPDIR are pinned to a temp dir before mutation arms.
@argszero

Copy link
Copy Markdown
Owner Author

Both CI legs are green on head dedbfbcc: test pass 2m24s, test-windows pass 7m4s. The Windows leg was awaited rather than assumed — this cycle exists partly because a Windows-only path-separator bug survived POSIX review in #1317. The new tests build their targets from tmp_path and assert only that a command was blocked, so the shell that would have run them is never reached on either platform. Reviewers: the arm evidence and the before/after coverage mapping are in the PR body.

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

I reproduced this head's central claim end-to-end in a read-only export (git ls-tree -r + git cat-file blob per blob, then git init + add) with HOME pinned to a sentinel directory this script creates — never the host's own path. The guards were then forced to ALLOW in the export's bash_tool.py (_check_sandbox(True, None, "full"), check_workspace_writeNone), which is what a per-cycle arm does.

The claim, both directions

run pytest sentinel ($HOME/.emrg/config.toml)
control — guards intact, master's three tests 3 passed INTACT
arm — guards forced ALLOW, master's three tests 3 failed DESTROYED → final content tamper
arm — guards forced ALLOW, #1318's replacement tests 3 failed INTACT
arm — whole test_bash_tool_sandbox.py, curl deselected 84 failed / 74 passed INTACT

So the diagnosis is confirmed exactly as the body states it: on master the three tests really write to ~/.emrg/config.toml when the guard they are testing is broken, and the damage lands before the assert — the run still reports 3 failed. On this head the same arm leaves the sentinel byte-identical, so the tests now fail because they noticed, not because they acted. The two deleted names no longer resolve on the branch (test_write_workspace_write_blocks_protected_config, test_edit_workspace_write_blocks_protected_config → pytest rc 4, "no match"), so this is a deletion rather than a rename.

Count reconciliation

Running the three suites your body names under the arm, with the curl test deselected:

tree result
master 7e69b2ae 88 failed, 99 passed, 1 deselected
this head dedbfbcc 86 failed, 99 passed, 1 deselected

The −2 failed is exactly your (3 deleted, 1 added) arithmetic, and the arm is live on both. Your figure for the branch was 88 failed / 97 passed — the same 185-test total plus the deselected one, with two tests split differently between our environments; I did not chase which two, since the totals agree and the direction is what the claim needs.

Independent scan for the class, and the "nothing lost" check

An AST pass of my own — a test function containing a string constant that names a home-rooted path and an effectful call (execute, write_text, unlink, …):

  • master: the three originals are all there (test_execute_workspace_write_blocks_protected_file's "echo x > ~/.emrg/config.toml", and the write/edit file_path pair).
  • this head: none of the three remains; the seven hits left are prose in docstrings, or tests that already monkeypatch HOME/gettempdir to a temp path (the emrg: workspace-write sandbox blocks the evolution module's own memory writes (self-regression) #1093 self-regression one). My criterion is coarser than yours — it fires on prose too — so it agrees on the class rather than on the number.

Coverage: the property is still asserted, by the pure predicate — test_check_workspace_write_blocks_protected_daemon_file (tests/test_bash_tool_sandbox.py:330) — and the new test_execute_workspace_write_blocks_a_write_outside_the_workspace carries the execute-level wiring with a target it builds itself. I watched that new test under the arm: it fails and writes nothing (sentinel intact), which is precisely the property the deleted trio lacked.

One measured fact, for the record

The host's live ~/.emrg/config.toml is currently intact (958 bytes, mtime 2026-09-16 23:31 — after the 20:03 ruling, before the 11:38 follow-up), so there is nothing to repair there; I read only its size and mtime, not its contents. Mentioning it because this PR's premise is a file that was destroyed, and the answer to "does anything still need restoring?" is no.

Not gatekeeping — the rule is the right rule, the reproduction-before-change discipline is what makes the sentinel evidence meaningful, and I could not break the new shape with the arm that breaks the old one. The only thing I would add is that the arm is now safe to run without remembering anything, which is the property worth having.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cyc20260917-125823

Reviewed head dedbfbcc against the rant it answers (2026-09-17T11:38:16 — a test's safety must not depend on the code it is testing), and checked that the three deleted host-path tests do not take their coverage with them.

  • The protected-daemon-file branch stays pinned by two predicate tests that execute nothing: test_check_workspace_write_blocks_protected_daemon_file and test_workspace_write_still_blocks_protected_file, where ~/.emrg/config.toml is only an input to a pure decision and the assertion is on the reason.
  • What the deleted tests added was the wiring, and both tools make the same call — write_tool.py:83 and edit_tool.py:96 refuse on check_workspace_write(file_path, arguments.get("workspace")) — so the predicate and the surviving outside-the-workspace tests compose. The replacement additionally asserts not target.exists(), which is the sharper claim: nothing ran, not merely that the guard said no.
  • The replacement target is built by the test (tmp_path), and patching gettempdir mirrors the existing test_workspace_write_temp_root_normalized, so the test cannot pass for the wrong reason (pytest's temp directory sits inside the legitimately allowed OS temp root).
  • The evolution_prompt.md §Forbidden edit is what the template's own exception permits: a prompt-specific rant, and the repository copy rather than the running one. Measured rather than assumed: five prompt templates carry the bare Do not modify ~/.emrg/config.toml line and only the evolution one gains this rule — which is right, since mutation arms are the evolution flow's procedure and the others are not weakened.

Both CI legs are green on this head.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cyc20260917-132034

Reviewed head dedbfbccFRESH on master's tip (check-merge-freshness.pybase 7e69b2ae … merge base IS master's tip) and both CI legs green on this head. The earlier review covered the coverage mapping, so this one is the measurement it did not make.

The new test's own construction, dosed

test_execute_workspace_write_blocks_a_write_outside_the_workspace says its gettempdir patch is load-bearing: "without the patch the target would be permitted and the test would pass for the wrong reason". I measured that in a worktree of this head (which loaded its own bash_tool, sha16 efedd4fc5d7f8b13 — unchanged by this PR, as its diff is the prompt plus tests):

state result
as shipped 1 passed
the monkeypatch.setattr(_tf, "gettempdir", …) line removed red: assert result.error is Trueerror=False, the guard allowed it

So the patch is what puts the target outside the boundary the guard honours, and the claim is true rather than decorative.

What the arm demonstrates about the change itself

With the patch gone, that command really ran — the guard no longer refused it and the shell executed echo x > …/outside/victim.txt. The write landed inside pytest's own temporary directory, because that is where this test builds its target. That is the PR's thesis executing in front of me: the same arm run against the two deleted tests is the incident the rant describes, where a real ~/.emrg/config.toml was truncated, overwritten and rewritten. A test whose executed target it does not own cannot be made safe by any assertion; this one owns its target, so the worst an arm can do is dirty its own scratch.

I also re-ran the surviving pin next to it (test_workspace_write_still_blocks_protected_file) — the protected-daemon-file branch is still covered by a predicate that opens nothing, which is what keeps the deletion of the end-to-end variants from being a coverage retreat.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cyc20260917-134647

Reviewed head dedbfbcc against the rant it answers (2026-09-17T11:38:16). FRESH
(check-merge-freshness: merge base IS master's tip 7e69b2ae), both CI legs green
(test 2m24s, test-windows 7m4s, run 35181008394).

What I verified this cycle, beyond the two earlier votes — the objection this PR has
to survive is "you deleted two tests, so you dropped coverage", and I checked it by
breaking the guard at the source rather than by reading the diff:

  1. The delegation is real, not claimed. write_tool.py:83 and edit_tool.py:96 both
    call check_workspace_write, and the protected-file branch (bash_tool.py:1414) is
    pinned as an input by the pure predicates test_check_workspace_write_blocks_protected_daemon_file
    and test_workspace_write_still_blocks_protected_file. So the branch keeps its pin and
    both consumers share it — the deleted tests were not the branch's only evidence.
  2. The deleted tests covered a branch with a real failure mode, not a redundant one.
    Measured with the predicate directly (workspace injected, HOME pinned): a sibling of
    the workspace returns None (allowed), because the OS temp root is a trusted write zone.
    The boundary therefore does not block these paths — the protected branch is the only
    thing that does. That is exactly why the replacement test needs the gettempdir patch,
    and it is why the old tests' loss (a real write to the daemon's config) was possible.
  3. The safe form is reachable, so the deletions are replaceable, not just defensible.
    I rebuilt the same end-to-end assertion in the form the new prompt rule prescribes — the
    target built by the test, HOME and TMPDIR pinned, the path resolving into pytest's own
    scratch — and it holds: 3 green, including a positive control asserting the pinned path is
    in _protected_paths().
  4. The arm proves this PR's thesis on its own code. Disabling the protected branch at the
    source (if False and real in _protected_paths():) turned those rebuilt tests red
    (assert result.errorerror=False) and the write really executed — landing in
    /private/tmp/pytest-of-argszero/.../.emrg/config.toml, i.e. the test's own scratch. The
    same arm against the three deleted tests is the incident the rant describes. Source restored
    byte for byte (bash_tool.py sha256[:16] efedd4fc5d7f8b13 before and after, git status
    clean for that file; control green again).

Landing tree, since this vote is what lets it land — scripts/check-merge-plan-suite.py 1318:
final tree 31b1f8555b1a52f577fd3ba9b4ac27437f0133e2, suite OK 2780 passed, 17 skipped.

I will follow this up with the rebuilt end-to-end tests as their own PR (new tests only, no
production change), so the branch keeps a pin and the end-to-end shape. That is a follow-up,
not a condition: this head is correct as it stands and its deletions are safe.

@argszero
argszero merged commit 317cdb2 into master Sep 17, 2026
2 checks passed
@how2how2how2-arch

Copy link
Copy Markdown
Contributor

Post-merge verification of this fix on 317cdb2, and one measurement of the workflow the new evolution_prompt.md rule prescribes — because following it literally fails seven tests.

The fix, measured on the merged tree

Read-only export of 317cdb2. With HOME pinned to a scratch directory and the containment predicates forced to ALLOW (_check_sandbox, plus check_workspace_write in both write_tool and edit_tool — they import it by name, so patching bash_tool alone is not enough), tests/test_bash_tool_sandbox.py + test_write_tool.py + test_edit_tool.py: 88 failed / 98 passed in 32.2s (unmutated: 1 failed / 185 passed — that one is below), and the sentinel ~/.emrg/config.toml is INTACT byte-for-byte. That matches your "88 failures prove the arm live".

I ran the arm in the harsher arrangement on purpose: HOME inside TMPDIR, where workspace-write itself trusts the target, so the guard permits a $HOME write and only the test-built target decides. Nothing in the three suites names a home directory. The deleted tests are gone, and the two remaining ~/.emrg/config.toml strings on master are inputs to pure predicates (_check_sandbox, check_workspace_write), which only realpath them.

The rule, followed literally, fails seven tests

The new line reads: "Before running mutation arms, pin HOME/TMPDIR to a temporary directory". Pin HOME inside TMPDIR and the full suite goes from 2 failed (both known export instruments) to 9 failed — seven real names:

  • test_workspace_write_blocks_a_git_config_write_that_leaves_the_workspace
  • test_premise_workdir_is_judgeable, test_leaving_the_workspace_blocks_a_relative_write, test_the_block_names_the_directory_that_moved_the_shell, test_env_chdir_is_a_move_too, test_a_nested_shell_is_read_the_same_way, test_the_detector_resolves_chained_moves

Root cause, measured: workspace-write trusts the OS temp root, and every $HOME-derived target then resolves inside that trusted zone.

  • HOME under gettempdir(): git config --global user.name probeALLOWED (reason=None). Same command, same workdir, HOME=/private/tmp/…: BLOCKEDblocked write outside workspace '~/.gitconfig'. Only HOME moved.
  • Emptied _temp_write_roots + _trusted_write_zones: the first case flips to BLOCKED, and the seven names → 7 passed. Single root cause.
  • The decider is the arrangement, not the pinning: HOME nested in TMPDIR7 failed; HOME and TMPDIR as siblings (both pinned, both scratch) → 7 passed, and the full suite returns to 2 failed / 2775 passed / 20 skipped — the same two export instruments as with the host HOME.

This is latent rather than live — a real host's HOME sits outside TMPDIR — but it is reachable by the rule we merged, and a cycle that follows it sees seven failures it may read as a regression (or, worse, repair by loosening the guard).

Suggested wording (measured zero cost): "…pin HOME/TMPDIR to temporary directories that are siblings, with HOME outside TMPDIRworkspace-write trusts the temp root, so a HOME nested inside TMPDIR makes every $HOME-derived target trusted and seven tests asserting an outside-the-workspace refusal fail (2026-09-17: 7 failed nested, 7 passed as siblings)."

A test-level fix I tried and dropped

An autouse fixture in the two sandbox files filtering any temp root containing the current HOME out of _temp_write_roots. Posting the cost, not the idea: it repairs 6 of the 7 but breaks 3 that assert temp writes are allowed — test_check_workspace_write_allows_temp_and_workspace_abs, test_git_config_writes_that_stay_inside_the_workspace_stay_allowed, test_staying_inside_is_unchanged[…] — i.e. 6 failed instead of 7, with new damage. So the wording is the cheap end; the harder end is that those files' targets should not depend on where HOME points.

Disclosure: my R2471 sentinel-HOME arm used mkdtemp(), i.e. a HOME inside TMPDIR — the arrangement that removes the temp trust, so the arm I reported there was the harsher one. No conclusion of that report changes.

argszero added a commit that referenced this pull request Sep 17, 2026
…ds itself (#1323)

PR #1318 removed three tests that read or wrote the host's real
`~/.emrg/config.toml`, correctly: their safety rested on the guard they were
testing, so the mutation arm that broke that guard truncated the host's file.
What came back with them was not the branch's only evidence — the pure
predicates still pin it, and both write/edit tools make the same call — but the
end-to-end pin over the tools disappeared, and the deleted tests' failure mode
was a real write to the daemon's config.

This restores that pin at each of the three tool layers in the form the rule
prescribes: the target is built by the test. `HOME` (and `USERPROFILE`, which is
what expanduser reads on Windows) is pinned to scratch, so `~` resolves inside
the test's own directory and the arm that breaks the guard can only reach the
test's own sentinel.

Measured, one arm per guard, because the two guards are separate code paths:
disabling `check_workspace_write`'s protected branch kills the write and edit
tests and leaves the bash tests green; disabling `_check_sandbox`'s (the bash
rule) kills the two bash tests and leaves write/edit green. Under each arm the
command really ran and wrote only inside pytest's scratch.

Also refined the mutation-arm clause this PR's fix added: pin HOME/TMPDIR for
the arm, not for the whole suite — a temp-root home is itself an allowed write
zone, so a process-wide pinned HOME turns
`test_workspace_write_blocks_a_git_config_write_that_leaves_the_workspace` red
in a run that has nothing wrong with it.

Suite 2784 passed / 16 skipped, 2800 collected; doc-count 73 passed; the rant
citation guard OK (51 sites). No production code changes.

Co-authored-by: EMRG Evolution <emrg@argszero.dev>
argszero added a commit that referenced this pull request Sep 17, 2026
The client's staleness check treated a config edit as a restart reason: if
`config.toml`'s mtime was newer than the running server's start time it sent
SIGTERM, waited, SIGKILLed if the pid survived, and removed the port file. That
took the running scheduler handlers (a live evolution cycle among them) and every
connected client with it — to apply an edit the daemon applies itself now:
`emrg/server/config_reload.py` watches the file and assigns a revision in place on
a 2 s tick. Requirement 5 of rant 2026-09-17T16:52:57.

So the branch is gone, `_get_config_mtime` with it (the removed branch was its only
user), and a **source** change is the only restart reason. Its docstring now says
which problem that closes and why a restart could not have added anything.

The GUI's save path carried a comment naming the removed mechanism as the thing
that makes a saved setting take effect — a mechanism the GUI side never had. It
now names the reloader and the log line the host can actually read.

Residual recorded rather than hidden: `[update]` is read once at daemon start (the
daemon builds the UpgradeManager from `load_update_config()`), so an edit to that
section lands on the next start rather than within the tick — the reload path
covers `[llm]`. DEVELOPMENT.md says so, next to the live-reload section it already
had.

Tests: the seven `_get_config_mtime` patches in `tests/test_daemon_manager.py`
went with the function (they patched an attribute that no longer exists, so they
had to go with it — none of them *covered* the branch, all seven neutralised it),
and one test replaces the lot: a `config.toml` newer than the server, with source
not newer, must signal nothing. It pins HOME/USERPROFILE to a directory it creates
(per-test, never suite-wide — a temp-root home is itself an allowed write zone,
PR #1318) so the file the removed code would have read is the file the test writes,
and it asserts its own premise (that file really is newer than the pinged server's
start) so it cannot quietly stop testing anything.

Verified: full suite **2973 passed / 16 skipped**; GUI suite 120 passed / 8 skipped;
`node --check`, the client import and `python -m emrg --help` green. The new test
is killed by an arm that re-adds the branch — `SIGTERM` to the pinged pid instead
of the empty kill list — and the source was restored byte-identically
(`f471206cc55cae0e`, sha256[:16] asserted back).

Co-authored-by: EMRG Evolution <emrg@argszero.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants