Harden unified evaluator against candidate tampering - #110
Merged
Merged
Conversation
Close the three deterministic holes in the readonly-fingerprint machinery, then enforce read-only at the filesystem level instead of only detecting changes afterwards. - Fingerprint __pycache__/.pyc entries instead of ignoring them (a stale .pyc shadows its .py at import time); paired with PYTHONDONTWRITEBYTECODE=1 so a run never flags its own caches. - Reject readonly_files.txt entries that resolve outside the sandbox; the entry is task-supplied data and could previously hash /etc/hostname or a sibling file. - Lock the write bit on readonly paths before the run and restore it in finally, so a rewritten scorer fails at the write instead of being discovered after it already scored. - Drop the login shell (-lc -> -c): profile scripts are attacker-writable state and are otherwise sourced into the scoring shell. - Align the code default of parse_stdout_json with the yaml (False), since parsing combined_score from candidate-authored stdout is a spoofing path. Adds the first test coverage for the fingerprint machinery.
ReactionOptimisation is the worst single-point defect in the codebase: the evaluator adopted the score the candidate wrote into its own summary (one archived submission literally did summary["score"] = 100.0 and got it). The domain's fourth task, dtlz2_pareto, already recomputed summarize(history) and validated the candidate's claimed summary against it -- but that pattern was never backported to the other three. Backport it: recompute the score from the experiment history the emulator actually produced, instead of trusting a candidate-authored scalar. Also add benchmarks/_shared/candidate_sandbox.py, the reusable subprocess isolation helper derived from TopologyOptimization (the one benchmark that got it right) plus PIDTuning's three-layer output validation, with functional tests.
This task is where the archived exploit scored 1.0 by reporting a negative base cycle time (t = -1.0), which turned the shared fixed-cost term into a "rebate" and maxed all three sub-scores. The candidate now runs in a subprocess via candidate_sandbox and writes submission.json; the evaluator never exec_modules it. The scorer validates base_cycle_time > 0, order_multiples as positive integers of the right length, and recomputes order_quantities itself. A candidate cannot make its own numbers drive the score anymore. Verified: the honest baseline still scores its published value (0.3034), and a negative-cycle submission scores 0 with an explicit candidate_error.
… output All five tasks loaded baseline/init.py into the scoring process (sys.path injection + `from baseline.init import solve`) and adopted whatever the candidate returned without checking it. A submission reporting a negative base cycle time scored 1.0. Each evaluate.py now runs the candidate through benchmarks/_shared/ candidate_sandbox.py, which copies it into a throwaway directory so the task tree (including verification/reference.py) is not reachable, and reads back only submission.json. The scorer validates shape and bounds itself and recomputes every derived quantity rather than trusting the reported one. Honest baselines score bit-identical values to before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Beyond the shared exec_module problem, these seven tasks let the candidate supply the instance data the schedule was scored against, so an easy instance could be substituted for the real one. The feasibility checker was already correct; it was checking a schedule against the wrong problem. The instance is now loaded by the evaluator from the task's own data and handed to the candidate as a read-only input. The candidate runs in a subprocess and returns only a schedule, which is checked for feasibility and scored against the instance the evaluator holds. Task.md/README updated to state the new contract in both languages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The seven subtasks shared one submission module that was imported into the scorer. It now runs as a subprocess against a schema'd JSON contract, and evaluate_submission.py recomputes each metric instead of reading the value the submission reports. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… fiber, phase) Twelve tasks handed the candidate ownership of the problem, the physics and the metric all at once: validate.py imported baseline/init.py and then called the candidate's own build_problem(), forward model and scoring functions -- and scored the oracle with the candidate's ruler too, so the models on the leaderboard were never measured against the same scale. The problem is now built by verification/problem.py, handed to the candidate as read-only inputs, and the candidate returns only its decision variables (a phase map, a power allocation, a schedule). Everything else it writes is dropped, with the discarded key names recorded under contract.ignored_submission_keys for audit. verification/metrics.py runs the forward model and computes the score, and the oracle goes through the same functions. Shared logic lives in benchmarks/Optics/_shared/, outside every benchmark directory, so no copy_files.txt entry can pull it into the sandbox. Two archived exploits are reproduced as tests and no longer pay: the Dammann tanh(64*core/scale) squeeze on cv_orders, and the self-consistent target that let a flat phase map claim ~100%. Honest baselines score bit-identical values. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
subprocess.run had no timeout on any of the three stages. The unified harness does cap the whole eval_command (evaluator/python.py), so a hang was already scored as a failure -- but as an opaque kill of the wrapper, with no record of which stage hung and no metrics.json written at all. Each stage now runs under FRONTIER_EVAL_EVALUATOR_TIMEOUT_S (default 1800s) and a timeout is recorded as that named failed stage with combined_score 0. This also bounds the script when it is invoked directly rather than through the harness. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same gap as MolecularMechanics: subprocess.run had no timeout in all five tasks. The harness's own eval_command cap already prevented an unbounded hang; this makes the failure legible (metrics.json is still written, with the error recorded) and bounds direct invocation. The existing `except Exception` already catches TimeoutExpired, so no new branch is needed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…oring it All three tasks scored a candidate on gate count and depth without ever checking the circuit still computed the same thing, and utils.load_solver() imported the candidate into the scoring process. An empty circuit scored ~6.39, matching the 6.5079 that tops the archived leaderboard. An equivalence gate now runs before any metric is computed, on the canonical circuit that gets scored rather than the raw one, and a failure is invalid (combined_score -1e18), not a low score: task_02 (3-5q) compares the full unitary exactly; task_01/03 sample state fidelity on |0..0> plus four Haar-random states. The random states are load-bearing: qftentangled's output is a superposition of two product states that ~n CNOTs can fake, so a |0..0>-only check passes that shortcut. A test asserts it passes with |0..0> alone and is rejected once the random states are added. The candidate now runs via benchmarks/_shared/qiskit_candidate_runner.py and exchanges QASM3, so a QuantumCircuit subclass that lies about count_ops/depth has nothing to lie to. Circuits wider than their input must carry a layout; the endpoint mapping is recovered from the measurement map where one exists rather than from the candidate's declaration. Also: task_03's own shipped baseline was exploiting the missing gate, using approximation_degree=0.95 to cut 247 two-qubit gates to 214 at a fidelity of 0.23. Removed. All three baselines now pass at fidelity 1.000000000000. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ite values Both tasks exec_module'd the candidate into the evaluator, so a submission could monkeypatch _simulate and report combined_score 999.0 with a 7C current. Reproduced against a reconstructed pre-fix evaluator to confirm the tests discriminate: 999.0 before, voltage_cutoff after. The candidate now runs in a subprocess and returns only its two arrays (currents_c, switch_soc) as JSON; every cutoff -- voltage, plating margin, temperature, time -- is evaluated in the parent, where the candidate has no code. The runner rejects a candidate that calls sys.exit(0) during import to short-circuit it and leave a submission.json of its own. Validation now rejects bool, NaN and Inf explicitly. This matters: json round- trips NaN/Infinity literals by default, and NaN compares false against every bound, so which branch caught it was luck. Profile gains a hard plating-loss cutoff (0.015 Ah, 0.5% of nominal) to match SPMe, which already had one. Its soft term only cost 5.63 of 100 points there while time was weighted 0.5, so trading half a percent of capacity per cycle for charge time was net-positive under the old scoring. A 20k-sample sweep of the feasible region tops out at 9.48e-06 Ah -- 1580x below the cap -- so this is a guardrail, not a new scoring term. Honest baselines score bit-identical values (66.16356426696784 / 71.28056205398363). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Risk limits were enforced by multiplying the score by (1 - penalty), computed inside the scoring process the candidate was exec_module'd into. Deleting the penalty was worth ~100/100: the MIP task's constraint-violating solution has an objective of 1895 against the reference's 139418 and still scored 100 once the multiplier was gone. Violating a constraint now zeroes the instance and invalidates the run. Tolerances are the worst residual a reference-grade convex solver actually produces, times ten. Turnover is 1e-4 rather than 1e-6 deliberately: it is an L1 sum over 50/26 terms and first-order solver error accumulates, so 1e-6 would fail the reference solution itself on 2 of 10 seeds. At 1e-4 against a 0.2 limit that buys at most 0.1 points. The candidate runs in a subprocess with FRONTIER_* stripped from its environment and returns only weight/lot vectors. The oracle is no longer imported at scoring time: the reference objectives are frozen as constants, regenerable with --regenerate-reference-table (verified bit-identical), and reference.py is out of agent_files/copy_files. All three honest cvxpy solutions now score 100.0000 (from 99.9992 / 99.9964 / 100.0000), and a solver that merely relaxes the turnover limit by 1% scores 0 -- a test asserts its objective really is better than the reference's, so the zero is the gate working, not a bad solution. Side effect that changes published baselines: all three shipped baselines were themselves infeasible and only scored at all because the penalty was soft. Repaired, they move 32.98 -> 58.38, 17.92 -> 19.47, and 37.50 -> 99.96. The MIP number means that task now has almost no headroom left; its anchors need retuning. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mm.c is compiled into mdriver, and the score was scraped from mdriver's stdout -- the parser took the last line matching "Score = ... = N/100". Six lines in mm.c printing that string from an atexit handler was 100/100. Verified against the pre-fix parser on byte-identical mdriver output: 100.0 then, 28.145173 now. mdriver now writes a JSON record to a path given by -o, stamped with a per-run token that run_eval.sh generates and hands it on stdin. read_run_token() is the first statement in main() and closes stdin before anything can reach the allocator. The token is never exported and never on disk while mdriver runs. The parser scores that record only, and only if the token matches. A candidate that eats stdin to steal the token now fails loudly (exit 2, score 0) instead of passing silently. NOT FIXED, and deliberately not papered over: a candidate that steals the token pre-main, replays it onto fd 0 with dup2 so main() still starts, reads the -o path from /proc/self/cmdline and forges the record from atexit still scores 100. That is in frontier_eval/known_exploit_token_replay.c and asserted by a strict xfail, so it is a fact under test rather than an assumption. Closing it needs the allocator outside the grading process, which an allocator benchmark cannot do. Task.md/README say so in both languages. Also: readonly_files covered only the traces, so mdriver.c, the Makefile and the support sources were unprotected. Listed file by file, since the framework recurses and `make` must still write *.o into that directory. One behaviour change worth noting: the published score is no longer quantized. The old channel was mdriver's "%.0f" printf, so scores were integers -- 101 levels for an evolutionary search to climb. The record carries full precision (28.145173 where the old pipeline said 28). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… system These four tasks let the candidate build the optical system and hand back an object the evaluator then measured. The archived gpt-5.4 submission returned a _LookupSystem whose measure_at_z() returned the target field itself and scored 0.9999999999. The candidate now returns arrays in an .npz and nothing else: phases, or thickness in metres for the multispectral task. Three separate things now stop that exploit -- the contract has no "output field" field to forge, np.load runs with allow_pickle=False so an object with a measure_at_z method cannot be deserialized at all, and the target is built by the scorer. Replayed through the full run_eval.sh, the original exploit gets combined_score -1e18. Problem construction moves to verification/problem_spec.py, which also owns the scoring constants; the forward physics moves to benchmarks/_shared/optics_holographic.py. The metric formulas are unchanged. Also fixes a real bug: multispectral_focusing's baseline called PolychromaticPhaseModulator without the required `n`, so the task raised TypeError on the installed torchoptics 1.0.2 and could never have run. Its decision variable is now physical thickness with n=1.5, which is what the shared-hardware premise actually means -- so that task's scores are not comparable with its historical ones. The other three are. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cs recomputed Four tasks with four different candidate/evaluator couplings, all exec_module'd into the scoring process: DiffSim passes a simulate_fn callback, so the candidate now returns only knots and a call count and the parent recomputes loss/feasibility with its own canonical.simulate. CoFlyers returns a parameter dict and the parent re-runs all 8 physics cases. EV2Gym is closed-loop (~112 steps x 3 cases), so the candidate runs as a resident subprocess answering one action per step over a dedicated pipe -- env, reward and get_statistics stay in the parent, and the action still goes through the range check. SustainDC is scored as an improvement over a NoOp reference, and the reference was computed in the same process as the candidate. Reproduced: a candidate behaving byte-identically to NoOp, wrapping benchmark_core.run_episode at module level to multiply NoOp's carbon and water by 1000, went from 8.415 to 99.95. Now 0.00. The env, NoOpPolicy, SCENARIOS, NOISE_TOLERANCE and score_episode all live where no candidate code has run, and _assert_scoring_integrity checks the frozen constants before and after scoring. Honest scores bit-identical: 0.4607170813812293, 45.62863404341821, 100.0 / 99.96840069399254. SustainDC is asserted as an interval, not a value -- the same NoOp policy varies ~0.015% run to run, which is presumably why NOISE_TOLERANCE exists, and is a real hazard for a relative score. Unrelated scoring bug found in EV2Gym and deliberately NOT fixed here: a do-nothing policy has total_reward exactly 0.0, and _score_case's max(1.0, -total_reward) floor turns that into MAX_NORMALIZED_SCORE = 1000, ten times the official baseline of 100. energy_user_satisfaction stays ~76 so the 1e-3 gate does not catch it. Fixing it changes published baselines, so it is pinned by a strict xfail instead and needs a separate decision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The child runs under the same uid as the scorer, so /proc/<ppid>/ stays readable and a candidate can recover the scorer's cwd, argv and environ, and from there reach a reference solution this module keeps out of the sandbox. env_allowlist raises the cost but does not close it. Written down rather than partially mitigated: a half-measure here would mostly buy the appearance of safety. Closing it needs isolation_mode=docker or a uid/mount namespace. What this module does guarantee is the property the scores depend on -- the candidate cannot execute inside the scoring process. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…m git The discriminator test pulled the pre-fix parser from HEAD, so it skipped as soon as the fix was committed -- a test that silently stops running is not a discriminator. The old stdout-scraping logic is now inlined, and the test asserts it still yields 100.0 on the spoofed output. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sandbox The sandbox is a copy, and the readonly fingerprint only ever covered the copy. But FRONTIER_ENGINEERING_ROOT is set to spec.repo_root -- the real repo -- and the candidate runs under the scorer's own uid, so it can write to the tree the sandbox was copied from. That is strictly worse than tampering with the sandbox. The sandbox is thrown away at the end of the run; the source tree is not. A candidate could score honestly this run and leave a rewritten scoring module behind that rigs every later evaluation of the task, and nothing would report it -- the sandbox fingerprint comes back clean because the sandbox really was untouched. This cannot be prevented from inside the process: chmod is reversible by the owner, and FRONTIER_ENGINEERING_ROOT cannot be stripped because the scorer-side scripts depend on it. So the run is refused instead. The source tree's readonly paths are fingerprinted alongside the sandbox's, a mismatch sets source_tree_violation and INVALID_COMBINED_SCORE, and the error message says the damage persists and names the tree to restore. The test asserts the sandbox fingerprint stays clean while the source one fires -- that clean sandbox is precisely why this went unnoticed. Found by a subagent auditing Robotics/PIDTuning, where a candidate rewriting verification/evaluator.py in the source tree scored 999.0 against an honest 0.036626766599899996. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both evaluators copied the whole benchmark tree to a temp dir, ran the candidate inside it, and only then exec_module'd the scoring module from that same tree -- which the candidate had just had write access to. The scoring module then located references/scenarios.json relative to its own __file__, so the candidate also owned the world its submission was judged against. That is the JobShop defect again: the candidate supplying the instance data. Both are now loaded before the candidate starts: the trusted scenarios bytes and the scoring module (and numpy) are read from the original benchmark directory first, and the candidate runs via candidate_sandbox against a minimal staged tree holding only itself and its own copy of scenarios.json. What it does to that copy is irrelevant. Structural gate added on the returned submission: NaN/Inf rejected explicitly (NaN > a_max is False, so a NaN trajectory passed every limit check), wrong control dimensions, unknown or duplicate scene ids, oversized sample counts. Scoring formulas, physics and the hard-feasibility gate are untouched, and honest baselines are bit-identical: UAV 28.851886471062496, DOAN 0.07220216606498171 (arrival 12.850000000000046). Three attacks, each verified against a restored pre-fix evaluator: overwriting the scorer 1e9 -> -1e18, swapping the scenarios 100.0 -> 0.0, and a kitchen-sink that does both plus poisons numpy and self-reports a perfect score, exiting 0 so a crash cannot be mistaken for a defence -> 0.0. None of the 28 archived candidates for these two tasks exploited this. It was reachable, not reached. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test_importing_the_oracle_from_the_sandbox_fails claimed in its docstring to run "with the harness's env pointers set" but never set them, so the evaluator died looking for benchmarks/_shared/candidate_sandbox.py and the test failed before reaching the thing it was meant to prove. It now stages benchmarks/_shared into the sandbox root and points FRONTIER_ENGINEERING_ROOT there -- at the sandbox, not the real repo -- so the evaluator can load its isolation helper while the oracle stays absent. Also adds the missing `import os` (the only `import os` in the file was inside an exploit source string). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…exit codes These three did not have the exec_module hole the rest of this branch is about -- they already ran the candidate in a subprocess and recomputed the FEM themselves, with hard feasibility gates rather than a penalty multiplier. My earlier grep counted them by mistake. The real defects were subtler: 1. Deferred import (ISCSO2015, ISCSO2023). build_fem_and_evaluate() did `from fem_truss2d import TrussFEM2D` inside the function, which runs *after* the candidate subprocess returns. Same uid, and the candidate owns the sandbox files, so the write bits the harness strips can be chmod'd back -- verified: a plain write gives EACCES(13), and after chmod it succeeds. The candidate substitutes the solver and the scorer imports it. Both tasks reported a 1.0 kg structure as valid; the ISCSO2015 champion is 6619.66 kg. Imports now happen at module level, before any candidate code exists. 2. Return code ignored (ISCSO2015, TopologyOptimization). Both recorded program_returncode and kept scoring. A candidate writing submission.json then calling os._exit(1) scored -195.9152621065792; it is now -1e18. ISCSO2023 checked `returncode != 0 or proc.stderr`, which killed a run over a numpy warning -- now return code only. 3. ISCSO2023's _wrap() imported openevolve unguarded, so on a machine without it the whole evaluate() raised and the task was permanently -1e18. Fixing that surfaced a second bug: the degraded path returned a bare metrics dict, and run_eval._normalize_result only accepts dicts with a "metrics" key, so artifacts were silently discarded -- taking the error message and the num_evaluations caveat with them. Honest scores bit-identical: -5401.589001522704, -77813242.90462679, -195.9152621065792. num_evaluations is still self-reported by the candidate and unverifiable in this architecture. It is gated but marked unverified in artifacts and metrics, and a test asserts the attack still works rather than implying it does not. This one IS exploited in the archive: grok-4.20 reports min(num_eval, max_eval) on ISCSO2023 and a hardcoded 2850 on ISCSO2015. The two solver hijacks above are reachable but show no archive evidence of use. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he candidate Five tasks, five variants of the same ordering mistake -- the scoring code was imported, or its data loaded, only after the candidate had already run and had write access to the filesystem it was loaded from. MannedLunarLanding shared its work_dir with the Octave validator. Octave resolves function names from cwd before addpath and sources .octaverc unconditionally at startup, so a candidate could drop in a same-named .m or an .octaverc: 999999.0 and 888888.0 against an honest 4577.437043, both now 0.0. Octave is now exec'd directly (no bash -lc, which sourced a ~/.bash_profile the previous candidate could write), with --norc, its own HOME, and a fresh scorer-owned directory holding only a validated numeric results table. predict_modality handed the candidate --dataset-dir pointing at the directory holding test_mod2.h5ad -- the ground truth. The archived gpt-5.4 submission read it and returned it verbatim as its prediction, tagged method_id="cached_test_mod2". Truth now lives in a scorer-private directory (with existing leaked copies quarantined on startup) and the candidate sees only train_mod1/train_mod2/test_mod1: 0.9958333 -> 0.0. A second hole in the same task: the scorer re-ran itself as a subprocess with PYTHONPATH=<repo_root>, so a candidate-written <repo_root>/anndata.py would shadow its imports. That subprocess is gone: 1.0 -> honest 0.6079436994797487. MuonTomography exec'd its scorer after the candidate and parsed the score from that scorer's stdout: 987654.0 -> honest 199.32012533144325. CarAerodynamicsSensing did `import torch`, sys.path.insert, `from models import ...` and torch.load(ckpt) all after the candidate exited -- and torch.load without weights_only is arbitrary code execution in the scoring process. Reordered, weights_only=True, PYTHONPATH dropped. This task cannot run here (no data, no checkpoint, requires CUDA), so it has no end-to-end numbers; the ordering invariant is tested with a stub torch instead. Honest scores bit-identical for the four that run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nds on
The driver runs the candidate with runpy.run_path in its own process -- that is
the design, the driver is the sandbox -- and timed each repeat with
time.time(). Python resolves that on the module object at call time, so a
candidate doing `import time; time.time = lambda: 0.0` made every repeat report
0.0s. HighReliableSimulation scores T0/(runtime_median * err_log_ratio), so
that was worth 10400103.73 against an honest ~262: a factor of ~39600.
Two layers, both in the shared file so all four tasks get them (LDPC,
PMDSimulation, RayleighFadingBER, HighReliableSimulation -- the other three
were equally exposed):
* the driver binds the clock to a local before runpy runs, and uses
monotonic rather than time, so rebinding the module attribute does nothing;
* run_sampler_repeats bounds the self-reported total by the wall clock the
parent measured, which the child cannot reach at all. Reporting more than
the subprocess was alive, or almost none of it, fails the run.
The bound is deliberately loose (5s slack, 5s startup allowance, 50% floor) so
a slow import or a GC pause cannot fail an honest run. It does not make a
forged clock impossible: a candidate scaling every repeat down by a modest
constant stays inside the window. It removes the "report zero" case, which is
the one worth four orders of magnitude. Said plainly rather than papered over.
Found by an agent auditing HighReliableSimulation, whose own evaluator-level
wall-clock check this generalises to the root cause in the shared file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e scorer These four (with HighReliableSimulation, committed earlier) used runpy.run_path(candidate) inside the scoring process. Unlike EngDesign, they genuinely cannot be read as data: the evaluator pulls a *class* out of the namespace and calls sampler.sample(...) once per batch, so ast.literal_eval is not an option for any of them. A test pins that judgement, and will say so if a task ever degrades to plain data delivery. The candidate now runs in the shared sampler_isolation driver -- a string constant owned by the scorer, written into a scorer-owned temp directory outside both the sandbox and the benchmark tree -- which imports the trusted runtime first, then the candidate, and returns only JSON numbers. Medians, validity and score are recomputed by the parent. LDPC, Rayleigh and HRS also move to call_mode="canonical": their init.py only ever forwarded to the benchmark's own loop, so the candidate now supplies sample() and nothing else, and forging an aggregate becomes structurally impossible rather than merely checked. Honest scores unchanged. PMD stays on the candidate loop -- it implements its own weight clipping and adaptive biasing, and switching it would move honest scores. Its aggregates are validated (finiteness, integrality, total_samples <= rows actually produced, probability domain) but a candidate that runs real samples and then reports the reference value still passes. Closing that means switching it to canonical and recalibrating R0_DEV, which is a product decision; it is written up in the module docstring. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… using pipes Two defects in the shared helper, both reported by agents that hit them: 1. subprocess.run(timeout=...) kills only the direct child. The candidate is a session leader (_preexec calls setsid), so anything it forked kept running after the evaluator believed it had stopped -- same uid, same filesystem, still able to write. The group is now killed on timeout AND on clean exit, guarded so it can never signal the scorer's own group. 2. Output went to pipes. A grandchild inherits them, so communicate() blocks on EOF until *it* exits rather than until the candidate does: a candidate that forks a daemon and returns immediately hung the evaluator for its whole timeout. The same pipes deadlock a candidate that writes past the 64KB buffer with nothing draining it. Output now goes to scorer-owned files and only the last 8000 chars are kept. Three tests: an orphan of a timed-out candidate, a daemon forked by a candidate that exits 0, and a candidate writing 4MB to each stream. Each asserts on observed behaviour (a beacon file that must stop advancing, a run that must complete) rather than on the implementation. The suite for this file went from 37.9s to 10.4s, which is the daemon hang disappearing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The candidate, the reference implementation, the tolerance comparison, the
clock, and the log fd the parent read its score from all lived in one process.
The cheapest attack did not involve a kernel at all: POPCORN_FD is an
inherited writable fd whose number is in the environment, so a candidate could
write "check: pass" and "benchmark.0.mean: 1.0" at import time and os._exit(0).
Worth 1.0e9 against an honest ~4900.
Split three ways. The parent parses the bench spec from the original tree,
picks each rep's perturbation, computes 1e9/gmean itself, and bounds the
self-reported time by its own wall clock. A trusted worker loads only the
benchmark's own code -- its directory has no submission.py -- generates the
inputs, keeps the authoritative copies in its own memory, and checks the
candidate's outputs against its own reference at the benchmark's own
tolerances. The candidate worker only runs custom_kernel.
Invariants: every timed rep is verified (k reps, k outputs, k checks -- there
is no unchecked rep to cut corners in); every rep gets a different input, so a
cached replay cannot match; identical output fingerprints across reps are
rejected without reference to tolerance; the trusted channel is authenticated
by a nonce handed over before the candidate process exists, so it is not in
argv, the environment, or on disk.
Attacks A (forge the log) and B (replace check_implementation and the clock)
go from 1.0e9 to 0 with valid=0. Forging alongside an honest kernel now yields
exactly the honest score.
Also fixed: MLA benchmarked with recheck=False over 100 reps sharing one
kv_cache, which custom_kernel appends to, so later reps measured a longer
seq_len than earlier ones and only one rep was ever checked. FA/MLA could stop
after 3 samples. readonly_files did not cover baseline/{reference,task,utils}.py
or task.yml. copy_files was "." and shipped TriMul's 472-line Triton solution
and MLA's mla_code_*.py into the candidate's run directory.
CAVEAT, stated plainly: there is no GPU here (torch is 2.13.0+cpu; the eight
L20Ds are 94-98% full), so no real kernel was ever run. Everything above was
measured on a CPU stand-in that exercises the real harness and adapter with a
CPU reference. MLA's and TriMul's adapters have never executed at all -- MLA's
utils.match_reference calls .cuda() unconditionally. On the stand-in the new
pipeline is systematically ~1.6x slower than the old one (gmean 313545ns vs
198709ns), cause not fully identified; likely 10 verified reps versus 100 in a
tight loop. These three tasks' numbers are NOT comparable to their historical
ones until re-measured on real hardware.
Known and NOT closed: a candidate can move work out of the timed window by
returning a placeholder from custom_kernel and computing in a patched
save_output. The wall-clock gate covers run but not flush, because TriMul
legitimately writes 1.6GB there. Only observability was added
(flush_to_run_ratio).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ate runs
All three loaded verification/evaluator.py from FRONTIER_ENGINEERING_ROOT --
the real repo -- after the candidate subprocess had returned, and the trusted
module then re-read its own references/ relative to __file__. So the candidate
owned both the scorer and the scenario. Measured against restored pre-fix
evaluators in isolated repo copies:
PIDTuning honest 0.036626766599899996; swap the scorer -> 999.0;
poison pid_config.json while submitting honest gains -> 11151.84
RobotArm honest 0.2921925682511491; swap the scorer -> 1.0 (the
supremum of 1/(1+T), i.e. cycle_time 0.0)
Quadruped honest 0.022154337029966706; swap the scorer -> 999.0;
poison gait_config.json with an out-of-range gait -> 0.5866806
The sharpest of these needed no invalid submission at all: editing only the
`eval` section of Quadruped's config (control_kp 3.5 -> 5.0, three hard gates
off) and submitting the *published baseline gait unchanged* scored
0.022173636756049386. The gain came entirely from the candidate redefining its
own rollout.
verification/ and references/ are now staged into a scorer-private temp
directory and exec_module'd from there before the candidate starts; the
candidate runs via candidate_sandbox with FRONTIER_ENGINEERING_ROOT stripped.
All three attacks now yield 0.0 or -1e18, and honest scores are bit-identical.
RobotArm's NaN hole is fixed too, and the PoC is worth recording: an all-NaN
waypoint array passed _validate_format and every joint, velocity, acceleration
and collision check, because they are all `>`/`<` comparisons and NaN compares
False against everything. It did not actually pay out only because
scipy's CubicSpline refuses non-finite y -- a third-party assertion was the
sole thing standing between a candidate and 1.0. Explicit finite checks now
sit in the benchmark itself.
RobotArm also loaded plane.urdf and kuka_iiwa/model.urdf through
pybullet_data.getDataPath(), which resolves into writable site-packages --
outside the repo, so no fingerprint covers it. Inverting the URDF joint limits
moved an honest trajectory to 0.0. The scorer's view of those assets is now
pinned to a private copy.
Also fixes test_robotics_b, which wrote __pycache__ into the readonly
directories it then asserted were clean, so a second consecutive run failed
its own test -- and those caches would trip the new source-tree fingerprint.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ry timed pass The candidate is C++ here, so nothing exec_module'd it -- but the shape was the same. The candidate binary ran in the scorer's own working directory during validation, and only *afterwards* did the scorer compile evaluate.cpp, the program whose stdout it then scraped "Throughput : N Mbps" from. A candidate that rewrote evaluate.cpp from its validation run chose its own score: 1.0e9 against an honest ~21. The timed phase also checked nothing at all -- evaluate.cpp only looked at exit codes, and the two hash tasks sent the digest to /dev/null. Being honest during validation and doing nothing while timed was worth 4.0x on AES, 3.0x on SHA-256, 1.6x on SHA3. Now: the reference implementations are imported and self-tested against FIPS-197 / SP 800-38A / FIPS-180-4 / FIPS-202 vectors, the candidate is compiled exactly once, and only then does it run for the first time. Nothing is compiled afterwards, so the primitive is gone rather than guarded. Every timed iteration is verified against expected bytes computed in the scorer's memory, with a fresh key/IV or prefix each time; the score is computed from seconds this process measured. Attack A falls back to the honest band, attack B is rejected at "wrong output on timed iteration 1". Also fixes a bug that made these three tasks score 0 for honest candidates on this machine: _discover_openssl_paths() added `-isystem /usr/include` when the OpenSSL headers live there, which breaks libstdc++'s `#include_next <stdlib.h>`. And SHA3's validate.cpp ended in an unconditional `return 0`, so its correctness gate rested entirely on a regex over text the candidate could write into. The 566-line evaluator existed as four byte-identical copies; it now lives once in benchmarks/_shared/crypto_eval.py. Honest scores are wall-clock throughput and cannot be bit-identical. Interleaved runs, N=9 per arm: AES 20.939 -> 20.632 (-1.5%), SHA-256 35.300 -> 34.277 (-2.9%), SHA3 67.039 -> 74.103 (+10.5%). The SHA3 move is the scorer's own overhead changing -- the old harness redirected each spawn to /dev/null, the new one reads the digest back through a pipe (3.738 vs 4.495 Mbps measured in isolation). Not padded back to match. BEFORE numbers required patching the -isystem bug into the old evaluator, since it otherwise cannot produce a score here at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_PATCH_SCORER writes its PWN stub to Path.cwd().parent, which is the temp root the sandbox workdir sits in. That is a faithful simulation -- a real candidate would try exactly that, and it correctly fails to move the score. But the debris outlived the test. test_optics_fiber's probe walks up parent directories looking for reachable task files, found a verification/ this suite had created in the temp root, and failed for a reason unrelated to the code under test: 4 optics failures and 3 structural ones in a full-suite run, none reproducible in isolation. Clean up what we scatter. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Several tests write attack candidates straight into the real benchmark tree (TaskEnv in test_inventory_optimization.py, among others) and restore the honest source in a finally block. Any hard interruption -- Ctrl-C, a kill, an OOM, a CI timeout -- skips that block, and what stays behind in a *tracked* source file is a working exploit. That is not hypothetical: killing a full-suite run left a candidate in general_meio/baseline/init.py whose solve() returned hardcoded levels and submitted all -1 when it could reach verification/reference.py. It was one `git add benchmarks/` away from being committed as the shipped baseline. pytest_sessionfinish now diffs benchmarks/ against what was already dirty at sessionstart and prints what this session leaked, with the checkout command to undo it. Reporting, not prevention -- the real fix is for those tests to work on a copy -- but an interrupted run can no longer leave an exploit behind silently. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
agent_files.txt is not a manifest of what the candidate may open -- the harness reads each entry and puts its full source into artifacts, which the evolutionary algorithms then feed to the model as context. So listing verification/reference.py does not merely make the answer reachable, it writes the answer into the prompt. 21 tasks did that with a working solution: InventoryOptimization x5 verification/reference.py (stockpyl optimizers) ReactionOptimisation x4 verification/reference.py (summit SOBO) Optics adaptive x4 verification/reference_controller.py Optics fiber x4 verification/oracle.py (CP-SAT, pure-Python DP) Optics holographic x4 verification/reference_solver.py (slmsuite WGS-Kim) On joint_replenishment the baseline scores 0.3034 and that reference scores 0.8244 -- 2.7x, for `from verification.reference import solve`. Optics records score_gap_oracle_minus_candidate, so the oracle is expected to be ahead there too (holographic multifocus: 0.3927 candidate against 0.5174 oracle). Nothing about scoring depends on shipping it: combined_score is the candidate's own score in all 21 (run_eval.py:150 for Inventory, parse_result.py:57 for Optics), and the reference figure is only recorded alongside. The evaluator still imports the file from disk, unaffected; only the prompt loses it. JobShop's seven deliberately keep theirs. Their constraints.txt requires "pure Python (standard library only), no external solver/library usage", so the OR-Tools CP-SAT reference cannot legally be copied -- it is a statement of what a professional solver achieves, not an answer. Checked that it leaks no optimum or bound; those stay with the evaluator. Honest scores are unaffected by construction and verified: 26 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mula baseline/result_log.txt claimed 595822.2514256956 over three scenes. The current evaluator and Task.md both score `coverage_ratio * 100 - energy * 0.5`, and the real baseline is 28.851886471062496 over four scenes -- the archived file overstated it about twenty-thousandfold, so anyone using it as a reference point would have drawn the opposite conclusion about whether a submission improved on the baseline. The archived numbers are internally consistent with a different formula: scene_1's 489981.77605038136 is exactly 0.7^2 * 1e6 - 18.2239, while the current formula gives 60.89. The file's own Notes mention "squared coverage scoring". The formula and the scene set changed after it was written; the log did not. Regenerated by running the current evaluator rather than by choosing between the two formulas. The code and Task.md agree with each other, and Task.md's worked example already shows 28.85, so the linear formula is the live contract and the log was simply stale. Every per-scene value in the new file has been checked to reproduce from that formula, and their mean equals the combined_score the harness reports. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Close the three deterministic holes in the readonly-fingerprint machinery,
then enforce read-only at the filesystem level instead of only detecting
changes afterwards.
.pyc shadows its .py at import time); paired with PYTHONDONTWRITEBYTECODE=1
so a run never flags its own caches.
entry is task-supplied data and could previously hash /etc/hostname or a
sibling file.
finally, so a rewritten scorer fails at the write instead of being
discovered after it already scored.
state and are otherwise sourced into the scoring shell.
parsing combined_score from candidate-authored stdout is a spoofing path.
Adds the first test coverage for the fingerprint machinery.