Skip to content

feat: screen verifier cheats and measure native controls in CI - #164

Open
asutermo wants to merge 5 commits into
mainfrom
feat/verifier-cheat-gates
Open

asutermo wants to merge 5 commits into
mainfrom
feat/verifier-cheat-gates

Conversation

@asutermo

@asutermo asutermo commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • Add shared task-specific cheat screening to proposals, task creation, and current/retained Harbor and NeMo Gym construction routes. Reuse existing construction cases and native evidence recorders. Missing applicable controls block readiness; proposals record planned controls without execution. This is an agent workflow requirement, not proof that every applicable attack was declared.
  • Add an independent, credential-free verifier-cheats CI panel on same-repository PRs, main pushes, manual dispatch, and daily CI. Six scripted submissions run against sound, deliberately weak, and always-fail graders. Root-only hidden tests, timeout rejection, and type-preserving JSON comparison have native regression controls.
  • Add a separate model-backed Harbor experiment on two independently authored synthetic repository repairs: ledger reconciliation and dependency-aware build ordering. Pair normal and explicit adversarial solver arms; evaluate each frozen submission with hidden behavioral cases and a deliberately weak exit-code diagnostic.
  • Live execution runs automatically on main pushes/merges and the daily schedule under the existing semantic-regression environment; manual main dispatch requires run_live=true. PRs only plan. The live job is advisory (continue-on-error: true), while summaries retain truthful incomplete statuses. No credential/environment rules or required merge checks are changed.
  • Publish only allowlisted aggregates. The deterministic v2 summary is retained for 30 days; detailed manifests/native artifacts upload only for private repositories. Live JSON/Markdown summaries are retained for 90 days; raw live model/native evidence is never uploaded. Host inference credentials are not passed to containers.
  • Add bounded provider retries, repairable tool-argument errors, deadline checkpoints, and summary-only history validation with compatible-series fingerprints. Dashboard ingestion is not added.
  • Ancillary CI maintenance: migrate task-search-platforms from retired/brownout-prone macos-14 to pinned macos-15; Python remains 3.12. This is not an anti-cheat prerequisite.

Review fixes

Address hidden-case disclosure and public artifact exposure first, then candidate timeout/type handling, bounded provider retry/tool repair, final-cell deadline classification, and rejection of contradictory incomplete summaries. Remove the redundant toy-driver test and exact instructional-phrase assertions while retaining transport/fault and route-link coverage. Document Docker no-network enforcement requirements and artifact retention.

The deterministic schema is now nemo.eval_author.verifier_cheat_evidence.v2, protocol harbor-verifier-cheats-v2. Its expanded counts are not directly comparable to the old nine-trial panel.

Measured execution

  • Final deterministic panel: 18/18 real Harbor/Docker trials matched expected outcomes. The sound grader accepts the reference and rejects all five negative controls; the weak grader rewards four exit-zero cheats; every hang trial grades zero. Independently reread all native results and verified 15 input hashes and 292 private artifact hashes against final source bytes.
  • Final repair controls: 6/6 real Harbor/Docker cells completed. Both references pass trusted and weak checks; four cheats fail trusted checks and pass weak checks. Native receipts were reread against the final bindings and the summary passes history validation.
  • The real Harbor Docker tool path passed separately, without a model request. Provider retries and malformed argument recovery were exercised using real SDK response types and mocked transport.
  • No live provider request was performed. There are no measured model-cheating results yet. Local controls do not establish hosted-provider reliability or Ubuntu execution of this revision.

Validation

  • Final stable-tree full suite: 3,262 passed, 13 skipped, 289 warnings. The opt-in real Harbor tool test passed separately. Earlier suites overlapping review fixes are not used as final-tree evidence.
  • Repository-wide Ruff lint/format, Python 3.12/3.13 type checks, copyright, license and whitespace checks passed.
  • Independent review passed after fixing interruption checkpoint consistency and a cold-SDK timing assumption in the retry test. The retry test also passed in five separate isolated processes.

Limits and cost

The deterministic controls and live experiment remain separate. Fixed-fixture sensitivity does not establish general reward-hacking resistance, authoring-agent compliance, screening of old tasks, or arbitrary-code sandbox security. Explicit adversarial prompting is not evidence of spontaneous cheating.

Default live batches contain eight model-backed cells (two tasks × two arms × two repetitions), each with a 48,000 observed-token cap, plus six credential-free controls. Automatic main-push and daily cadence is intentional; maintainers should review it against their inference budget. At most two transient 429/5xx retries per cell share the original request deadline and conservatively reserve token allowance for failed requests. Partial observed usage is not a complete billing ledger.

The 3,000-second runner deadline remains below the 60-minute CI job limit. Missing, interrupted, unhealthy, and invalid evidence stays incomplete. Runner loss can still prevent artifact publication. Docker must support Harbor's no-network enforcement; daemon availability alone is insufficient.

