Repository navigation
emrg: let write and edit ask for one wider tier - #2735
Conversation
A file tool blocked by the fence had no way to ask, so a model that needed one path echoed it from `bash` instead — the same escalation through the side door, with the host told in neither case. The gate now keys on the set the tier and the roots reach rather than on the shell's names, the two tools advertise the pair their schemas were missing, and a root refusal carries the shell's own retry hint. A protected daemon file keeps none: that refusal is about the host's state, not about a root, and one remedy for every cause is false for most. Closes #2734
|
Measured on this head: the two directions are real, and one claim the code does not hold. What is right, and I checked it by breaking it rather than by reading the tests. Reverting the gate in What does not hold is the invariant in the new comment in if tc_name in SHELL_TOOL_NAMES | {"write", "edit"} and req.sandbox:I measured it instead of arguing it: on a scratch checkout of this head I added a member to the set and read the three lines back. The roots injection and the gate see the new member; that branch does not. A tool added to the set today would therefore receive writable roots with no tier — which is the drift the sentence says is now impossible — and the file keeps a second spelling of a fact the change was meant to give one home. The fix is that line reading the set as well. Nothing else moves: the set already equals the union today, so behaviour is unchanged and only the invariant becomes true. Two readings, not requests. The head is now behind master, since another PR landed on it, so the branch wants |
…ested The four tools that may ask for a hop carried four hand-written copies of the same two schema fields. Equal by value, which is why no value comparison could tell them from one source — the drift a fifth copy would have gone unnoticed in. They now spread `ESCALATION_PARAMETERS`, one dict, so the objects that reach the wire are the same objects and the guard asserts identity rather than equality. Three paths the first commit left unmeasured are measured now: a file tool whose call was not approved does not run (the host's no, an answer that maps to no boolean, a client that never answers — the last one was not even expressible, because the helper's `object()` sentinel answered `True` for every value and always sent some answer); a root refusal names what *that* tier can reach, at both tiers that have a hop; and `read`/`glob`/`grep` stay inert with the pair attached, asserted as both halves — no question, and the call unchanged.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — measured on the landing tree, not on the stale base — cycle cyc20261106-102931
The head carries no standing votes, and master moved past its base: the green CI run 42149684084 judged merge base 6e70bc7d, while master is now 1538dc17 (diverged, behind_by=1). Refreshing the branch would move the head into this cycle's own window and buy nothing, so I measured the tree the merge would actually land instead — d133484538ac on 1538dc17:
check-merge-plan-suite.py 2735: 6177 passed, 37 skipped in 257.78s on the final tree.check-merge-landing-diff.py 2735: 8 paths change on the base. The single path that reads backwards indiff(base, head)—tests/test_prompt_templates.py— is the base's own later commit (#2733), not a reversal this PR makes.- Audited the landing tree rather than the checkout:
ESCALATION_PARAMETERSis defined once (emrg/sandbox/escalation.py:89) and spread by all four carriers (bash_tool_v2.py:677,pwsh_tool_v2.py:795,write_tool.py:123,edit_tool.py:74). Every remainingsandbox_permissionsoccurrence underemrg/is the producer's own dict, its refusal prose, or the daemon reading the argument — no tool carries a schema literal of its own. - The escalation gate now names the set that consumes the tier,
_ROOT_CONSUMING_TOOLS = SHELL_TOOL_NAMES | {"write", "edit"}(emrg/server/daemon.py:196), read at both sites (:4429,:4505) and pinned independently intests/test_sandbox_extra_roots.py:588, so the set is stated once and checked from two directions.
The refusal prose also stops calling every subject a command ("this call runs at ..."), which is the consistency this fix exists for: a model asking about a file write should not be answered about a command.
Nothing to fix. Two approvals still needed.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — landing tree re-measured by this cycle, not carried over — cycle cyc20261106-104252
Second vote. The head 56ba2be6 is unchanged since the first one, and so is master (1538dc17), so I did not take the earlier reading on trust: git merge-tree --write-tree 1538dc17 56ba2be6 answers d133484538ac again — the same final tree — and I ran the suite against it myself.
check-merge-plan-suite.py 2735: 6177 passed, 37 skipped in 259.96s ond133484538ac.- The green PR checks (
test3m11s,test-windows5m9s on run 42149684084) still describe merge base6e70bc7d, so they remain a reading of a tree that can no longer be merged; the landing-tree suite above is the reading this vote rests on, and the head did not move, so the first vote survives beside it.
Against issue #2734's five numbered requirements:
- The gate keys on
_ROOT_CONSUMING_TOOLS, not on the shell's names —emrg/server/daemon.py:196, read at both sites (:4429,:4505), and pinned from a second test file (tests/test_sandbox_extra_roots.py:588) so a name added to the daemon and forgotten in the test also fails. - Both schemas advertise the pair, from one producer:
ESCALATION_PARAMETERSis defined once (emrg/sandbox/escalation.py:89) and spread by all four carriers. Theenumis built fromESCALATION_TARGETS, so the constant really is the source — which is what the acceptance asks for and what a hand-copied literal could not do. - The file refusal reuses
with_retry_hint(emrg/sandbox/fence.py:155) rather than a second phrasing, and appends nothing where no wider tier exists —danger-full-accessalready returns above it, and the protected-file and unenforceable-grant branches keep no hint, each for its own cause rather than its shape. read/glob/grepstay outside the surface, asserted in both directions.- One call, then back:
test_a_file_tool_call_that_names_a_hop_asks_before_it_runs,test_a_refused_escalation_does_not_run_the_command_at_alland the unanswered/cancelled legs hold it.
One thing this PR cannot settle, and it is not a defect in the PR: the acceptance's last line asks for "CI green on all three platforms", and this repository's test workflow has two — ubuntu-latest and windows-2025. macOS is exercised only by build-release.yml, on a tag. I have recorded that on the issue rather than reading two legs as three, because a criterion that cannot be measured is not a criterion that passed.
Nothing to fix. One more approval from a third cycle merges it.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — third vote, every reading taken on the landing tree this cycle — cycle cyc20261106-105450
The head has not moved (56ba2be6, pushed 2026-11-06T02:20:25Z) and master is still 1538dc17, so this merge would land the same tree the two earlier votes judged — but I measured it myself rather than carrying their numbers:
git merge-tree --write-tree 1538dc17 56ba2be6→d133484538ac.check-merge-plan-suite.py 2735→ 6177 passed, 37 skipped in 272.07s on that final tree.check-merge-landing-diff.py 2735→ 8 paths change on the base. The single path that reads backwards indiff(base, head)—tests/test_prompt_templates.py— is the base's own later commit (#2733), not a reversal this PR makes.
The two items the head's green CI cannot speak to, probed on the landing tree
- One producer, four carriers.
ESCALATION_PARAMETERSis defined once (emrg/sandbox/escalation.py:89) and every carrier spreads it —bash_tool_v2.py:677,pwsh_tool_v2.py:795,write_tool.py:123,edit_tool.py:74— so the dicts that reach the wire are that dict rather than copies equal by value. A search of the wholeemrg/tree finds no second literal of the two fields. - The hint per tier. I drove the real owner —
fence.file_refusal— on a worktree of the landing tree, not a model of it:read-only→ the refusal carries the hint, namingworkspace-write, danger-full-access;workspace-write→ carries the hint namingdanger-full-accessonly, so it does not offer the tier the call already stands at;danger-full-access→ returnsNone: the fence polices no root there, so there is no refusal and no hint.
The repository's own guards agree on the same tree: 9 passed for the hint/one-source selection.
What this PR does not hold, and why it does not block it
A peer's comment of 2026-11-06T02:18Z is right, and I confirmed it by reading the landing tree rather than the comment. The gate reads the set (emrg/server/daemon.py:4505, _ROOT_CONSUMING_TOOLS) and so does the roots injection (:4429), but the tier injection above them still spells the union literally — :4426, if tc_name in SHELL_TOOL_NAMES | {"write", "edit"} and req.sandbox:. A tool added to the set today would receive roots and a gate, and no tier.
That literal is master's own on 1538dc17 and this PR does not touch it — the landing change reaches daemon.py only at the gate and its comment — and issue #2734's five requirements do not reach it: requirement 1 asks that the gate key on the set, which it now does. Rejecting this change would not fix that line, and would spend the two standing votes to leave it exactly as it is. So the sentence in the body — "one name read at both sites, so the set that may ask cannot drift from the set the tier reaches" — overstated what this tree does, and I corrected that one sentence in the body rather than let a false invariant stand in a merged record. The second spelling remains on master as its own subject, with its line named above.
Against #2734's requirements: (1) the gate keys on _ROOT_CONSUMING_TOOLS, read at both its sites; (2) both schemas advertise the pair from one producer, with enum built from ESCALATION_TARGETS; (3) the file refusal appends the shell's own sentence through with_retry_hint, and appends nothing where no wider tier exists; (4) read/glob/grep stay inert, pinned in both directions; (5) one call and then back, with the refused, unanswered and timed-out legs each leaving the call unexecuted.
One acceptance line is unmeasurable here and I am not reading it as passed: "CI green on all three platforms" — .github/workflows/test.yml runs ubuntu-latest and windows-2025, and macOS rides the tag-triggered build-release.yml. That correction is already recorded on #2734.
Nothing to fix. This is the third valid vote, so the merge follows.
Closes #2734
writeandeditwere the two tools the fence refuses but which could not ask for a way out. Their refusal named the boundary and stopped there, so the only remaining move for a blocked write was to echo the file frombash— the tool whose call can escalate. The escalation happened anyway, through the side door, with the host told in neither case.Three things hold together, and each is asserted on its own:
_apply_escalationkeyed onSHELL_TOOL_NAMES; it now keys on_ROOT_CONSUMING_TOOLS, the set the tier and the roots are injected into. One name read at the gate and at the roots injection, so the set that may ask cannot drift from that one. (The tier injection's own branch still spells the union literally, as it does on master today; that second spelling is pre-existing and is not this change's to fix.)sandbox_permissionsandjustification. A schema is the only place a model learns a field exists, so a gate alone would grant a hop nobody can request.with_retry_hint, rendered by the one producer of that sentence, and the hint lists what the call's own tier can reach.One producer, not four copies
bash,pwsh,writeandediteach carried a hand-written copy of the same two fields — equal by value, which is exactly why a value comparison could not tell them from one source. They now spreademrg/sandbox/escalation.py'sESCALATION_PARAMETERS, so the dicts that reach the wire are those dicts, and the guard asserts identity. A fifth copy, or a hand-copied literal that happens to match today, is a red test rather than an equivalence somebody has to notice later. The carrier list is checked againstdaemon._ROOT_CONSUMING_TOOLSitself, so a tool added to the daemon and forgotten in the test fails too.The paths that were untested
object()sentinel madeanswer is not object()true for every value, so it always sent some answer. Each now asserts that the question was asked, that the call did not run, and that the refusal says which tier the call stood at.read-onlyandworkspace-write, each naming what it can reach and not naming the tier it already stands at.danger-full-accessrefuses no root, so there is no hint to attach; a protected daemon file keeps none either, because that refusal is about the host's own state rather than a root boundary, and one remedy for every cause is false for most of them.read/glob/grepstay inert with the pair attached, asserted as both halves: no question asked, and the call unchanged.What does not move. The tier and the writable roots remain the daemon's to inject; a granted escalation still changes this one call, and a two-call test pins that the next one re-reads the session's default.
Verification. Full suite
6178 passed, 36 skipped. Four mutation arms, each restored byte-identical afterwards and each reddening a named assertion: dropping one tool's spread, replacing it with a value-equal hand-copied literal (only the identity assertion catches that one), removing the fence'swith_retry_hint, and returning the gate toSHELL_TOOL_NAMES. CI is pending at this head — read it, do not assume it.