Skip to content

Commit 79edfe9

Browse files
committed
Phase 1.6: example configs, README, end to end test
Two example configs, one with everything undeclared and one declaring git_remote:branch_name so the README can show an AUTHORIZED row. The README now carries the real table from a real run on this box, verbatim, along with what each verdict and each persistence class means. The paragraph that matters is the one about the transient row. deleted_ref passes because Run A created a ref and deleted it, so nothing is visible to a later check of remote state, and the channel was open at the time it was used. A probe of visible state cannot see that. Saying so in the README is more useful than quietly reporting a PASS. The end to end test runs the CLI as a subprocess from a different working directory, against the example config, and reads the JSON off disk. It skips when git is missing rather than failing. Version moves to 0.1.0.dev0 in both pyproject.toml and __init__.py. The em dash scan, in the test and in CI, now covers examples/ too.
1 parent 05a8a4d commit 79edfe9

8 files changed

Lines changed: 255 additions & 18 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ jobs:
3535
run: |
3636
# The pattern is written as a bash escape so this workflow file stays
3737
# ASCII and does not match itself.
38-
if grep -rn --binary-files=without-match $'\u2014' src tests README.md pyproject.toml .github; then
38+
if grep -rn --binary-files=without-match $'\u2014' src tests examples README.md pyproject.toml .github; then
3939
echo "em dash found, see the matches above (house rule: use commas, colons, or parentheses)"
4040
exit 1
4141
fi

README.md

Lines changed: 116 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,11 @@ Agent sandboxes isolate machines. Nothing isolates agent runs.
55
runprobe answers one deterministic question: can one supposedly isolated agent
66
run leave information somewhere another supposedly isolated run can recover it?
77

8-
**Status: v0.0.1, Phase 0, no adapters yet.**
8+
**Status: v0.1.0-dev, Phase 1, filesystem and git_remote adapters.**
99

10-
Phase 0 is the skeleton only: package layout, CLI, config loader, run context,
11-
report writer, tests, CI. There are no surface adapters, so `runprobe probe`
12-
cannot yet probe anything and exits non-zero by design. A probe that reported
13-
PASS without having probed anything would be worse than no probe at all.
10+
Two surfaces can be probed today: a shared directory and a shared git remote,
11+
thirteen carriers between them. The `http_cache` adapter and the Artifactory
12+
mailbox fixture are not built yet.
1413

1514
## Install
1615

@@ -27,17 +26,18 @@ Python 3.11 or newer.
2726

2827
```
2928
runprobe --version
30-
runprobe probe --config surfaces.json --report runprobe-report.json
29+
runprobe probe --config examples/surfaces.json --report runprobe-report.json
3130
```
3231

3332
`surfaces.json` describes the shared surfaces you want checked:
3433

3534
```json
3635
{
3736
"surfaces": [
38-
{ "name": "shared_tmp", "type": "filesystem", "params": { "path": "/tmp" } }
37+
{ "name": "filesystem", "type": "filesystem" },
38+
{ "name": "git_remote", "type": "git_remote" }
3939
],
40-
"declared": ["shared_tmp:file_content"]
40+
"declared": ["git_remote:branch_name"]
4141
}
4242
```
4343

@@ -48,17 +48,113 @@ runprobe probe --config surfaces.json --report runprobe-report.json
4848
reported AUTHORIZED rather than FAIL.
4949