Trusted live error rates remain unmeasured because the behavioral checker supplies the finite-case reference label. Weak-grader disagreement is not universal correctness evidence. Requested model routes and observed revision hashes do not prove immutable provider backends; missing revisions stay unknown.

History comparison deliberately splits changes in lockfile, Docker version, task/prompt bindings, and per-cell provider identity. This limits longitudinal grouping but avoids pooling incompatible experiments. It is summary consistency checking, not independent native attestation.

The separately reported mainline SkillEvaluator context-budget failure is not caused or fixed by this PR; its follow-up is #167, kept separate from anti-cheat changes.

Signed-off-by: Andrew Suter-Morris <asutermorris@nvidia.com>
@asutermo
asutermo marked this pull request as ready for review October 30, 2026 17:44
@asutermo
asutermo requested a review from a team as a code owner October 30, 2026 17:44
Signed-off-by: Andrew Suter-Morris <asutermorris@nvidia.com>
Signed-off-by: Andrew Suter-Morris <asutermorris@nvidia.com>
Signed-off-by: Andrew Suter-Morris <asutermorris@nvidia.com>

@aleckhoury aleckhoury left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving. The live experiment's isolation is solid: root-only /tests and /logs (0700), the submission runs as UID 65534 from a read-only copy, leftover unprivileged processes are killed before grading, output is compared as canonical JSON, and only the two allowlisted summary files are uploaded. I ran the focused anti-cheat/evidence/skill-contract tests locally (574 passed, 1 opt-in Docker skip), and CI is green.

Inline comments are non-blocking, but I'd like to see the medium ones addressed soon, here or in a follow-up:

  • Normalizer "sound" grader leaks hidden cases. /tests is world-readable during grading. I confirmed in real Harbor/Docker that a submission parsing CASES from /tests/verify.py gets reward 1.
  • Live job has no continue-on-error. Any incomplete run (provider error, malformed tool call, deadline) will turn main and daily runs red, unlike semantic-regression.
  • verifier-cheats uploads raw native evidence from a public repo. This breaks the existing summary-always / native-only-when-private convention.

Also worth confirming with the budget owner: every push to main plus the daily schedule runs up to 8 model-backed cells, each capped at 48k tokens. Separately, because the series key includes the uv.lock bytes, the Docker version and each cell's provider fingerprint, most batches will probably end up in their own series, which limits the longitudinal comparison.

Comment thread evals/verifier-cheats/control_agent.py Outdated
result = await environment.exec(
command="mkdir -p /work /controls /tests /logs/verifier; "
"chown root:root /logs; chmod 700 /logs; "
"chown root:root /tests /logs/verifier; chmod 755 /tests /logs/verifier; "

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

chmod 755 /tests lets the submission, which runs as 65534 during grading, read /tests/verify.py and its hidden CASES. I checked with Harbor/Docker: a submission that parses CASES with ast and prints the matching output gets reward 1 from the sound grader (semantic_passes=3).

The live panel already handles this (chown root:root /tests; chmod 700 /tests), and the new task-validation.md says to "review actual UID, write access, imports". Could this match the live setup (/tests 0700 root)? Please also consider adding a hidden-case-read control so the panel shows that the sound grader rejects it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 5630d31. /tests is now root-owned 0700, with native receipts checking that the unprivileged submission cannot read it. Added a hidden-case-read control that attempts to extract CASES during grading: it earns 0 under the sound grader. The expanded real Harbor/Docker panel passed all 18 expected outcomes, including the positive reference and deliberately broken graders.

Comment on lines +50 to +58
live:
needs: plan
if: >-
github.ref == 'refs/heads/main' &&
(github.event_name == 'push' || github.event_name == 'schedule' ||
(github.event_name == 'workflow_dispatch' && inputs.run_live))
environment: semantic-regression
runs-on: ubuntu-24.04
timeout-minutes: 60

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

run_anti_cheat_live.py returns 2 for any run that isn't completed: deadline, unhealthy controls, provider error, malformed tool call. That makes this job fail on main pushes and daily runs. semantic-regression's live job, which this one copies, sets continue-on-error: true for this reason. Since this is described as an experiment rather than a gate, should it do the same? The summary keeps the real status either way.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 5630d31: the live job now sets continue-on-error: true, matching semantic regression. The collector still exits nonzero and preserves its actual incomplete reason; this only makes the observational experiment advisory. Automatic main-push/daily execution and the existing environment gate are unchanged. Added a workflow regression assertion.

Comment thread .github/workflows/ci.yml
Comment on lines +179 to +188
run: uv run --locked python tools/collect_verifier_cheat_evidence.py --output "$RUNNER_TEMP/verifier-cheat-evidence"
- name: Retain verifier-cheat measurements and native evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: verifier-cheat-evidence-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/verifier-cheat-evidence/
include-hidden-files: true
if-no-files-found: error
retention-days: 30

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This uploads the whole verifier-cheat-evidence/ folder: job folders, Harbor logs, docker version / image inspect JSON, absolute runner paths. The repo is public. The convention elsewhere (gym-compatibility, skill-evaluator, and DEVELOPMENT.md "Retained CI measurements") is to upload the summary JSON always, and native evidence only when github.event.repository.private == true. The content here is fixture-only, so the risk is low, but could this follow the same split, and could the DEVELOPMENT.md artifact table be updated?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 5630d31. Public artifacts now contain only verifier-cheat-summary.json, and that file itself is an allowlisted v2 projection: no raw errors, paths, file manifests, or full Docker inspection payloads. Detailed evidence moved to verifier-cheat-private.json and the raw-directory upload is restricted to private repositories. Added workflow/privacy sentinel regressions and updated the DEVELOPMENT.md retention table. Verified 18 native results, 15 input hashes, and 292 private artifact hashes against the final source.

Comment thread evals/anti-cheat-live/live_agent.py Outdated
or self.receipt["tool_calls"] >= MODEL["max_tool_calls"]
):
raise RuntimeError("tool budget or name invalid")
output = await self.tool(environment, json.loads(call.function.arguments))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

