feat(sandbox): generate CLI recording shims via record_cli - #73
feat(sandbox): generate CLI recording shims via record_cli#73alexandrujircan wants to merge 5 commits into
Conversation
|
Claude finished @alexandrujircan's task in 1m 18s —— View job Code Review in Progress
|
d98c447 to
acf1bd4
Compare
b3a0c0d to
04dbb05
Compare
acf1bd4 to
ce8e5ea
Compare
uipreliga
left a comment
There was a problem hiding this comment.
Review: coder_eval — pr:73
Scope: pr:73 · branch feat/sandbox-record-cli · ce8e5ea · 2026-08-03T17:33Z · workflow variant
Change class: complex — generates executable shim scripts into the sandbox, mutates PATH resolution order, adds a new SandboxConfig list field with a validator, and changes an existing criterion field from required to defaulted; correctness requires reasoning about path containment, collision handling, and cross-platform execution
Overall a strong, well-factored PR (9/10) — clean layering, a 310-line dedicated test module, and no critical or security-breaking defects — but the new record_cli recorder concentrates its risk exactly where an eval harness can least afford it: a log preserved across runs, a guard that raises ValueError instead of its intended RuntimeError on macOS (2 tests red at HEAD), a shape-only tool validator that permits PATH poisoning/interpreter recursion and uncompilable shim source, and a traceless pass on log-write failure — four paths that can change a task's score or final_status for byte-identical agent output, all fixable with small local edits before merge.
Summary
| Axis | Score | 🔴 | 🟠 | 🟡 | 🔵 | Top Issue |
|---|---|---|---|---|---|---|
| 1. Code Quality & Style | 9.2 / 10 | 0 | 0 | 1 | 3 | record_cli collision guard has coverage gaps — extensionless-name-only match (misses .cmd twins) and no check against template content already in cli_mocks/ |
| 2. Type Safety | 8.4 / 10 | 0 | 1 | 1 | 1 | Unvalidated tool name is interpolated raw into the generated shim's module docstring (and .cmd twin), allowing broken or injected shim source |
| 3. Test Health | 8.5 / 10 | 0 | 0 | 3 | 0 | record_cli's declared merge strategy is the only SandboxConfig merge field missing from the test_merge_strategy_annotations parametrize list |
| 4. Security | 9.4 / 10 | 0 | 0 | 1 | 1 | record_cli has no reserved-name guard: tool: python3 makes the generated shim's own #!/usr/bin/env python3 re-resolve to itself, an infinite exec loop that hangs the task; tool: git/uv/curl silently shadow those binaries for run_command criteria too |
| 5. Architecture & Design | 9.4 / 10 | 0 | 0 | 1 | 1 | parse_log is a test-only, divergent duplicate of the JSON-Lines reader in criteria/cli_called.py |
| 6. Error Handling & Resilience | 8.4 / 10 | 0 | 1 | 1 | 1 | Seeded recorder log is preserved rather than truncated, so a prior run's invocations score the current run (DIRECT_WRITE / reused --run-dir) |
| 7. API Surface & Maintainability | 9.9 / 10 | 0 | 0 | 0 | 1 | The task guide never states that exit_code defaults to 1, so - tool: curl silently makes the shadowed tool fail |
| 8. Evaluation Harness Quality | 9 / 10 | 0 | 1 | 0 | 0 | Collision guard compares an UNRESOLVED sandbox root via relative_to(), raising pathlib ValueError instead of the intended RuntimeError — two tests red on macOS |
Overall Score: 9 / 10 · Weakest Axis: Type Safety at 8.4 / 10
Totals: 🔴 0 · 🟠 3 · 🟡 8 · 🔵 8 across 8 axes.
Blockers
- [Axis 2] Unvalidated
toolname is interpolated raw into the generated shim's module docstring (and .cmd twin), allowing broken or injected shim source (src/coder_eval/models/sandbox.py:364) — The only guard ontoolisif "/" in v or "\\" in v or v in {".", ".."}:(models/sandbox.py:364), whose docstring justifies it purely as a path check ("The shim is written as<RECORD_CLI_DIR>/<tool>"). But the same value is ALSO interpolated unquoted into generated Python source at cli_recorder.py:23 —"""Recording shim for{tool}- generated by coder_eval SandboxConfig.record_cli.— and into a Windows batch line at sandbox.py:524 (f'python "%~dp0{spec.tool}" %*'). Only theTOOL = {tool!r}binding is repr-escaped. Verified empirically against PR HEAD:RecordedCli(tool='a"""b')validates, andcompile(render_recorder(spec))raisesSyntaxError: unterminated string literal (detected at line 14); a name such asx") or __import__("os").system(...) or ("(no/, so it passes the validator) is embedded as executable code. The model's own tests only probe path-shaped names (tests/test_sandbox_record_cli.py:253 —["../evil", "a/b", "a\\b", ".", "..", "", " uip"]), so the quote case is untested. Fix: constrain the field at the schema level to what an executable name can actually be —tool: str = Field(pattern=r"^[A-Za-z0-9._+-]+$", ...)(or the equivalent check invalidate_tool_name) — and additionally escape the docstring interpolation ({tool!r}or strip it from the docstring) sorender_recordercannot emit invalid source. Cross-axis: also a security finding (task-YAML-to-code injection) and an axis-1/8 finding (silent harness break scored as agent failure). ACEnnnlint rule forbidding a bare{name}(non-!r) placeholder in a code-generating template is mechanically detectable. - [Axis 6] Seeded recorder log is preserved rather than truncated, so a prior run's invocations score the current run (DIRECT_WRITE / reused --run-dir) (
src/coder_eval/sandbox.py:511) — The seeding guard is conditional:
log_path = self.sandbox_dir / RECORD_CLI_LOG
if not log_path.exists(): # <-- sandbox.py:511
log_path.write_text("", encoding="utf-8")At setup time nothing in this run has written the log yet, so the guard's ONLY effect is to preserve a log left by a previous run — and the shim appends, so records accumulate. PreservationMode.DIRECT_WRITE (the default for driver: docker, orchestration/config.py:26) runs the sandbox directly in run_dir/artifacts/<task_id>, which the orchestrator deliberately does not clear (orchestrator.py:1039-1053: "DIRECT_WRITE deliberately does NOT clear the target dir, so a reused --run-dir (or --resume) can leave a prior run's files ... and silently perturb file-based criteria").
Reproduced at PR HEAD: pre-seed <target>/cli_mocks/calls.jsonl with one uip ixp projects delete proj-1 record, then Sandbox(...).setup(target_dir=target) -> the stale line survives verbatim, and CliCalledCriterion(verb='ixp projects delete', min_count=1) scores 1.0 with zero agent activity in this run. Same agent output, different score depending on whether the run dir was reused — the scoring-correctness class.
Fix: seed unconditionally (log_path.write_text("", encoding="utf-8")), and ideally clear the whole cli_mocks/ directory before regenerating so stale shims for tools no longer in record_cli don't stay on PATH. Add a test that pre-populates the log and asserts it is empty after setup(target_dir=...).
3. [Axis 8] Collision guard compares an UNRESOLVED sandbox root via relative_to(), raising pathlib ValueError instead of the intended RuntimeError — two tests red on macOS (src/coder_eval/sandbox.py:497) — The guard builds its message with
f"'{rel}' already provides one ({clash.relative_to(self.sandbox_dir)}). "but clash derives from user_dir = self._resolve_within_sandbox(rel, ...), which returns (self.sandbox_dir / rel).resolve() (sandbox.py:305) — a symlink-resolved path — while self.sandbox_dir is stored unresolved (Path(tempfile.mkdtemp(...)) or the caller's target_dir). When the sandbox root traverses a symlink, relative_to raises before the raise RuntimeError(msg) on line 501 ever runs. Verified at PR HEAD: uv run pytest tests/test_sandbox_record_cli.py gives ValueError: '/private/var/.../mocks/uip' is not in the subpath of '/var/.../' for TestGeneration::test_collision_with_user_mock_raises, and the routed coverage report shows line 501 uncovered — the documented RuntimeError is never actually raised anywhere in the suite. macOS is the everyday case (/var → /private/var); on Linux any --run-dir under a symlinked mount (e.g. /data → /mnt/data) under DIRECT_WRITE hits it too. Fix: compute the root once as root = self.sandbox_dir.resolve() and use clash.relative_to(root) (or clash.name), and keep the test asserting pytest.raises(RuntimeError, match="already provides one") so line 501 is genuinely covered.
Non-blocking, but please consider before merge
-
[Axis 1] record_cli collision guard has coverage gaps — extensionless-name-only match (misses
.cmdtwins) and no check against template content already incli_mocks/(src/coder_eval/sandbox.py:493) — src/coder_eval/sandbox.py:455-458 justifies the new PATH ordering with an absolute claim:Generated recorders go FIRST:
_generate_cli_recordersrefuses togenerate a shim whose name a user mock dir already provides, so this
order can never silently shadow a task's own mock
But the guard at line 493 is clash = user_dir / spec.tool / if clash.exists(): — it never checks f"{spec.tool}.cmd", even though line 524 writes (recorder_dir / f"{spec.tool}.cmd") into the directory that is now prepended ahead of the user's. Failure scenario: on a Windows host, a task with mock_path_dirs: ["mocks"] containing mocks/uip.cmd plus record_cli: [{tool: uip}] passes setup with no error; PATHEXT resolves uip to cli_mocks/uip.cmd (first on PATH) instead of the task's own mock, silently changing what the agent runs and what gets graded — exactly the outcome the comment promises is impossible, on the only platform the .cmd twin exists for. Extend the guard to the whole generated name set (spec.tool, plus f"{spec.tool}.cmd", and ideally the other PATHEXT extensions .bat/.exe), or drop the absolute wording from the comment and document the residual case.
2. [Axis 2] RecordedCli.exit_code has no POSIX range constraint — exit_code: 256 makes the shim exit 0 while the log records 256 (src/coder_eval/models/sandbox.py:338) — exit_code: int = Field(default=1, ...) (models/sandbox.py:338-344) accepts any integer, but the rendered shim ends in sys.exit(main(sys.argv)) (cli_recorder.py:88) so the value is truncated mod 256 by the OS. Verified at PR HEAD: RecordedCli(tool='uip', exit_code=256) validates, and running the rendered shim gives actual exit status for exit_code=256: $?=0 while the emitted record is {"ts": ..., "tool": "uip", "argv": ["--foo", "bar"], "exit": 256}. So a task author who configures a failing tool silently gets a succeeding one — the agent observes exit 0, changes behaviour, and the score changes; the log's exit field also stops describing the process's real status. exit_code=-1 is likewise accepted (→ real status 255). This model's neighbours already constrain their numeric fields (ResourceLimits.max_cpus/max_pids use gt=0, models/sandbox.py:36/:42), so add ge=0, le=255.
3. [Axis 3] record_cli's declared merge strategy is the only SandboxConfig merge field missing from the test_merge_strategy_annotations parametrize list (src/coder_eval/models/sandbox.py:422) — grep -rln "record_cli\|RecordedCli" tests/ tasks/ experiments/ docs/ returns only tests/test_sandbox_record_cli.py and docs/TASK_DEFINITION_GUIDE.md — no task YAML, no experiment YAML, no resolver test. The field at models/sandbox.py:422 is record_cli: list[RecordedCli] | None = MergeField(strategy="replace", ...) and its own description asserts "Replaced (not merged) across config layers, like mock_path_dirs", yet: (1) tests/test_merge_strategy_annotations.py carries a HARDCODED parametrize list that includes (SandboxConfig, "mock_path_dirs", "replace") at line 34 but has no record_cli row, so the annotation is unguarded; (2) there is no test_experiment_resolver.py case exercising the 5 layers (default → exp defaults → task → variant → CLI) for it; (3) there is no -D sandbox.record_cli=... override test; (4) no test parses a task YAML containing sandbox: record_cli: into a TaskDefinition, so the documented YAML shape in docs/TASK_DEFINITION_GUIDE.md is only prose. This is the shared-rubric Review Criterion 10 gap. Add the (SandboxConfig, "record_cli", "replace") row plus one resolver test asserting a variant-level record_cli replaces (does not append to) a task-level one.
4. [Axis 3] No test executes a shim by bare name through PATH, so the #!/usr/bin/env python3 shebang and the .cmd twin's body are unprotected (the chmod and PATH-order claims do not hold) (tests/test_sandbox_record_cli.py:37) — The only invoker is the helper at tests/test_sandbox_record_cli.py:35-43:
return subprocess.run([sys.executable, str(shim), *args], ...)
That runs the script through an explicit interpreter with an absolute path, so three mechanisms the agent actually depends on are never exercised: the #!/usr/bin/env python3 shebang, the +x bit applied at src/coder_eval/sandbox.py:517 (shim.chmod(shim.stat().st_mode | 0o111)), and PATH resolution of a bare uip against the prepended cli_mocks/ directory. Drop the chmod, or the shebang, or break the PATH prepend, and all 29 tests still pass. Add one POSIX test that runs subprocess.run(["uip", "projects", "list"], env={**os.environ, "PATH": f"{recorder_dir}{os.pathsep}{os.environ['PATH']}"}) and then grades the log. Relatedly, the .cmd twin is asserted only to exist — tests/test_sandbox_record_cli.py:55 is assert (recorder_dir / "uip.cmd").is_file() — its contents (python "%~dp0uip" %*, CRLF line endings) are never asserted or run, so a malformed batch line ships silently.
5. [Axis 3] validate_tool_name reject-branch tests miss the names that clobber the feature's own generated artifacts (tests/test_sandbox_record_cli.py:253) — The parametrize at tests/test_sandbox_record_cli.py:253 is ["../evil", "a/b", "a\\b", ".", "..", "", " uip"] — it covers every branch of validate_tool_name (src/coder_eval/models/sandbox.py:362-365) but no name that collides with an artifact the generator itself writes into cli_mocks/. Both cases are real and reproduce today:
RecordedCli(tool="calls.jsonl")— validates fine, then sandbox.py:514-516 writes the shim source over the seeded log. Verified: afterSandbox(...).setup(),cli_mocks/calls.jsonlis 2261 bytes beginning#!/usr/bin/env python3/"""Recording shim forcalls.jsonl.... The log everycli_calledcriterion reads by default is destroyed with no error.record_cli: [RecordedCli(tool="uip.cmd"), RecordedCli(tool="uip")]— verifiedcli_mocks/ends up as['calls.jsonl', 'uip', 'uip.cmd', 'uip.cmd.cmd']withuip.cmdcontaining@echo off\nREM Generated by coder_eval Sa..., i.e. theuip.cmdshim was silently overwritten by theuipbatch twin. Order-dependent clobber.
Add "calls.jsonl" (and a <tool>.cmd-vs-<tool> pair) to the reject cases and extend validate_tool_name to reject LOG_FILENAME and .cmd-suffixed names, or make the generator refuse to overwrite an existing file in cli_mocks/.
6. [Axis 4] record_cli has no reserved-name guard: tool: python3 makes the generated shim's own #!/usr/bin/env python3 re-resolve to itself, an infinite exec loop that hangs the task; tool: git/uv/curl silently shadow those binaries for run_command criteria too (src/coder_eval/models/sandbox.py:364) — validate_tool_name (models/sandbox.py:362-366) checks only shape — if "/" in v or "\\" in v or v in {".", ".."}: raise ... — never identity. Nothing rejects python, python3, env, sh, git, uv, node, and the generated dir is prepended AHEAD of everything (sandbox.py:459-462, resolved.append(generated) before the mock_path_dirs loop).
The shim's own interpreter is resolved through that same poisoned PATH: cli_recorder.py:22 is #!/usr/bin/env python3, and the Windows twin at sandbox.py:523 is f'python "%~dp0{spec.tool}" %*' — a bare python, looked up in a directory the harness just put first on PATH.
VERIFIED at PR HEAD: rendered render_recorder(RecordedCli(tool="python3", exit_code=7)) to ./python3, chmod +x, then PATH="$PWD:$PATH" timeout 5 python3 -c "print('hi')" → exit 124 (killed by timeout). env re-resolves python3 to the shim, which re-execs env, forever; the command never returns. tool: python on Windows gives the same recursion through the .cmd twin. Under driver: tempdir none of ResourceLimits.max_pids/max_cpus is enforced (ResourceLimits docstring, models/sandbox.py:20-22), so this spins until the task timeout.
Second-order integrity impact worth stating: the recorder dir does not stay confined to the agent. orchestrator.py:1084 passes resolved_mock_path_dirs as env_path_prepend to agent.start(...), and _sync_sandbox_command_path_with_agent later re-uses the agent's SDK PATH as Sandbox._command_base_path, which _build_run_command_env prepends (sandbox.py:909). So a record_cli entry for git/python/uv also shadows those binaries for every run_command criterion, silently changing the score.
Fix: reject a reserved set in validate_tool_name (at minimum python, python3, env, sh, bash, node, git, uv, cmd), and make the shim independent of PATH — emit #!<sys.executable> (or os.path.realpath(sys.executable)) instead of #!/usr/bin/env python3, and use that absolute interpreter in the .cmd line rather than a bare python. Add a test asserting RecordedCli(tool="python3") raises. CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:N/I:N/A:H
7. [Axis 5] parse_log is a test-only, divergent duplicate of the JSON-Lines reader in criteria/cli_called.py (src/coder_eval/cli_recorder.py:104) — cli_recorder.py:104 def parse_log(text: str) -> list[dict[str, object]]: is shipped in src/coder_eval/ but called from nowhere in src/ — grep -rn "parse_log" src/ at PR HEAD returns exactly one hit, its own definition; the only callers are tests/test_sandbox_record_cli.py (lines 117, 149, 161, 173, 187, 310). Its docstring (lines 107-108) asserts it is "Shared with tests and any caller that wants the log without duplicating the JSON-Lines handling in :mod:coder_eval.criteria.cli_called", but criteria/cli_called.py was NOT changed to use it and still carries its own copy at lines 209-223 (records: list[dict[str, Any]] = [] / malformed = 0 / for line in content.splitlines(): ...). The two copies have already drifted at birth: the criterion counts non-JSON lines AND non-dict JSON into malformed and surfaces that in details ("Skipped N unparseable log line(s)", line 258), whereas parse_log silently continues on both. So the module that claims to be the single source of truth for the log format is the one no production code reads — a reader can safely change parse_log believing they changed grading behaviour. Either make CliCalledChecker._check_impl consume parse_log (moving the malformed count into it, e.g. returning tuple[list[dict[str, Any]], int]) so there is one parser, or delete parse_log from the shipped package, move the helper into tests/, and drop the misleading docstring sentence.
8. [Axis 6] Recorder shim drops log records on OSError with a bare pass (cli_recorder.py:68-69), turning an unwritable log into an "agent never ran the command" score (src/coder_eval/cli_recorder.py:68) — In the rendered shim:
with open(LOG_PATH, "a", encoding="utf-8", newline="\n") as handle:
handle.write(json.dumps(entry) + "\n")
except OSError:
pass # <-- cli_recorder.py:68-69The narrow OSError scope is right (I verified the ensure_ascii claim holds: json.dumps({'a': '\udcff'}) -> '{"a": "\\udcff"}', so a surrogate from undecodable argv cannot raise, and the ASCII-only payload cannot hit UnicodeEncodeError on the utf-8 write). The problem is the pass: this log is the sole evidence the cli_called criterion scores on, and a dropped record is invisible to everyone.
Reproduced at PR HEAD: chmod 444 the seeded log, run the shim once -> the shim exits 0 with empty stderr, and SuccessChecker.check(CliCalledCriterion(verb='ixp projects configure-model', min_count=1)) returns score=0.0, error=None, details="0 invocation(s) matched ...; 0 invocation(s) recorded in 'cli_mocks/calls.jsonl'" — byte-identical to the agent never having run the command. The criterion is already careful to distinguish a missing log (harness fault, error= set) from an empty one; an unwritable log falls back into the "agent did nothing" bucket instead.
Fix: keep the command working (correctly best-effort), but leave a trace — on OSError, attempt one open(LOG_PATH + ".error", "a") sentinel (wrapped in its own try/except), and/or sys.stderr.write("coder_eval recorder: log write failed: %r\n" % exc). Then have cli_called surface the sentinel as error= rather than score 0. Also bind the exception (except OSError as exc) so the message is available at all.
Nits
-
[Axis 1] Recorder log filename
calls.jsonlis declared twice (LOG_FILENAME vs RECORD_CLI_LOG) with nothing tying writer to reader (src/coder_eval/cli_recorder.py:19) — cli_recorder.py:19 declaresLOG_FILENAME = "calls.jsonl"(the path the generated shim writes:LOG_PATH = os.path.join(SHIM_DIR, {log_filename!r})), while src/coder_eval/models/sandbox.py:314-315 independently declaresRECORD_CLI_DIR = "cli_mocks"/RECORD_CLI_LOG = f"{RECORD_CLI_DIR}/calls.jsonl"(the path the sandbox seeds at sandbox.py:509 and the pathCliCalledCriterion.lognow defaults to). The two literals must agree for the feature to work at all, but no code or assertion connects them — cli_recorder.py already imports fromcoder_eval.models, so it can derive the name instead:LOG_FILENAME = PurePosixPath(RECORD_CLI_LOG).name. Filed Low rather than Medium because the round-trip testtest_cli_called_grades_the_generated_log_with_no_log_path_configured(tests/test_sandbox_record_cli.py:190) would fail on drift; the duplication is still a second source of truth a reader has to notice. -
[Axis 1]
resolved_mock_path_dirsdocstring no longer matches the ordering it returns (omits the prepended recorder dir) (src/coder_eval/sandbox.py:441) — The property's docstring (sandbox.py:440-444) was left untouched by the change: "Absolute paths of configured mock dirs that exist on disk. / Returned in the order they appear inSandboxConfig.mock_path_dirs". As of this PR the first element can becli_mocks/, which appears inrecord_cli, not inmock_path_dirs(lines 459-462), and the same drift hits_prepare_mock_path_dirsone method up: its docstring at line 422 still says "Apply +x to plain files in eachmock_path_dirsentry" while it now also chmods+xevery file in the generated recorder dir — including thecalls.jsonllog. Update both summaries to mention the generated recorder directory and that it is returned first; the explanatory rationale is already in the body comment at 455-458 but is invisible to anyone reading only the docstring. -
[Axis 1]
shim.chmod(...)duplicates the_prepare_mock_path_dirs+x pass that runs immediately afterwards (src/coder_eval/sandbox.py:517) — sandbox.py:209-214 already sequences the two steps and says so:Generate recording shims for
record_clitools (before the +x passbelow, which also covers them)
self._generate_cli_recorders()
self._prepare_mock_path_dirs()
_prepare_mock_path_dirs (line 433-436) iterates self.resolved_mock_path_dirs, which now includes the recorder dir, and ORs in 0o111 for every plain file under it — so shim.chmod(shim.stat().st_mode | 0o111) at line 517 is dead work. Either drop line 517 and rely on the documented +x pass, or keep the chmod and drop the "which also covers them" clause so only one mechanism is described as owning the bit.
4. [Axis 2] record_cli: list[RecordedCli] | None accepts duplicate tool entries; the later shim silently overwrites the earlier one, so the effective exit_code/stdout depends on list order (src/coder_eval/models/sandbox.py:422) — record_cli: list[RecordedCli] | None = MergeField(strategy="replace", ...) (models/sandbox.py:422-423) has no uniqueness constraint on tool, while sandbox.py:514-516 writes every entry to the same name (shim = recorder_dir / spec.tool; shim.write_text(render_recorder(spec), ...)). Verified at PR HEAD: SandboxConfig(record_cli=[RecordedCli(tool='uip', exit_code=0), RecordedCli(tool='uip', exit_code=7)]) validates and yields [('uip', 0), ('uip', 7)], so the agent silently gets the last entry's exit code. Note the PR is deliberately strict about the other collision — _generate_cli_recorders raises RuntimeError when a mock_path_dirs entry provides the same name (sandbox.py:493-501) — so leaving the intra-list collision silent is inconsistent with the guard it just added. Add a @model_validator(mode="after") on SandboxConfig rejecting duplicate record_cli[*].tool values.
5. [Axis 4] recorder_dir / spec.tool is never containment-checked, so a Windows drive-relative tool name writes the shim outside cli_mocks/ (src/coder_eval/sandbox.py:515) — Every other task-author-supplied path in this module goes through _resolve_within_sandbox (mock_path_dirs at sandbox.py:464, starter_files at 554, mount_point at 346), but the tool name does not: shim = recorder_dir / spec.tool (sandbox.py:515) and (recorder_dir / f"{spec.tool}.cmd") (sandbox.py:525) join an unvalidated string straight onto the resolved dir.
On POSIX the "/" in v or "\\" in v check at models/sandbox.py:364 holds the line. On Windows — which this PR explicitly targets, per sandbox.py:475-476 "A .cmd twin is written beside it so a bare uip also resolves through Windows PATHEXT lookup" — pathlib's drive-relative semantics defeat it. VERIFIED at PR HEAD: RecordedCli(tool="D:evil") is ACCEPTED, and PureWindowsPath(r"C:\sandbox\cli_mocks") / "D:evil" → D:evil, i.e. the sandbox prefix is discarded and an executable file is written to the current directory of drive D. (Same-drive C:evil is harmless: it yields C:\sandbox\cli_mocks\evil.) On Windows the sandbox is rooted under Path.home() (sandbox.py:52-54), so a second drive letter escapes containment. Also accepted and worth closing at the same seam: a\x00b (validator passes; write_text then dies with ValueError: embedded null byte mid-setup()), -rf, ~, ....
Fix: replace the shape checks with an allowlist in validate_tool_name — re.fullmatch(r'[A-Za-z0-9._+-]{1,64}', v) rejects drive-relative, NUL, control, and leading-- names in one rule — and, defensively, route the shim path through self._resolve_within_sandbox(f"{RECORD_CLI_DIR}/{spec.tool}", field="record_cli tool") so it obeys the same containment invariant as every neighbouring path. CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:N/I:L/A:N
6. [Axis 5] Feature split leaves all recorder filesystem behaviour inside the 1145-line Sandbox class while the new top-level module owns only a string (src/coder_eval/sandbox.py:469) — class Sandbox now spans lines 100-1244 (1145 lines, 36 methods; file grew 1162 → 1244) and already mixes tempdir lifecycle, git/template application, venv + npm provisioning, plugin-tools management, command execution, file IO, preservation and cleanup. This PR adds a seventh concern — generating executable shim source — entirely inside it: sandbox.py:469 def _generate_cli_recorders(self) -> None: owns collision detection, recorder_dir.mkdir (504), log seeding (510-511), shim.write_text + chmod (515-517), and the Windows .cmd twin (518-529), while the new module cli_recorder.py contributes only _TEMPLATE and a 3-line render_recorder. The natural split is the inverse: give cli_recorder.py a generate(recorder_dir: Path, specs: list[RecordedCli]) -> None that owns the on-disk representation (shim + .cmd twin + seeded log, all of which are its format), leaving Sandbox to resolve the path and call it — the same shape _apply_starter_files_source / _apply_repo_source already use for delegated setup steps. While doing so, consider a name that does not read as "records the coder-eval CLI" and does not sit as a bare top-level module immediately adjacent to the unrelated coder_eval/cli/ Typer package.
7. [Axis 6] Deterministic record_cli misconfiguration is categorized as a retryable SANDBOX_SETUP_ERROR and re-attempted three times (src/coder_eval/sandbox.py:501) — raise RuntimeError(msg) (sandbox.py:501) propagates out of setup(), which the orchestrator wraps in execute_with_retry(..., context={"component": "sandbox"}) (orchestrator.py:1059-1064). Verified: categorize_error(RuntimeError(<the collision message>), {"component": "sandbox"}) -> ErrorCategory.SANDBOX_SETUP_ERROR, whose RetryConfig(max_retries=2, initial_delay=10.0, backoff_multiplier=1.5) yields delays of ~10.3s and ~17.0s — so an unfixable YAML conflict burns 3 attempts and ~27s per task, and logs the confusing message three times. (The masked ValueError from finding #1 categorizes identically.) No resource leak: _setup_tempdir's except Exception at sandbox.py:235-244 rmtree's the self-created tempdir before re-raising, and skips it for a caller-supplied target_dir by design.
Fix: raise a non-retryable typed error for task-config faults (e.g. an AgentConfigError-style sibling routed to a max_retries=0 category), or add a record_cli/mock_path_dirs pattern to the sandbox arm of _categorize_by_component.
8. [Axis 7] The task guide never states that exit_code defaults to 1, so - tool: curl silently makes the shadowed tool fail (docs/TASK_DEFINITION_GUIDE.md:447) — Line 447 says only "Each shim records the invocation, writes the configured stdout/stderr, and exits with exit_code", and the example at line 443 uses - tool: curl with no exit_code. The default is failure (exit_code: int = Field(default=1, ...), models/sandbox.py:338-343) — a non-obvious, behaviour-changing default that a task author reading only the guide will not learn (a shimmed git/uv would start failing every call). Add to line 447: "exit_code defaults to 1 — an unconfigured tool looks like a failing one; set exit_code: 0 if the agent should see success." While there, describe tool in prose too; it currently appears only inside the YAML example.
What's Missing
Parallel paths:
- 🟠
Sandbox._refresh_plugin_tools_dir/uip_search_pathwas not updated for the generated recorder dir: once the orchestrator syncs the agent's PATH back (orchestrator.py:1084→set_command_base_path→_refresh_plugin_tools_dir, sandbox.py:737),shutil.which("uip", path=uip_search_path)resolves tocli_mocks/uipfor the PR's own headline example (record_cli: [{tool: uip}]), which is not inside anode_modules/@uipathtree, soresolve_uipath_plugin_dirreturns None andPLUGIN_TOOLS_DIRsilently stops being exported to everyrun_commandcriterion (the MST-9795 pin). No code, comment, doc or test covers this interaction. (trigger: src/coder_eval/sandbox.py) (restates: Axis 4: record_cli has no reserved-name guard / recorder dir shadows harness binaries on the shared PATH) - 🟡 The
agent_judgesandbox copy was not updated:evaluation/sub_agent.pycopytrees the whole sandbox into the judge dir filtered only byignore_patterns, andcli_mockswas not added toresources/default_ignore_patterns.yaml(nor to theignore_patternsfloor inmodels/criteria.py), so a Bash-enabled judge now sees harness-generated shim source pluscalls.jsonlsitting in the workspace as if the agent had authored them. (trigger: src/coder_eval/sandbox.py) - 🟡 The reader side of the new contract was not migrated:
criteria/cli_called.pykeeps its own inline JSON-Lines loop instead of calling the newcli_recorder.parse_log, so the writer's module ships a parser no production path uses and the two already differ on malformed-line accounting. (trigger: src/coder_eval/cli_recorder.py) (restates: Axis 5: parse_log is a test-only, divergent duplicate of the reader in criteria/cli_called.py)
Tests:
- 🟡 The shim's explicitly-designed stdin invariant is unasserted: the docstring and commit message state "stdin is deliberately never read: it would block whenever the sandbox leaves it on an open pipe, hanging the task", but no test in
tests/test_sandbox_record_cli.pyruns the shim with stdin attached to an open pipe (stdin=subprocess.PIPE, never closed) and asserts it returns — the exact hang the design is protecting against. (trigger: src/coder_eval/cli_recorder.py) - 🟡 No test exercises
setup(target_dir=...)(DIRECT_WRITE) or a secondsetup()over an already-populatedcli_mocks/, so neither the log-seeding branch (if not log_path.exists()) nor the "regenerated on every sandbox setup" claim in the shim docstring is pinned — regeneration over an existing shim and a pre-existing log are both untested. (trigger: src/coder_eval/sandbox.py) (restates: Axis 6: seeded recorder log is preserved rather than truncated (DIRECT_WRITE / reused --run-dir)) - 🟡 No test invokes a shim the way the agent does — bare tool name resolved through the prepended PATH — so the
#!/usr/bin/env python3shebang and the.cmdtwin's body (python "%~dp0<tool>" %*, its line endings) are asserted only by file existence. (trigger: tests/test_sandbox_record_cli.py) (restates: Axis 3: no test executes a shim by bare name through PATH; .cmd body unasserted) - 🔵 No test covers concurrent recording: an agent can run several shimmed commands in parallel (background bash,
xargs -P), and every shim appends to the samecli_mocks/calls.jsonlwith a plainopen(..., "a")+write, which is only atomic up toPIPE_BUF-sized lines — a long argv (multi-KB prompt argument) can interleave and be dropped byparse_log/cli_calledas unparseable. Neither a test nor a documented size limit exists. (trigger: src/coder_eval/cli_recorder.py)
Downstream consumers:
- 🟡 The
cli_calledYAML example was not updated for the field's new default:docs/TASK_DEFINITION_GUIDE.md:798still readslog: "mocks/calls.jsonl" # Path to the JSON Lines invocation log (required)immediately above the new paragraph at :809 saying it defaults tocli_mocks/calls.jsonl. The same stale "explicit log" shape is the only form shown in theCliCalledCriteriondocstring examples (models/criteria.py:382,394), so no surface shows the zero-config form the feature exists to enable. (trigger: docs/TASK_DEFINITION_GUIDE.md) - 🟡 Making
CliCalledCriterion.logoptional traded a load-time error for a grade-time one, and no compensating validation was added: a task that omitslogand has neitherrecord_clinor a mock writing tocli_mocks/calls.jsonlnow passesplanand fails at check time as a harness fault (missing-logerror=).TaskDefinitionalready carries precedent for exactly this cross-check (check_directory_reference_compatibility, models/tasks.py:532), so a validator rejecting "default log with no producer" is the natural, missing counterpart. (trigger: src/coder_eval/models/criteria.py) - 🔵 Nothing was updated to account for
cli_mocks/being harness-written content inside the graded workspace: it is preserved into the run artifacts and is visible to workspace-widerun_commandcriteria (ruff check .,pytest,git status --porcelaincleanliness gates) and to any glob-based file criterion for template/starter-file tasks whose content sits at the sandbox root — neither the docs nor a default ignore entry addresses it. (trigger: src/coder_eval/sandbox.py)
Daily/nightly:
- 🟡 The PR's stated motivation is a downstream suite whose five hand-written recording mocks drifted, but nothing states the blast radius on that nightly suite: no migration note, and a partial migration is a hard failure rather than a no-op — adding
record_cli: [{tool: uip}]to a task that still carries itsmock_path_dirsmock raisesRuntimeErrorout ofsetup()(sandbox.py:493-501) and, being categorized as a retryableSANDBOX_SETUP_ERROR, burns three attempts per task before failing. (trigger: src/coder_eval/models/sandbox.py) - 🟠 The nightly path is the worst case for the log-seeding guard and the PR does not say so:
driver: dockerdefaults toPreservationMode.DIRECT_WRITE(orchestration/config.py:26), which never clears the target dir, so on a reused--run-diror--resumethe previous attempt's recorded invocations survive and score the current run. (trigger: src/coder_eval/sandbox.py) (restates: Axis 6: seeded recorder log is preserved rather than truncated (DIRECT_WRITE / reused --run-dir))
Harness & Lint Improvements
Static checks (lint / type):
- [ce-lint] CE026 — code-generating template placeholders must be
!r-converted. New ruletests/lint/rules/ce026_template_placeholders_repr.py, wired intoALL_RULESintests/lint/runner.py. Forbids a bare{name}field in any module-level string constant insrc/coder_eval/that is Python source (name matches*_TEMPLATE/*_SOURCE, or the literal starts with a#!shebang / isast.parse-able): every replacement field must use!ror an explicit format spec.src/coder_eval/cli_recorder.py:23(bare{tool}in the module docstring) and:25(bare{log_filename}) are the only bare fields in_TEMPLATE— lines 35-41 already use{tool!r}/{exit_code!r}/{log_filename!r}— so the rule fires on exactly the two defective sites with zero noise.# noqa: CE026for a deliberate non-code template. Prevents: A2-high — unvalidatedtoolinterpolated raw into generated shim source:RecordedCli(tool='a"""\nimport os…')passesvalidate_tool_nameand renders either uncompilable source (silent harness break scored as an agent failure) or executable code (task-YAML → code injection). Same shape atsrc/coder_eval/sandbox.py:523(f'python "%~dp0{spec.tool}" %*'). - [ce-lint] CE032 — generated-source templates must render, parse, and pass the
src/rule set. Whole-tree rule wired as a@pytest.mark.linttest class (like CE027–CE031, since it must render before it can parse): for each code-shaped template constant insrc/coder_eval/, substitute repr-safe dummy values for every field,ast.parse()the result (aSyntaxErrorfails the gate), then run the existing AST rules over that tree — plus apass-only handler check that, unlike CE005, also covers narrow excepts (except OSError: pass) with no explanatory comment. Generated code is currently invisible to ruff, pyright, bandit and every CE rule because it lives inside a string literal. Prevents: A6-medium —src/coder_eval/cli_recorder.py:68-69except OSError: pass(unbound, no comment) silently drops the log record that is thecli_calledcriterion's sole evidence, turning an unwritable log into a score-0 "agent never ran the command". Also back-stops A2-high: a template whose rendering cannot parse fails atmake lintinstead of at agent-invocation time. - [ce-lint] Extend CE030 (doc-schema parity) to
SandboxConfigandRecordedCli. One-line change toDOCUMENTED_MODELSintests/lint/doc_schema_parity.py, which today registers onlyTaskDefinition,RunLimits,Dataset,SimulationConfig(all →docs/TASK_DEFINITION_GUIDE.md).SandboxConfigis the third-D-reachable root and is task-authored, yet carries no documentation obligation — which is exactly why this PR could addrecord_cliplus a whole new nested user-facing model (tool/exit_code/stdout/stderr) with the gate green. Prevents: A7-low — the guide (docs/TASK_DEFINITION_GUIDE.md:447) never states thatexit_codedefaults to 1, so- tool: curlsilently makes the shadowed tool fail every call, andtoolappears only inside a YAML example with no prose. CE030 would have failed the build until both were documented. - [ce-lint] CE033 — a containment root stored on
selfmust be.resolve()d at assignment. New ruletests/lint/rules/ce033_resolved_containment_roots.pyinALL_RULES: flag an assignment to aself.*_dir/self.*_rootattribute whose RHS isPath(tempfile.mkdtemp(...)),tempfile.TemporaryDirectory(...), or a bare parameterName, with no terminal.resolve(). Only 4Path(tempfile.mkdtemp(...))sites exist insrc/(sandbox.py:199,codex_agent.py:1170,user_simulator.py:256,docker_runner.py:539), so noise is nil. This fixes the class at the source; the alternative decidable form (X.relative_to(Y)whereYis not.resolve()-terminated — 10 call sites insrc/) is a weaker variant. Prevents: A8-high —src/coder_eval/sandbox.py:186-201storessandbox_dirunresolved while_resolve_within_sandbox(:305) returns.resolve()d paths, soclash.relative_to(self.sandbox_dir)at :497 raisesValueErrorinstead of the intendedRuntimeErrorat :501. Two tests intests/test_sandbox_record_cli.pyare red on every macOS machine (/var→/private/var) and line 501 has zero coverage;sandbox.py:1022is the same latent shape. - [ce-lint] CE034 — in
sandbox.py, config-supplied strings must be joined through_resolve_within_sandbox, never the bare/operator. New rule scoped tosrc/coder_eval/sandbox.py: flagast.BinOp(op=Div)whose left operand is a sandbox-derived path and whose right operand is not a string literal or module-level constant (i.e. anAttributesuch asspec.tool, an f-string built from one, or a subscript), unless the enclosing function is_resolve_within_sandboxitself. The module already routes every other author-supplied path through that containment seam (mock_path_dirs:464,starter_files:554,mount_point:346); the rule makes the convention mechanical. Prevents: A4-low —shim = recorder_dir / spec.tool(sandbox.py:515) and(recorder_dir / f"{spec.tool}.cmd")(:525) skip containment entirely, so a Windows drive-relativetool: "D:evil"(accepted byvalidate_tool_name) discards the sandbox prefix and writes an executable outside the sandbox;a\x00blikewise reacheswrite_textand dies mid-setup(). - [ce-lint] CE035 — model fields that escape into the OS must declare a constraint. Whole-tree rule with two checks: (a) any
strfield on a model insrc/coder_eval/models/whose name appears as the RHS attribute of aPath/join anywhere insrc/must declarepattern=(or be aLiteral/enum); (b) anyintfield whose name matches*exit_code*/*exit_status*must declarege=0, le=255. Sibling precedent for (b) already exists —ResourceLimits.max_cpus/max_pidsusegt=0(models/sandbox.py:36/:41). Document the cross-model name-collision caveat for (a) in the rule docstring. Prevents: A2-high / A4-low (RecordedCli.toolhas only shape checks atmodels/sandbox.py:364— no/, no\, not./..— so quotes, newlines, NUL,-rf, drive letters and reserved names all pass;pattern=r"^[A-Za-z0-9._+-]+$"closes injection, containment and most reserved-name cases at one seam) and A2-medium (exit_code: int = Field(default=1, …)atmodels/sandbox.py:338accepts256, which the OS truncates to a real status of 0 — an author's failing tool becomes a succeeding one, and the log'sexitstops describing the process). - [ce-lint] CE036 — generated launchers must not invoke a bare interpreter name. New rule: forbid a
#!/usr/bin/env <interp>shebang, or a barepython/sh/nodecommand, inside any code-shaped string constant insrc/coder_eval/— a generated artifact must embed an absolute interpreter (sys.executable/os.path.realpath(sys.executable)). Rationale for the docstring: the harness prepends the generated directory to PATH (sandbox.py:459-462→orchestrator.py:1084), so any bare name in generated content is resolved through a PATH the harness itself just poisoned. Prevents: A4-medium (CVSS AV:L/AC:L/PR:H/UI:N/S:U/C:N/I:N/A:H) —cli_recorder.py:22#!/usr/bin/env python3andsandbox.py:523barepython:tool: python3makes the shim's own interpreter re-resolve to the shim, an infiniteenv→shim→envexec loop that hangs the task until the task timeout (reproduced:TimeoutExpired, log never written).ResourceLimits.max_pids/max_cpusare not enforced underdriver: tempdir. - [ce-lint] CE037 — no test-only public helper in the shipped package. Whole-tree rule: a module-level
definsrc/coder_eval/with a public name, zero references anywhere insrc/, and references intests/, must be underscore-prefixed, re-exported through a package__init__/__all__, or moved intotests/. This makes CLAUDE.md's "Clean Code: no dead code" mechanical; exemptions go through an explicitEXEMPTmap with a reason (mirroring CE030's registry style) for genuinely library-public API. Prevents: A5-medium —src/coder_eval/cli_recorder.py:104 parse_logis called from nowhere insrc/(its own definition is the solegrep -rn parse_log src/hit); all six callers are intests/test_sandbox_record_cli.py. Its docstring positions it as the canonical log reader whilecriteria/cli_called.py:209-223keeps a second, already-divergent copy (parse_logsilentlycontinues where the criterion countsmalformedand reports it at :258) — so a maintainer can editparse_logbelieving they changed grading behaviour. - [bandit-codeql] Extend the bandit/CodeQL scan to rendered generated source. Add a step to the security job (
.github/workflows/pr-checks.ymlbandit invocation, andmake verify) that materialises every code-shaped template insrc/coder_eval/intotmp/generated-src/with dummy values and runs bandit over that directory — the same rendering seam CE032 uses. Today bandit sees_TEMPLATEas an inert string literal, so nothing in the generated shim is scanned, even though the shim is the file the agent actually executes inside the sandbox. Prevents: A2-high (task-YAML → code injection in the rendered shim) and A6-medium (except OSError: pass) — both live entirely inside a string literal and are currently invisible to every configured security and quality scanner.
Harness improvements (not statically reachable):
- Add macOS to the CI test matrix (
.github/workflows/pr-checks.ymlruns the suite onubuntu-latestonly; there is awindows-smokejob but no darwin runner). Cheaper alternative if a third runner is unacceptable: a session-scoped conftest fixture that pointsTMPDIRat a symlinked directory for the Linux run, reproducing the resolved/unresolved split deterministically. Why not static: The defect manifests only as a runtime path mismatch under a symlinked temp root; Linux/tmpis a real directory, so the code reads fine and CI stays green. CE033 catches the code shape going forward, but only an OS with a symlinked temp root proves the existing suite passes where developers actually run it. Prevents: A8-high —tests/test_sandbox_record_cli.pyis 2 failed / 27 passed on every macOS machine at PR HEAD (ValueError: '/private/var/…' is not in the subpath of '/var/…'), andsandbox.py:501'sRuntimeErrorpath has zero coverage in the whole suite. CI approved a file that is red for the author. - Fresh-sandbox invariant:
setup()must own and reset every artifact it generates. Make_generate_cli_recordersclearcli_mocks/wholesale and seedcalls.jsonlunconditionally instead of underif not log_path.exists():, then add a test that pre-populates<target>/cli_mocks/calls.jsonlwith a record, runsSandbox(...).setup(target_dir=target), and asserts the log is empty afterwards. Generalise it as a standing checklist item for any future harness-owned artifact underPreservationMode.DIRECT_WRITE. Why not static: The bug is cross-run filesystem state (a reused--run-dir, or--resumeafter a mid-run crash), not a code shape — the guard atsandbox.py:511is locally idiomatic and reads as defensive. Only a test that seeds the directory beforesetup()distinguishes "preserve" from "reset". Prevents: A6-high — withDIRECT_WRITE(thedriver: dockerdefault,orchestration/config.py:26) the orchestrator deliberately does not clear the target dir, the shim appends, and a prior run'suip ixp projects deleterecord scores the current run:CliCalledCriterion(min_count=1)returns 1.0 with zero agent activity. Same agent output, different score depending on run-dir reuse. - Generated-artifact / guard parity test. Extract one
generated_names(spec) -> set[str]helper (spec.tool,f"{spec.tool}.cmd", plus any future PATHEXT twin), have both the writer and the collision guard consume it, and add a test asserting that the set of names_generate_cli_recordersactually wrote equals the set the guard checked — plus a refusal to overwrite any pre-existing file inside the generator's own directory. Why not static: The written-name set only exists after running the generator against a real directory; no AST rule can relateshim.write_textat one line toclash.exists()at another. This is the same shape as the event-reassembly parity tests the repo already requires (assert over the full name set, not a hand-picked subset). Prevents: A1-medium (the guard checks onlyuser_dir / spec.tool, neverf"{spec.tool}.cmd", so on Windows a task's ownmocks/uip.cmdis silently shadowed bycli_mocks/uip.cmd— precisely what the "can never silently shadow" comment atsandbox.py:455-458promises is impossible) and A3-medium (tool: "calls.jsonl"overwrites the seeded log with shim source — reproduced, 2261 bytes;[tool: "uip.cmd", tool: "uip"]leavesuip.cmdclobbered by the batch twin — both silent, both scoring 0). - Execute the shim the way the agent does: by bare name, through PATH. Add a POSIX test that runs
subprocess.run(["uip", "projects", "list"], env={**os.environ, "PATH": f"{recorder_dir}{os.pathsep}{os.environ['PATH']}"})and then grades the resulting log, plus a content assertion onuip.cmd(python "%~dp0uip" %*, CRLF) instead of the current existence-only check. Why not static: Shebang honouring, the+xbit, and Windows PATHEXT resolution are OS behaviours; no lint rule can assert that a bare tool name resolves to the generated shim. Prevents: A3-medium — the sole invoker (tests/test_sandbox_record_cli.py:39) is[sys.executable, str(shim), *args], an explicit interpreter on an absolute path, so the#!/usr/bin/env python3line is never exercised and the.cmdbody is never run or asserted. A malformed batch line or a broken shebang ships with all 29 tests green. - Harness-fault vs agent-failure resilience test for the recorder log.
chmod 444the seeded log, invoke the shim, and assert thatcli_calledreports a harness fault (error=set) rather thanscore=0.0— which requires the shim to leave a trace onOSError(acalls.jsonl.errorsentinel and/or a stderr line, each in its own try/except) andcli_calledto surface it. Generalise as a rule of thumb: any criterion whose evidence the harness itself produces needs an "evidence unavailable" state distinct from "agent did nothing". Why not static: Distinguishing a harness fault from a genuine agent miss is a semantic contract about scoring, and the trigger (unwritable log — ENOSPC, permission change, agent tampering) is runtime environment state. CE032 catches the tracelesspass; only this test pins the resulting verdict. Prevents: A6-medium — reproduced: with the log read-only the shim exits with the configured code, stdout/stderr empty, and the criterion returnsscore=0.0, error=None, details="0 invocation(s) recorded …"— byte-identical to the agent never running the command. The criterion already distinguishes a missing log (harness fault) from an empty one; an unwritable log defeats exactly that distinction. - Make the merge-strategy table exhaustive by construction. Replace the hardcoded parametrize list in
tests/test_merge_strategy_annotations.py:31-48with one derived frommodel_fieldsover the three-D-reachable roots (AgentConfig/RunLimits/SandboxConfig): iterate every field, look it up in an explicit expected-strategy mapping, and fail on any field absent from that mapping. Adding a merge-relevant field then forces an explicit strategy decision in the same change. Why not static: CE014 already enforces that an annotation exists but explicitly "only requires the annotation, not a particular strategy" — the intended strategy is a semantic decision. Only table completeness is checkable, and that needs the runtime Pydantic model registry, not an AST walk. Prevents: A3-medium —record_cliis the one merge-relevantSandboxConfigfield missing from that list (mock_path_dirs/replace sits on line 34), so a future edit flipping it tostrategy="append"would passmake lintand the entire suite while contradicting the field's own documented contract ("Replaced (not merged) across config layers, like mock_path_dirs",models/sandbox.py:432). - Reserved-name denylist plus an exec-loop regression test. Reject
python,python3,env,sh,bash,node,git,uv,cmd(and the generator's own artifact names) invalidate_tool_name, and add a hard-timeout test asserting that a generated shim on a prepended PATH never re-execs itself. Document the shadowing hazard indocs/TASK_DEFINITION_GUIDE.md:434-455, which currently covers onlymock_path_dirscollisions. Why not static: The denylist itself is a code fix, but the consequence — an unbounded exec loop, andgit/uv/curlbeing shadowed for everyrun_commandcriterion via_sync_sandbox_command_path_with_agent→_build_run_command_env— is runtime PATH-resolution behaviour that only a bounded subprocess test can demonstrate. Prevents: A4-medium —tool: python3hangs the task until the task timeout with no diagnostic (reproduced:TimeoutExpired,calls.jsonlnever written), andtool: git/uv/curlsilently changes what everyrun_commandcriterion executes, and therefore the score. - Assert that deterministic task-config faults are non-retryable. Add a test pinning
categorize_error(or the raised type) for therecord_cli/mock_path_dirscollision to amax_retries=0category — via a typed non-retryable error inerrors/instead of the bareRuntimeErroratsandbox.py:501, or a config-fault pattern in the sandbox arm of_categorize_by_component. Why not static: Retry policy is a runtime mapping from exception + context to aRetryConfig; an AST rule can see theraise RuntimeError(...)but not that it lands inSANDBOX_SETUP_ERRORwithmax_retries=2. Prevents: A6-low — an unfixable YAML conflict currently burns 3 attempts and ~27s of backoff per task and logs the same confusing message three times, on a fault that cannot possibly succeed on retry.
Top 5 Priority Actions
- Seed the recorder log unconditionally at /Users/religa/src/coder_eval/src/coder_eval/sandbox.py:511 (drop the
if not log_path.exists():guard, and ideally clear the wholecli_mocks/dir before regenerating), because underPreservationMode.DIRECT_WRITEor a reused--run-dirthe append-mode shim lets a prior run's invocations score the current one — reproduced ascli_calledreturning 1.0 with zero agent activity in the current run. - Resolve the sandbox root once before the containment comparison at /Users/religa/src/coder_eval/src/coder_eval/sandbox.py:497 (
clash.relative_to(self.sandbox_dir.resolve()), or justclash.name), since_resolve_within_sandboxreturns a symlink-resolved path whilesandbox_diris stored raw — on macOS (/var->/private/var) this raises a pathlibValueErrorout ofsetup(), turning a deterministic task-config error into a thrice-retriedSANDBOX_SETUP_ERRORand leaving the documentedRuntimeErrorat line 501 permanently uncovered with two tests red. - Replace the shape-only check in
validate_tool_nameat /Users/religa/src/coder_eval/src/coder_eval/models/sandbox.py:364 with a strict allowlist (re.fullmatch(r"[A-Za-z0-9._+-]{1,64}", v)) plus a reserved-name set (python/python3/env/sh/bash/node/git/uv/cmd,calls.jsonl,<tool>.cmd), and emit an absolute interpreter instead of#!/usr/bin/env python3(/Users/religa/src/coder_eval/src/coder_eval/cli_recorder.py:22) and barepython(sandbox.py:523) — todaytool: python3hangs the task in an infinite exec loop,tool: git/uvsilently shadows binaries forrun_commandcriteria via orchestrator.py:1084,tool: calls.jsonloverwrites the log the criterion grades, and an embedded\"\"\"renders uncompilable (or executable) shim source at cli_recorder.py:23. - Stop swallowing recorder log-write failures at /Users/religa/src/coder_eval/src/coder_eval/cli_recorder.py:68 — bind the exception, write a stderr line and/or a
calls.jsonl.errorsentinel, and havecriteria/cli_called.pysurface that aserror=— because an unwritable log currently produces output byte-identical to "the agent never ran the command" (score=0.0, error=None), defeating the missing-vs-empty distinction the seeding logic exists to provide. - Close the remaining schema and single-source gaps: bound
exit_codewithge=0, le=255at /Users/religa/src/coder_eval/src/coder_eval/models/sandbox.py:338 (256 silently becomes exit 0, so a tool configured to fail is observed succeeding), reject duplicaterecord_cli[*].toolentries (models/sandbox.py:422) and widen the collision guard to the.cmdtwin (/Users/religa/src/coder_eval/src/coder_eval/sandbox.py:493), add the missing(SandboxConfig, "record_cli", "replace")row to tests/test_merge_strategy_annotations.py, and either haveCliCalledCheckerconsumeparse_log(/Users/religa/src/coder_eval/src/coder_eval/cli_recorder.py:104) or move that test-only, already-divergent duplicate parser into tests/.
Stats: 0 🔴 · 3 🟠 · 8 🟡 · 8 🔵 across 8 axes reviewed.
1faad7b to
d8937a3
Compare
ReviewRead the full branch diff and ran the checks locally (macOS). Verdict: fundamentally correct, architecturally consistent, and genuinely useful. It fills a real gap — asserting on what the agent executed against a shadowed CLI, which Test status
Must fix1.
never gets built. The user instead gets an opaque 2. Worth addressing3. 4. Clustered short flags defeat the alias work. 5. Bare negative positionals are swallowed. 6. 7. Doc contradiction: the 8. Note-only: criteria What's right about it
The two features (criterion + recorder) are coupled only by the default |
uipreliga
left a comment
There was a problem hiding this comment.
Fix the issues identified before merging.
d8937a3 to
2ec7154
Compare
607cd22 to
5366688
Compare
2ec7154 to
b22206e
Compare
cli_called reads a JSON Lines invocation log, but nothing produced one: every
suite had to hand-write a recording mock and get the record shape right, making
the format a contract between the harness and each consumer repository. That is
how contracts drift — and it drifted inside a single downstream suite, where two
of five mock templates were copies that never gained the log.
record_cli closes the loop. Declaring a tool generates a self-contained shim
into cli_mocks/, PATH-prepended through the existing mock_path_dirs machinery,
appending records to cli_mocks/calls.jsonl — which cli_called now reads by
default, so a task sets neither mock_path_dirs nor log:.
sandbox:
record_cli:
- {tool: uip, exit_code: 1, stderr: "not connected\n"}
- {tool: curl}
The shim records the invocation, writes the configured output, and exits.
Nothing is executed: no network, no auth, no side effects.
It stubs a tool; it does not proxy one, and it serves no per-invocation
responses. Recording a REAL executable on the way through depends on the tool
being installed, on PATH order, and usually on live credentials — state the
harness cannot guarantee — so that stays a hand-written wrapper under
mock_path_dirs, as does anything needing a fixture set. Keeping the generated
shim to the case that is always well-defined is what lets every record carry a
real exit code and keeps the shim free of platform-conditional code.
Decisions worth noting:
- A .cmd twin ships beside each shim so a bare `uip` also resolves through
Windows PATHEXT lookup.
- The log is seeded empty: a correct run that calls nothing must satisfy
max_count: 0, while a MISSING log (mock never ran) must still fail.
- stdin is never read — it would block whenever the sandbox leaves it on an open
pipe, hanging the task.
- A name collision with a mock_path_dirs entry raises instead of letting
directory order silently decide which executable runs.
- The rendered shim imports nothing from coder_eval and is pure ASCII: it runs
inside a sandbox where this package is not installed.
21 new tests, including the round trip that matters — generate, execute, then
grade the produced log with cli_called and no log: configured. Full suite, ruff,
pyright and all 166 custom lint rules pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three blockers, all reproduced against the branch before changing anything. 1. `tool` was interpolated unescaped into generated shim source. The validator only rejected path separators, so `tool: 'a"""b'` validated and produced a shim that fails to compile; a crafted name reached executable position. Constrained the field to `^[A-Za-z0-9._+-]+$` and covered quote/newline/space cases in the tests, which had only probed path-shaped names. 2. The recorder log was seeded only `if not log_path.exists()`, so its sole effect was PRESERVING a previous run's log. Under DIRECT_WRITE (the docker default, which deliberately does not clear the target dir) a stale record scored the current run: a `min_count: 1` criterion returned 1.0 with zero agent activity. The log is now truncated unconditionally and the recorder directory wiped before regeneration, so a shim for a tool no longer declared cannot linger on PATH. 3. The collision guard built its message with `clash.relative_to(sandbox_dir)`, comparing a resolved path against an unresolved root. Wherever the sandbox traverses a symlink (macOS /var, a symlinked --run-dir on Linux) that raised ValueError instead of the intended RuntimeError, so the friendly error never existed and the branch was red on macOS. The message no longer computes a relative path, and the path-prepend test compares resolved to resolved -- it had passed only because Windows and Linux tempdirs are not symlinked. Also: parse_log had no production callers while the checker re-implemented the same JSON-Lines loop, so it now returns (usable, unusable_count) and is the single reader. The module is renamed cli_recorder -> invocation_log: it owns both halves now, and CE004's prefix match reads `coder_eval.cli_recorder` as the cli layer. Fixed the guide's `log:` comment, which said "(required)" a line above the paragraph documenting its default. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
b22206e to
27d81fb
Compare
…positional Two shapes the review flagged as leaving the guard hole one keystroke away. `-yf` parsed as a single flag named `yf`, so an `aliases: ["y"]` predicate -- added precisely to close the `-y`/`--yes` gap -- missed it, and an `absent` guard passed on a confirmed delete. Clustered short flags are now split per character, so `-rf` matches predicates on `r` and `f`. A bare `-1` became a flag named `1` and vanished from the positionals: the same silent disappearance that let `--yes proj-1` slip a delete past a guard. Numeric tokens stay positional. Both stay declaration-driven, consistent with value binding: a name the criterion mentions is taken whole, so a genuine multi-char short flag still matches (`-rf` declared) and `head -1` still parses as a flag when declared. `-fvalue` binds when `f` is value-bearing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Rebased onto 1.
|
The severe one first: nothing stopped `tool: python3`, and the shim's `#!/usr/bin/env python3` resolved through the very PATH this feature prepends, so the shim re-execed itself until the task timed out (tempdir enforces no pid cap). `git`/`uv`/`curl` were also shadowed for run_command criteria, since the orchestrator reuses resolved_mock_path_dirs as the command base PATH. The interpreter is now baked in as an absolute path in both the shebang and the .cmd line, and a reserved set is rejected at load time. Other findings: - The collision guard only checked the bare name, so on Windows a task's own `mocks/uip.cmd` was silently shadowed by the generated `uip.cmd` that PATHEXT resolves first -- the exact outcome the comment above the PATH ordering claims is impossible. It now covers .cmd/.bat/.exe, and the generator refuses to overwrite a file it already wrote this setup. - `tool: calls.jsonl` overwrote the log every criterion reads; `uip.cmd` as a tool name clobbered the generated twin. Both rejected. - `exit_code` accepted any int while sys.exit truncates mod 256, so `exit_code: 256` produced a *succeeding* tool with 256 in the log. Bounded 0-255. - The shim swallowed a failed log write with a bare `pass`, making an unwritable log score identically to "the agent never ran the command". It now writes a `.error` sentinel plus stderr, and cli_called surfaces that as an error rather than scoring an incomplete log. - record_cli was the only SandboxConfig merge field missing from the merge-strategy parametrize list. - Docs: exit_code's default of 1 was never stated, so a bare `- tool: curl` silently made the tool fail. Test gap worth naming: every existing test invoked shims as `sys.executable <abs path>`, so the shebang, the +x bit and the PATH prepend were exercised by nothing -- removing any of them kept the suite green. Added a POSIX test that runs a bare `uip` through the prepended PATH and grades the log, plus assertions on the .cmd body and the absolute shebang. Also extended the tool-name reject cases with the injection-shaped names (a triple quote, newline, `;`, `$`) that an earlier patch claimed to add but silently missed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The recorder dir goes first on a PATH the orchestrator syncs back and reuses for
run_command criteria, so `record_cli: [{tool: uip}]` -- the example in every doc
here -- made `shutil.which("uip")` resolve to the shim. A shim is not inside a
node_modules/@UiPath tree, so resolve_uipath_plugin_dir returned None and
PLUGIN_TOOLS_DIR silently stopped being exported (the MST-9795 pin). Verified:
which('uip') on uip_search_path -> <sandbox>/cli_mocks/uip.CMD
which('uip') on discovery path -> C:\Users\...\.bun\bin\uip.EXE
Plugin discovery now filters the generated dir out. A hand-written mock under
mock_path_dirs shadows the lookup the same way, but that predates this feature
and narrowing it would change existing tasks, so it stays as-is.
Also from the review's "What's Missing":
- cli_mocks/ was not in default_ignore_patterns.yaml, so the agent_judge
workspace copy handed a Bash-enabled judge the generated shim source and
calls.jsonl as if the agent had authored them. Artifact capture uses a separate
list, so the log is still preserved as evidence.
- The shim's stdin invariant -- never read, because an open pipe would hang the
task -- was the design's stated reason for a choice and nothing asserted it.
Added a test that leaves stdin=PIPE unwritten and unclosed and asserts the shim
still returns.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Worked through the What's Missing section too — I had only acted on Blockers and the follow-up comment before, so thanks for the structure that made the gap obvious. Fixed in The 🟠 that mattered most
A shim is not inside a Deliberately narrow: a hand-written mock under Also fixed
Deferred, with reasons
GateFull suite 3777 passed, 5 failures that all reproduce on One thing I cannot verify from here: the bare-name PATH test skips on Windows, so it will execute for the first time on CI (or on your macOS). That is the test most worth watching, since its absence is what let the shebang, the Your approval is on |