5050
Config validation fails closed. An unknown key, an empty surface list, a
51-
duplicate surface name, a malformed `declared` entry, or an unknown surface type
52-
is an error with a non-zero exit, never a warning. In Phase 0 the surface type
53-
registry is empty, so every `type` value is currently rejected.
51+
duplicate surface name, a malformed `declared` entry, an unknown surface type,
52+
an unknown adapter param, or a `declared` entry naming a carrier that does not
53+
exist are all errors with a non-zero exit, never warnings.
54+
55+
`--keep-work` prints the temporary work directory and leaves it in place, which
56+
is the way to look at what Run A actually planted.
57+
58+
### Surfaces
59+
60+
`filesystem`: a directory both runs can reach.
61+
62+
| Param | Default | Meaning |
63+
|-------|---------|---------|
64+
| `path` | a directory the probe creates and deletes | the shared directory to probe |
65+
| `content_reads` | `true` | when false, Run B lists but never opens a file |
66+
| `check_xattr` | `true` | when false, the extended attribute carrier is skipped |
67+
68+
`git_remote`: a bare repository both runs can reach. Needs `git` on PATH and
69+
reports every carrier SKIPPED if it is missing.
70+
71+
| Param | Default | Meaning |
72+
|-------|---------|---------|
73+
| `remote` | a bare repo the probe creates and deletes | a local path to a bare repo. URLs are rejected in this version |
74+
| `check_dangling` | `true` | when false, the dangling object carrier is skipped |
75+
76+
Given a `remote`, the probe pushes a branch named `runprobe-probe` rather than
77+
touching a branch you already have, and deletes the refs it pushed afterwards.
78+
The dangling commit survives cleanup, which is what until-gc means.
79+
80+
## Example run
81+
82+
`runprobe probe --config examples/surfaces.json`, on a Linux box with git 2.53
83+
and a filesystem that supports extended attributes:
84+
85+
```
86+
SURFACE CARRIER VERDICT PERSISTENCE DETAIL
87+
---------- --------------- ------- ----------- -----------------------------------------------------------------------------------------------------------------
88+
filesystem file_content FAIL durable nonce in the bytes of notes.txt
89+
filesystem file_name FAIL durable nonce in the file name zzFILE_5ba4b7d73d3d76cc
90+
filesystem directory_name FAIL durable nonce in the directory name zzHELP_5ba4b7d73d3d76cc_SEEK_IDEA
91+
filesystem symlink_target FAIL durable nonce in the target of cache-link, which resolves to nothing
92+
filesystem xattr FAIL durable nonce in user.runprobe on plain.txt
93+
git_remote branch_name FAIL durable nonce in the branch name refs/heads/msg-5ba4b7d73d3d76cc, seen by ls-remote
94+
git_remote tag_name FAIL durable nonce in the tag name refs/tags/zz-5ba4b7d73d3d76cc, seen by ls-remote
95+
git_remote commit_message FAIL durable nonce in the commit message: sync note zzMSG_5ba4b7d73d3d76cc for the next run
96+
git_remote author_name FAIL durable nonce in the author name: zzAUTH_5ba4b7d73d3d76cc
97+
git_remote file_content FAIL durable nonce in the bytes of data.txt
98+
git_remote file_path FAIL durable nonce in the tracked path notes/zz-5ba4b7d73d3d76cc.txt
99+
git_remote deleted_ref PASS transient the created and then deleted ref is not in ls-remote, which a visible-state check cannot see and watch mode would
100+
git_remote dangling_object FAIL until-gc nonce in the message of dangling commit 2c24ed2a40b3, unreachable but present
101+
```
102+
103+
Exit code 1.
104+
105+
Twelve of thirteen carriers are open, which is the expected result: these two
106+
surfaces are shared on purpose, and the probe is showing you what "shared"
107+
actually buys anyone who wants to use it. The interesting rows are the ones
108+
that are not file content. Four of the five filesystem carriers are names, and
109+
blocking content reads (`"content_reads": false`) closes exactly one of them.
110+
111+
With `examples/surfaces-declared.json`, which declares
112+
`git_remote:branch_name`, that row reads as follows. It is a separate run, so
113+
the nonce differs:
114+
115+
```
116+
git_remote branch_name AUTHORIZED durable nonce in the branch name refs/heads/msg-d71693e752ae38b8, seen by ls-remote
117+
```
118+
119+
The exit code is still 1, because twelve other carriers are still undeclared.
120+
121+
## Reading the table
122+
123+
One row per (surface, carrier). A carrier is one channel through a surface, not
124+
the surface as a whole, because a surface is almost never open or closed as a
125+
unit.
126+
127+
| Verdict | Meaning |
128+
|---------|---------|
129+
| FAIL | Run B recovered the nonce Run A planted, on a channel nobody authorized |
130+
| PASS | Run B did not recover the nonce on this channel |
131+
| AUTHORIZED | recovered, and this channel is in `declared`, so it does not fail the run |
132+
| SKIPPED | the channel could not be tested here, for example xattrs on a filesystem without them, or any git carrier without git. Never treat this as PASS |
133+
| ERROR | the adapter failed, or did not report a carrier it declared. Treated as a failure, never as a pass |
134+
135+
The persistence column says how long a recovered carrier survives.
136+
137+
| Class | Meaning |
138+
|-------|---------|
139+
| durable | it stays until somebody deletes it |
140+
| transient | it is gone by the time a later check looks, like a ref created and deleted between the two runs |
141+
| until-gc | it is unreachable but still stored, and stays readable until garbage collection removes it |
142+
| unknown | not classifiable without knowing the operator's retention policy |
143+
144+
A PASS on a transient carrier is worth reading twice. `git_remote:deleted_ref`
145+
passes because Run A created a ref and deleted it, so nothing is visible to a
146+
later check of remote state. The channel was open at the time it was used. A
147+
probe of visible state cannot see that, and the row is there to say so: this is
148+
the case that needs the write-side correlation of watch mode, not a better
149+
listing.
54150

