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
30 changes: 21 additions & 9 deletions docs/adr/011-box/ADR-0011-the-box.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
adr: ADR-0011
status: draft
liveness: operating (three boxes under tests and live runs, the coding tools under tests and one live console run, the watch under tests and on the bench; the real-box tests are opt-in)
liveness: operating (three boxes under tests and live runs, each box's answer to a missing deadline pinned, the coding tools under tests and one live console run, the box tree's bound resolved in the box, the watch under tests and on the bench; the real-box tests are opt-in)
date: "2026-09-25"
area: box
kind: new
Expand Down Expand Up @@ -97,7 +97,7 @@ attaches an internal network with no DNS, no egress and no reach to the host.
### C3: Three tools over a tree, the tree may be anywhere, and an edit lands once _(enforced: mechanical)_ ^c3

- **Subject**: every `read_lines`, `search` and `edit` call.
- **Violated when**: a tool reads a path outside the tree, in a box one whose text leaves `workdir`
- **Violated when**: a tool reads a path outside the tree, in a box one resolving outside `workdir`
(S14), an edit lands more than once or where `old` was absent, or a later write carries an earlier
read.

Expand All @@ -108,7 +108,7 @@ raises on a bad pattern; `rg` when the tree has it, else `grep -rE`; both read h
count named, and keeps each line's own ending. Edits serialise on one lock.

A tree on this machine refuses a path that resolves outside it: `..`, an absolute path elsewhere, a
symlink out. In a box the check reads the text only. `read` and `list_dir` go over the same tree.
symlink out. A box's tree resolves it in the box (S14). `read` and `list_dir` go over the same tree.

### C4: The watch reports the change as the tree shows it; the model's own checks end the run _(enforced: mechanical)_ ^c4

Expand Down Expand Up @@ -181,7 +181,8 @@ with mixed endings stays mixed.

- **Landing evidence**: `tests/test_code_tools.py` (nine: exactness, the single-file filename, the
empty file, CRLF and mixed endings, hidden files, paths that leave the tree, the listing);
`tests/test_daytona.py`, the box tree's listing.
`tests/test_daytona.py`, the box tree's listing; `tests/test_box.py`, symlinks out refused in both
trees.

### D3: `hub/tools/watch.py`: `Watch`, `Criteria`, `criteria`, `accept` ^d3

