Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/deep-dive.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
27 changes: 26 additions & 1 deletion grapharc/harness/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
67 changes: 67 additions & 0 deletions tests/test_harness_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import ctypes # noqa: F401
import os
import shutil
import signal
import sqlite3 # noqa: F401
import sys
import sysconfig
Expand All @@ -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])

Expand Down Expand Up @@ -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",
Expand Down
Loading