diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index b75defc6..2b2e3656 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -4258,9 +4258,15 @@ def _inject_tool_arguments( ) -> None: """Inject the parts of a tool call the model must not choose. - Three rules. The first two are one fact — the session's cwd is where a - filesystem tool works — split by what the value decides for that tool: a - boundary (pinned) or a preference (a default). The third is the tier. + Five classes, in the order the body writes them. The first two are one + fact — the session's cwd is where a filesystem tool works — split by what + the value decides for that tool: a boundary (pinned) or a preference (a + default). The third is the tier. The last two are the ones a caller would + get wrong left to itself: the root list, and the parent a delegation is + born from. Every key this method writes is named below + (``tests/test_session_sandbox.py`` pins the enumeration to the code), and + that is the point of the list — it is the only map a reader has for asking + what the daemon overrides in a call it did not write. * **the session's cwd is where a filesystem tool works — for the tools that run a command.** Injected unconditionally into the shell tools, @@ -4298,9 +4304,35 @@ def _inject_tool_arguments( test_shell_tool_mount.py`` now pins by driving this method and then the tool rather than asserting the argument dict alone. * **the sandbox tier is the task's, not the agent's** (rant - 2026-08-20T15:46:50). ``write``/``edit`` also receive it, together with - the workspace boundary, so that under read-only they cannot clobber the - host's uncommitted tree (community issue #979). + 2026-08-20T15:46:50). ``write``/``edit`` also receive it, under the key + ``sandbox``, together with the workspace boundary, so that under + read-only they cannot clobber the host's uncommitted tree (community + issue #979). + * **the host names the writable roots, and only the host.** The tools + that write under a root — the shell tools, ``write`` and ``edit``, the + same set the tier reaches — receive ``writable_roots`` from the + session's ``sandbox_roots``, injected **unconditionally** (rant + 2026-10-09T09:43:39 §4). The difference from the tier above is + deliberate on both sides: the tier's injection is guarded by + ``req.sandbox`` because a call carrying no tier belongs to a host + session whose unconfined default is silence, and silence cannot widen + anything. A list of roots has no such neutral value, and an honoured + model-supplied list would be a sandbox whose boundary the sandboxed + decided — so whatever the session holds, including nothing, is what the + call runs with. + * **the parent of a delegation is a fact, not a field.** ``subagent`` and + ``boss`` receive ``_parent_session_id``, ``_parent_cwd``, + ``_parent_tools`` and ``_parent_sandbox``, all of them unconditional + (issues #2451, #2465, #2467), for one reason in four parts: the child's + slot is derived from the parent's id and cwd, so a caller naming its own + would name a wrong one; the child inherits **this turn's** tool set, so + a round that may not use a tool cannot hand it to a session it spawns; + and it inherits **this turn's** tier — ``req.sandbox``, the same value + injected above — so a task's child is as confined as the task. Reading + that tier from the session object instead was a hole: a scheduled task's + tier is resolved from ``tasks.yml`` into the request and never written + onto the session, so every child such a task spawned ran unconfined at + ``policy.DEFAULT_MODE`` while the task body itself was confined. ``workspace`` is a new key for the shell tools and the old executor ignores it (it reads ``command``/``timeout``/``workdir``/``sandbox`` diff --git a/tests/test_session_sandbox.py b/tests/test_session_sandbox.py index 32b71661..5f285288 100644 --- a/tests/test_session_sandbox.py +++ b/tests/test_session_sandbox.py @@ -187,6 +187,59 @@ def test_the_client_turn_path_resolves_the_tier_before_the_loop_starts(): assert "resolve_client_tier" not in inject_source +def test_the_injection_documents_every_argument_it_writes(): + """The docstring is the only map of what this method overrides. + + A reader asking what the daemon puts into a tool call it did not write has + one instrument — the enumeration in this method's docstring — and nothing + else reads it. It said "three rules" while the body wrote nine keys in five + classes, and the two it left out were exactly the safety injections: the + host's writable roots, and the parent a delegation is born from. A reader + who trusted the list concluded that both came from the model, which is the + opposite of what the code does — so the prose did not merely lag the code, + it misdescribed the boundary the code exists to hold. + + Prose drifts while every behavioural test stays green, so the drift is + measured here instead of remembered. The key set is derived from the source, + so a key the method starts writing is visible without anyone maintaining a + list of keys; ``pinned`` then turns that into a decision rather than an + accident, and fails in the other direction too — a key dropped from the code + whose bullet outlives it leaves the prose documenting something that is not + written. + + Both directions are the guard, and neither is a behavioural claim: the + injection's own behaviour is pinned by the tests that drive it + (``test_shell_tool_mount.py``, ``test_boss_tool.py``, + ``test_subagent_tool.py``). This one pins the map. + """ + source = inspect.getsource(EmrgServer._inject_tool_arguments) + written = set(re.findall(r'args\["(\w+)"\]\s*=', source)) + + pinned = { + "workdir", # the session cwd: pinned for the shell tools, a default for glob + "path", # grep's spelling of the same fact + "sandbox", # the task's tier + "workspace", # the write boundary that rides with the tier + "writable_roots", # the host's roots, injected unconditionally + "_parent_session_id", # a delegation's parent — four facts the model + "_parent_cwd", # must not choose, all injected unconditionally + "_parent_tools", + "_parent_sandbox", + } + assert written == pinned, ( + f"the injection writes {sorted(written)}, the guard expects " + f"{sorted(pinned)} — decide which is right, then update both the code's " + f"docstring and this pin" + ) + + doc = EmrgServer._inject_tool_arguments.__doc__ or "" + undocumented = sorted(k for k in written if f"``{k}``" not in doc) + assert not undocumented, ( + f"the injection writes {undocumented} and its docstring never names " + f"them — the list a reader audits the sandbox surface from is incomplete" + ) + + # ── the daemon's one write path and its broadcast ─────────────────────────