Skip to content

Audit and harden the eval harness; unblock the sandboxed independent critic - #2

Merged
aarontaycheehsien merged 7 commits into
protocol-first-empirical-search-builderfrom
dev/claude
Sep 26, 2026
Merged

aarontaycheehsien merged 7 commits into
protocol-first-empirical-search-builderfrom
dev/claude

Conversation

@aarontaycheehsien

@aarontaycheehsien aarontaycheehsien commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner

Summary

This PR audits the eval harness for correctness before expensive benchmarks and fixes the bugs the audit demonstrated. It also works through Phases 0–2 of the resulting remediation plan, which covers why a real end-to-end generated build (CD011926) failed its completion gate. Every fix has a regression test that fails on the pre-change code.

Details: EVAL_HARNESS_AUDIT.md (findings and E2E evidence) and EVAL_REMEDIATION_PLAN.md (plan and per-phase status).

Type of Change

  • Bug fix (non-breaking change fixing an issue)
  • New feature (non-breaking change adding functionality)
  • Documentation update
  • New methodological filter / validated hedge
  • Breaking change (fix or feature that alters existing behavior)

Related Issue

None.

Changes Made

Eval harness audit (evals/)

  • Gold leakage: PMIDs the fixture gives the skill (development, seed, protocol seed records) are always counted as seen, so they cannot inflate never-reviewed recall.
  • Stale results: a non-empty --run-dir is refused.
  • Critic before/after:
    • the round-1 snapshot must match the hash frozen in the critic's evidence bundle;
    • critic and ledger evidence are resolved against the live agent workspace, since the manifest records absolute paths into it;
    • an undefined ablation delta is reported as null, not 0.0.
  • Incomplete runs: a Codex timeout is a failed run, not a crash. The suite refuses generated strategies whose run failed the completion gate.
  • Aggregation: undefined-recall topics are kept out of means and regression diffs.
  • Unreadable workspace: mkdtemp's owner-only ACL (Python 3.13+ on Windows) made every file written by the sandboxed agent unreadable to the harness. The workspace root now inherits its parent's ACL.

Phase 0: independent critic and re-screen inside the Codex sandbox (scripts/isolated_runner.py)

  • On timeout, the child's whole process tree is killed. Before, killing only the codex.cmd wrapper left communicate() blocked on the grandchildren.

  • New isolated_runner.py preflight command, run at intake per SKILL.md, so a runner that cannot start stops the build in seconds rather than 45 minutes in.

  • Inside a Codex sandbox the child runs as a separate sandbox user with no Codex login and no usable root-certificate store. When codex login status fails, the runner:

    • copies auth.json into a private, owner-only home for that one child;
    • exports the machine's trusted roots to a CA bundle there;
    • removes the copied credentials first when the child exits.

    This was verified live, nested in the eval sandbox configuration. Both failing and successful children leave no token copy behind.

Phase 1: fail fast on build recording errors

  • manifest_tool add refuses --kind mesh for outputs that are not from mesh_tool.
  • manifest_tool add reports the gate's hash-binding findings as binding_warnings when they arise.
  • audit-scaffold fills the required limits/filters notes from the locked protocol, or lists them as placeholders.
  • mesh_tool explains shell-split multi-word arguments. The PowerShell guidance warns against Start-Process -ArgumentList.

Phase 2: harness known gaps

  • final_strategy.txt is scored only if it matches the strategy input of the final search the gate bound (exit 3).
  • An automated leakage_scan of the transcript runs on every scored run (exit 5 on a hit).
  • A transient-failure relaunch starts from a clean run dir.
  • The suite:
    • finds generate.py's <topic>/run-<UTC>/ layout and uses the latest run;
    • reports never-reviewed recall;
    • refuses --runs > 1 without --no-cache.
  • The default --timeout is 7200 s, and elapsed_seconds is recorded.

CD010657 pilot fixes (added after opening)

  • Screening decision: an evidenced no on any required criterion now excludes, regardless of criterion order. Before, an earlier unclear returned uncertain first, contrary to references/candidate-screening.md.
  • Fill-in-place re-binding: a later add --output P --supersedes P re-binds P only when P is a fill-in-place artifact (a screening worksheet or a provenance-blinded pilot round). A broader draft of this change let an edited final QA be laundered past its hash check, and there is now a regression test for that. The pilot manifest validates cleanly under the narrowed rule.

