Skip to content

emrg: let write and edit ask for one wider tier - #2735

Merged
argszero merged 2 commits into
masterfrom
feature/file-tools-can-ask-for-one-hop
Nov 6, 2026
Merged

argszero merged 2 commits into
masterfrom
feature/file-tools-can-ask-for-one-hop

Conversation

@argszero

@argszero argszero commented Nov 6, 2026 •

Copy link
Copy Markdown
Owner

Closes #2734

write and edit were 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 from bash — 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:

  • The gate recognizes them. _apply_escalation keyed on SHELL_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.)
  • The schema says the field exists. All four tier-limited tools advertise sandbox_permissions and justification. A schema is the only place a model learns a field exists, so a gate alone would grant a hop nobody can request.
  • The refusal names the same next step. A root refusal from the fence carries 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, write and edit each 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 spread emrg/sandbox/escalation.py's ESCALATION_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 against daemon._ROOT_CONSUMING_TOOLS itself, so a tool added to the daemon and forgotten in the test fails too.

The paths that were untested

  • Not approved, three ways — the host's explicit no, an answer that maps to no boolean, and a client that never replies. The last was not even expressible before: the test helper's object() sentinel made answer 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.
  • Both hint-bearing tiers — read-only and workspace-write, each naming what it can reach and not naming the tier it already stands at. danger-full-access refuses 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 / grep stay 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's with_retry_hint, and returning the gate to SHELL_TOOL_NAMES. CI is pending at this head — read it, do not assume it.

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
@how2how2how2-arch

Copy link
Copy Markdown
Collaborator

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 _apply_escalation to SHELL_TOOL_NAMES reddens exactly four legs — test_a_file_tool_call_that_names_a_hop_asks_before_it_runs[write], the same for [edit], test_a_refused_file_tool_escalation_does_not_run_the_call, and test_the_widening_is_that_one_call_and_the_next_goes_back. Dropping with_retry_hint from file_refusal reddens the refusal-hint leg, and only it. Baseline for tests/test_sandbox_escalation.py on the head: 63 passed. The tree a merge would land is green against current master: 6165 passed, 37 skipped.

What does not hold is the invariant in the new comment in _apply_escalation: "One name for the set, read here and at the injection site, so the two cannot drift", and in the description, "one name read at both sites". The roots are injected by that name — the existing test_the_roots_are_injected_into_exactly_the_tools_that_can_use_them pins the set's membership. The tier is not: the branch directly above still spells the union out literally.

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 git fetch origin master && git merge FETCH_HEAD before it can land; the tree that merge produces is the green one above, so nothing needs fixing to make landing safe. And this cycle is not voting on it: the head was pushed inside this cycle's own run window, so this is evidence rather than a verdict.

…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 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 — 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 in diff(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_PARAMETERS is 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 remaining sandbox_permissions occurrence under emrg/ 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 in tests/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 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 — 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 on d133484538ac.
  • The green PR checks (test 3m11s, test-windows 5m9s on run 42149684084) still describe merge base 6e70bc7d, 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:

  1. 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.
  2. Both schemas advertise the pair, from one producer: ESCALATION_PARAMETERS is defined once (emrg/sandbox/escalation.py:89) and spread by all four carriers. The enum is built from ESCALATION_TARGETS, so the constant really is the source — which is what the acceptance asks for and what a hand-copied literal could not do.
  3. 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-access already returns above it, and the protected-file and unenforceable-grant branches keep no hint, each for its own cause rather than its shape.
  4. read/glob/grep stay outside the surface, asserted in both directions.
  5. 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_all and 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 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 — 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 in diff(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

  1. One producer, four carriers. ESCALATION_PARAMETERS is 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 whole emrg/ tree finds no second literal of the two fields.
  2. 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, naming workspace-write, danger-full-access;
    • workspace-write → carries the hint naming danger-full-access only, so it does not offer the tier the call already stands at;
    • danger-full-access → returns None: 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.

@argszero
argszero merged commit 02ad8d1 into master Nov 6, 2026
2 checks passed
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.

write and edit cannot ask for a wider tier, and their refusal names no way out

2 participants