Expand Down Expand Up @@ -257,8 +258,19 @@ to; the person picks `--offline` when the directory holds what the network must
name.
- **S13**: The VM box's `exec`, and the `run` derived from it, take a timeout in seconds only: a
missing one fails while the in-VM `timeout` guard is formatted, before any command runs, where the
docker and remote boxes run with no deadline. No caller passes none today, and no test pins it.
- **S14**: The box tree's bound reads the path's text only, so a symlink inside `workdir` that
points elsewhere in the box is read and written through, where the tree on this machine refuses
it. The box holds nothing of this machine (C2), so what it reaches is the box's own. Resolving the
path in the box before the check is owed; no test pins it.
docker and remote boxes run with no deadline. `tests/test_box.py` pins both:
`test_the_vm_box_refuses_a_missing_deadline_before_any_command_runs` and
`test_no_deadline_reaches_exec_bare_never_as_a_large_number`. One caller passes none, where this
row once said no caller did: the bench's codex arm at a time budget of 0
(`bench/swe/codex_cli.py`, `test_time_budget_zero_means_no_deadline` in
`tests/test_swe_codex_cli.py`).
- **S14**: The box tree's bound read the path's text only, so a symlink inside `workdir` pointing
elsewhere in the box was read and written through. `BoxTree` now resolves the path in the box, as
written and as normalised, and refuses either outside `workdir`. The resolver keeps every byte of
a name, a newline it ends with included, so a link into a sibling named as the tree plus a newline
is refused; its answer is three NUL-ended paths and nothing else. Pinned against `LocalTree`:
`test_the_box_tree_resolves_a_symlink_in_the_box_before_its_bound`,
`test_a_resolver_answer_that_is_not_three_nul_ended_paths_is_refused`.
- **S15**: A process the model runs in the box reads anywhere already (S6), so the tree's bound is
no boundary against one: a link it swaps between the resolve and the read reaches what that
process could read itself.
2 changes: 1 addition & 1 deletion docs/adr/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
| [ADR-0008](008-program/ADR-0008-the-program.md) | 008-program | draft | unimplemented | ADR-0004, ADR-0005, ADR-0007 |
| [ADR-0009](009-backend/ADR-0009-backends.md) | 009-backend | draft | partial (the router and both shapes of the session CLI operate and report the count; the subscription CLI hands back no count; the mixed-response refusal, the session CLI's failed-call envelope and the gate over an acting session are owed) | ADR-0002, ADR-0004, ADR-0006, ADR-0007 |
| [ADR-0010](010-delegation/ADR-0010-delegation.md) | 010-delegation | draft | operating (the round trip, a peer ending without answering, a peer whose backend raises and an answer landing early are pinned by tests; no bench exercises the path) | ADR-0001, ADR-0002, ADR-0005, ADR-0006 |
| [ADR-0011](011-box/ADR-0011-the-box.md) | 011-box | draft | operating (three boxes under tests and live runs, the coding tools under tests and one live console run, the watch under tests and on the bench; the real-box tests are opt-in) | ADR-0002, ADR-0003, ADR-0005, ADR-0006, ADR-0007 |
| [ADR-0011](011-box/ADR-0011-the-box.md) | 011-box | draft | operating (three boxes under tests and live runs, each box's answer to a missing deadline pinned, the coding tools under tests and one live console run, the box tree's bound resolved in the box, the watch under tests and on the bench; the real-box tests are opt-in) | ADR-0002, ADR-0003, ADR-0005, ADR-0006, ADR-0007 |
| [ADR-0012](012-bench/ADR-0012-the-bench.md) | 012-bench | draft | operating (the set runner, the in-box grader, the closure, the run identity and both arms are under tests; that compared bench runs share instances, head and budgets is the reader's check; no CLI-arm bench run on the hard set has the package indexes closed) | ADR-0002, ADR-0003, ADR-0006, ADR-0009 |
| [ADR-0013](013-agent/ADR-0013-the-agent.md) | 013-agent | draft | operating (the wake, the gate, the caps, the hop count, the cursor, the lock and the continuous run with its checkpoint are pinned by tests; supervision and the lease are not in the process) | ADR-0001, ADR-0002, ADR-0003, ADR-0007, ADR-0010 |
| [ADR-0014](014-instrument/ADR-0014-the-instrument.md) | 014-instrument | draft | operating (eleven instruments build from one config and run under tests against scripted commands and stores; the command-line check prints a measurement unvetted and has no test; a lock-holder and a scheduled-job instrument are owed) | ADR-0005, ADR-0007 |
Expand Down
63 changes: 55 additions & 8 deletions hub/tools/box.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,29 +152,76 @@ def inside(workdir: str, path: str) -> None:
raise PermissionError(f"{path} is outside {workdir}")


# each argument's physical path, NUL-ended (a name may hold a newline), symlinks followed as the
# kernel does and a missing tail kept as written: `realpath -m` is GNU only, and `readlink -n` reads
# alike on both. No name goes through `$(...)` bare: that drops a newline the name ends with, so the
# parent is cut by expansion and the link target carries a sentinel past its last byte
_RESOLVE = r"""
for arg do
r=/ todo=$arg n=0
while [ -n "$todo" ]; do
c=${todo%%/*}
case $todo in */*) todo=${todo#*/} ;; *) todo= ;; esac
case $c in
'' | .) ;;
..) r=${r%/*}; r=${r:-/} ;;
*)
p=${r%/}/$c
if [ -L "$p" ]; then
n=$((n + 1))
[ "$n" -le 40 ] || exit 1
t=$(readlink -n -- "$p" && printf x) || exit 1
t=${t%x}
case $t in /*) r=/ ;; esac
todo=$t${todo:+/$todo}
else
r=$p
fi
;;
esac
done
printf '%s\0' "$r"
done
"""


class BoxTree:
"""The code tools' `Tree` over a box's `workdir`: `rg` when the image has it, else `grep -rE`;
every path is bound to the tree (`inside`) before the box sees it."""
every path is bound to the tree before the box sees it, by its text (`inside`) and then as the
box resolves it, so a symlink out of `workdir` is refused as `LocalTree` refuses it."""

def __init__(self, box: Box):
self.box = box
self._rg: bool | None = None

def _bound(self, path: str) -> None:
async def _bound(self, path: str) -> None:
root = getattr(self.box, "workdir", None) # a box without one (test doubles) has no bound
if root:
inside(root, path)
if not root:
return
inside(root, path)
# the shell tools open the path as written, `read` and `write` its normalised form (`abs`):
# a symlink before a `..` takes the two apart, so both must stay in the tree
joined = path if path.startswith("/") else posixpath.join(root, path)
argv = ["sh", "-c", _RESOLVE, "lion-resolve", root, joined, posixpath.normpath(joined)]
r = await self.box.exec(argv)
real = r["stdout"].split("\0")[:-1] # three paths, each NUL-ended, nothing after the last
if r["rc"] != 0 or not r["stdout"].endswith("\0") or len(real) != 3:
raise PermissionError(f"{path} did not resolve in the box: {r['stderr'].strip()[-300:]}")
top = real[0].rstrip("/") + "/"
for target in real[1:]:
if target != real[0] and not target.startswith(top):
raise PermissionError(f"{path} resolves to {target}, outside {root}")

async def read(self, path: str) -> str:
self._bound(path)
await self._bound(path)
return await self.box.read(path)

async def write(self, path: str, text: str) -> None:
self._bound(path)
await self._bound(path)
await self.box.write(path, text)

async def list_dir(self, path: str) -> list[str]:
self._bound(path)
await self._bound(path)
rc, out = await self.box.run(f"ls -1Ap -- {shlex.quote(path)} 2>&1") # -p: `/` after a directory
if rc != 0:
raise FileNotFoundError(out.strip() or f"ls exited {rc}")
Expand All @@ -184,7 +231,7 @@ async def search(self, pattern: str, path: str, include: str | None) -> str:
if self._rg is None:
rc, _ = await self.box.run("command -v rg >/dev/null")
self._rg = rc == 0
self._bound(path) # inside the tree, or refused
await self._bound(path) # inside the tree, or refused
if self._rg:
# -H: a single-file path drops the filename column without it
argv = [
Expand Down
103 changes: 102 additions & 1 deletion tests/test_box.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

import pytest

from hub.tools import BoxTree, DaytonaSandbox, DockerSandbox, git_diff
from hub.tools import BoxTree, DaytonaSandbox, DockerSandbox, LocalTree, Sandbox, git_diff

SHIM = """#!{python}
import os, subprocess, sys
Expand Down Expand Up @@ -145,6 +145,81 @@ async def go():
assert box.started is False


def test_the_box_tree_resolves_a_symlink_in_the_box_before_its_bound(tmp_path, monkeypatch):
# the bound read the path's text, so a link inside `workdir` read and wrote through to its target
for k, v in shim(tmp_path).items():
monkeypatch.setenv(k, v)
work, away = tmp_path / "work", tmp_path / "away"
(work / "pkg" / "inner").mkdir(parents=True)
away.mkdir()
(away / "secret.py").write_text("secret = 1\n")
(work / "a.py").write_text("a = 1\n")
(work / "link.py").symlink_to(away / "secret.py")
(work / "out").symlink_to(away)
(work / "rel").symlink_to("../away")
(work / "hop").symlink_to("pkg/inner")
(work / "same.py").symlink_to("a.py") # a link that stays in the tree is followed
(work / "sub").symlink_to("pkg")
(work / "line\nbreak.py").write_text("lb = 1\n") # a name holding a newline is still one path
twin = tmp_path / "work\n" # a sibling named as the tree plus a newline: a link into it leads out
(twin / "sub").mkdir(parents=True)
(twin / "secret.py").write_text("twin = 1\n")
(work / "twin").symlink_to("../work\n")
(work / "sub\n").mkdir() # a link whose target ends in a newline still lands on that target
(work / "sub\n" / "c.py").write_text("c = 1\n")
(work / "nl").symlink_to("sub\n")
tree, local = BoxTree(DockerSandbox("img", workdir=str(work))), LocalTree(work)

async def refused(op) -> None:
with pytest.raises(PermissionError, match="outside"):
await op

async def go():
for t in (tree, local):
for path in ("link.py", "out/secret.py", "rel/secret.py", str(work / "out" / "secret.py")):
await refused(t.read(path))
await refused(t.write(path, "owned\n"))
await refused(t.write("out/new.py", "owned\n")) # a missing file behind a link out
await refused(t.list_dir("out"))
await refused(t.search("secret", "out", None))
assert await t.read("same.py") == "a = 1\n"
assert await t.read("line\nbreak.py") == "lb = 1\n"
assert await t.read("nl/c.py") == "c = 1\n"
await refused(t.read("twin/sub/../secret.py"))
await refused(t.write("twin/sub/../new.py", "owned\n"))
await t.write("sub/b.py", "b = 1\n")
assert await t.list_dir("sub") == ["b.py", "inner/"]
# `ls` opens `out/..` as the kernel does, past the link; `write` opens `hop/../out/x.py` as
# its text normalises, `out/x.py`: each form is bound
await refused(tree.list_dir("out/.."))
await refused(tree.write("hop/../out/x.py", "owned\n"))

asyncio.run(go())
assert sorted(p.name for p in away.iterdir()) == ["secret.py"]
assert sorted(p.name for p in twin.iterdir()) == ["secret.py", "sub"]
assert (away / "secret.py").read_text() == "secret = 1\n"
assert (work / "pkg" / "b.py").read_text() == "b = 1\n"


@pytest.mark.parametrize(
"stdout",
["/w\0/w/a\0/w/a\0tail", "/w\0/w/a\0/w/a", "/w\0/w/a\0", "", "/w\0/w/a\0/w/a\0/w/b\0"],
)
def test_a_resolver_answer_that_is_not_three_nul_ended_paths_is_refused(stdout):
# a fourth record, a missing last NUL, two records, nothing, or bytes past the last NUL: not a frame
class Box:
workdir = "/w"

async def exec(self, argv, timeout=None):
return {"rc": 0, "stdout": stdout, "stderr": ""}

async def go():
with pytest.raises(PermissionError, match="did not resolve"):
await BoxTree(Box()).read("a")

asyncio.run(go())


@pytest.mark.parametrize("timeout", [5.0, None])
@pytest.mark.parametrize("transport", ["daytona", "docker"])
def test_box_timeout_seam(monkeypatch, transport, timeout):
Expand Down Expand Up @@ -174,8 +249,16 @@ async def sdk_stub(command, **kw):
box._box = types.SimpleNamespace(process=types.SimpleNamespace(exec=sdk_stub))
else:
box = DockerSandbox("offline-fixture")
given, exec_ = [], box.exec

async def spy(argv, **kw): # `run` hands the transport the deadline as given, None included
given.append(kw.get("timeout", "absent"))
return await exec_(argv, **kw)

monkeypatch.setattr(box, "exec", spy)
result = asyncio.run(box.run("echo ok", timeout=timeout))
assert result == (0, "ok") and len(calls) == 1
assert given == [timeout] if transport == "docker" else calls[0][1]["timeout"] == timeout


@pytest.mark.parametrize("timeout", [5.0, None])
Expand Down Expand Up @@ -219,3 +302,21 @@ def waited(aw, timeout):
else:
assert sdk_timeout == 20 and waits == [20.0]
assert "timeout -s KILL 5 " in line and "timeout -s KILL 5 echo ok" in " ".join(argv)


def test_the_vm_box_refuses_a_missing_deadline_before_any_command_runs(tmp_path, monkeypatch):
# the docker and remote boxes take None as no deadline (above); the VM's guest `timeout` needs
# seconds, so None fails there before a `container` process starts
spawned = []

async def subprocess_stub(*argv, **kw):
spawned.append(argv)
raise AssertionError("a command ran")

monkeypatch.setattr(asyncio, "create_subprocess_exec", subprocess_stub)
box = Sandbox(tmp_path, "offline-fixture", cli="container")
with pytest.raises(TypeError):
asyncio.run(box.exec(["echo", "ok"], timeout=None))
with pytest.raises(TypeError):
asyncio.run(box.run("echo ok", timeout=None))
assert spawned == []
Loading