Skip to content

feat(reference)!: directory-only references + anti-cheat permission window - #106

Open
akshaylive wants to merge 8 commits into
mainfrom
akshaya/fix_referencing
Open

feat(reference)!: directory-only references + anti-cheat permission window#106
akshaylive wants to merge 8 commits into
mainfrom
akshaya/fix_referencing

Conversation

@akshaylive

Copy link
Copy Markdown
Collaborator

Reference solutions were readable by the agent under evaluation — it shares a filesystem with the harness, so an agent could grep the task directory for the answer instead of solving the task. This closes the passive-read path and adds an adversarial probe to CI.

Read this first: known gap

This is defense-in-depth, not a boundary. chmod(2) is gated on owner-or-CAP_FOWNER, and the container runs as root, which owns the bind-mounted reference copy. An agent that deliberately runs chmod 755 /work/references regains access:

orchestrator chmod 000 -> rc=0
  agent read while shielded: Permission denied
  AGENT RUNS chmod 755      : rc=0 (SUCCEEDED)
  agent read after chmod    : CANARY=42

Verified on Docker Desktop even with DAC_OVERRIDE, DAC_READ_SEARCH, FOWNER and CHOWN all dropped — the owner check passes with no capability at all. Dropping those caps does close it where the mount preserves a non-root owner (native Linux), so they are kept.

Passive reads are blocked; an adversarial agent is not. Full containment requires running the agent as a non-root uid that does not own the reference — deliberately left as follow-up rather than bolted on here. Documented in CLAUDE.md, docs/DOCKER_ISOLATION.md, docs/TASK_DEFINITION_GUIDE.md, the module docstring, and the probe task itself, so nobody reads a green probe as "the reference is unreachable".