55151
Exit codes:
56152

57153
| Code | Meaning |
58154
|------|---------|
59155
| 0 | the probe ran and found no undeclared channel |
60156
| 1 | the probe ran and found a FAIL or an ERROR |
61-
| 2 | the probe could not run (bad config, or no adapters registered) |
157+
| 2 | the probe could not run (bad config, or the report could not be written) |
62158

63159
## Why
64160

@@ -109,8 +205,14 @@ These are non-claims, stated up front.
109205
it does not.
110206
- **No runtime enforcement.** Watch mode, meaning server-side correlation of
111207
writes and reads across run identities, is not in v0.1.
112-
- Phase 0 additionally ships **no surface adapters**, so nothing can be probed
113-
yet.
208+
- **Two surfaces so far.** Phase 1 ships the `filesystem` and `git_remote`
209+
adapters. The `http_cache` adapter, which is the one that reproduces the July
210+
2026 incident directly, is not built yet.
211+
- **Run A and Run B are subprocesses, not separate users or containers.** They
212+
get disjoint working directories, disjoint temp directories, and an
213+
environment scrubbed to four variables, and every read and write against a
214+
surface happens inside one of them. They still share a uid. Stronger
215+
separation is deferred, and stated here rather than implied away.
114216

115217
## Dependencies
116218

examples/surfaces-declared.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"surfaces": [
3+
{ "name": "filesystem", "type": "filesystem" },
4+
{ "name": "git_remote", "type": "git_remote" }
5+
],
6+
"declared": ["git_remote:branch_name"]
7+
}

examples/surfaces.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"surfaces": [
3+
{ "name": "filesystem", "type": "filesystem" },
4+
{ "name": "git_remote", "type": "git_remote" }
5+
]
6+
}

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "runprobe"
7-
version = "0.0.1"
7+
version = "0.1.0.dev0"
88
description = "Cross-run isolation probing for autonomous agent environments"
99
readme = "README.md"
1010
requires-python = ">=3.11"

