diff --git a/docs/deep-dive.md b/docs/deep-dive.md index 1c93ae9..b5dbbd3 100644 --- a/docs/deep-dive.md +++ b/docs/deep-dive.md @@ -254,7 +254,7 @@ A stable system is not one that claims to have no edges — it is one whose edge - **`.env` and `grapharc.toml` follow the same discovery rule: the working directory, and nowhere else.** Neither searches parent directories — a run must not be governed by a file you did not know about, and must not be *billed* to one either. **This is a behaviour change:** the credential loader used to walk up to `/`, so a `.env` in an ancestor directory (a `$HOME` one on a shared box, a client project one above a demo checkout) was picked up silently. If you relied on that, move the file into the directory you run from, `export` the variable, or pass `env_file=` to name it explicitly. A real environment variable still beats any file. - **`grapharc run` has no budget unless you give it one.** Set any of `--max-tokens`, `--max-iterations`, `--max-seconds`, or `--max-concurrency`; without them each dimension is unlimited and the gate admits a topology of any worst-case cost. -**Verified this pass:** `pytest` → green, 2,148 selected and 13 deselected (the live ones); `ruff check .` clean; all eight `grapharc demo` stages green, plus the `trace` / `metrics` / `viz` / `replay` tour against a freshly recorded demo trace; the wheel builds and imports all submodules in a clean virtualenv with `[all]`, and `0.1.6` on PyPI is that wheel. The counts are a snapshot, not a property of the project — `pytest` re-derives them in one command, which is the only reason they are quoted, and `tests/test_deep_dive.py` fails this line rather than letting it drift. +**Verified this pass:** `pytest` → green, 2,151 selected and 13 deselected (the live ones); `ruff check .` clean; all eight `grapharc demo` stages green, plus the `trace` / `metrics` / `viz` / `replay` tour against a freshly recorded demo trace; the wheel builds and imports all submodules in a clean virtualenv with `[all]`, and `0.1.6` on PyPI is that wheel. The counts are a snapshot, not a property of the project — `pytest` re-derives them in one command, which is the only reason they are quoted, and `tests/test_deep_dive.py` fails this line rather than letting it drift. [ROADMAP.md](../ROADMAP.md) tracks what is built and what is not, item by item. diff --git a/grapharc/harness/executor.py b/grapharc/harness/executor.py index eb107ef..49b6bcc 100644 --- a/grapharc/harness/executor.py +++ b/grapharc/harness/executor.py @@ -653,7 +653,32 @@ def run(self, spec: ToolSpec, args: dict[str, Any]) -> Any: raise SandboxViolation( f"tool {spec.name!r} exceeded its {spec.timeout_seconds}s timeout" ) - kind, payload = parent_conn.recv() + try: + kind, payload = parent_conn.recv() + except EOFError: + # `poll()` is true at EOF as well as on a readable message, so a + # child that died without sending — `os._exit`, a segfault, an + # OOM-kill, a guard that killed the process rather than raising — + # lands here. It used to escape as a bare `EOFError('')`, which + # names nothing: not the tool, not the cause. The agent loop + # catches it under its blanket `except Exception` and reports + # `TOOL_ERROR: ` with nothing after the colon, so a model is told + # its call failed and given no way to tell why or self-correct. + # + # Reported as a tool failure rather than a violation: the child + # dying is not evidence it tried to escape confinement, and a + # `SandboxViolation` is a specific accusation. The exit code is + # what distinguishes the cases, so it is in the message; a + # negative one is the signal that killed it. + proc.join(5) + code = proc.exitcode + signal_note = ( + f" (killed by signal {-code})" if code is not None and code < 0 else "" + ) + raise RuntimeError( + f"tool {spec.name!r} failed: the sandboxed child exited without " + f"sending a result, exit code {code}{signal_note}" + ) from None proc.join(5) if kind == "violation": raise SandboxViolation(payload) diff --git a/tests/test_harness_gate.py b/tests/test_harness_gate.py index df61f24..4d84294 100644 --- a/tests/test_harness_gate.py +++ b/tests/test_harness_gate.py @@ -14,6 +14,7 @@ import ctypes # noqa: F401 import os import shutil +import signal import sqlite3 # noqa: F401 import sys import sysconfig @@ -40,6 +41,15 @@ def _echo(**kwargs): return kwargs +def _exits_without_sending(**kwargs): + """A child that dies mid-tool, standing in for a crash or an OOM-kill.""" + os._exit(3) + + +def _killed_by_signal(**kwargs): + os.kill(os.getpid(), signal.SIGKILL) + + def _policy(rules): return PermissionPolicy(rules=[PermissionRule(**r) for r in rules]) @@ -206,6 +216,63 @@ def read_secret(path: str) -> str: harness.call("read_secret", {"path": str(secret)}) +@pytest.mark.skipif( + not hasattr(sys, "addaudithook") or sys.platform == "win32", + reason="requires POSIX fork + audit hooks", +) +@pytest.mark.parametrize( + ("label", "body"), + [ + pytest.param("exit", _exits_without_sending, id="clean-exit"), + pytest.param("kill", _killed_by_signal, id="sigkill"), + ], +) +def test_a_sandboxed_child_that_dies_without_sending_names_the_tool( + tmp_path, label, body +): + """`poll()` is true at EOF as well as on a readable message. + + So a child that died without sending — `os._exit`, a segfault, an OOM-kill + — reached `recv()` and raised a bare `EOFError('')`. That names nothing: + not the tool, not the cause. The agent loop catches it under its blanket + `except Exception` and reports `TOOL_ERROR: ` with *nothing after the + colon*, so a model is told its call failed and given no way to tell why. + """ + workspace = tmp_path / "ws" + workspace.mkdir() + reg = ToolRegistry() + reg.register(ToolSpec(name="dies", description="", fn=body)) + policy = _policy([{"action": "allow", "pattern": "dies"}]) + harness = Harness(reg, policy, workspace=str(workspace)) + + with pytest.raises(RuntimeError) as caught: + harness.call("dies", {}) + + message = str(caught.value) + assert "dies" in message, message + assert "exited without sending a result" in message, message + # Reported as a tool failure, not an accusation: a child dying is not + # evidence it tried to escape confinement. + assert not isinstance(caught.value, SandboxViolation) + + +@pytest.mark.skipif( + not hasattr(sys, "addaudithook") or sys.platform == "win32", + reason="requires POSIX fork + audit hooks", +) +def test_a_signal_death_is_distinguishable_from_a_clean_exit(tmp_path): + """The exit code is what tells the two apart, so it has to be in the text.""" + workspace = tmp_path / "ws" + workspace.mkdir() + reg = ToolRegistry() + reg.register(ToolSpec(name="killed", description="", fn=_killed_by_signal)) + policy = _policy([{"action": "allow", "pattern": "killed"}]) + harness = Harness(reg, policy, workspace=str(workspace)) + + with pytest.raises(RuntimeError, match="killed by signal 9"): + harness.call("killed", {}) + + @pytest.mark.skipif( not hasattr(sys, "addaudithook") or sys.platform == "win32", reason="requires POSIX fork + audit hooks",