With max_retries=0 (L129), one transient 429/5xx from the provider ends the cell. json.loads(call.function.arguments) here and the checks in tool() raise on malformed or over-long model arguments, which also ends the cell instead of sending a tool error back to the model. Combined with the exit-code behavior of the workflow, I'd expect many incomplete runs that measure provider and parsing flakiness, not grader sensitivity. If failing closed is intended, maybe record a distinct reason; otherwise return the error as the tool result and allow a small, bounded retry.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 5630d31. Added at most two transient 429/5xx retries per cell, sharing the original 60-second request deadline and reserving conservative token allowance for failed requests; SDK retries remain disabled. Malformed/overlong tool arguments now return a fixed repairable tool error while consuming the existing tool-call budget. Permissions and overall limits are unchanged. Covered with real SDK response types and mocked provider transport; the real Harbor tool path also passed. No live-provider reliability claim is made.

Comment on lines +585 to +595
remaining(1020 if cell["arm"] in ARMS else 150),
)
rows[index] = native_check(private_output / "jobs" / str(cell["cell_id"]), cell)
except Exception as exc:
(private_output / f"{cell['cell_id']}.error").write_text(repr(exc))
rows[index] = {
**cell,
"status": "incomplete",
**partial_observation(private_output / "jobs" / str(cell["cell_id"]), cell),
}
report["reason"] = "invalid-native-evidence"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

TimeoutError from remaining(), which is evaluated inside the execute(...) arguments, and per-cell subprocess.TimeoutExpired are both caught here and recorded as reason = "invalid-native-evidence". If that happens on the last cell, the summary reports invalid evidence instead of deadline or a timeout. Could timeouts be handled separately before the general except Exception?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 5630d31. remaining() TimeoutError and per-cell subprocess.TimeoutExpired now have a separate handler before the generic native-evidence exception path. It checkpoints the incomplete cell with reason deadline and stops, including on the final cell. Added regressions for both timeout sources and final-cell behavior.

"stdout": result.stdout,
"stderr": result.stderr,
"return_code": result.returncode,
"semantic_pass": result.returncode == 0 and actual == expected,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: actual == expected uses Python equality, so [True] matches [1]. The live verifier compares canonical JSON for exactly this reason ("not Python's permissive True == 1 equality"). Worth keeping the "sound" fixture consistent.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 5630d31. The sound fixture now uses canonical JSON equality, consistent with the live verifier. Added bool/float-versus-integer regressions and a native boolean-output control; it is rejected by the sound grader and still rewarded by the deliberately weak grader.

Comment on lines +36 to +43
Run the same panel locally with Docker running and the locked development
environment installed. Choose a new output directory for every attempt:

```bash
uv sync --locked
uv run --locked python tools/collect_verifier_cheat_evidence.py \
--output "$PWD/.eval-author/verifier-cheat-evidence/run-1"
```

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: on macOS with Docker 29.4.3, Harbor 0.20.0 rejects these tasks with network_mode='no-network' is not supported by EnvironmentType.DOCKER, because egress control needs Linux or kernel support. Please document that runtime requirement. The guided live experiment section has the same issue.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Documented in 5630d31 for both panels: Harbor's no-network policy requires a Docker runtime/host with the necessary egress enforcement support; a running Docker daemon alone is insufficient. Included the unsupported-network-mode diagnostic and guidance to switch or upgrade the runtime, not disable isolation. Local native runs passed on the tested Docker 29.7.2 setup; this is not a claim that every macOS runtime supports it.