Note
Stacked on #72 — based on
feat/cli-called-criterion, and targeting it so the diff shows only this change. Retarget tomainonce #72 merges.Why
#72 adds
cli_called, which reads a JSON Lines invocation log — but nothing in the harness produces one. Every suite has to hand-write a recording mock and get the record shape right, which makes the log format a contract between this repo and each consumer.That is exactly how contracts drift, and it already drifted inside a single downstream suite: of five mock templates under one skill, two were copies that never gained the JSONL sink, so tasks overlaying them silently record nothing. A file you copy can be half-copied. Config cannot.
What
Declaring a tool under
sandbox.record_cligenerates a recording shim:Each shim records the invocation, writes the configured
stdout/stderr, and exits withexit_code. Nothing is executed — no network, no auth, no side effects.The sandbox writes the shims into
cli_mocks/, PATH-prepends that directory through the existingmock_path_dirsmachinery, and appends records tocli_mocks/calls.jsonl— whichcli_callednow reads by default. A task sets neithermock_path_dirsnortemplate_sourcesnorlog:.Deliberately narrow
It stubs a tool; it does not proxy one, and it serves no per-invocation responses.
An earlier revision of this PR had a
mode: passthroughthat recorded and then delegated to the real executable. I removed it. Recording a live tool depends on state the harness cannot guarantee — the tool being installed, PATH ordering, usually live credentials — and it was a third of the shim (find_real_toolalone was 26 of 106 lines, plus the only platform-conditional code path) serving 6 of 50 downstream tasks, all of which are already covered by an existing wrapper. It was also the sole reason a record could carryexit: null, an asymmetry that leaked into the criterion's documented contract.Consequences of dropping it, all improvements: the shim is 68 lines, imports only
json/os/sys/time, has no branch on platform, and every record carries a real exit code. Suites needing a proxy or a fixture set keep a hand-written mock undermock_path_dirs— and re-addingmodelater is a non-breaking defaulted field if a second consumer asks.Decisions worth reviewing
.cmdtwin ships beside each shim for Windows PATHEXT lookup.max_count: 0, while a missing log (mock never ran, or wrote elsewhere) must still fail. feat(criteria): add cli_called for structured invocation matching #72's checker treats those differently, so they have to stay distinguishable.mock_path_dirsentry already provides an executable of the same name, setup fails loudly instead of letting directory order decide which one runs.coder_evaland is pure ASCII — it runs inside a sandbox where this package is not installed, under whateverpython3is on PATH there.Testing
21 new tests in
tests/test_sandbox_record_cli.py. The load-bearing one is the round trip — generate a shim, actually execute it, then grade the log it wrote withcli_calledand nolog:configured. That contract is only testable because writer and reader now ship together.Also covered:
.cmdtwin generation, PATH ordering, collision rejection, seeded-empty log, quoted arguments with spaces and multi-line heredoc payloads surviving as singleargvelements, several tools sharing one log tagged bytool, append ordering, negative guards on both an empty and a populated log, model validation including path-traversal rejection ontool, and assertions that the rendered shim is pure ASCII, imports nothing from this package, and contains nosubprocess/exec/popen/systemcall.make lint— 166 passed;ruff checkclean;pyrightback to its 3 pre-existingopenai_codeximport errors.mainatcc2cfc7(3×test_reports_stats_nonfinite, 2×test_sandboxsymlink tests needing Windows privileges). One run reported a 6th failure in one of those already privilege-flaky symlink tests; it did not reproduce across two further runs.Run on Windows/Python 3.13.9, so Linux CI is the real check.
Downstream effect
UiPath/skillsmaintains five recorders across its IXP suite. This replaces two of them — the offlineuipandcurlmocks in its base template — covering 42 of its 50 IXP tasks, which also stop repeatinglog: mocks/calls.jsonlon every criterion.The other three stay hand-written, and each for a reason this PR scopes out deliberately:
case "$1 $2 $3"returning canned JSON per verb) that overlay the base template for 2 tasks whose correct path starts with a read.That is the intended split rather than a shortfall: the tasks needing a fixture set keep a mock, and the collision check means a task cannot half-adopt
record_cliwhile still shipping its ownuip— it fails loudly at setup instead.🤖 Generated with Claude Code