What changed

  • task.reference is directory-only. code:/file: removed — a directory is the only shape that can be permission-gated as a unit. The removed keys raise a migration error naming the replacement. reference_comparison gains a required reference_file.
  • orchestration/permissions.py::set_permissions — async CM that chmods paths for a block. Windows stack, so a nested re-grant (mode=READ_ONLY_MODE) restores the enclosing mode, not the original; the stack also subsumes what a refcount would do. Crash-safe: finally + asyncio.shield on both halves + atexit + chained SIGINT/SIGTERM.
  • Sandbox.set_permissions enforces only inside a container, gated on CODER_EVAL_IN_CONTAINERnot on sandbox.driver, which the in-container entrypoint rewrites to tempdir (a driver-based gate would silently disable the feature on exactly the path that needs it; regression-guarded).
  • Docker: throwaway read-write copy at /work/references (:ro cannot be chmod'd — EROFS), empty tmpfs masking the in-task-dir original, four caps dropped.
  • $REFERENCE_DIR token in judge files: + REFERENCE_DIR env for run_command. reference_code removed from the criteria SPI; CheckContext.reference_dir supersedes it.
  • tasks/anti_cheat_reference — adversarial probe, tagged smoke-pass, wired into e2e-smoke (EXPECTED_SMOKE_PASS_RUN 7→8; the glob names the subdir explicitly because tasks/*.yaml is not recursive).

The task directory is deliberately not shielded: under docker it is a :ro mount (EROFS) and the same YAML is readable at /work/input regardless, so it only produced a per-turn warning.

How the bugs were found

The probe task caught a real bug on its first live run — the agent read /work/references in full, because a :ro mount can't be chmod'd so the code was shielding an in-container staged copy while the real mount stayed readable. Every unit test passed; only an agent reaching for the actual path exposed it. A subsequent 8-axis review found the chmod-restore bypass above, plus: task_dir shielding was inert under docker, resolve_reference_dir keyed container detection on a bare /work/references probe (would hijack any host with that path), the acquire half of the window wasn't cancellation-shielded, _stage_reference leaked its tempdir on copy failure, and agent_judge silently dropped $REFERENCE_DIR entries when include_reference: false. All fixed here.

Verification

  • make check / make lint (177) / make test (3955 passed, 8 skipped) green. make typecheck unchanged at 3 pre-existing openai_codex import errors (confirmed identical on a clean checkout).
  • Probe run live under docker: 1/1, all 6 criteria pass, agent denied, zero chmod warnings.
  • CI smoke-pass invocation simulated locally: tasks_run=8 matches EXPECTED_SMOKE_PASS_RUN.
  • Key guards mutation-tested (revert the fix → the test fails): the writable mount, the container gate, the _setup wiring, and the stack semantics.

Breaking changes

reference: {code:} / {file:}reference: {directory:}. reference_comparison requires reference_file. Third-party criteria must drop reference_code from _check_impl/_check_impl_async. Judge prompts change shape for reference-bearing tasks (agent_judge mounts instead of inlining; llm_judge inlines labelled per-file blocks), so such suites need re-baselining.

🤖 Generated with Claude Code

…indow

Reference solutions were readable by the agent under evaluation, which shares
a filesystem with the harness — an agent could grep the task directory for the
answer instead of solving the task.

- `task.reference` is now directory-only. `code:`/`file:` are removed (a
  directory is the only shape that can be permission-gated as a unit); the
  removed keys raise a migration error naming the replacement.
  `reference_comparison` gains a required `reference_file`.
- New `orchestration/permissions.py::set_permissions` — an async context
  manager that chmods paths for the duration of a block. Windows STACK, so a
  nested re-grant restores the enclosing mode rather than the original.
  Crash-safe (finally + shield + atexit + SIGINT/SIGTERM).
- `Sandbox.set_permissions` wraps it and enforces only inside a container,
  gated on CODER_EVAL_IN_CONTAINER — NOT on sandbox.driver, which the
  in-container entrypoint rewrites to "tempdir".
- Docker mounts a throwaway read-write copy of the reference at
  /work/references (`:ro` cannot be chmod'd — EROFS), masks its in-task-dir
  original with an empty tmpfs, and drops DAC_OVERRIDE/DAC_READ_SEARCH/
  FOWNER/CHOWN.
- Criteria address the reference via `$REFERENCE_DIR` (judge `files:`) and the
  REFERENCE_DIR env var (`run_command`). `reference_code` is removed from the
  criteria SPI; `CheckContext.reference_dir` supersedes it.
- `tasks/anti_cheat_reference` is an adversarial probe wired into CI smoke.

KNOWN GAP (documented in CLAUDE.md, docs/DOCKER_ISOLATION.md and the module):
this is defense-in-depth, not a boundary. chmod(2) is gated on
owner-or-CAP_FOWNER and the container runs as root owning the copy, so an
agent that deliberately runs `chmod 755 /work/references` regains access
(verified on Docker Desktop even with all four caps dropped). Passive reads
are blocked; an adversarial agent is not. Full containment requires running
the agent as a non-root uid — follow-up.

BREAKING CHANGE: `reference: {code: ...}` and `reference: {file: ...}` are no
longer accepted; use `reference: {directory: <dir>}`. `reference_comparison`
now requires `reference_file`. Third-party criteria must drop the
`reference_code` parameter from `_check_impl`/`_check_impl_async`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread tests/test_reference_permissions.py Fixed
Comment thread tests/test_reference_permissions.py Fixed
Comment thread tests/test_reference_permissions.py Fixed
Comment thread src/coder_eval/orchestration/permissions.py Fixed
Comment thread tests/test_reference_permissions.py Fixed
Comment thread tests/test_reference_permissions.py Fixed
Review fixes:
- Move permissions.py to a top-level leaf (fs_permissions.py): sandbox.py
  importing from orchestration/ was a layering inversion that would become a
  real cycle the moment the module needed anything from coder_eval.
- Hard-fail when in-container with a declared reference but no /work/references
  mount. The old fallback resolved to the UN-masked reference under the :ro
  task-dir bind, which the window then cannot chmod (EROFS) — so the run would
  complete with the solution readable, reporting a normal pass/fail.
- Scrub keys now match what the judge was actually shown: render_reference_dir
  truncates per file but collect_reference_secrets returned the untruncated
  text, so any file over max_file_chars was echoed verbatim into persisted
  transcripts and CriterionResult.details.
- Bound the inlined reference block (200k chars) with an explicit omission
  marker; unbounded, a large tree blew the judge's context into a 0.0 score.
- One token-matching rule (`path_uses_token`) shared by the judge resolver and
  the load-time validator; they disagreed, so `$REFERENCE_DIRECTORY/x` was a
  sandbox path to one and a reference consumer to the other.
- Validator now also catches `$REFERENCE_DIR` in a run_command `command`.
- `check`/`check_async` take turn_records/context keyword-only, matching
  `_check_impl*` — an untyped caller could otherwise bind a str to turn_records.
- Drop the stale `reference_code` parameter from ~12 test overrides, including
  one that forwarded it positionally into a now keyword-only base (latent
  TypeError, unreached only because that checker never runs).
- Set the crash-handler installed flag only after a successful install; drop
  the sub_agent alias; cache the resolved reference source instead of
  re-stat-ing it; fix the stale `task.reference.file` comment and the
  docs/EXTENDING.md SPI exemplar.

CodeQL:
- Replace `pytest.raises` with explicit try/except in three tests — CodeQL
  cannot model it as catching, so it reported the following asserts as
  unreachable and their variables as unused (alerts 77/78/80).
- Use 0o700 instead of group-readable 0o750 in the mode-preservation test (75).
- `await task` no-effect alert cleared by the same restructure (76).
- `_handlers_installed` write is now the last statement in the guarded block (79).

New coverage (+15): `_setup` actually arms the feature (mutation-verified);
`$REFERENCE_DIR` resolver incl. the `$REFERENCE_DIRECTORY` lookalike and the
no-reference case; REFERENCE_DIR env var set/unset; chmod-refusal skips its
pop; unresolvable paths warn; out-of-order release of differing modes;
deterministic render ordering and per-file truncation; rmtree of a tree left at
mode 000; and a CI drift guard asserting EXPECTED_SMOKE_PASS_RUN and the
smoke-pass globs match the tagged task set (both mutation-verified).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/coder_eval/fs_permissions.py Fixed
Comment thread tests/test_reference_permissions.py Fixed
Comment thread tests/test_reference_permissions.py Fixed
@UiPath UiPath deleted a comment from github-actions Bot Aug 11, 2026
akshaylive and others added 3 commits August 11, 2026 17:00
- Move the install-once flag from a module-level global onto _PermissionStack.
  A mutable global read only by its own writer reads as dead to static analysis
  (py/unused-global-variable), and the state belongs with the registry whose
  entries the handlers restore.
- Use string-target monkeypatch in the two new tests instead of re-importing
  coder_eval.fs_permissions, which the module already imports with
  `from ... import` (py/import-and-import-from).

Also adds the crash-safety test the review flagged as missing: asserts the
atexit hook and signal handlers install on the first push, do not re-install on
the second, and that restore_all actually restores. Mutation-verified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Windows Smoke Test job runs the full suite on a Windows runner, where
`chmod` honours only the read-only bit — so mode 000 never takes and 17 of the
new assertions read back 0o555. `os.geteuid` also does not exist there.

Skips the host-side POSIX-mode tests on win32 (matching the existing idiom in
test_docker_runner_mounts.py) and replaces the geteuid root check with a
portable helper.

This is NOT a coverage gap for Windows users: the window is enforced only when
CODER_EVAL_IN_CONTAINER=1, which only DockerRunner sets, and Docker Desktop on
Windows runs LINUX containers — so the in-container orchestrator that performs
the chmod is on Linux and behaves exactly as these tests assert. A Windows host
only sees the window under `driver: tempdir`, where it is a deliberate no-op on
every platform. The real behaviour stays covered by the Linux jobs and by
tasks/anti_cheat_reference, which runs inside the container.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
uipreliga

This comment was marked as outdated.

akshaylive and others added 2 commits August 11, 2026 21:29
…anti-cheat

Works through the pr:106 review (1 critical, 7 high, 15 medium, 11 low) plus
the outstanding CodeQL alerts. The theme is the layer that decides scores.

Scoring correctness (an eval-config error must not read as an agent failure):
* reference_comparison now raises CheckerMisuseError (-> FinalStatus.ERROR) for
  a typo'd/unreadable/empty reference_file instead of returning a gating 0.0,
  which was counted against the agent's pass rate and silently zeroed every row
  of a dataset-fanned suite. reference_file also gained a load-time validator
  (non-empty, relative, no ..), and the orchestrator pre-flights it during
  _stage_reference so it fails before the agent burns a token.
* Reference integrity: the tree is hashed at staging and re-verified before
  grading. The window is per-turn and the docker mount must be writable, so an
  agent-backgrounded writer could previously overwrite the reference and drive
  reference_comparison to 1.0. Mismatch now raises ReferenceTamperedError.
* Judge scrubbing is keyed on what reached the prompt (JudgeContext records the
  reference bytes it attached), not on include_reference. The documented
  `include_reference: false` + `files: [$REFERENCE_DIR/rubric.md]` combination
  was persisting the solution verbatim into the archived judge transcript.
  agent_judge now also passes max_file_chars, so the key matches truncated text.

Anti-cheat, fail closed:
* Stop dropping FOWNER/CHOWN. The in-container orchestrator that APPLIES the
  window is the same root process with the same caps, so dropping FOWNER breaks
  the harness's own chmod wherever the bind mount preserves a non-root owner
  (native Linux). Verified: container root + uid-1000-owned dir + FOWNER dropped
  -> "Operation not permitted". The drop only bit where it also disabled the
  control. The re-chmod hole stays the documented KNOWN GAP (needs a non-root
  agent uid, not a smaller capability set).
* A window that cannot be applied is now a hard error (strict=True whenever
  Sandbox actually enforces), not a warning — an unprotected run must not be
  indistinguishable from a protected one downstream.

fs_permissions:
* Crash handlers install from the event-loop thread. They were installed from
  push(), which only runs on an asyncio.to_thread worker where signal.signal
  raises ValueError into a swallowing except — so SIGTERM had NO restore, and
  the flag latched anyway. Install now reports success and is retried if it
  fails; a failed install logs WARNING.
* The acquire moved inside the try. asyncio.shield protects the inner task, not
  the await, so a cancel on __aenter__ skipped the finally while every chmod
  completed — leaving the path at 000 with no matching pop and a stale registry
  entry that poisoned the next window.
* pop() keeps its entry when the restoring chmod fails, so restore_all still
  holds the pre-window mode.
* Precise signal typing removes the blanket `# type: ignore`; SIG_IGN is handled.

Cleanup / dedupe:
* rmtree_restrictive moved to path_utils and WIRED IN — it had no production
  caller, while both live cleanup sites used the swallowing rmtree its own
  docstring rejects, orphaning mode-000 reference trees.
* _cleanup keys on _reference_staging_root, recorded before the copy, so a
  copytree that raises does not leak a partial copy of the solution.
* Reference resolution and the copytree ignore list are shared between the two
  drivers (resolve_host_reference_dir, REFERENCE_COPY_IGNORE).

API / validators:
* SuccessChecker.check/check_all/check_all_async take trailing args keyword-only
  (reference_code was removed from the middle, so positional callers misbound
  silently). scrub_reference is list[str] | None — str satisfied the old union.
* The reference-consumer validator narrows with isinstance instead of untyped
  getattr, and matches ${REFERENCE_DIR} via a new command_uses_token seam.
* Sandbox gained a reference_dir constructor kwarg, mirroring task_dir.

Probe:
* verdict.txt/command_executed are weight 0 — `weight` does not soften a strict
  AND gate, and this task blocks the e2e-smoke bucket.
* Step 3 used $TASK_DIR, which is NOT in the agent's environment, so it expanded
  to empty and the tmpfs-mask check was inert. It now hunts the path with find.
* Verified live against a rebuilt container: 1/1, all six criteria, agent denied.

Lint (each traceable to a defect above): CE033 no unreferenced private helper in
src/, CE034 acquire inside the try of an async CM, CE035 no gating 0.0 from an
except OSError in a checker. All three verified to fire on the original code.

Also: 100% coverage on the in-container branch (was 0%, and docker is the only
driver the feature runs on); fs_permissions 89% -> 98%; assert the dropped-cap
set exactly; pin the probe's canary to its own detector; Makefile smoke globs
pinned to CI's; migration + score-comparability + image-lockstep notes; removed
an inert `# nosec` (bandit does not flag asyncio.create_subprocess_shell).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…o wire

The module docstring said "No in-tree caller needs the inner form today ... The
stack exists so that adding one does not require reworking this module", which
reads as textbook speculative generality — and the PR review duly filed it as
one, recommending the stack and READ_ONLY_MODE be deleted.

They should not be. The re-grant is a designed seam for live success criteria:
early-stop verdicts run mid-turn, inside the 000 window, so a live criterion
that consults the reference has to read it exactly while the agent cannot. A
flat set/restore cannot express that and a refcount actively breaks it.

Records that, plus the remaining work and the three non-obvious constraints
found while scoping it (all deliberately NOT implemented here):

* the window belongs around the watcher's verdict LOOP — tightest placement,
  which matters because a chmod is global state and the agent runs concurrently,
  so the re-grant is visible to it for as long as it is open;
* that loop is a sync StreamCallback, so it needs a sync twin pushing onto the
  same registry;
* live_verdict gains no parameter — it reads a per-task accessor, which must be
  a ContextVar and not os.environ, because `run_batch -j 8` shares one process
  and a process-global would leak one task's reference into a sibling's verdict
  under parallelism only. (REFERENCE_DIR today is set only in the env= dict for
  run_command subprocesses, so it is not readable in-process.)

Also scopes it: only the reference is shielded, never the sandbox. Reading the
static reference mid-turn cannot break LiveVerdict monotonicity; reading the
half-written sandbox can, and is the end-state peeking live_verdict rules out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@akshaylive

Copy link
Copy Markdown
Collaborator Author

Review addressed — all 8 blockers, 15 non-blocking, 11 nits

Pushed as c0cdd72 (fixes) + d3e30aa (docstring). Verified with mutation testing: each fix was reverted individually and the guarding test confirmed to fail.

Blockers

# Finding Resolution
1 _rmtree_restrictive had no caller Hoisted to path_utils.rmtree_restrictive, wired at orchestrator.py:2417 and docker_runner.py:620; test retargeted at _cleanup
2 fs_permissions "speculative generality" Declined — see below
3 In-container branch 0% covered orchestration/evaluation.py now 100%; new container_mode fixture + 5 tests
4 Scrub gated on include_reference JudgeContext.reference_secrets records what actually reached the prompt; both judges key on it; agent_judge now passes max_file_chars
5 SIGINT/SIGTERM restore dead Installed from the event-loop thread; install reports success so a failure is retried, not latched; WARNING not DEBUG
6 --cap-drop FOWNER self-defeating Drop removed; failed window is now a hard error
7 weight: 0.5 gates e2e-smoke Both corroboration criteria → weight: 0
8 Typo'd reference_file → gating 0.0 CheckerMisuseErrorFinalStatus.ERROR, plus a load-time validator and a _stage_reference pre-flight

On #6 — reproduced the failure directly

container root + uid-1000-owned dir + FOWNER dropped
  -> chmod: Operation not permitted

The in-container orchestrator that applies the window is the same root process with the same caps as the agent, so dropping FOWNER breaks the harness's own chmod wherever the bind mount preserves a non-root owner (native Linux, i.e. CI) — the run then completes unprotected while looking protected. The drop only bit where it also disabled the control. Kept, and Sandbox.set_permissions now passes strict=True whenever it enforces, so an unapplied window fails the run instead of producing a normal-looking score.

On #2 — declined, with the underlying doc defect fixed

The reviewer's facts were right (READ_ONLY_MODE has no in-tree caller) but the conclusion wasn't. The re-grant is a designed seam for live success criteria: early-stop verdicts run mid-turn, inside the 000 window, so a live criterion consulting the reference must read it exactly while the agent cannot. A flat set/restore can't express that; a refcount actively breaks it.

What misled the review was the docstring, which said "No in-tree caller needs the inner form today... The stack exists so that adding one does not require reworking this module." d3e30aa replaces that with the real rationale plus the remaining work and three non-obvious constraints found while scoping it (window around the verdict loop, sync twin needed for the StreamCallback, accessor must be a ContextVar not os.environ because -j 8 shares a process). Deliberately not implemented in this PR.

Also note deleting the signal chaining (as #2 proposed) would have deleted the fix for #5.

Beyond the listed findings

  • Reference integrity — the tree is hashed at staging and re-verified before grading. The window is per-turn and the docker mount must be writable, so an agent-backgrounded writer could previously overwrite the reference and drive reference_comparison to 1.0. Now ReferenceTamperedError.
  • Probe step 3 was inert — it used $TASK_DIR, which is not in the agent's environment (only in run_command's subprocess env), so it expanded to empty and the tmpfs-mask check never ran. Now hunts the path with find.
  • The # nosec was fully inert — bandit doesn't flag asyncio.create_subprocess_shell at all, so both ids were dead, not just B604. Removed rather than narrowed.
  • 3 lint rules, each verified to fire on the original code: CE033 (no unreferenced private helper), CE034 (acquire inside the try of an async CM — asyncio.shield protects the inner task, not the await), CE035 (no gating 0.0 from except OSError in a checker).

Verification

4200 passed, 181 lint, ruff clean, bandit clean (no inert suppressions), typecheck at its pre-existing baseline. Coverage: evaluation.py 82.9% → 100%, fs_permissions.py 89.2% → 97.6%.

Ran the probe live against a rebuilt container: 1/1, all six criteria, agent denied at every step. Step 3's find returned nothing (tmpfs mask working, and genuinely exercised for the first time), and the agent pasted the detector pattern from task.yaml into findings.txt where the self-non-matching regex correctly did not fire.

"""Dropped reference files change what the judge grades against, so the
omission must be visible in the prompt rather than read as 'the reference
doesn't implement that'."""
import coder_eval.evaluation.judge_context as jc
await started.wait()
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
…the task dir

The `--cap-drop DAC_OVERRIDE --cap-drop DAC_READ_SEARCH` added for the
reference anti-cheat broke EVERY `driver: docker` task. The container runs as
root but does not own the framework-owned bind mounts -- on native Linux they
preserve the uid that ran coder-eval -- so all its access is an "other" access
that only ever worked via the capability. The in-container orchestrator died on
its first `open('/work/output/task.log', 'w')` with EACCES, taking
byod_smoke_test (which has nothing to do with references) down with it. macOS
Docker Desktop hid this: virtiofs reports the mount as root-owned.

`grant_container_access` widens the framework-owned mounts host-side, so access
goes through the `other` bits instead of a capability. `chmod -R o+rwX`
semantics; read-only for what the container merely consumes. The drop and the
widening are counterparts -- drop without widening kills every docker task,
widen without dropping makes the mode-000 window a no-op.

Also replaces the symmetric `-v <host task dir>:<host task dir>:ro` mount with
a shielded COPY at /work/task_dir, held at mode 000 for every agent turn
alongside the reference. Verified against a real container: `:ro` makes the
window inexpressible (`chmod: Read-only file system`), and read-write without a
copy chmods the operator's own `tasks/` tree (host dir came back 0600; cleanup
then failed with Permission denied). This retires the `--tmpfs` mask and closes
a leak it could not reach -- a flat `tasks/foo.yaml` has parent `tasks/`, so the
old mount exposed every sibling task's reference solution.

Symmetry was never load-bearing: run_task_internal_command uses --task-dir only
to seed TASK_DIR, and never re-reads the path. TASK_DIR is exposed solely to
run_command criteria, so the agent loses nothing legitimate.

Does NOT hide the task definition: task.yaml is also staged at /work/input, and
that mount is untouched by the window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@uipreliga

Copy link
Copy Markdown
Collaborator

Code Review — PR #106

Reviewed per .claude/shared/review-rubric.md (Review Principles + the 18-item Review Criteria) and .claude/commands/coder-eval-code-review.md's Severity Standard. No fixes applied — findings only, as requested.

Review Metadata

Timestamp 2026-08-12T08:25:00Z
Git SHA 9192aa0e4e8ed54aff64621d304a7a99488782d7
Branch akshaya/fix_referencing (vs main)
Scope full branch diff — 67 files, +4979/−1099
Reviewers gemini-3.1-pro-preview, gpt-5.3-codex (via mcp__multi__codereview), Opus
make verify passed — 4146 passed, 3 skipped, coverage 92.04%

Nothing here is 🔴 Critical or 🟠 High. Every finding below was reproduced against the code; two reviewer findings were downgraded after verification (noted inline), and none of the six Mediums blocks the anti-cheat control from working as advertised under docker.


🟡 Medium

M1 — grant_container_access(output_dir, writable=True) recursively world-writes the real run directory, and never reverts it.

  • File: src/coder_eval/isolation/docker_runner.py:638 (helper at :467)
  • Trigger: Checklist 7 (resource/state cleanup) + security-class; Principle "bug-free code"
  • Why it is real: output_dir = self.rt.run_dir.resolve() (:608) is the operator's real per-task run directory, not a throwaway copy like the other four widened trees (:1019, :1063, :1093, input_dir under staging). grant_container_access walks root.rglob("*") and ORs in 0o006+0o001, so after any driver: docker task the host's runs/<id>/<variant>/<task>/…task.json, task.log, preserved artifacts — is left mode o+rw. Nothing restores it (unlike fs_permissions, there is no window/atexit pairing here). On a shared host or a CI runner with more than one uid, any local user can rewrite a finished eval result, which is precisely the integrity property this PR exists to protect.
  • CVSS: CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:N (5.5)
  • Recommendation: widen only what the container must write and restore it afterwards, or run the widening against a staging output dir that is copied into run_dir on completion. At minimum, restrict to 0o002/0o001 on directories rather than o+rw on every file, and say so in docs/DOCKER_ISOLATION.md.

M2 — the DAC cap drop is container-wide, but the compensating widening covers only framework-owned mounts.

  • File: src/coder_eval/isolation/docker_runner.py:1335 (drop) vs :1476:1522 (_auto_mount)
  • Trigger: Checklist 1 (correctness / edge cases); focus area (c)
  • Why it is real: --cap-drop DAC_OVERRIDE --cap-drop DAC_READ_SEARCH removes container root's bypass on every bind mount, and the docstring at :467 explains exactly why that broke /work/output. The same reasoning applies to the mounts that were not widened: agent.plugins[].path (:1497), sandbox.template_sources[].path (:1504), agent.system_prompt_file's parent (:1512), cfg.extra_mounts (:1522), and the LITELLM_COST_LOG parent (:1397). Those are the user's real directories mounted :ro, and on native Linux the bind preserves the host uid, so container root now reaches them only through the other bits. A checkout or template dir created under umask 077 (common on hardened distros and some CI images) becomes 0700 and every docker task fails at sandbox setup with EACCES — where before it silently worked via CAP_DAC_OVERRIDE. It fails loudly, which is why this is Medium and not High, but the error will not point at the cap drop.
  • Recommendation: add a host-side preflight that stats each auto-mounted path and raises/warns with an explicit "not reachable with DAC_OVERRIDE dropped — chmod o+rX or use extra_mounts of a copy" message. Do not widen the user's real directories in place (see M1).

M3 — the empty-tmpfs mask is gone from the code but still documented as present on 7 surfaces, including two that overstate the guarantee to an operator.

  • File: src/coder_eval/isolation/docker_runner.py:1431,1440,1518; src/coder_eval/orchestration/evaluation.py:68; docs/DOCKER_ISOLATION.md:307; docs/TASK_DEFINITION_GUIDE.md:1303,1332; CLAUDE.md:144; tasks/anti_cheat_reference/anti_cheat_reference.yaml:24; tests/test_reference_permissions.py:1
  • Trigger: Principle "CLAUDE.md adherence" + Checklist 3 (ripple completeness); confirmed independently by 3/3 reviewers
  • Why it is real: _reference_mount_args states plainly "No tmpfs mask any more" (:1263) and no --tmpfs is emitted anywhere, yet:
    • _build_argv:1431 still numbers the mask as mechanism chore: Bump astral-sh/setup-uv from 4.2.0 to 8.3.2 #1 and :1440 explains its mount-ordering; :1518 calls it "the exact hole the tmpfs mask above closes".
    • resolve_reference_dir's docstring justifies its container-mount-wins branch on "Resolving relative to task_file would therefore find that empty mask" — the stated reason for a load-bearing branch no longer exists.
    • docs/DOCKER_ISOLATION.md:307 tells operators "an empty tmpfs is layered over its original location … so the agent cannot reach the solution through $TASK_DIR". The real protection is now a non-recursive chmod 000 on /work/task_dir that holds only during a turn. The doc promises a structural mask; the code provides a temporal one.
    • CLAUDE.md:144 contradicts itself in one paragraph: "The task directory is not shielded (:ro mount → EROFS…)" and then describes the tmpfs mask — while orchestrator.py's window comment says "task_dir is shielded ALONGSIDE reference_dir. It previously was not".
    • The probe task's own description claims the container "drops DAC_OVERRIDE / DAC_READ_SEARCH / FOWNER / CHOWN". Only the first two are dropped, and the PR body explains at length why the other two are deliberately kept. A future reader will trust the task narrative over the argv.
    • tests/test_reference_permissions.py:1 asserts in prose that the task dir is "deliberately NOT shielded" and cites test_task_dir_is_not_shielded, which does not exist; the actual test (:592) asserts observed["task_dir"] == "DENIED".
  • Recommendation: one sweep over those nine locations. Since CLAUDE.md is the SSOT the review/plan commands read, fix it first.

M4 — a strict (enforced) window fails OPEN when Path.resolve() raises, and the comment that authorises this is now false.

  • File: src/coder_eval/fs_permissions.py:400406
  • Trigger: Checklist 1 + 6 (fail-closed / allowlist posture); found by gpt-5.3-codex, verified
  • Why it is real: the resolve loop logs a warning and continues regardless of strict. If every path drops out, resolved is empty → no crash handlers, no push, held == [] → the block runs with no window at all and the run completes reporting a normal pass/fail. That is the exact outcome strict=True was introduced to prevent ("an unprotected run that reports a normal pass/fail is worse than no run", :212). The comment at :403 — "Same fail-open outcome as a chmod refusal, so it gets the same visibility" — was true before strict existed and is now wrong: a chmod refusal fails closed. Likelihood is low (resolve(strict=False) raises mainly on ELOOP/ENAMETOOLONG), which is why this is Medium.
  • Recommendation: thread strict into the resolve loop and raise PermissionWindowError there; drop the stale comment.

M5 — a strict push that raises mid-list leaves the already-pushed paths at mode 000 with no matching pop.

  • File: src/coder_eval/fs_permissions.py:448457
  • Trigger: Checklist 7 (cleanup on error paths); gemini-3.1-pro-preview rated this Critical — I rate it Medium and disagree with its severity
  • Why it is real, and why Medium: the behaviour is exactly as documented — the list comprehension aborts, the CM's finally re-awaits push_task, that await re-raises, and _pop_all never runs. Recovery then depends entirely on _registry.restore_all firing from atexit. That holds today only because strict=True is reachable solely in-container (Sandbox.enforces_permission_windows), where the process owns exactly one task and exits shortly after. Nothing at that call site states or enforces that invariant, so the day strict becomes reachable on a host run_batch -j N, one task's aborted push strands a path at 000 mid-run for every sibling sharing it. Also worth noting: PermissionWindowError propagates into run()'s broad except Exception, so the process continues to cleanup with the entry still on the stack (rmtree_restrictive absorbs that, by design).
  • Recommendation: have _push_all catch, unwind what it pushed, and re-raise (raise … from) so the failure still aborts the run without leaving state behind — or assert the one-task-per-process invariant in the docstring at :452 where the trade-off is argued.

M6 — the reference is now duplicated into the /work/task_dir copy, and neither the probe nor the docs cover that second path.

  • File: src/coder_eval/isolation/docker_runner.py:1059; tasks/anti_cheat_reference/anti_cheat_reference.yaml
  • Trigger: Checklist 4 (a test that would not catch the regression); focus area (b)
  • Why it is real: _prepare_task_dir_mount copytree's the whole task directory with ignore=[".git"] only, so for the canonical layout (tasks/<name>/{task.yaml,reference/}) the solution exists at both /work/references/… and /work/task_dir/reference/…. chmod is non-recursive, so during a turn only the two parents sit at 000 — that does block traversal once the DAC caps are dropped, so the probe passes. But between turns and after the final turn both trees are readable, and _verify_reference_integrity hashes only self._reference_dir. So documented KNOWN GAP (b) is wider than CLAUDE.md and docs/DOCKER_ISOLATION.md describe: there are two readable copies, and the task-dir one is mentioned nowhere. Grading integrity is unaffected (grading reads /work/references, and writable=False withholds o+w from both copies), which caps this at Medium.
  • Recommendation: exclude task.reference.directory from the task-dir copytree (a one-line addition to the ignore callable) so there is a single shielded copy, and extend the probe with a between-turns / post-window assertion or an explicit note that it does not cover one.

🔵 Low (reported, not fixed)

L1 — digest_tree ignores directory topology and symlinks. src/coder_eval/path_utils.py:37 filters to p.is_file() and not p.is_symlink(), so adding/removing an empty directory, or planting a symlink, leaves the digest unchanged. gpt-5.3-codex rated this High; I disagree — grading reads file content (reference_comparison reads one file, iter_reference_files skips symlinks and dirs), so there is no path from an undetected empty-dir mutation to a forged score. Every mutation that can move a score (content edit, file add/remove, rename of a non-empty dir, file→symlink swap) already changes the digest. Worth hardening for defence-in-depth, not urgent.

L2 — atexit.register(restore_all) re-registers on every window when the signal install fails. src/coder_eval/fs_permissions.py:281 deliberately does not latch _handlers_installed unless all signal handlers went in, but _install_crash_handlers calls atexit.register unconditionally on each retry. For a library embedder driving coder_eval off the main thread, that is one more atexit entry per turn — unbounded growth and N redundant restore_all calls at exit. Move the atexit.register behind its own one-shot flag.

L3 — YAGNI: READ_ONLY_MODE and the stack-instead-of-refcount design have no production consumer. src/coder_eval/fs_permissions.py:135. The module docstring spends ~25 lines specifying a future live-criterion mid-turn re-grant under a "NOT WIRED UP YET" header, and no shipped call site nests windows (the single set_permissions([...]) in _communicate_with_retry wraps the whole retry loop). CLAUDE.md lists YAGNI and "no speculative features" explicitly. gemini-3.1-pro-preview independently flagged this and also settled on Low. The reasoning in the docstring is good enough that I would leave the code and instead move the unimplemented-consumer spec to an issue, so the module documents what it does.

Perf note (not a finding): _prepare_task_dir_mount copytree's the task directory once per docker task, and for a flat tasks/foo.yaml layout the source is the entire tasks/ tree. It is 640 KB in this repo, so this is free today — but it scales with fixture size × task count × -j N, where the old symmetric :ro bind was free. Worth a comment at :1059 noting the trade-off.


Positives

  • The strict=True fail-closed decision is the right call and is argued at the seam where it is made (fs_permissions.py:207216). "A broken anti-cheat control is indistinguishable from a working one in every downstream consumer" is exactly the failure mode that matters for an eval harness.
  • Gating enforces_permission_windows on CODER_EVAL_IN_CONTAINER instead of sandbox.driver, with TestSandboxDriverGate pinning it, closes a bug that would have silently disabled the whole feature on the only path that needs it. resolve_reference_dir keying on the same var, rather than a bare /work/references probe, is the same insight applied twice.
  • CE034 compliance in set_permissions (:423:445) is genuinely subtle and correct: the acquire inside the try, the finally re-joining a shielded push it may have been cancelled out of, and the shielded unwind. Both external reviewers called this out as a positive. Same for pop's chmod-inside-the-lock (:237:243), whose comment explains the concrete corruption releasing early would cause.
  • Routing reference_comparison's bad-reference_file cases to CheckerMisuseErrorFinalStatus.ERROR, with the # noqa: CE035 reserved for the one case that really is the agent's, is the right split — and pre-validating in _validate_reference_consumers before the agent burns a token is better still.
  • Deriving the judge scrub set from JudgeContext.reference_secrets (what was actually attached) instead of criterion.include_reference (what was configured) fixes a real leak: $REFERENCE_DIR/… in files: with include_reference: false. Emitting both the truncated and untruncated forms, because scrub_reference matches by exact substring, is the kind of detail that is usually missed.
  • Narrowing scrub_reference to list[str] specifically because str satisfies Iterable[str] and would have had its characters iterated as sub-8-char "secrets" (silently redacting nothing) is a good use of the type system as a guard.
  • The probe task is the strongest artifact here: it found a real leak on its first live run that every unit test had passed. Documenting its scope ("read it as 'an agent that merely looks cannot find it'") in the task itself is the right instinct — M3 is about keeping that narrative true, not about the approach.

🤖 Generated with Claude Code — multi-model review (gemini-3.1-pro-preview + gpt-5.3-codex + Opus), findings verified against the tree at 9192aa0. No code changed.

@uipreliga
uipreliga self-requested a review August 12, 2026 15:29

@uipreliga uipreliga 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.

Fix what you agree with, maybe ask someone else also for review?

@bai-uipath bai-uipath 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.

Approve — but please do a full suite dry-run before this lands. Not a formality: this changes container-wide access semantics, and there are a lot of features used by only a handful of tasks that all touch these paths. Unit tests and the smoke bucket won't reach them.

It has to be a native-Linux host (the VM or an ADO agent, not Docker Desktop — virtiofs hides the entire cap-drop class), over a slice that covers templates, plugin mounts, a uip-auth task, a simulation and a reference-bearing task, grepping the logs for EACCES. The reason: dropping DAC_OVERRIDE/DAC_READ_SEARCH removes container root's bypass on every bind mount, but only the framework-owned copies get widened. Every auto-mounted host path keeps its host modes and must now be world-readable, and ~/.uipath is mounted read-write and is written to in-container.

Checked and clear from my side: pre_run/post_run, $TASK_DIR criteria, simulation turns, and fixtures reaching the agent all sit outside the window.

Likely risks

Rated 0-10 on likelihood of actually breaking something, 0 = no risk.

Area Risk Note
Host mounts under the cap drop 6 Plugins, template dirs, extra mounts keep host modes; ~/.uipath needs write access it may not have
Out-of-tree criteria 4 reference_code is gone from the SPI — other suites' custom criteria break at call
Downstream task migration 4 On the current pin directory: loads but the judge silently drops the reference, so there's no safe intermediate state: migration and pin bump have to be atomic
Probe in the blocking smoke bucket 4 Gating criteria depend on model behaviour, so it can redden unrelated PRs
Judge prompt shape 3 One header line for single-file references; agent_judge consumers move from inlined to mounted
New strict failures 3 A refused chmod now errors the task rather than warning
Run dir left world-writable 3 Widening is never reverted
Per-task task-dir copy 1 Negligible nested, whole tree for flat layouts

Fix if you agree, otherwise lgtm

  • The reference exists twice. The task-dir copy carries it too, and the integrity hash only covers the dedicated mount. Excluding reference.directory from that copytree leaves one shielded copy.
  • Scope the claim in the docs. Where a suite mounts its repo root as a plugin, the same answer key is readable outside the window for the whole run, and the staged task YAML still carries the full criteria — worth saying plainly so a green probe isn't read as coverage.
  • Stale narrative. The tmpfs mask and the FOWNER/CHOWN drop are still described as present across several shipped surfaces, including the probe task's own description, which is the artifact people will trust over the argv.

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.

4 participants