Skip to content

Commit 07b106a

Browse files
committed
Harden FormalPR-Holdout asset and harness execution
1 parent a6236c9 commit 07b106a

1 file changed

Lines changed: 173 additions & 24 deletions

File tree

scripts/run_formalpr_holdout.py

Lines changed: 173 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
#!/usr/bin/env python3
22
"""Download a frozen FormalPR-Holdout release and emit aggregate metrics only.
33
4-
Fail-closed: never prints protected labels or case ids. Ordinary OVK CI should
5-
not invoke this without HOLDOUT_DOWNLOAD_TOKEN (or a pre-fetched artifact).
4+
Fail-closed properties:
5+
6+
* remote release assets require an independently supplied SHA-256 digest;
7+
* archive extraction rejects path traversal, links, devices, and special files;
8+
* the downloaded evaluator runs with a minimal environment that contains no
9+
GitHub or holdout download token;
10+
* aggregate output is validated against the public schema and inspected for
11+
protected fields before it is written or printed.
612
"""
713

814
from __future__ import annotations
915

1016
import argparse
17+
import hashlib
1118
import json
1219
import os
1320
import subprocess
@@ -16,8 +23,13 @@
1623
import tempfile
1724
import urllib.error
1825
import urllib.request
19-
from pathlib import Path
26+
from pathlib import Path, PurePosixPath
27+
from typing import Any
28+
29+
from jsonschema import Draft202012Validator
2030

31+
ROOT = Path(__file__).resolve().parents[1]
32+
AGGREGATE_SCHEMA = ROOT / "schemas" / "holdout.aggregate_metrics.schema.json"
2133

2234
FORBIDDEN_SUBSTRINGS = (
2335
"expected_status",
@@ -33,28 +45,99 @@
3345
"syn-invalid-input-01",
3446
"syn-unknown-surface-01",
3547
)
48+
FORBIDDEN_KEY_FRAGMENTS = (
49+
"case_id",
50+
"case_ids",
51+
"expected_",
52+
"ground_truth",
53+
"label",
54+
"diff_text",
55+
"counterexample_text",
56+
)
3657

3758

3859
def _fail(msg: str) -> None:
3960
raise SystemExit(f"fail-closed: {msg}")
4061

4162

42-
def assert_aggregate_safe(payload: dict) -> None:
43-
text = json.dumps(payload)
63+
def _walk_keys(value: Any, *, path: str = "$") -> list[tuple[str, str]]:
64+
findings: list[tuple[str, str]] = []
65+
if isinstance(value, dict):
66+
for key, child in value.items():
67+
key_text = str(key)
68+
lowered = key_text.lower()
69+
if any(fragment in lowered for fragment in FORBIDDEN_KEY_FRAGMENTS):
70+
findings.append((f"{path}.{key_text}", key_text))
71+
findings.extend(_walk_keys(child, path=f"{path}.{key_text}"))
72+
elif isinstance(value, list):
73+
for index, child in enumerate(value):
74+
findings.extend(_walk_keys(child, path=f"{path}[{index}]"))
75+
return findings
76+
77+
78+
def _schema_errors(payload: dict[str, Any]) -> list[str]:
79+
if not AGGREGATE_SCHEMA.is_file():
80+
return [f"aggregate schema missing: {AGGREGATE_SCHEMA}"]
81+
schema = json.loads(AGGREGATE_SCHEMA.read_text(encoding="utf-8"))
82+
validator = Draft202012Validator(schema)
83+
return [
84+
f"{'/'.join(str(part) for part in error.absolute_path) or '$'}: {error.message}"
85+
for error in sorted(validator.iter_errors(payload), key=lambda item: list(item.absolute_path))
86+
]
87+
88+
89+
def assert_aggregate_safe(payload: dict[str, Any]) -> None:
90+
schema_errors = _schema_errors(payload)
91+
if schema_errors:
92+
_fail("aggregate schema validation failed: " + "; ".join(schema_errors))
93+
94+
text = json.dumps(payload, sort_keys=True)
4495
for token in FORBIDDEN_SUBSTRINGS:
4596
if token in text:
4697
_fail(f"aggregate output contains protected token {token!r}")
47-
if payload.get("leakage_guard", {}).get("labels_emitted") is not False:
98+
99+
forbidden_keys = _walk_keys(payload)
100+
if forbidden_keys:
101+
rendered = ", ".join(path for path, _key in forbidden_keys)
102+
_fail(f"aggregate output contains protected field names: {rendered}")
103+
104+
leakage = payload.get("leakage_guard", {})
105+
if leakage.get("labels_emitted") is not False:
48106
_fail("leakage_guard.labels_emitted must be false")
49-
if payload.get("leakage_guard", {}).get("case_ids_emitted") is not False:
107+
if leakage.get("case_ids_emitted") is not False:
50108
_fail("leakage_guard.case_ids_emitted must be false")
109+
if leakage.get("fail_closed") is not True:
110+
_fail("leakage_guard.fail_closed must be true")
111+
112+
reviewer_time = payload.get("reviewer_time")
113+
if isinstance(reviewer_time, dict) and reviewer_time.get("notes"):
114+
_fail("reviewer_time.notes is not permitted in public aggregate output")
115+
51116
if "lanes" not in payload:
52117
_fail("aggregate missing lanes")
53-
# Refuse single pass-rate collapse as the only metric surface.
54118
if set(payload.keys()) <= {"pass_rate", "schema_version", "benchmark"}:
55119
_fail("refusing pass-rate-only payload")
56120