Testing

  • The full suite passes: python -m pytest tests -q gives 973 passed.
  • Each new regression test was run against the pre-change code to confirm it fails there.
  • python scripts/pubmed_tool.py doctor reports ok: true.
  • End-to-end: three evals/generate.py CD011926 attempts.
    • The first two exposed the timeout and ACL bugs.
    • The third ran cleanly through the fixed harness. The gate correctly rejected the incomplete build, and nothing was scored. The nested-critic failure behind that is what Phase 0 fixes.
    • The leakage scan found that transcript clean across 160 actions.
  • Nested sandbox probes: preflight passes in 7.3 s and a critic-shaped call returns in 8 s from inside the Codex sandbox; both previously hung.

Checklist

  • I have tested these changes locally
  • I have updated documentation if needed
  • I have not committed any API keys or secrets
  • My code follows the project's code style
  • I have verified tools still work with pubmed_tool.py doctor

Reviewer notes

  • Behavior changes:
    • run_suite.resolve_strategy now returns a 4-tuple;
    • a generated suite row needs completion_gate.json plus a clean scorecard.json;
    • generate.py has new exit codes 3 (scored file is not the gated strategy) and 5 (leakage);
    • codex-runner tests stub codex_child_home so they don't depend on the machine's login;
    • run_child replaces subprocess.run as the patch point for isolated-runner tests.
  • Credential handling (accepted risk): the sandboxed child gets a copy of the host Codex auth.json. If that child refreshes the token, the host login may need signing in again. The claude-code-cli runner is not provisioned this way.
  • Still open: Phase 3, one full passing generate.py run with a live critic ablation, has not been run yet.
  • Base branch: this targets protocol-first-empirical-search-builder, not main. The work builds on protocol-first code, and the two lines are intentionally kept separate.

🤖 Generated with Claude Code

aarontaycheehsien and others added 7 commits September 26, 2026 16:25
- Count PMIDs given to the skill (development/seed/protocol seeds) as seen,
  so they never inflate never-reviewed recall.
- Refuse a non-empty --run-dir so stale artifacts are not scored.
- Verify the critic round-1 snapshot against the evidence-bundle hash and
  resolve critic/ledger evidence against the live agent workspace before it
  is deleted (absolute paths); report an undefined ablation delta as null.
- Require a passing completion_gate.json before the suite scores a
  generated strategy; keep undefined-recall topics out of means and diffs.
- Treat a Codex timeout as a failed run instead of a crash.
- Create the agent workspace root with an inherited ACL: mkdtemp's
  owner-only ACL (Python 3.13+ on Windows) left sandbox-written files
  unreadable to the harness.

Adds EVAL_HARNESS_AUDIT.md with findings, E2E results, and known gaps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- run_child launches the critic/re-screen child in its own process group
  and, on timeout, kills the whole tree (taskkill /T on Windows, killpg on
  POSIX). subprocess.run killed only the codex.cmd wrapper, so communicate()
  blocked on pipes held by node/codex.exe and --timeout never fired.
  Timeout errors now carry the child's last stdout/stderr.
- isolated_runner.py preflight checks the child CLI is signed in for the
  current account and makes one trivial bounded call. Inside a Codex
  sandbox the child runs as a separate sandbox user with no login, which
  made every nested critic hang; preflight now reports that in 0.2 s.
- SKILL.md runs the preflight before record work; press-critic.md gains
  runner troubleshooting. Tests retarget run_child patches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Inside a Codex sandbox the critic/re-screen child runs as a separate
sandbox user with no Codex login and no usable root-certificate store,
so nested children hung, then failed TLS once signed in.

When `codex login status` fails for the current account, the codex-cli
runner copies the host auth.json into a private owner-only home for that
one child (outside the staged workspace), exports the machine's trusted
roots to a CA bundle unless one is configured, and removes the copied
credentials before the home when the child exits. The execution record
notes codex_home host/private-copy; preflight reports it.

Verified nested in the eval sandbox configuration: preflight ok in 7.3 s,
critic-shaped call ok in 8 s, no credential copy left on success or
failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- manifest_tool add refuses --kind mesh for outputs that are not mesh_tool
  artifacts (the gate rejected term-diff outputs only at handoff).
