feat(record_cli): serve a different canned response per invocation - #150
Conversation
A `record_cli` shim answered every invocation with one fixed exit_code/stdout/stderr, so an agent whose next step depends on what the tool just told it could not be evaluated: `uip ixp dummy1` and `uip ixp dummy2` got the same reply. Each entry may now declare `responses`, a list of rules tried in declaration order, first match wins, falling back to the entry's own three fields for anything no rule claims. `exit_code` defaults to 0 on a rule (the opposite of the entry default of 1): a rule exists because the author described that invocation. `when` is not a second pattern language. The criterion's matcher moved to `argv_match.py` -- stdlib-only, plain dicts -- and both surfaces lower to one spec dict, so the pattern that serves a response is the pattern that grades it. `render_recorder` embeds that module's SOURCE into the shim, which runs where coder_eval is not installed; CE047 keeps its imports stdlib-only, since one package import there would make every shadowed CLI die with an ImportError the agent reads as "the tool is broken". `FlagMatch` moved to the new cycle-free leaf `models/cli_match.py` alongside `CliMatch` and the shared verb/flag validators: models/sandbox.py cannot import from models/criteria.py, which already takes RECORD_CLI_LOG from it. Two deliberate divergences from the criterion, both tested: `ignore_flags` is empty on a rule (grading must not depend on --output; dispatch may), and `tool` stays criterion-only, addressing a log record rather than argv. Also fixes a pre-existing silent no-match: a flag written into a verb (`verb: "ixp projects get --output json"`) validated and then matched nothing, because a verb is compared against the non-flag arguments -- the criterion scored 0 against a log holding that exact call. Now rejected on every surface, reusing the splitter's own is_number rule so `head -1` stays legal. The shim records `"rule": <index>` when a rule answered, and omits the key when none did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @alexandrujircan's task in 2m 27s —— View job Code Review in Progress
|
…a shim fault Addresses review on #150. Blocker: `FlagMatch.matches_regex` was never compiled at validation. The criterion's checker pre-flighted it, but the response-rule surface that now shares the model evaluates the pattern INSIDE the sandbox, where the shim swallowed the PatternError and served its fallback -- a log line byte-identical to a legitimate no-match. The task scored differently for identical agent behaviour, with nothing on any report surface. The compile moved into `FlagMatch`, so both surfaces refuse the pattern at load, and the now-unreachable checker pre-flight is gone. Second half of the same chain: when the shim's rule evaluation does raise, it returns the error and `record()` books it as `rule_error`, so an eval-config fault can no longer read as a clean no-match; `cli_called` fails the whole log on it, the way it already fails on the write-failure sentinel. Tested by corrupting a rendered shim -- the only route left now that the pattern cannot load. Also from the review: - The lowered spec crossed the model/matcher seam as `dict[str, Any]` read with permissive `.get(...) or <default>`, whose failure direction is always "unconstrained" -- a rule that matches everything, or a criterion that scores 1.0 on any log. It is now `MatchSpec` / `FlagPredicate` / `ResponseRule` TypedDicts with required keys indexed directly, so a key renamed on either side is a pyright error on both. Tests pin that the TypedDict key sets equal the model field sets, which is what makes the one cast honest. - `FlagMatch.needs_value` was dead after the matcher extraction while `argv_match.predicate_needs_value` documented a mirror contract nothing enforced. Deleted; the survivor now says it is the only implementation. - A `responses` rule an earlier rule already claims was accepted silently, unlike every other unusable declaration on this surface. Now a load error for the two decidable cases: an exact duplicate, and a verb-only rule whose verb prefixes a later one under the same flag parsing. - CE047 grew a namespace half: an embedded module may not bind a top-level name the shim binds itself, since the shim's definition wins and the resulting TypeError is swallowed into "every invocation gets the fallback". Its target set now derives from `invocation_log.EMBEDDED_MODULES` instead of a second hardcoded path, and a test asserts it matches a file that exists -- a rule guarding zero files must fail, not pass. - The three rendered-shim invariants only ever rendered the rules-less shape. Parametrized over both; the spliced shape did violate the ASCII one, so `argv_match.py` is ASCII-only now, by rule rather than by luck. - Parity test closed the other direction (a facet added to `CliMatch` alone passed before), `MergeField` dropped from `RecordedCli.responses` (never a merge root, so the strategy was inert metadata that read as a knob), doubled paren in CLAUDE.md, and the guide's example no longer uses the one flag a rule may key on but the criterion rejects. BREAKING CHANGE: a flag written inside a `verb:` (e.g. `verb: "ixp projects get --output json"`) is now rejected when a task loads, on `cli_called` and on a `record_cli` response rule. It previously validated and then matched nothing, so the criterion scored 0 against a log holding that exact call. Move the flag to `flags:`. An invalid `matches_regex` is likewise a load error rather than a check-time one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — that review found a real defect chain, not a hypothetical one. All two blockers and all five non-blocking items are addressed in dbc5afb. Blockers1 + 2 (one chain). That makes the checker's pre-flight unreachable, so it is deleted, and its two tests moved from check-time to load-time (including the For the second half — when the shim's rule evaluation does raise — I did not make Non-blocking
NitsAll taken: doubled paren fixed; BREAKING CHANGE footer added to dbc5afb, covering both the flag-in-verb rejection and the Verification: |
… use
CodeQL flagged the import as unused: the only reference was inside a QUOTED
`cast("FlagPredicate", ...)`, which pyright resolves but a static importer
scan cannot see. Unquoting makes it a genuine runtime reference, which is what
the alert was asking for and costs one name lookup per flag predicate at
config-load time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Two critical shim-safety issues and a moderate response-rule validation issue remain unresolved.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds ordered, per-invocation canned responses to record_cli using shared CLI argument-matching semantics.
Changes:
- Adds response rules, fallback behavior, and rule logging.
- Shares matching and validation with
cli_called. - Adds CE047 lint enforcement, documentation, and tests.
File summaries
| File | Review |
|---|---|
tests/test_sandbox_record_cli.py |
Tests response dispatch and fallback. nit (1 vote): Use “infer” after “leaves which ... to.” |
tests/test_custom_lint.py |
Tests CE047 behavior. |
tests/test_cli_match_parity.py |
Tests matching parity. |
tests/test_cli_called_criterion.py |
Updates matcher and validation tests. |
tests/lint/runner.py |
Registers CE047. |
tests/lint/rules/ce047_embedded_shim_stdlib_only.py |
Implements CE047. critical (2 votes): Imported bindings can bypass collision checks and overwrite shim globals; pass bound import names through _check_name. |
src/coder_eval/sandbox.py |
Reports generated response-rule counts. |
src/coder_eval/models/sandbox.py |
Adds response rules and reachability validation. moderate (2 votes): The reachability check can reject reachable rules when later flag predicates alter parsing. |
src/coder_eval/models/criteria.py |
Integrates shared matching definitions. |
src/coder_eval/models/cli_match.py |
Defines match models and validation. |
src/coder_eval/models/__init__.py |
Exports the new models. |
src/coder_eval/invocation_log.py |
Renders response-aware shims. critical (2 votes): SHIM_GLOBALS omits imported template globals, allowing embedded bindings to overwrite names such as sys; include every template-bound name. |
src/coder_eval/criteria/cli_called.py |
Uses the shared matcher. |
src/coder_eval/argv_match.py |
Implements shared argument matching. |
docs/TASK_DEFINITION_GUIDE.md |
Documents response rules. nit (2 votes): Use “infer” rather than “inference” after “meant to.” |
CLAUDE.md |
Updates architecture and lint guidance. |
Review details
Suppressed comments (1)
tests/test_sandbox_record_cli.py:753
- Use the verb “infer” after “leaves which ... to.”
to inference, and reads enough like a command line to invite flags."""
- Files reviewed: 16/16 changed files
- Comments generated: 4
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- CE047's SHIM_GLOBALS omitted the template's own imports (json, os, sys,
time), so an embedded `sys = None` passed the rule and then broke the
shim's `sys.stdout.write`. The set now covers them, and a test parses a
rendered shim and asserts SHIM_GLOBALS is EXACTLY what it binds -- the
omission was possible only because the list was maintained by hand.
- CE047 checked where an import came from but not the name it binds, so
`from typing import TypedDict as RULES` walked past the collision check.
Import-bound names now go through it too; a plain `import sys` is exempt,
since it binds the very module the shim imports anyway.
- The unreachable-rule check rejected a REACHABLE rule. A flag predicate makes
its flag known and value-bearing in that rule's parse only, so for
`--profile prod ixp projects get` a verb-only `ixp projects` rule leaves
`prod` positional and does NOT match, while a later `ixp projects get` with
`flags: {profile: prod}` does. The prefix proof now requires BOTH sides to be
free of flag predicates.
Plus the grammar fix in the guide.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nvocation-responses One conflict, in CLAUDE.md's CE-rules paragraph: main landed a DIFFERENT CE047 (agent-roster parity across the onboarding surfaces, #157). Both branches claimed the next free number, which is the collision the runner's own duplicate-id assertion is written for -- "the loser must renumber" -- and main's is merged, so this branch's embedded-shim rule becomes CE048: tests/lint/rules/ce047_embedded_shim_stdlib_only.py -> ce048_... id, runner import, test class, and the CE047 references in argv_match.py and invocation_log.py move with it. Main's CE047 text is kept verbatim in the paragraph and CE048 appended after it. Nothing else conflicted; tests/test_custom_lint.py auto-merged, and the two rules' test classes (TestCE047AgentRosterParity, TestCE048EmbeddedShim- StdlibOnly) now sit side by side. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
18077f5 to
22373c3
Compare
…f splicing it The shim carried argv_match.py's whole source in its own module namespace, so any top-level name the module and the template both bound was rebound by the template's definition and swallowed by respond() -- every invocation quietly serving the entry defaults. CE048's name-collision half and SHIM_GLOBALS existed only to guard that. The module is now written into the recorder directory beside the shim and imported as a sibling, so the shim binds `select_rule` and nothing else, and the collision class is gone structurally rather than lint-guarded. Two failure modes the import introduced, found in review and fixed here: - A sidecar that will not import left the tool ON PATH, answering nothing and RECORDING NOTHING -- byte-identical in the log to a call the agent never made, so `max_count: 0` over a forbidden call scored 1.0 with no error. The import is guarded; the fault is booked as a new `sidecar_error` record key and scored 0.0 by `cli_called`, alongside the other agent-reachable untrustworthy-log paths. It deliberately does NOT escalate: the sidecar sits in the agent-writable recorder dir, so escalating would let an agent launder a failure into an ERROR. - The recorder directory is agent-writable and holds a file per shadowed tool, so at the HEAD of sys.path it shadowed the sidecar's own stdlib imports: a task declaring `tool: typing.py` broke every rules-bearing shim in the sandbox. It is appended now, with any existing copy removed first (compared by realpath -- sys.path[0] is resolved while SHIM_DIR is not). CE048 keeps its id and its load-bearing stdlib-only half, renamed to match what it now guards. `"calls.jsonl"` collapses to one declaration. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n agent failure `cli_called` scored a gating 0.0 when the recorder booked `rule_error`, which books a mistake in the task author's own response spec as though the agent had failed the task. It now raises CheckerMisuseError, which `handle_criterion_errors` re-raises to FinalStatus.ERROR. Deliberately NOT uniform with the checker's other refuse-to-score paths, and the comment says so, because the reason is not obvious and the natural instinct is to make all five match. A missing log, a write sentinel, a `sidecar_error` and an unusable record are all things an AGENT can cause -- `rm` the log, fill the disk, delete the matcher beside the shim, append garbage -- so escalating any of them would hand an agent a way to convert a failing run into an ERROR. `rule_error` is the only one it cannot reach: its sole producer is a rule the task author wrote that faulted inside the shim. Also documents the three shim diagnostic keys on `CliCalledCriterion`, whose record-schema docstring still claimed extra keys were ignored -- untrue for two of them now that they decide the verdict. The write-failure sentinel path gets its first test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Unit tests cover every part of response dispatch except the one this task exists for: the shim is generated by the IN-CONTAINER orchestrator, so the interpreter baked into its shebang is the container's. `driver: docker` is the only way to reach that, since run_task_internal_command rewrites `driver: docker` -> `tempdir` before building that orchestrator. The dispatch detector is a `file_contains` over cli_mocks/calls.jsonl requiring `"rule": 0` and `"rule": 1`, NOT the text the agent captured. The shim writes `rule` only when that rule actually answered, so no agent behaviour can forge it and no prompt-compliance failure can suppress it. The captured-stdout criterion cannot carry that weight: this task's YAML is serialised to /work/input and mounted again at /work/task_dir, both readable, so RESPONSE_ONE / RESPONSE_TWO are reachable with `cat` -- the exposure anti_cheat_reference already documents. Verified against a codegen regression that renders `RULES = []`: it raises nothing, so neither rule_error nor sidecar_error is booked, both cli_called criteria pass on argv alone and a transcribing agent passes captured.txt. Only the log criterion fails. Without it the probe would report SUCCESS with per-invocation dispatch dead -- the single thing it was written to detect. captured.txt is kept as the weaker agent-visible half, and the recorded flake lever is now "zero every agent-dependent criterion", which leaves a probe that still proves dispatch rather than one that proves nothing. TestRecordCliProbeIntegrity reads the task through TaskDefinition rather than raw YAML, so a rule's `exit_code` default comes from CliResponse (0) and the entry's from RecordedCli (1) instead of being hand-copied where a drift would go unnoticed. NOT YET RUN END TO END: needs `make docker-image` and a live model. Owner runs it on Haiku before merge; it is in the BLOCKING e2e-smoke bucket. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…c parity `RecordedCli` and `CliResponse` are the fields a task author writes by hand, so an undocumented one is exactly the P0/P1 shape CE030 exists to catch. Both were already fully documented, so this is a zero-diff guardrail: it passes today and fails the moment a field is added without a doc line or a reasoned exemption. `CliMatch` stays deliberately unregistered — its fields live in the `cli_called` reference, and a third nested model under `SandboxConfig` would start the tree-walk that bullet exists to prevent. Also corrects CLAUDE.md, which still listed CE030's tracked set as four models, and marks that list a convenience copy of the SSOT. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two independent final reviewers converged on the same three defects.
CRITICAL -- the `rule_error` escalation was an ERROR-laundering lever.
`cli_mocks/calls.jsonl` lives in the sandbox the agent writes to, so
echo '{"argv":[],"rule_error":"x"}' >> cli_mocks/calls.jsonl
turned an honest FAILED into FinalStatus.ERROR, whose category is "error", not
"failed" -- the run is discarded rather than counted against the agent. Editing
the shim or the sidecar does it too. The premise that only a task author could
produce `rule_error` was simply wrong, and it is unfixable from the log: nothing
in an agent-writable artifact distinguishes an author's faulty spec from a forged
record. So all five refuse-to-score paths are uniform at a gating 0.0 again, and
the attribution moves to where the agent cannot participate -- RecordedCli now
runs the real matcher over every rule at LOAD time, so an unevaluable spec is a
validation error before a sandbox exists.
HIGH -- the sidecar was imported by NAME. Appending SHIM_DIR to sys.path puts the
recorder dir behind PYTHONPATH and site-packages, so an impostor argv_match
exporting select_rule served every response while the log still recorded
`"rule": 0` -- both the criterion and the probe reported healthy dispatch. It is
loaded by absolute path now; SHIM_DIR is dropped from sys.path entirely, which is
still required because the sidecar's own `re`/`typing` resolve through it.
MEDIUM -- the probe accepted SWAPPED dispatch: requiring `"rule": 0` and
`"rule": 1` somewhere in the log passes when each rule answers the other's
invocation. Two regexes now tie each argv to its own index.
Also: `cli_called` says plainly that the log is EVIDENCE, NOT ATTESTATION, since
three surfaces had begun claiming a tamper-resistance it cannot provide; the
sidecar regains the no-subprocess scan it lost when it stopped being spliced
(CE048 cannot substitute -- `os` is allowlisted); the probe-integrity test now
runs a real shim instead of comparing the YAML to itself; and the fault checks are
scoped to `criterion.tool` so a `uip` fault stops failing an unrelated `curl`
guard.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- `cli_called` may never raise on log content, because the log is agent-writable and `CheckerMisuseError` becomes a FinalStatus.ERROR the agent would prefer to a FAILED. Thirteen enumerated adversarial log shapes must all produce a scored result, and a companion test pins the mirror-image defect: refusing to raise must not become refusing to fail. Verified by re-introducing the escalation -- 3 of the 13 cases fail. - CLAUDE.md's prose copy of the CE030 registry is now checked against the registry. It had already gone stale at four names after a sixth was registered, and nothing sensed it. - tasks/README.md calls itself the map for tasks/, and its smoke Members list had no sensor and had already lost `opencode_smoke_test`. Added the sensor and the missing entry. Three further candidates are deferred to .claude/harness-candidates.md with the reason each exceeds the promote-now budget. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
uipreliga
left a comment
There was a problem hiding this comment.
Made some changes to the code; if you are happy with them, merge the PR.
…nvocation-responses
Conflicts, and how they were resolved:
- tests/lint/runner.py — a CE-number collision. main landed CE048-CE056 while
this branch held CE048 for the sidecar-shim rule. The runner's own anti-shadow
assert names the tie-break ("the loser must renumber"), so
SidecarShimStdlibOnly moves to **CE057**: file renamed to
ce057_sidecar_shim_stdlib_only.py, and every CE048 reference belonging to this
rule renumbered across argv_match.py, models/sandbox.py, the rule itself and
its tests. main's CE048 (no in-process Typer command call) keeps the id.
- CLAUDE.md — both sides edited the module listing and the "Recent additions"
paragraph. Kept main's new entries (reports_html / reports_stats / formatting
/ telemetry / isolation/ / optimize/) and re-inserted this branch's
invocation_log.py rewrite plus the argv_match.py sidecar line; appended the
sidecar rule to main's rewritten paragraph as CE057.
- .claude/harness-candidates.md — both sides appended to the same backlog
section. Kept both sets of entries.
Two silent breaks git could not flag, fixed here:
- pyproject's `[tool.ruff.lint] external` needs CE057. main's new
TestRuffExternalCoversEveryRule requires every rule id to be listed, or the
documented `# noqa` turns into a red `make check`.
- tasks/README.md was missing `pi_smoke_test`. This branch adds the
TestTasksReadmeSmokeMembers parity guard; main independently added a
`smoke`-tagged pi_smoke_test.yaml with no such guard to answer to. The merge
is the first point where both exist, so the list is completed here.
Verification: ruff format + ruff check clean; `pytest -m lint` green except the
pre-existing Windows CE033 UTF-8 decode; full suite 5203 passed / 22 failed,
every one reproduced on clean origin/main (Windows symlink privilege, absent
litellm + openai_codex, float.numerator, CE033) or `live`-marked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Problem
A
record_clishim answered every invocation with one fixedexit_code/stdout/stderr. So an agent whose next step depends on what the tool just told it could not be evaluated:uip ixp dummy1anduip ixp dummy2got the same reply, and anything needing a real answer fell back to a hand-written mock undermock_path_dirs.What you can write now
Rules are tried in declaration order, first match wins, and anything unclaimed gets the entry's own three fields.
exit_codedefaults to 0 on a rule — the opposite of the entry default of 1 — because a rule exists precisely because the author described that invocation. The log now carries"rule": <index>when a rule answered and omits the key when none did, so "returned the default" and "rule 2 answered, and looks like the default" are no longer the same line.whenis not a second pattern languageThe criterion's matcher moved to
src/coder_eval/argv_match.py— stdlib-only, plain dicts — and both surfaces lower to one spec dict:cli_calledcallsargv_matches(criterion.match_spec, argv); the checker lost ~160 lines.render_recorderembeds that module's source into the shim (read as a package resource), so the pattern that serves a response is the pattern that grades it. A test asserts the embedded copy is the shipped source verbatim; another asserts it is embedded only when the entry declares rules, so a shim with no rules is byte-identical to before.CE047 (new lint rule) keeps that module's imports stdlib-only: the shim runs where
coder_evaland its dependencies are not installed, and one package import there would make every shadowed CLI die with anImportErrorthe agent reads as "the tool is broken".FlagMatchmoved to the new cycle-free leafmodels/cli_match.py, alongsideCliMatchand the shared verb/flag validators —models/sandbox.pycannot import frommodels/criteria.py, which already takesRECORD_CLI_LOGfrom it.Two deliberate divergences from the criterion, both pinned by tests in
tests/test_cli_match_parity.py:cli_calledignore_flagsdefault["output"]— grading must not depend on a flag that changes nothing[]— dispatch may legitimately answer differently for--output jsontoolDrive-by fix: a flag inside a verb matched nothing, silently
verb: "ixp projects get --output json"validated and then could never match, because a verb is compared against the non-flag arguments. Silent in the worst direction:cli_calledscored 0 against a log holding that exact call. Pre-existing on the criterion; now a validation error on every surface, naming the fix. It reuses the splitter's ownis_numberrule, sohead -1stays legal.Review notes
whenis mapping-only — a barewhen: "ixp dummy1"is rejected with the{verb: ...}spelling in the message. An earlier draft accepted the string as shorthand; it was dropped so a pattern has one shape.FlagMatchkeeps its scalar shorthand (flags: {output: json}=={equals: json}). A single-valued predicate has only one facet a scalar could mean, and removing it would be a breaking change to every existing task. Happy to revisit separately if we want strict one-way-only.Verification
ruff format,ruff check,pyright, and the lint-marked suite are clean on the touched files. 4660 tests pass. Eight failures on my Windows box are pre-existing and unrelated — verified identical atorigin/mainwith these changes stashed: symlink-privilege (4),float.numeratorintest_reports_stats_nonfinite(3), and a UTF-8 decode in the CE033 drift test (1).🤖 Generated with Claude Code