57121

122+
def sha256_file(path: Path) -> str:
123+
digest = hashlib.sha256()
124+
with path.open("rb") as handle:
125+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
126+
digest.update(chunk)
127+
return digest.hexdigest()
128+
129+
130+
def verify_asset_digest(path: Path, expected_sha256: str | None) -> str:
131+
actual = sha256_file(path)
132+
expected = (expected_sha256 or "").strip().lower()
133+
if expected:
134+
if len(expected) != 64 or any(char not in "0123456789abcdef" for char in expected):
135+
_fail("asset SHA-256 must contain exactly 64 hexadecimal characters")
136+
if actual != expected:
137+
_fail(f"holdout asset digest mismatch: expected {expected}, got {actual}")
138+
return actual
139+
140+
58141
def download_release_asset(
59142
*,
60143
repo: str,
@@ -74,6 +157,8 @@ def download_release_asset(
74157
except urllib.error.HTTPError as exc:
75158
_fail(f"cannot read release {tag} from {repo}: HTTP {exc.code}")
76159

160+
if str(release.get("tag_name", "")) != tag:
161+
_fail(f"release API returned unexpected tag {release.get('tag_name')!r}")
77162
asset = next((a for a in release.get("assets", []) if a.get("name") == asset_name), None)
78163
if asset is None:
79164
_fail(f"asset {asset_name!r} not found on release {tag}")
@@ -92,20 +177,65 @@ def download_release_asset(
92177
return dest
93178

94179

180+
def _safe_member_target(dest: Path, member_name: str) -> Path:
181+
pure = PurePosixPath(member_name)
182+
if pure.is_absolute() or any(part in {"", ".", ".."} for part in pure.parts):
183+
_fail(f"unsafe archive member path: {member_name!r}")
184+
target = (dest / Path(*pure.parts)).resolve()
185+
try:
186+
target.relative_to(dest.resolve())
187+
except ValueError:
188+
_fail(f"archive member escapes extraction root: {member_name!r}")
189+
return target
190+
191+
95192
def extract_tarball(tarball: Path, dest: Path) -> Path:
96193
dest.mkdir(parents=True, exist_ok=True)
97194
with tarfile.open(tarball, "r:gz") as tar:
98-
# Python 3.12+ supports filter=; keep compatible call.
99-
try:
100-
tar.extractall(dest, filter="data")
101-
except TypeError:
102-
tar.extractall(dest)
103-
roots = [p for p in dest.iterdir() if p.is_dir()]
195+
members = tar.getmembers()
196+
if not members:
197+
_fail("holdout release archive is empty")
198+
for member in members:
199+
target = _safe_member_target(dest, member.name)
200+
if member.issym() or member.islnk() or member.isdev() or member.isfifo():
201+
_fail(f"archive contains forbidden special member: {member.name!r}")
202+
if member.isdir():
203+
target.mkdir(parents=True, exist_ok=True)
204+
continue
205+
if not member.isfile():
206+
_fail(f"archive contains unsupported member type: {member.name!r}")
207+
target.parent.mkdir(parents=True, exist_ok=True)
208+
source = tar.extractfile(member)
209+
if source is None:
210+
_fail(f"cannot read archive member: {member.name!r}")
211+
with source, target.open("wb") as output:
212+
while True:
213+
chunk = source.read(1024 * 1024)
214+
if not chunk:
215+
break
216+
output.write(chunk)
217+
218+
roots = [path for path in dest.iterdir() if path.is_dir()]
104219
if len(roots) != 1:
105220
_fail(f"expected one release root, found {len(roots)}")
106221
return roots[0]
107222

108223

224+
def _harness_environment(home: Path) -> dict[str, str]:
225+
home.mkdir(parents=True, exist_ok=True)
226+
env = {
227+
"HOME": str(home),
228+
"LANG": os.environ.get("LANG", "C.UTF-8"),
229+
"LC_ALL": os.environ.get("LC_ALL", "C.UTF-8"),
230+
"PATH": os.environ.get("PATH", ""),
231+
"PYTHONHASHSEED": "0",
232+
"PYTHONDONTWRITEBYTECODE": "1",
233+
}
234+
# Deliberately omit GITHUB_TOKEN, HOLDOUT_DOWNLOAD_TOKEN, cloud credentials,
235+
# signing keys, and every other inherited variable.
236+
return env
237+
238+
109239
def run_harness(
110240
*,
111241
release_root: Path,
@@ -114,12 +244,10 @@ def run_harness(
114244
ovk_sha: str,
115245
verified_sha: str | None,
116246
output: Path,
117-
) -> dict:
247+
) -> dict[str, Any]:
118248
evaluate = release_root / "harness" / "evaluate.py"
119249
if not evaluate.is_file():
120250
_fail("release artifact missing harness/evaluate.py")
121-
# Preferred layout: corpus/cases + corpus/labels (v0.1.0+).
122-
# Legacy fallback: cases/ + labels/ at release root.
123251
if (release_root / "corpus" / "cases").is_dir():
124252
corpus_root = release_root / "corpus"
125253
labels_dir = release_root / "corpus" / "labels"
@@ -128,30 +256,42 @@ def run_harness(
128256
labels_dir = release_root / "labels"
129257
else:
130258
_fail("release artifact missing corpus/cases or cases/")
259+
if not labels_dir.is_dir():
260+
_fail("release artifact missing labels directory")
131261

132262
cmd = [
133263
sys.executable,
264+
"-I",
134265
str(evaluate),
135266
"--corpus-root",
136267
str(corpus_root),
137268
"--labels-dir",
138269
str(labels_dir),
139270
"--predictions",
140-
str(predictions),
271+
str(predictions.resolve()),
141272
"--holdout-release-tag",
142273
holdout_tag,
143274
"--ovk-commit-sha",
144275
ovk_sha,
145276
"--output",
146-
str(output),
277+
str(output.resolve()),
147278
]
148279
if verified_sha:
149280
cmd.extend(["--verified-source-sha", verified_sha])
150-
# Do not pass --print-aggregates; we load the file and re-sanitize.
151-
proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
281+
282+
proc = subprocess.run(
283+
cmd,
284+
cwd=str(release_root),
285+
env=_harness_environment(output.parent / "harness-home"),
286+
capture_output=True,
287+
text=True,
288+
check=False,
289+
timeout=300,
290+
)
152291
if proc.returncode != 0:
153-
# Avoid echoing stderr if it might contain paths with case ids — redact.
154292
_fail(f"evaluate.py exited {proc.returncode}")
293+
if not output.is_file():
294+
_fail("evaluate.py did not produce aggregate output")
155295
payload = json.loads(output.read_text(encoding="utf-8"))
156296
assert_aggregate_safe(payload)
157297
return payload
@@ -166,6 +306,11 @@ def main(argv: list[str] | None = None) -> int:
166306
default=None,
167307
help="Defaults to FormalPR-Holdout-<tag>.tar.gz",
168308
)
309+
parser.add_argument(
310+
"--asset-sha256",
311+
default=None,
312+
help="Expected immutable SHA-256. Required for remote downloads.",
313+
)
169314
parser.add_argument(
170315
"--artifact",
171316
type=Path,
@@ -185,6 +330,7 @@ def main(argv: list[str] | None = None) -> int:
185330

186331
token = os.environ.get("HOLDOUT_DOWNLOAD_TOKEN") or os.environ.get("GITHUB_TOKEN")
187332
asset_name = args.asset_name or f"FormalPR-Holdout-{args.tag}.tar.gz"
333+
expected_digest = args.asset_sha256 or os.environ.get("HOLDOUT_ASSET_SHA256")
188334

189335
with tempfile.TemporaryDirectory(prefix="ovk-holdout-") as tmp:
190336
tmp_path = Path(tmp)
@@ -198,13 +344,16 @@ def main(argv: list[str] | None = None) -> int:
198344
"HOLDOUT_DOWNLOAD_TOKEN (or GITHUB_TOKEN) required to download "
199345
"private FormalPR-Holdout release assets"
200346
)
347+
if not expected_digest:
348+
_fail("HOLDOUT_ASSET_SHA256 or --asset-sha256 is required for remote holdout assets")
201349
tarball = download_release_asset(
202350
repo=args.repo,
203351
tag=args.tag,
204352
asset_name=asset_name,
205353
dest=tmp_path / asset_name,
206354
token=token,
207355
)
356+
verify_asset_digest(tarball, expected_digest)
208357
release_root = extract_tarball(tarball, tmp_path / "extract")
209358
payload = run_harness(
210359
release_root=release_root,
@@ -216,15 +365,15 @@ def main(argv: list[str] | None = None) -> int:
216365
)
217366

218367
args.output.parent.mkdir(parents=True, exist_ok=True)
219-
args.output.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
368+
args.output.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
220369
summary = (
221370
f"FormalPR-Holdout aggregates ok: {payload.get('cases_scored')} cases, "
222371
f"{len(payload.get('lanes', {}))} lanes, tag={args.tag}. Labels not emitted."
223372
)
224373
print(summary)
225374
if args.print_aggregates:
226375
assert_aggregate_safe(payload)
227-
print(json.dumps(payload, indent=2))
376+
print(json.dumps(payload, indent=2, sort_keys=True))
228377
return 0
229378

230379

0 commit comments

Comments
 (0)