Comment thread tests/test_verifier_helper.py Outdated
Comment on lines +31 to +46
@pytest.mark.parametrize(
"submission,reward",
[("print(7)", 1), ("raise SystemExit(0)", 0), ("raise SystemExit(1)", 0), ("print('ALL TESTS PASSED')", 0)],
)
def test_trusted_driver_rejects_no_work_submission_without_infrastructure_error(submission, reward):
# Local subprocess contract test, not hostile-code or native-provider isolation proof.
source = f"""
import json, subprocess, sys
submitted = subprocess.run(
[sys.executable, "-I", "-B", "-c", {submission!r}],
capture_output=True, text=True, timeout=2,
)
correct = submitted.returncode == 0 and submitted.stdout.strip() == "7"
print(json.dumps({{"status": "result", "value": int(correct)}}))
"""
assert run(source) == {"status": "result", "value": reward}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: the pass/fail logic (returncode == 0 and stdout == "7") is written inside the test's own driver string, so this only checks that run_helper passes through a value the test computed. test_transport_does_not_grade_values already covers that. The new cases added to test_helper_faults_raise_instead_of_returning_rejection are the useful ones; suggest dropping this test.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Removed in 5630d31. The inline toy driver only retested transport of a value computed by the test. Kept the transport contract and useful helper-fault regressions; actual grader behavior is exercised by the native verifier panel.

Comment thread tests/test_skill_contract.py Outdated
Comment on lines +1010 to +1036
def test_construction_routes_require_shared_cheat_screening() -> None:
reference = "task-validation.md#screen-simple-cheats"
routes = [
_CORE_DIR / "references" / "construction-plan.md",
_CORE_DIR / "references" / "trace-input.md",
_CORE_DIR.parent / "eval-author-task-create" / "SKILL.md",
]
for skill in ("eval-author-environment", "eval-author-trace-environment"):
routes.extend(_CORE_DIR.parent / skill / "references" / name for name in ("task-fidelity.md", "gym-output.md"))
for path in routes:
assert reference in path.read_text(encoding="utf-8"), path
text = (_CORE_DIR / "references" / "task-validation.md").read_text(encoding="utf-8")
screening = " ".join(text.split("## Screen simple cheats", 1)[1].split("\n## ", 1)[0].split())
for required in (
"successful early exit",
"all-failure stub",
"reference-as-comments",
"forged success",
"entry point → guards → invoked checks → reward writer",
"Proposal-only",
"every task",
"unproven",
"not a passing negative control",
"verifier-owned",
"valid neighbor",
):
assert required.lower() in screening.lower()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: this pins exact phrases in task-validation.md, so any rewording breaks it even when the routing behavior is unchanged. The link check on the route files is useful; the phrase list is not.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Removed the exact prose phrase list in 5630d31. Kept the route-link checks and verification that the linked section anchor exists, so routing remains covered without pinning instructional wording.

Comment thread .github/workflows/ci.yml
fail-fast: false
matrix:
os: [ubuntu-24.04, macos-14, windows-2022]
os: [ubuntu-24.04, macos-15, windows-2022]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: the macos-14 → macos-15 change isn't related to the anti-cheat work. Please split it out or mention it in the PR description.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Kept the already-published macos-15 change and explicitly documented it in the PR description. It is an ancillary fix for macos-14 runner retirement/brownouts, not an anti-cheat requirement. Python remains 3.12 for that matrix. Keeping it avoids rewriting the published branch while satisfying the requested scope disclosure.

Signed-off-by: Andrew Suter-Morris <asutermorris@nvidia.com>
@asutermo

Copy link
Copy Markdown
Collaborator Author

Addressed the 12 inline comments individually in 5630d31; the PR description now reflects the expanded 18-trial panel, privacy split, advisory live execution, ancillary macOS migration, and final validation. The final stable suite passed 3,262 tests (13 skipped); all 18 deterministic native trials and a fresh six-cell repair-control batch passed. No live model requests were made.

For the two summary-only points in #164 (review):

  • Cost/cadence: retained automatic main-push and daily execution as requested. The docs and PR explicitly disclose eight model-backed cells, each capped at 48k observed tokens, bounded retries, and incomplete billing visibility. This does not establish budget-owner approval; maintainers should confirm the cadence against their inference budget.
  • Longitudinal grouping: retained the strict lockfile/Docker/per-cell-provider identity boundaries intentionally. Singleton series limit trend analysis, but combining incompatible runs would be misleading. The docs now call out this limitation; no dashboard/statistical inference claim is added.

The separately reported mainline context failure is addressed by #167, not this anti-cheat change. PR review state is unchanged; nothing has been merged.

This branch has not been deployed

No deployments
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.

2 participants