Skip to content

Commit d361330

Browse files
committed
fix: require trusted recovery manifest digest
1 parent 95e311b commit d361330

5 files changed

Lines changed: 67 additions & 24 deletions

File tree

‎examples/complete_edit_pilot.py‎

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from __future__ import annotations
88

99
import argparse
10+
import hmac
1011
import json
1112
import os
1213
import shutil
@@ -56,7 +57,10 @@ def _read_regular(path: Path) -> bytes:
5657

5758

5859
def verify_parent_evidence(
59-
interrupted: Path, parent_manifest: Path, source_root: Path
60+
interrupted: Path,
61+
parent_manifest: Path,
62+
expected_parent_manifest_sha256: str,
63+
source_root: Path,
6064
) -> dict[str, bytes]:
6165
"""Verify independently supplied parent identity and exact immutable artifacts."""
6266
parent = interrupted.resolve(strict=True)
@@ -66,6 +70,8 @@ def verify_parent_evidence(
6670
if manifest_resolved.is_relative_to(parent):
6771
raise ValueError("Parent manifest must be supplied outside the parent artifact directory")
6872
manifest_bytes = _read_regular(parent_manifest)
73+
if not hmac.compare_digest(digest_bytes(manifest_bytes), expected_parent_manifest_sha256):
74+
raise ValueError("Parent manifest digest differs from the independently supplied digest")
6975
manifest = json.loads(manifest_bytes)
7076
required = {"schema", "parent_directory", "artifacts"}
7177
if not isinstance(manifest, dict) or set(manifest) != required:
@@ -124,14 +130,19 @@ def missing_cell(rows: list[dict], task_ids: list[str]) -> tuple[str, str, int,
124130

125131

126132
def run(
127-
repo: Path, interrupted: Path, output: Path, tokenizer: Path, parent_manifest: Path
133+
repo: Path,
134+
interrupted: Path,
135+
output: Path,
136+
tokenizer: Path,
137+
parent_manifest: Path,
138+
parent_manifest_sha256: str,
128139
) -> dict:
129140
root = Path(__file__).resolve().parents[1]
130141
if subprocess.check_output(["git", "status", "--porcelain"], cwd=root, text=True).strip():
131142
raise ValueError("Freeze recovery executor before inference")
132143
if output.exists() or (interrupted / "summary.json").exists():
133144
raise ValueError("Use fresh output and an incomplete original pilot")
134-
parent = verify_parent_evidence(interrupted, parent_manifest, root)
145+
parent = verify_parent_evidence(interrupted, parent_manifest, parent_manifest_sha256, root)
135146
protocol = json.loads(parent["protocol.json"])
136147
if digest_bytes(_read_regular(MANIFEST)) != protocol["manifest_sha256"]:
137148
raise ValueError("Frozen manifest changed")
@@ -194,9 +205,7 @@ def run(
194205
"restoration": asdict(restoration),
195206
}
196207
append_row(output / "controls.jsonl", control)
197-
old_controls = [
198-
json.loads(x) for x in parent["controls.jsonl"].decode().splitlines()
199-
]
208+
old_controls = [json.loads(x) for x in parent["controls.jsonl"].decode().splitlines()]
200209
previous = next(c for c in old_controls if c["task_id"] == task_id)
201210
if not (
202211
baseline.passed
@@ -280,13 +289,21 @@ def run(
280289
for name in ("repo", "interrupted", "output", "tokenizer"):
281290
parser.add_argument("--" + name, type=Path, required=True)
282291
parser.add_argument("--parent-manifest", type=Path, required=True)
292+
parser.add_argument("--parent-manifest-sha256", required=True)
283293
parser.add_argument("--allow-local-execution", action="store_true")
284294
args = parser.parse_args()
285295
if not args.allow_local_execution:
286296
parser.error("Explicit local execution acknowledgement is required")
287297
print(
288298
json.dumps(
289-
run(args.repo, args.interrupted, args.output, args.tokenizer, args.parent_manifest),
299+
run(
300+
args.repo,
301+
args.interrupted,
302+
args.output,
303+
args.tokenizer,
304+
args.parent_manifest,
305+
args.parent_manifest_sha256,
306+
),
290307
indent=2,
291308
)
292309
)

‎examples/results/edit-format-pilot/RECOVERY.md‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ task because available disk fell below its prespecified 2 GiB floor. The first
77
discarded or repeated.
88

99
The recovery executor completes only that unattempted condition. Before calling
10-
the model it verifies every original experiment source hash, the task manifest,
11-
the model digest, the unchanged parameters, and a unique 23-cell partial matrix.
10+
the model it verifies an independently recorded SHA-256 digest for the external
11+
parent manifest, every original experiment source hash, the task manifest, the
12+
model digest, the unchanged parameters, and a unique 23-cell partial matrix.
1213
It reconstructs the same prompt from the pinned mutant and repeats the scoring
1314
controls, requiring the original test identifiers. The disk floor remains in
1415
force. An error from the remaining model call counts as its outcome, without a
@@ -18,9 +19,16 @@ interrupted records remain unchanged.
1819
```bash
1920
python -m examples.complete_edit_pilot --repo /path/to/boltons \
2021
--interrupted /path/to/interrupted-run --output /fresh/recovery-run \
21-
--tokenizer /path/to/pinned-qwen-tokenizer.json --allow-local-execution
22+
--tokenizer /path/to/pinned-qwen-tokenizer.json \
23+
--parent-manifest /trusted/path/parent-manifest.json \
24+
--parent-manifest-sha256 "$INDEPENDENTLY_RECORDED_SHA256" \
25+
--allow-local-execution
2226
```
2327

28+
Obtain `INDEPENDENTLY_RECORDED_SHA256` from a trusted record made independently
29+
of the interrupted run and its parent manifest. Computing it from either input
30+
at recovery time does not establish the parent identity and must not be used.
31+
2432
The combined result spans two process windows. It remains an exposed-task
2533
diagnostic, not a heldout comparison. A single validated repair shows that the
2634
interface can work; it does not establish a reliable repair system or a retrieval

‎src/cegraph/parser.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -261,8 +261,8 @@ def parse_python_file(repo_root: Path, file_path: Path) -> ParsedModule:
261261
if file_path.is_symlink() or not file_path.resolve().is_relative_to(repo_root.resolve()):
262262
raise ValueError("Source file must be contained in the repository without symlinks")
263263
before = file_path.lstat()
264-
if not stat.S_ISREG(before.st_mode):
265-
raise ValueError("Source file must be a regular file")
264+
if not stat.S_ISREG(before.st_mode) or before.st_nlink != 1:
265+
raise ValueError("Source file must be a single-link regular file")
266266
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
267267
try:
268268
descriptor = os.open(file_path, flags)
@@ -272,9 +272,10 @@ def parse_python_file(repo_root: Path, file_path: Path) -> ParsedModule:
272272
opened = os.fstat(descriptor)
273273
if (
274274
not stat.S_ISREG(opened.st_mode)
275+
or opened.st_nlink != 1
275276
or (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino)
276277
):
277-
raise ValueError("Source file changed or is not a regular file")
278+
raise ValueError("Source file changed or is not a single-link regular file")
278279
with os.fdopen(descriptor, "r", encoding="utf-8") as handle:
279280
descriptor = -1
280281
source = handle.read()

‎tests/test_edit_pilot_recovery.py‎

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -43,16 +43,14 @@ def test_ambiguous_or_out_of_protocol_recovery_is_rejected(change):
4343
missing_cell(rows, ["a", "b", "c"])
4444

4545

46-
def parent_fixture(tmp_path: Path) -> tuple[Path, Path, Path]:
46+
def parent_fixture(tmp_path: Path) -> tuple[Path, Path, Path, str]:
4747
source_root = tmp_path / "source"
4848
source_root.mkdir()
4949
(source_root / "executor.py").write_text("value = 1\n")
5050
parent = tmp_path / "parent"
5151
parent.mkdir()
5252
protocol = {
53-
"source_sha256": {
54-
"executor.py": digest_bytes((source_root / "executor.py").read_bytes())
55-
}
53+
"source_sha256": {"executor.py": digest_bytes((source_root / "executor.py").read_bytes())}
5654
}
5755
artifacts = {
5856
"protocol.json": json.dumps(protocol).encode(),
@@ -71,14 +69,14 @@ def parent_fixture(tmp_path: Path) -> tuple[Path, Path, Path]:
7169
}
7270
)
7371
)
74-
return source_root, parent, manifest
72+
return source_root, parent, manifest, digest_bytes(manifest.read_bytes())
7573

7674

7775
def test_parent_evidence_requires_independent_manifest_and_contained_regular_files(
7876
tmp_path: Path,
7977
) -> None:
80-
source_root, parent, manifest = parent_fixture(tmp_path)
81-
verified = verify_parent_evidence(parent, manifest, source_root)
78+
source_root, parent, manifest, manifest_digest = parent_fixture(tmp_path)
79+
verified = verify_parent_evidence(parent, manifest, manifest_digest, source_root)
8280
assert set(verified) == {
8381
"parent_manifest",
8482
"protocol.json",
@@ -88,23 +86,33 @@ def test_parent_evidence_requires_independent_manifest_and_contained_regular_fil
8886
inside_manifest = parent / "parent-manifest.json"
8987
inside_manifest.write_bytes(manifest.read_bytes())
9088
with pytest.raises(ValueError, match="outside the parent artifact directory"):
91-
verify_parent_evidence(parent, inside_manifest, source_root)
89+
verify_parent_evidence(parent, inside_manifest, manifest_digest, source_root)
9290

9391
outside = tmp_path / "outside"
9492
outside.write_text("forged\n")
9593
(parent / "results.jsonl").unlink()
9694
(parent / "results.jsonl").symlink_to(outside)
9795
with pytest.raises(ValueError, match="regular file"):
98-
verify_parent_evidence(parent, manifest, source_root)
96+
verify_parent_evidence(parent, manifest, manifest_digest, source_root)
97+
98+
99+
def test_parent_evidence_rejects_manifest_changed_after_digest_is_supplied(
100+
tmp_path: Path,
101+
) -> None:
102+
source_root, parent, manifest, manifest_digest = parent_fixture(tmp_path)
103+
manifest.write_text("{}")
104+
with pytest.raises(ValueError, match="independently supplied digest"):
105+
verify_parent_evidence(parent, manifest, manifest_digest, source_root)
99106

100107

101108
def test_parent_source_paths_cannot_escape_source_root(tmp_path: Path) -> None:
102-
source_root, parent, manifest = parent_fixture(tmp_path)
109+
source_root, parent, manifest, _ = parent_fixture(tmp_path)
103110
protocol = json.loads((parent / "protocol.json").read_text())
104111
protocol["source_sha256"] = {"../outside.py": "0" * 64}
105112
(parent / "protocol.json").write_text(json.dumps(protocol))
106113
payload = json.loads(manifest.read_text())
107114
payload["artifacts"]["protocol.json"] = digest_bytes((parent / "protocol.json").read_bytes())
108115
manifest.write_text(json.dumps(payload))
116+
manifest_digest = digest_bytes(manifest.read_bytes())
109117
with pytest.raises(ValueError, match="contained relative path"):
110-
verify_parent_evidence(parent, manifest, source_root)
118+
verify_parent_evidence(parent, manifest, manifest_digest, source_root)

‎tests/test_graph.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,12 @@ def swap_then_open(path, flags):
7878
monkeypatch.setattr("cegraph.parser.os.open", swap_then_open)
7979
with pytest.raises(ValueError, match="changed or became unsafe"):
8080
parse_python_file(tmp_path, source)
81+
82+
83+
def test_parser_rejects_hardlinked_source(tmp_path: Path) -> None:
84+
source = tmp_path / "source.py"
85+
source.write_text("value = 1\n")
86+
os.link(source, tmp_path / "second-name.py")
87+
88+
with pytest.raises(ValueError, match="single-link regular file"):
89+
parse_python_file(tmp_path, source)

0 commit comments

Comments
 (0)