src/runprobe/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@
44
information somewhere another supposedly isolated run can recover it?
55
"""
66

7-
__version__ = "0.0.1"
7+
__version__ = "0.1.0.dev0"
88

99
__all__ = ["__version__"]

tests/test_end_to_end.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""End to end: the installed CLI, the example configs, a real filesystem, a real git.
2+
3+
Everything else in the suite calls into the package. This runs the command the
4+
way an operator runs it, from a different working directory, and reads the JSON
5+
report off disk.
6+
"""
7+
8+
import json
9+
import shutil
10+
import subprocess
11+
import sys
12+
from pathlib import Path
13+
14+
import pytest
15+
16+
REPO_ROOT = Path(__file__).resolve().parent.parent
17+
EXAMPLES = REPO_ROOT / "examples"
18+
19+
needs_git = pytest.mark.skipif(shutil.which("git") is None, reason="git not on PATH")
20+
21+
22+
def run_cli(args, cwd):
23+
return subprocess.run(
24+
[sys.executable, "-m", "runprobe.cli", *args],
25+
cwd=cwd,
26+
capture_output=True,
27+
text=True,
28+
timeout=300,
29+
)
30+
31+
32+
def findings_of(report_path):
33+
data = json.loads(Path(report_path).read_text(encoding="utf-8"))
34+
return data, {f"{f['surface']}:{f['carrier']}": f for f in data["findings"]}
35+
36+
37+
@pytest.fixture(scope="module")
38+
def example_run(tmp_path_factory):
39+
cwd = tmp_path_factory.mktemp("e2e")
40+
result = run_cli(["probe", "--config", str(EXAMPLES / "surfaces.json")], cwd)
41+
return result, cwd
42+
43+
44+
@needs_git
45+
def test_the_example_config_exits_one(example_run):
46+
result, _ = example_run
47+
assert result.returncode == 1, result.stderr
48+
49+
50+
@needs_git
51+
def test_the_report_lands_at_the_default_path_in_the_working_directory(example_run):
52+
_, cwd = example_run
53+
assert (cwd / "runprobe-report.json").is_file()
54+
55+
56+
@needs_git
57+
def test_the_table_goes_to_stdout(example_run):
58+
result, _ = example_run
59+
assert result.stdout.splitlines()[0].startswith("SURFACE")
60+
assert "report written to" in result.stderr
61+
62+
63+
@needs_git
64+
def test_the_name_carriers_on_both_surfaces_fail(example_run):
65+
"""The two rows that matter most: neither one is a file content read."""
66+
_, cwd = example_run
67+
_, findings = findings_of(cwd / "runprobe-report.json")
68+
assert findings["filesystem:directory_name"]["verdict"] == "FAIL"
69+
assert findings["git_remote:branch_name"]["verdict"] == "FAIL"
70+
71+
72+
@needs_git
73+
def test_the_deleted_ref_passes(example_run):
74+
_, cwd = example_run
75+
_, findings = findings_of(cwd / "runprobe-report.json")
76+
assert findings["git_remote:deleted_ref"]["verdict"] == "PASS"
77+
assert findings["git_remote:deleted_ref"]["persistence"] == "transient"
78+
79+
80+
@needs_git
81+
def test_the_report_covers_all_thirteen_carriers(example_run):
82+
_, cwd = example_run
83+
data, findings = findings_of(cwd / "runprobe-report.json")
84+
assert len(findings) == 13
85+
assert data["schema_version"] == 1
86+
assert len(data["nonce"]) == 16
87+
assert data["run_id_a"] != data["run_id_b"]
88+
89+
90+
@needs_git
91+
def test_the_nonce_is_not_in_the_report_findings(example_run):
92+
"""The report says where the nonce was found, and shows it. That is the evidence."""
93+
_, cwd = example_run
94+
data, findings = findings_of(cwd / "runprobe-report.json")
95+
assert data["nonce"] in findings["filesystem:directory_name"]["detail"]
96+
97+
98+
@needs_git
99+
def test_the_declared_example_authorizes_one_row(tmp_path):
100+
result = run_cli(["probe", "--config", str(EXAMPLES / "surfaces-declared.json")], tmp_path)
101+
assert result.returncode == 1, result.stderr
102+
_, findings = findings_of(tmp_path / "runprobe-report.json")
103+
assert findings["git_remote:branch_name"]["verdict"] == "AUTHORIZED"
104+
assert findings["git_remote:tag_name"]["verdict"] == "FAIL"
105+
106+
107+
@needs_git
108+
def test_the_probe_leaves_nothing_behind_in_the_working_directory(tmp_path):
109+
run_cli(["probe", "--config", str(EXAMPLES / "surfaces.json")], tmp_path)
110+
assert sorted(p.name for p in tmp_path.iterdir()) == ["runprobe-report.json"]
111+
112+
113+
def test_the_example_configs_are_valid_json_and_load():
114+
from runprobe.config import load
115+
116+
for name in ("surfaces.json", "surfaces-declared.json"):
117+
config = load(EXAMPLES / name)
118+
assert [s.type for s in config.surfaces] == ["filesystem", "git_remote"]
119+
120+
assert load(EXAMPLES / "surfaces-declared.json").is_declared("git_remote", "branch_name")
121+
assert load(EXAMPLES / "surfaces.json").declared == []

tests/test_no_em_dashes.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
REPO_ROOT = Path(__file__).resolve().parent.parent
1818

19-
SCANNED_PREFIXES = ("src/", "tests/")
19+
SCANNED_PREFIXES = ("src/", "tests/", "examples/")
2020

2121

2222
def tracked_files():
@@ -51,6 +51,7 @@ def test_scan_covers_the_expected_files():
5151
assert "README.md" in names
5252
assert any(name.startswith("src/runprobe/") for name in names)
5353
assert any(name.startswith("tests/") for name in names)
54+
assert any(name.startswith("examples/") for name in names)
5455

5556

5657
def test_no_em_dashes_in_tracked_source_and_docs():

0 commit comments

Comments
 (0)