Skip to content

Commit 27d81fb

Browse files
fix(sandbox): repair record_cli defects found reviewing #73
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>
1 parent 8c51957 commit 27d81fb

6 files changed

Lines changed: 94 additions & 58 deletions

File tree

docs/TASK_DEFINITION_GUIDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -878,7 +878,7 @@ Use this instead of `command_executed` or `file_matches_regex` when a test shado
878878
```yaml
879879
- type: "cli_called"
880880
description: "Switched the project to the capable model"
881-
log: "mocks/calls.jsonl" # Path to the JSON Lines invocation log (required)
881+
log: "mocks/calls.jsonl" # Invocation log; omit it to use the record_cli default
882882
verb: "ixp projects configure-model" # Ordered prefix of the non-flag arguments
883883
positional: ["my_invoices-ixp"] # Non-flag arguments following the verb, in order
884884
flags:

src/coder_eval/criteria/cli_called.py

Lines changed: 2 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
"""CLI-called criterion checker — structured matching over an invocation log."""
22

3-
import json
43
import logging
54
import re
65
import shlex
76
from typing import TYPE_CHECKING, Any
87

98
from coder_eval.criteria.base import BaseCriterion, CheckContext, register_criterion
9+
from coder_eval.invocation_log import parse_log
1010
from coder_eval.models import CliCalledCriterion, CriterionResult, FlagMatch
1111

1212

@@ -100,19 +100,6 @@ def _flag_matches(predicate: FlagMatch, values: list[str] | None) -> bool:
100100
raise AssertionError(f"FlagMatch has no matcher arm: {predicate!r}")
101101

102102

103-
def _usable_argv(record: dict[str, Any]) -> list[str] | None:
104-
"""The record's ``argv`` when it is a list of strings, else None.
105-
106-
None means the record cannot be evaluated at all — a different thing from
107-
"evaluated and did not match", which is why the caller reports it rather than
108-
quietly treating it as a non-match.
109-
"""
110-
argv = record.get("argv")
111-
if isinstance(argv, list) and all(isinstance(item, str) for item in argv):
112-
return argv
113-
return None
114-
115-
116103
def _record_matches(criterion: CliCalledCriterion, argv: list[str], record: dict[str, Any]) -> bool:
117104
"""Whether one log record satisfies every configured facet of the criterion."""
118105
if criterion.tool is not None and record.get("tool") != criterion.tool:
@@ -207,25 +194,7 @@ def _check_impl(
207194

208195
content = sandbox.get_file_content(criterion.log)
209196

210-
usable: list[tuple[list[str], dict[str, Any]]] = []
211-
unusable = 0
212-
for line in content.splitlines():
213-
stripped = line.strip()
214-
if not stripped:
215-
continue
216-
try:
217-
parsed = json.loads(stripped)
218-
except ValueError:
219-
unusable += 1
220-
continue
221-
if not isinstance(parsed, dict):
222-
unusable += 1
223-
continue
224-
argv = _usable_argv(parsed)
225-
if argv is None:
226-
unusable += 1
227-
continue
228-
usable.append((argv, parsed))
197+
usable, unusable = parse_log(content)
229198

230199
if unusable:
231200
# A record we cannot read might BE the call a max_count: 0 guard
Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
"""Source template for the CLI recording shims that ``SandboxConfig.record_cli`` generates.
1+
"""The structured invocation log: the recording shim that writes it, and the reader.
2+
3+
Named for the artifact rather than the writer because both sides live here -- the
4+
shim template `SandboxConfig.record_cli` renders, and `parse_log`, which the
5+
`cli_called` criterion reads it back with.
26
37
The rendered script runs INSIDE the sandbox, where ``coder_eval`` is not
48
installed, so it imports nothing from this package: its configuration arrives as
@@ -101,21 +105,31 @@ def render_recorder(spec: RecordedCli) -> str:
101105
)
102106

103107

104-
def parse_log(text: str) -> list[dict[str, object]]:
105-
"""Parse recorder-log text into records, skipping unparseable lines.
108+
def parse_log(text: str) -> tuple[list[tuple[list[str], dict[str, object]]], int]:
109+
"""Parse recorder-log text into ``(usable, unusable_count)``.
106110
107-
Shared with tests and any caller that wants the log without duplicating the
108-
JSON-Lines handling in :mod:`coder_eval.criteria.cli_called`.
111+
A usable entry pairs the record's ``argv`` with the whole record. Unusable
112+
means unparseable, not an object, or an ``argv`` that is not a list of
113+
strings — counted rather than dropped, because a record that cannot be read
114+
might be the very call a negative guard forbids.
109115
"""
110-
records: list[dict[str, object]] = []
116+
usable: list[tuple[list[str], dict[str, object]]] = []
117+
unusable = 0
111118
for line in text.splitlines():
112119
stripped = line.strip()
113120
if not stripped:
114121
continue
115122
try:
116123
parsed = json.loads(stripped)
117124
except ValueError:
125+
unusable += 1
126+
continue
127+
if not isinstance(parsed, dict):
128+
unusable += 1
118129
continue
119-
if isinstance(parsed, dict):
120-
records.append(parsed)
121-
return records
130+
argv = parsed.get("argv")
131+
if isinstance(argv, list) and all(isinstance(item, str) for item in argv):
132+
usable.append((argv, parsed))
133+
else:
134+
unusable += 1
135+
return usable, unusable

src/coder_eval/models/sandbox.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,14 @@ class RecordedCli(BaseModel):
334334

335335
model_config = ConfigDict(extra="forbid")
336336

337-
tool: str = Field(description="Executable name to shadow on PATH (e.g. 'uip', 'curl', 'git')")
337+
tool: str = Field(
338+
pattern=r"^[A-Za-z0-9._+-]+$",
339+
description=(
340+
"Executable name to shadow on PATH (e.g. 'uip', 'curl', 'git'). Constrained to "
341+
"executable-name characters: the value is interpolated into generated shim source, so a "
342+
"quote or newline would emit a broken script"
343+
),
344+
)
338345
exit_code: int = Field(
339346
default=1,
340347
description=(

src/coder_eval/sandbox.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import tempfile
1010
from pathlib import Path
1111

12-
from .cli_recorder import render_recorder
12+
from .invocation_log import render_recorder
1313
from .models import (
1414
RECORD_CLI_DIR,
1515
RECORD_CLI_LOG,
@@ -494,22 +494,26 @@ def _generate_cli_recorders(self) -> None:
494494
if clash.exists():
495495
msg = (
496496
f"record_cli would generate a '{spec.tool}' shim, but mock_path_dirs entry "
497-
f"'{rel}' already provides one ({clash.relative_to(self.sandbox_dir)}). "
497+
f"'{rel}' already provides one ({rel}/{spec.tool}). "
498498
"Remove the record_cli entry to keep your own mock, or drop the file to use "
499499
"the generated recorder."
500500
)
501501
raise RuntimeError(msg)
502502

503503
recorder_dir = self._resolve_within_sandbox(RECORD_CLI_DIR, field="record_cli directory")
504+
# Wipe rather than reuse: DIRECT_WRITE (the docker default) does not clear the
505+
# target dir, so a reused --run-dir would leave a previous run's log to be
506+
# scored as this run's, and stale shims for tools no longer declared on PATH.
507+
if recorder_dir.exists():
508+
shutil.rmtree(recorder_dir, ignore_errors=True)
504509
recorder_dir.mkdir(parents=True, exist_ok=True)
505510

506511
# Seed the log so it always exists: `cli_called` treats a MISSING log as a
507512
# harness fault (score 0 even for a negative guard), which is right when a
508513
# mock never ran, but wrong for a correct run that legitimately called
509514
# nothing. An empty file distinguishes the two.
510515
log_path = self.sandbox_dir / RECORD_CLI_LOG
511-
if not log_path.exists():
512-
log_path.write_text("", encoding="utf-8")
516+
log_path.write_text("", encoding="utf-8")
513517

514518
for spec in self.config.record_cli:
515519
shim = recorder_dir / spec.tool

tests/test_sandbox_record_cli.py

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@
1313
import pytest
1414
from pydantic import ValidationError
1515

16-
from coder_eval.cli_recorder import parse_log, render_recorder
1716
from coder_eval.evaluation.checker import SuccessChecker
17+
from coder_eval.invocation_log import parse_log, render_recorder
1818
from coder_eval.models import (
1919
RECORD_CLI_DIR,
2020
RECORD_CLI_LOG,
@@ -44,6 +44,12 @@ def _run_shim(sandbox_dir, tool: str, args: list[str]) -> subprocess.CompletedPr
4444
)
4545

4646

47+
def _records(text: str) -> list[dict]:
48+
"""Just the records; parse_log also returns the unusable count."""
49+
usable, _ = parse_log(text)
50+
return [record for _, record in usable]
51+
52+
4753
class TestGeneration:
4854
def test_generates_shim_cmd_twin_and_seeded_log(self):
4955
sandbox = _sandbox("record_gen", record_cli=[RecordedCli(tool="uip")])
@@ -66,10 +72,38 @@ def test_recorder_dir_is_path_prepended_before_user_mocks(self):
6672
sandbox_dir = sandbox.setup()
6773
(sandbox_dir / "mocks").mkdir(exist_ok=True)
6874
resolved = sandbox.resolved_mock_path_dirs
69-
assert resolved[0] == sandbox_dir / RECORD_CLI_DIR
75+
# The property resolves symlinks; comparing an unresolved path passes on
76+
# Linux/Windows and fails wherever the tempdir traverses one (macOS /var).
77+
assert resolved[0] == (sandbox_dir / RECORD_CLI_DIR).resolve()
7078
finally:
7179
sandbox.cleanup(preserve=False)
7280

81+
def test_reused_target_dir_does_not_carry_a_prior_runs_log(self, tmp_path):
82+
"""DIRECT_WRITE does not clear the target dir, so a preserved log let a
83+
previous run's invocations score this one with zero agent activity."""
84+
target = tmp_path / "artifacts"
85+
stale = target / RECORD_CLI_LOG
86+
stale.parent.mkdir(parents=True, exist_ok=True)
87+
stale.write_text(
88+
json.dumps({"tool": "uip", "argv": ["ixp", "projects", "delete", "proj-1"]}) + "\n",
89+
encoding="utf-8",
90+
)
91+
sandbox = _sandbox("record_reuse", record_cli=[RecordedCli(tool="uip")])
92+
sandbox.setup(target_dir=target)
93+
assert (target / RECORD_CLI_LOG).read_text(encoding="utf-8") == ""
94+
criterion = CliCalledCriterion(description="deleted the project", verb="ixp projects delete", min_count=1)
95+
assert SuccessChecker(sandbox).check(criterion).score == 0.0
96+
97+
def test_stale_shim_for_an_undeclared_tool_is_removed(self, tmp_path):
98+
"""A shim left by a previous run would stay on PATH shadowing the real tool."""
99+
target = tmp_path / "artifacts"
100+
(target / RECORD_CLI_DIR).mkdir(parents=True, exist_ok=True)
101+
(target / RECORD_CLI_DIR / "curl").write_text("stale", encoding="utf-8")
102+
sandbox = _sandbox("record_stale_shim", record_cli=[RecordedCli(tool="uip")])
103+
sandbox.setup(target_dir=target)
104+
assert not (target / RECORD_CLI_DIR / "curl").exists()
105+
assert (target / RECORD_CLI_DIR / "uip").is_file()
106+
73107
def test_no_record_cli_leaves_no_directory(self):
74108
sandbox = _sandbox("record_absent")
75109
try:
@@ -114,7 +148,7 @@ def test_records_argv_and_fails_without_running_anything(self):
114148
assert proc.returncode == 1
115149
assert proc.stderr == "uip: not connected\n"
116150

117-
records = parse_log((sandbox_dir / RECORD_CLI_LOG).read_text(encoding="utf-8"))
151+
records = _records((sandbox_dir / RECORD_CLI_LOG).read_text(encoding="utf-8"))
118152
assert len(records) == 1
119153
assert records[0]["tool"] == "uip"
120154
assert records[0]["exit"] == 1
@@ -146,7 +180,7 @@ def test_quoted_argument_with_spaces_stays_one_element(self):
146180
try:
147181
sandbox_dir = sandbox.setup()
148182
_run_shim(sandbox_dir, "uip", ["fields", "rename", "--group", "Invoice Header"])
149-
records = parse_log((sandbox_dir / RECORD_CLI_LOG).read_text(encoding="utf-8"))
183+
records = _records((sandbox_dir / RECORD_CLI_LOG).read_text(encoding="utf-8"))
150184
assert records[0]["argv"][-1] == "Invoice Header"
151185
finally:
152186
sandbox.cleanup(preserve=False)
@@ -158,7 +192,7 @@ def test_multiline_argument_survives_as_one_element(self):
158192
try:
159193
sandbox_dir = sandbox.setup()
160194
_run_shim(sandbox_dir, "uip", ["fields", "update-prompts", "--updates", payload])
161-
records = parse_log((sandbox_dir / RECORD_CLI_LOG).read_text(encoding="utf-8"))
195+
records = _records((sandbox_dir / RECORD_CLI_LOG).read_text(encoding="utf-8"))
162196
assert len(records) == 1
163197
assert records[0]["argv"][-1] == payload
164198
finally:
@@ -170,7 +204,7 @@ def test_repeated_invocations_append_in_order(self):
170204
sandbox_dir = sandbox.setup()
171205
for n in range(3):
172206
_run_shim(sandbox_dir, "uip", ["documents", "upload", f"doc{n}.pdf"])
173-
records = parse_log((sandbox_dir / RECORD_CLI_LOG).read_text(encoding="utf-8"))
207+
records = _records((sandbox_dir / RECORD_CLI_LOG).read_text(encoding="utf-8"))
174208
assert [r["argv"][-1] for r in records] == ["doc0.pdf", "doc1.pdf", "doc2.pdf"]
175209
finally:
176210
sandbox.cleanup(preserve=False)
@@ -184,7 +218,7 @@ def test_several_tools_share_one_log_tagged_by_tool(self):
184218
sandbox_dir = sandbox.setup()
185219
_run_shim(sandbox_dir, "uip", ["projects", "list"])
186220
_run_shim(sandbox_dir, "curl", ["-s", "https://example.invalid"])
187-
records = parse_log((sandbox_dir / RECORD_CLI_LOG).read_text(encoding="utf-8"))
221+
records = _records((sandbox_dir / RECORD_CLI_LOG).read_text(encoding="utf-8"))
188222
assert [r["tool"] for r in records] == ["uip", "curl"]
189223
finally:
190224
sandbox.cleanup(preserve=False)
@@ -305,6 +339,14 @@ def test_rendered_shim_is_pure_ascii(self):
305339
source = render_recorder(RecordedCli(tool="uip"))
306340
source.encode("ascii")
307341

308-
def test_parse_log_skips_unparseable_lines(self):
309-
text = json.dumps({"tool": "uip", "argv": []}) + "\ngarbage\n\n"
310-
assert len(parse_log(text)) == 1
342+
def test_parse_log_separates_usable_from_unusable(self):
343+
text = (
344+
json.dumps({"tool": "uip", "argv": ["a"]})
345+
+ "\ngarbage\n\n"
346+
+ json.dumps({"tool": "uip", "argv": "not-a-list"})
347+
+ "\n"
348+
)
349+
usable, unusable = parse_log(text)
350+
assert [argv for argv, _ in usable] == [["a"]]
351+
# An argv that is not list[str] is unusable, not a non-match.
352+
assert unusable == 2

0 commit comments

Comments
 (0)