emrg: add the process-boundary bash tool beside the frozen one (bash tool v2, P1) - #1540
Conversation
The write-target walk visits every token and matches its word against the verb sets wherever it stands — that is what reaches `sudo rm` and `find . -exec rm`. `_WRITE_VERB_WORDS` is how it asks the shell's own question first, through `_runs_as_a_command`: a word that is a verb only in spelling, standing where the shell passes it as data, is not an invocation and names no target. Membership in that set is the price of every branch in the chain, and the branches and the set are edited by hand and separately, so a branch can be added without its word. `brotli` was: measured on master 398e231, through `_extract_write_targets` and then both tiers of `_check_sandbox`, echo brotli -o <outside>/f x targets ['<outside>/f'] BLOCK / BLOCK grep -rn brotli -o <outside>/f x targets ['<outside>/f'] BLOCK / BLOCK printf %s brotli -o <outside>/f targets ['<outside>/f'] BLOCK / BLOCK echo zip -o <outside>/f x targets [] ALLOW / ALLOW echo pzstd -o <outside>/f x targets [] ALLOW / ALLOW The three lines write nothing and were refused; the last two are the same shape for words that are in the set. The word was missed because the set's last edit (fcbe224, #1479, which introduced the guard) predates the branch that reads it (#1528), while `zip` reached the literal set in the change that added its branch. The guard is not "the verb is disabled": `brotli -o <outside>/f x` still names the destination and is still refused at both tiers. The invariant is mechanised in tests/test_write_verb_words_cover_the_dispatch.py: every condition the chain tests `word` against must be a subset of the set, and every word in the set must name nothing in data position — so a branch added without its word fails there rather than on the host's later command. Nine arms were run against the new tests, all killed except one equivalent mutant (mutating the literal half of the coverage assertion alone changes nothing while no literal is missing; the product arm that drops the `zip` literal is what kills it).
Rant 2026-09-21T18:50:03 ("bash tool v2"), design
.emrg/designs/bash-tool-v2-design.md P1 (macOS Seatbelt).
The old tool decides whether a command may write by scanning its text, so an
interpreter defeats it by construction (bash_tool.py:2880, "what it does not
reach is an interpreter"). This adds the second executor, whose boundary is the
OS process: `["bash", "-c", command]` is wrapped by `sandbox-exec` under a
derived Seatbelt profile, so a write outside the session's working directory is
refused by the kernel whatever language attempts it.
Delivery rules R1/R2/R3: the frozen file is untouched and nothing here imports
it; the new code is a self-contained package (`emrg/sandbox/`) plus its consumer
(`emrg/tools/bash_tool_v2.py`), with its own copy of the output framing and
decoding that survives the old file's deletion at P7.
Three properties, each with a test that fails when it is removed:
* fail closed — no backend means the command does not run, never a silent
unconfined fallback (SANDBOX_UNAVAILABLE, the blueprint's own message);
* the authorization root is the deployer's — D1 fixes the `and "workdir" not in
args` that let the model name the root it was trusted in (measured: workdir=$HOME
plus a write to .zshrc was allowed);
* one writable-root derivation (emrg/sandbox/roots.py) for the profile and, from
P7, the in-process fence, so `write` and `bash` cannot disagree about /tmp.
The switch is D10: `[sandbox] bash_tool_v2`, read once at startup, default off,
overridable with EMRG_BASH_TOOL_V2. Both executors answer to the tool name
`bash`, so exactly one is built. Windows fails closed (no backend yet) and Linux
runs unconfined and says so (D4) until P3 lands.
D5: the former `_trusted_write_zones()` is deleted, not renamed, and a test
asserts it does not reappear under another name.
The v2 tests' first CI run was red on both legs, and neither failure was in the
product — both were the tests assuming the host they were written on:
* `workspace_root="/tmp"` is not an absolute path on Windows, so eight tests died
in the policy layer's own assertion (`os.path.isabs("/tmp")` is False there).
Replaced with the root this host actually has, `os.path.abspath(os.sep)`.
* two Seatbelt-profile tests re-spelled the escaping instead of reading the
profile back, so a Windows path (whose backslashes double) failed a substring
test while the profile was correct. They now parse the grants out of the
profile and compare unescaped paths — a round trip, which is also the only
version of this test an arm that stops escaping can kill.
* `test_session_cwd_cannot_be_named_by_the_model` compared `str(PosixPath(...))`
against the platform's own spelling of that path.
* two tests needed the platform named instead of defaulted: the argv-fidelity
test must confine for darwin (no other platform has a backend), and the
tool-level fail-closed test must not silently exercise Linux's D4 deviation
(which runs the command unconfined before any provider is consulted). That
second one is now explicit about isolating the refusal path.
Also restored, under the Seatbelt guard, the integration half of the
classification rule: a missing cwd must reach the caller as its own error, not
as "no sandbox backend is usable" — measured, POSIX names the cwd as the
filename, which is exactly why the rule requires an independently usable cwd.
4956 passed / 21 skipped locally.
The Windows leg of the last CI run came down to one assertion: `canonical_path` on a NUL-byte spelling. On POSIX that spelling makes `realpath` raise `ValueError`, which the function catches and answers with the spelling unchanged; on Windows it raises nothing and `realpath` prefixes the cwd instead — so the assertion measured `ntpath.abspath`, not this function. Replaced with a direct test of the contract: force `realpath` to refuse and assert the spelling comes back unchanged. That is the path the `except` clause exists for, and it is platform-independent. Mutation arm: deleting the `except` turns it red, and only it. Linux is green on the previous push (4771 passed / 238 skipped).
|
I tested this PR on its head Measurements are on extracted trees ( 1. The boundary does what the table says — measured, not readMy own probe against
2. Fail-closed and the platform deviation, as predicates
3. The tree it would land as (this is the part CI has not measured)The branch is 2 master commits behind and, more importantly, stacked on an open PR:
I composed the merge rather than assuming it:
Suggestion: say in the body that this is stacked on #1539, or land #1539 first. Otherwise #1539's reviewers end up voting on a diff that will already have landed underneath them, and the reverse order silently turns 4. Finding: the default executor's
|
Merged on explicit host authorisation — cycle cyc20260922-094315The host authorised this merge directly, in these words (verbatim, 2026-09-22):
Asked which of the outstanding items to unblock first, the host answered The three-vote gate is therefore waived by host sovereignty, not satisfied, recorded here rather than argued away — the same disposition as #1457 (2026-09-20) and #1505 (2026-09-21). At the moment of merging the counter reads 0/3 valid votes: this head carries a merge of master pushed minutes ago, and the reviews before it belong to earlier heads. What was measured on the tree that is actually landingThe branch was refreshed first (
Scope, so the waiver is not read as wider than it is
|
Rant
2026-09-21T18:50:03— bash tool v2. This is P1 of the design (~/.emrg/designs/bash-tool-v2-design.mdv5): the macOS Seatbelt boundary, built beside the frozen tool, switched off by default.What the old tool cannot do
emrg/tools/bash_tool.pydecides whether a command may write by scanning its text. That is not a boundary, it is a parser, and the file says so itself (:2880, "what it does not reach is an interpreter").python3 -c 'open("/anywhere","w")'defeats it by construction, and so does every other language.What this delivers
["bash", "-c", command]is handed to/usr/bin/sandbox-execunder a derived Seatbelt profile, so a command runs inside a process boundary: a write outside the session's working directory (and the OS temp area) is refused by the kernel, whatever language or subprocess attempts it. Measured on this host, end to end:python3write outside the workspaceOperation not permitted), file absentsh -cwrite outside the workspace/etc/hostsread-onlymeans "no writes", not "no filesystem")read-only$VAR/;/ heredoc in the commandDelivery rules (design §1.6, host ruling)
emrg/tools/bash_tool.pyis untouched; the parallel bash-word fixes keep landing there while v2 is built.emrg/sandbox/,emrg/tools/bash_tool_v2.py) and imports nothing from the old file; a test scans import statements to keep it that way. The output framing and decoding are its own copy, because the copy is what survives P7._trusted_write_zones()(~/.emrg/evolution/.emrg/) is deleted, not renamed, and a test asserts it does not come back under another name.[sandbox] bash_tool_v2is read once at startup (the registry is built once), defaults to the old tool, andEMRG_BASH_TOOL_V2=1|0overrides it so it can be tried without editingconfig.toml.Fail closed
A request for confinement that cannot be honoured never becomes an unconfined run: no backend →
SANDBOX_UNAVAILABLE, the blueprint's own message, and the command is not spawned. Windows fails closed today (its ACL runner is P4); Linux runs unconfined and reportsmode = danger-full-accessrather than claiming a boundary it does not have (deviation D4, host-authorised 2026-09-21 18:29, deleted when P3 lands).The D1 root fix
The daemon injected
workdironly when the model had not supplied one —and "workdir" not in args. So the model could name the root it was being trusted in, and the whole home directory became "the workspace" (design §2.4). The session cwd is now injected unconditionally forbash/glob, exactly aswrite/editalready receiveworkspace: the value a command runs in and the value it may write under are one identity. A sandbox that takes its authorization root from the agent it is confining is not a sandbox.Tests
tests/test_bash_v2_policy.py— vocabulary (pinned equal to the frozen tool's, so a drift cannot hide), policy validation, root derivation, canonicalization, the fail-closed contract, the Seatbelt profile.tests/test_bash_v2_boundary.py— the real boundary end to end (darwin; skips elsewhere), the fail-closed half (runs on every platform, including Windows CI), the result surface and the decoder.tests/test_bash_v2_switch.py— the config switch, the registry choice, and the injected arguments.Three mutation arms were run against them and each is killed: widening the writable roots to
/(4 failures), letting a platform with no chain silently use another backend's runner (4), and flipping the default to v2 plus restoring theand "workdir" not in args(5).uv run pytest tests/— 4955 passed, 21 skipped (48 of them new).DEVELOPMENT.mddocuments the section; the decoder's console-codec exemption is extended to the twin file, with its reason recorded intests/test_script_decode_is_locale_independent.py.Not in this PR
Linux (
bwrap/landlock-run) is P3 and has no artifact yet; Windows' ACL runner is P4; escalation advertising is P5; deprecation and deletion P7. Until P7 the old tool keeps serving by default.