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
814from __future__ import annotations
915
1016import argparse
17+ import hashlib
1118import json
1219import os
1320import subprocess
1623import tempfile
1724import urllib .error
1825import 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
2234FORBIDDEN_SUBSTRINGS = (
2335 "expected_status" ,
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
3859def _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+
58141def 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+
95192def 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+
109239def 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