- manifest_tool add reports the gate's hash-binding findings as
  binding_warnings as soon as an artifact is edited after being bound.
- audit-scaffold fills reporting_notes limits/filters decisions from the
  locked protocol, or lists them as placeholders instead of failing only at
  render.
- mesh_tool explains shell-split multi-word arguments; the PowerShell
  guidance warns against Start-Process -ArgumentList and points to
  --variants-file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- generate.py scores final_strategy.txt only when its text matches the
  strategy input of the final topic-only search the gate bound (exit 3).
- generate.py scans the transcript for repository/fixture/qrel references
  and gold PMIDs used before any tool output showed them; the result is
  recorded as leakage_scan and a hit exits 5 (not a measurement).
- The Codex driver relaunches a transient failure from a clean run dir
  (only the staged prompt and protocol are kept).
- run_suite finds generate.py's <topic>/run-<UTC>/ layout, uses the latest
  run (which must pass the gate and have a clean scorecard), and reports
  never-reviewed recall per generated row and per source.
- --runs > 1 is refused without --no-cache; default --timeout is 7200 s
  (GUI too) and elapsed_seconds is recorded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI (Linux) failed test_a_sandboxed_child_gets_a_private_login_that_is_removed_afterwards:
it asserted a CA bundle is always exported, but the missing root store is a
Windows sandbox-user problem and write_ca_bundle correctly returns None on a
Linux runner with no default cafile. Stub the export to test the wiring, add
a test that a configured CA bundle is never overridden, and keep a
Windows-only test for the actual export.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e2280e936d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread evals/generate.py
if pmid not in shown and pattern.search(action):
undiscovered_gold.append({"pmid": pmid, "action": action[:200]})
shown.add(pmid)
shown.update(pmid for pmid, pattern in patterns.items() if pattern.search(output))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restrict PMID discovery to trusted retrieval output

Do not mark gold PMIDs as legitimately discovered from every command's output. If the agent runs a generic command such as find /workspace -type f -exec cat {} +, the command contains none of the forbidden markers, but fixture PMIDs in its output are added to shown; their subsequent use is therefore exempt and the run receives a clean leakage scan. This can silently admit answer-key-contaminated runs, so only output from known PubMed/retrieval commands should establish discovery.

Useful? React with 👍 / 👎.

Comment on lines +450 to +454
if not pem:
cafile = ssl.get_default_verify_paths().cafile
if cafile and Path(cafile).is_file():
return Path(cafile)
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fall back when certificate environment variables are empty

Handle defined-but-empty certificate variables without passing them through to ssl.get_default_verify_paths(). In that environment Python treats the empty SSL_CERT_FILE as an override and returns no cafile, so this function returns None and codex_child_home() leaves both certificate variables empty; the nested Codex child then still cannot establish TLS, defeating the provisioning path intended to unblock the critic. The newly added sandbox-home test reproduces this failure.

Useful? React with 👍 / 👎.

Comment thread evals/run_suite.py
Comment on lines +73 to +76
flat = generated_root / topic
if (flat / "final_strategy.txt").is_file():
return flat
runs = sorted(path for path in flat.glob("run-*") if path.is_dir()) if flat.is_dir() else []

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prefer the newest nested run over a legacy flat copy

Check for run-* directories before returning the flat topic directory. When a legacy flat run containing final_strategy.txt coexists with newer generate.py runs, this early return permanently selects the legacy result, so even a newer failed rerun is hidden despite the function and README explicitly promising that the latest run wins.

Useful? React with 👍 / 👎.

Comment on lines +131 to +135
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
return True

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat zombie descendants as terminated in the timeout test

Avoid using os.kill(pid, 0) alone as the POSIX liveness check because it returns success for zombie processes. After killpg() terminates the test grandchild, container environments whose PID 1 does not promptly reap orphans retain it as a zombie, making this new test wait ten seconds and fail even though the descendant is no longer running; this occurs in the repository's current test environment.

Useful? React with 👍 / 👎.

@aarontaycheehsien
aarontaycheehsien merged commit cb02566 into protocol-first-empirical-search-builder Sep 26, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant