Skip to content

Judge behavioral runs through the harness, and check the room they started in - #39

Open
volen-silo wants to merge 1 commit into
amd:mainfrom
volen-silo:harness-judge
Open

volen-silo wants to merge 1 commit into
amd:mainfrom
volen-silo:harness-judge

Conversation

@volen-silo

@volen-silo volen-silo commented Nov 9, 2026 •

Copy link
Copy Markdown
Collaborator

Builds on #33 and #34, which have merged. After this, behavioral runs on Copilot CLI
with no model credential, as routing already does.

Why

Part of #4. #34 left behavioral refused on any harness but Claude Code, because the
LLM judge that grades expected_behavior and unexpected_behavior called an Anthropic
model directly, whatever agent had produced the run. That kept a repo that authenticates
with the workflow's GITHUB_TOKEN from running graded behavioral evals.

What changes

A harness is judged by its own CLI. Harness.complete(prompt, model) asks the
model one question with no tools and returns the answer. The default runs the harness's
own command with a new RunSpec.tool_free, and reads the answer through normalizer()
the way a case's closing answer is read. For Copilot CLI that command grants nothing and
offers the model no tool, with --available-tools naming one that does not exist: the judge
reads evidence, it never acts on the machine it is reading about. It is an allowlist because a
denylist depends on spelling the CLI's own tool names, and shell is not one (the CLI's shell
tool is powershell). I checked against the real CLI (1.0.105): with the denylist the model
still saw and called powershell; with the allowlist it sees no tool and calls none, and a
real judge question still answers. The tool-free Copilot command also refuses extra flags,
since one appended after the allowlist could grant a tool back. Claude Code's command refuses tool_free
outright instead of running a question with permissions skipped; its judge stays the
inspect model, unchanged.

judge.grade takes an optional complete, passed through the scorer and the
behavioral runner, and uses it in place of the inspect model. A judge that fails or
prints no answer is reported against that expectation (judge failed: ...), like a
sandbox that could not be listed, so one failure neither ends the case nor hides the
deterministic checks. The judge sees text only: an image artifact is named in the
question, not read or attached. Each judge call starts a real process, so it is counted against the
same subprocess limit as the agent's own, once per judged expectation.

The room is checked on routing. Copilot CLI does not announce the skills it loaded,
so Harness.room(workspace, env) asks for them; for Copilot that is copilot skill list --json, which costs no model call. A routing run asks once per case, off the event loop,
inside the subprocess limiter and within the command's deadline, with a hung CLI stopped along
with its children, and records what was
visible and what was extra, so a routing report no longer says the room was not verified.
The behavioral command does not run the room check. The CLI's built-in skills are in every
session and are not counted as extras, and a skill the CLI reports as switched off is not in the room.

Messages say what is true. The two report messages that named Claude Code's
credential and its inspect_swe leg now name the harness that ran. docs/usage.md and
docs/harnesses.md are updated to match.

A bug found by the end-to-end test. On a subprocess harness, behavioral handed
inspect a no-provider model whose message limit is a test value of 6, so one real run
exceeded it and lost its transcript. The limit now comes from the model the harness would
run, not from inspect's placeholder.

The reusable workflow is unaffected

Nothing under .github/, action.yml, bootstrap/ or examples/ changes, and the only
existing test removed is the one that asserted the refusal this PR deletes. Claude Code stays the default harness and its judge path is
the existing one. The workflows pass no --harness, so a repo using them does not have
to change anything.

Testing

New tests cover complete and room against a stub executable (the answer, nothing
allowed, a throwaway home, a refusal, no answer, a hang, a missing CLI, a harness that refuses
the tool-free command, a CLI that was asked and could not answer, a failing exit status with
valid output, no time left), a recording of copilot skill list --json parsed as it is, the image note in the judge's question and the artifacts read behind it, a completion with no
time left, the judge inside the limiter, the report wording for each harness, the advice and the
variables it names, the limiter around the room check, a hung skill list, every harness's tool-free command, and
the message limit that reaches the task, the behavioral command not asking for the room and running each judge question inside the
limiter, and the agent's own command not carrying the judge's allowlist. They run the real behavioral command with the stub replaying the recorded
session and answering the judge: all three kinds of expectation graded, the judge asked with
nothing allowed and shown what the agent did, a negative verdict, and a failing judge. Each
deliberate break of a fix turns a test red. One test from #34 is removed,
test_behavioral_while_its_judge_is_not_available, because it asserted the refusal this change deletes.

Run locally on a Windows machine and in WSL (Ubuntu, Python 3.12): 670 tests pass in each in the
last full run. The Windows run needs a caveat. TestTheHostLegActuallyKillsTheProcess.test_the_work_after_the_decision_never_happens
is inconsistent on that machine, with or without this change: its 1.4 s wall-clock bound is exceeded by
the process-kill latency there (it failed 3 of 3 runs alone and 4 of 5 earlier, and passed in the last full
suite, and fails alone on a clean checkout there). It passes in CI on both runners and in WSL, so I read it as a bound that is too tight for that host
and unrelated to this change. In WSL the tests are run with git's global config kept out: with
commit.gpgsign set, four TestWhatABranchChanged tests that commit in a throwaway repository fail on
git commit before reaching any skillscope code.

Not in this PR

A container leg for a non-Claude harness, the harness input on the reusable workflow
and an install-harness action step, and Codex as a harness. All stay opt-in and none
changes what a default run does.

@volen-silo
volen-silo force-pushed the harness-judge branch 2 times, most recently from 014b472 to 66bffe4 Compare November 9, 2026 11:45
@johnl-amd johnl-amd added the agent-hub-reviewed agent-hub has reviewed this label Nov 9, 2026
@johnl-amd

johnl-amd commented Nov 9, 2026 •

Copy link
Copy Markdown
Collaborator

Review — needs work

Full review of the whole change (14 files, +616/-69), base 7a8c00b9ea153c5efc5679f60d7a5e9df0deb056, head 66bffe44ff2f2f5ccce97a38d3a4bf3634c2a18f.
Builds on #33 and #34 — both confirmed merged into main's ancestry (their merge commits precede this PR's base). Part of #4, which is open and genuinely about expanding harness support; this PR does not claim to close it.

Blocking

  • The room check reports "could not verify" even when it genuinely found the staged skill missing. skillscope/engine/no_sandbox.py:494-507, skillscope/routing.py:467,509,711-723, consumed via skillscope/engine/routing.py:502-512 (_room_report).
    _room_report's store.get(key) or [] and routing.py's checked = [o for o in outcomes if o.visible_skills] / room_checked_cases treat an empty visible_skills list identically whether the key was never written (check didn't run) or was written as [] (check ran and found none of the staged skills visible — the single worst-case this feature exists to catch). Reproduced directly: stubbing Copilot's skill list --json to return [] (a definite, successful answer that nothing is visible) still makes render_markdown print "> Note: the room was not verified... this run's harness could not say" — the message reserved for "nobody could ask" — instead of flagging the skill as missing from the session. This also contradicts the PR description's own claim that "the report no longer says the room was not verified" once the check can run.
    Fix: record whether the check ran as a signal distinct from whether anything was visible (e.g. a dedicated "checked" key, or store None vs [] and have _room_report/summarize tell them apart) instead of inferring "checked" from visible_skills truthiness.
    Confidence 100 · architectural · reproduced by running the actual report pipeline against a stubbed harness.

  • Harness.complete()'s deadline-exhaustion branch has no test. skillscope/harness/base.py:255-260 (new in this PR; the analogous branch in preflight(), lines 211-216, is tested). Verified by mutation: deleting the block and rerunning the full test_harness_hooks/test_harness_cli suite (74 tests) leaves everything green.
    Confidence 100 · test-coverage · verified by running.

  • Copilot.room()'s non-zero-exit branch has no test. skillscope/harness/copilot.py:180 (if proc.returncode != 0 or not isinstance(listed, list): return None). The two "cannot say" tests only make the stub exit non-zero with no stdout, which raises JSONDecodeError and is caught before the returncode != 0 check is ever reached. Verified by mutation: removing proc.returncode != 0 or from the condition leaves the suite green. A stub that prints a valid JSON list and then exits non-zero would reach this line with zero coverage.
    Confidence 100 · test-coverage · verified by running.

Non-blocking

  • docs/harnesses.md:137-146 says "Two things still assume Claude Code" but lists three bullets (Claude Code's own judge, the sandbox leg, and the hardcoded .claude/skills path). Confidence 90 · mechanical.
  • skillscope/engine/routing.py:506 (pre-existing, unchanged by this PR) — _room_report's docstring still frames the room check as something only the CLI's init event can answer; a harness like Copilot now also answers it through the new harness.room() path, which this comment doesn't mention. Pre-existing text, connected by pattern relevance to this PR. Confidence 70 · documentation.
  • skillscope/harness/claude_code.py:46-53 — ClaudeCode.command() raises a bare NotImplementedError when spec.tool_free is set, rather than the CompletionError that base.Harness.complete()'s docstring implies all judge-path failures become. Currently unreachable (nothing calls ClaudeCode.complete() this way), but the exception type wouldn't match the stated contract if that changes. Confidence 80 · mechanical.
  • tests/test_harness_cli.py's STUB and tests/test_harness_hooks.py's STUB_COPILOT duplicate a non-trivial preamble (UTF-8 reconfigure comment, --version handling, skill list --json handling) almost verbatim, with one detail already drifted (one prints one line for --version, the other two). A small shared builder would stop further drift. Confidence 70 · architectural.

Decisions for the author

  • tradeoff — Judging a non-Claude-Code harness through its own CLI means the judge's model and reasoning quality now vary by harness (Claude Code's inspect model vs. e.g. Copilot's pinned model through a different CLI), on top of the pre-existing "scores aren't comparable across harnesses" caveat. That's the necessary price of running behavioral with no Anthropic credential at all — worth confirming the existing non-comparability caveat is understood to now also cover judgment strictness, not just the agent's own behavior.
  • non-blocking-improvement — Nothing in the Harness contract enforces that a future harness's command() actually honors RunSpec.tool_free. Both registered harnesses handle it correctly today (Copilot by construction, Claude Code by explicit refusal), but a third harness (tracked by Add support for Codex agent harness #4) that forgot to check tool_free would silently let the judge run with full tool access — exactly the hazard complete()'s design exists to prevent. A documented requirement or a small conformance test on the base class would catch that before it ships.

Positive signals

  • Harness.complete()'s temp-directory cleanup and deadline.active() arithmetic (zero/negative leftover, no active deadline) were traced and are correct.
  • Copilot.room()'s broad except around a shutil.which(...)-returning-None case was traced precisely: passing None as argv[0] to subprocess.run raises TypeError in the parent process, which the except clause converts into the documented None ("cannot say") return — not a silent-failure smell.
  • judge.grade()'s broad except Exception around the harness-CLI judge call matches an existing pattern already used elsewhere in the module, always produces a visible failing check (judge failed: ...) instead of swallowing it, and correctly lets BaseException-derived control-flow exceptions propagate.
  • The behavioral message-limit bug this PR fixes (a subprocess harness getting capped at the mock model's test-sized limit) was independently confirmed real, and the fix is correct.

Deployment notes

None beyond the above: no new external dependency, no migration, and the reusable workflow surface (.github/, action.yml, bootstrap/, examples/) is confirmed untouched by the diff.

What this covered

Every file in the 14-file diff was read in full at the PR head by four parallel file-group workers (harness protocol + docs; room-check/report wiring; judge/scorer/cli plumbing; the two changed test files), each re-verified against the source before scoring. I independently reproduced the headline room-check bug by running the actual report pipeline against a stubbed Copilot CLI, and independently reproduced both missing-test-coverage findings myself by mutating out the relevant branch and rerunning the affected test files (green both times). I ran the full suite in a separate throwaway checkout (python -m unittest discover -s tests -t .): 639 tests, all green, matching the PR description's own count exactly. I read the PR's actual GitHub status checks rather than assuming: all 8 completed checks report success; the "blocked" mergeable_state reflects two requested-but-not-yet-given reviews, not a CI failure. I checked the flakiness claim about TestTheHostLegActuallyKillsTheProcess.test_the_work_after_the_decision_never_happens: that file is untouched by this diff, the test is a wall-clock-margin assertion, and under artificial heavy CPU oversubscription I observed the asyncio loop's scheduling delays growing close to its margin, consistent with "flaky under load," though I did not personally trigger an actual failure.

Did not run: agent-instruction-adherence (no CLAUDE.md/AGENTS.md exists in this repository); prior-changes (no earlier review of these files exists for this blind pass to read, and PR discussion was not read, by this method's own rule — the PR currently carries no comments regardless). History was checked lightly via git log/git show and surfaced nothing beyond confirming #33/#34's merge state. Not reconciled against any discussion on this PR.


🤖 Automated review by an agent.
Model claude-sonnet-5[1m] · Not a human review. Reply here if a finding is wrong.

@johnl-amd johnl-amd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Automated review · review-code · head 66bffe44

Review — no blocking findings

Full review of the whole change (base 7a8c00b9…head 66bffe44ff2f2f5ccce97a38d3a4bf3634c2a18f, +616/-69 across 14 files), split across five independent passes: three fan-out workers grouped by coupling (harness contract + judge; behavioral grading wiring; room-check reporting + docs) plus separate history and prior-changes/code-comments passes, each blind to the others and to this PR's existing comment thread. Part of #4 ("Add support for Codex agent harness" — still open, assigned to this PR's author), building on #33 and #34 (merged). The description accurately scopes itself: it adds Copilot support for behavioral, and explicitly lists what it does not do (a container leg for non-Claude harnesses, the reusable workflow's harness input, Codex itself) — matching the diff, which touches none of .github/, action.yml, or bootstrap/.

Blocking

None

Non-blocking

  • judge.grade() reads and base64-encodes every image artifact even when the result is immediately discarded — skillscope/engine/judge.py:260, :294-296, :298-305
    artifacts(paths) unconditionally reads image bytes from the sandbox and builds ContentImage/ContentText objects, but the complete is not None branch (the whole point of this PR) only ever reads content[0].text from that list, discarding every image that was just read and encoded. This runs on every judged expectation of every case graded through a non-default harness.
    Confidence 70 · logic · Fix: skip building/reading the image content when complete is set and the harness path doesn't use it, or restructure so artifacts() is only materialized for the model-based judge path.

  • Stale comment left behind by the deleted behavioral-refusal block — skillscope/cli.py:485-486 (guarding subject.preflight(args.model) in _prepare_graded_run)
    The comment reads: "The harness's own CLI is all a routing run reaches: its agent is a subprocess and there is no judge, so no provider is probed." _prepare_graded_run is now shared by both routing and behavioral (the refusal that previously kept non-default harnesses out of behavioral was removed a few lines above), and behavioral does now have a judge on a non-default harness, routed through the same CLI via harness_completer. The comment still frames this path as routing-only and claims "no judge" unconditionally.
    Confidence 85 · mechanical · Fix: update the comment to acknowledge a harness-routed judge exists for behavioral too.

  • Stale module comment still describes only the original (init-event) room-check mechanism — skillscope/engine/no_sandbox.py:54-61
    The comment above VISIBLE_SKILLS_KEY/EXTRA_SKILLS_KEY says the room check is "read off the CLI's system/init event… Only this leg can answer — claude-code drives the CLI through inspect_swe, which does not surface the init event," with no mention of the new, independent harness.room() path this PR adds (called before the CLI even runs) that populates the exact same two keys for harnesses with no init-event equivalent.
    Confidence 80 · mechanical · Fix: extend the comment to note the second room-check mechanism and how it relates to the first.

  • docs/usage.md's claim that "the host leg cannot read the CLI's session-init event, so it has no way to notice such contamination" is now directly contradicted by this PR's own feature — docs/usage.md:301-308
    This paragraph (pre-existing, untouched by this diff) sits between two paragraphs this PR did edit, and says the host leg has no way to notice a contaminated room on a non-Claude-Code harness — which is precisely what the new harness.room() mechanism this PR adds now does for Copilot, without needing an init event at all.
    Confidence 75 · documentation · Fix: update or remove this paragraph now that the feature it says doesn't exist, exists.

Decisions for the author

  • The harness-aware model/judge-swap decision lives in cli.py's cmd_behavioral, not in engine/behavioral.py — non-blocking-improvement. engine/routing.py's run() makes the equivalent decision (swap to NO_PROVIDER_MODEL for a non-default harness) inside the engine module itself, so every caller gets harness-correct behavior automatically. The behavioral equivalent is inline in the CLI layer; behavioral.run()/build_task() just accept whatever complete/message_limit they're handed. No live bug (there's only one caller today), but a second caller of behavioral.run() would silently get the old all-Claude-Code behavior unless it reimplements this exact branch.
  • Harness.room() has no host/container split or explicit host-only documentation, unlike command()/env() — non-blocking-improvement. command()/env() are resolved only through the separate host_command()/host_env() wrappers, a split added specifically so a future sandboxed leg can reuse the harness-level methods safely. Copilot.room() resolves self.binary inline inside the harness-specific method itself, with no such split, and neither Harness.room()'s base docstring nor docs/harnesses.md's new table row say it's host-only. It currently works only because room() is in practice called exclusively from the host-only no_sandbox.py, but nothing enforces that.
  • Two independent room-check mechanisms (the init-event-based one and the new harness.room()) write the same two keys with an implicit, untested precedence — tradeoff. They never both fire for any harness registered today (Claude Code's room() is a no-op; Copilot's normalizer never emits an init-shaped event), so this is currently safe, but nothing establishes or tests which one should win if a future harness implements both.
  • _isolate_advice's generic (non-Claude-Code) branch tells the user to "set the harness's credential" without naming which variable, unlike the Claude-Code-specific branch which names ANTHROPIC_API_KEY explicitly — non-blocking-improvement. Harness.credentials already holds the right data (e.g. Copilot's three candidate env vars) to make this concrete, and a harness with an empty credentials tuple would currently get told to "set the harness's credential" even though no such path exists for it.

Positive signals

  • RunSpec.tool_free is a clean application of the harness value-object pattern; ClaudeCode.command() refuses it explicitly rather than silently running with permissions skipped, and there is no code path that reaches ClaudeCode with tool_free=True while bypassing that guard (host_command() always calls command() first, and preflight() never sets the flag).
  • Harness instances are shared across concurrent cases, and both complete() and room() were confirmed stateless with respect to self — all per-call state is local, so the new asyncio.to_thread(harness.complete, ...)/asyncio.to_thread(harness.room, ...) call sites are safe under concurrency.
  • Argument threading from cli.py → behavioral.run → build_task → scorers.expectations(complete) → judge.grade(..., complete=complete) is correct at every hop, and default (Claude Code) behavior is provably unchanged when the harness is the default — complete/message_limit both stay None and the pre-existing computation is reproduced exactly.
  • The message-limit bug the PR describes finding via its end-to-end test is real and correctly fixed: message_limit_for(engine_models.resolve(args.model)) now uses the model the harness would actually run, not inspect's NO_PROVIDER_MODEL placeholder.
  • docs/harnesses.md and docs/usage.md were updated accurately alongside the code in the same commit, and this PR's additions build consistently on the documented groundwork left by #33/#34 (no prior invariant was found reintroduced or regressed).

Deployment notes

None beyond informational: this changes report wording only (which harness's credential/leg is named), with no schema, config, or default-behavior change for the existing Claude Code path. The reusable workflow and action.yml are untouched, as the description states.

What this covered

Every changed file read in full against the PR head by three independent fan-out workers grouped by coupling (skillscope/harness/base.py, claude_code.py, copilot.py, __init__.py, skillscope/engine/judge.py, tests/test_harness_hooks.py; skillscope/cli.py, skillscope/engine/behavioral.py, skillscope/engine/scorers.py, tests/test_harness_cli.py; skillscope/engine/no_sandbox.py, skillscope/routing.py, docs/harnesses.md, docs/usage.md), plus a dedicated history pass (checked this PR's changes against the invariants and groundwork left by #33/#34/the room-check/answer-key work) and a dedicated prior-changes/code-comments pass (checked whether review points from #33 still apply, and comment accuracy in the touched files). All 8 CI checks on this head commit are green (unit tests on ubuntu/windows × Python 3.10/3.12, sandbox machinery on both OSes, unused-imports, action-against-a-throwaway-repo).

Did not run: agent-instruction adherence (no CLAUDE.md/AGENTS.md exists in this repository). Not reconciled against this PR's existing comment thread — this pass was kept blind to it by design, per the review method's independence rule.


Not a human review. This is an automated code review; please verify findings independently before acting on them.

@volen-silo

Copy link
Copy Markdown
Collaborator Author

Thanks. No blockers, so I took the points that are cheap and keep the text true, and left two for later. Pushed as part of the same commit (hash changed).

Taken

  • cli.py comment: it now says both graded commands reach the harness's CLI, and that a behavioral judge is asked through it.
  • no_sandbox.py comment: it describes both room-check mechanisms and says which wins if a harness ever did both. Harness.room writes before the run and the init event, read later in the stream, overwrites it. No registered harness hits both, so I stated the precedence and did not add a test for it.
  • docs/usage.md: the paragraph is reworded. On Claude Code the host leg still can't read the init event; Copilot CLI is asked what a session would load, so a stray skill is named in the report. The host leg still refuses to run without a credential for either, since a room that was checked and found contaminated is still not the one that was asked for.
  • _isolate_advice: a non-Claude harness is now told to set one of its actual credential variables by name, taken from Harness.credentials. A harness with none, or one that is no longer registered, is told to remove the stray skills from the runner instead. The existing test that pinned the old wording now asserts the variable names, and a new test checks that setting them really does let the room be isolated.
  • Harness.room: the docstring and the docs/harnesses.md row now say it is host-only.

Left for later

  • Reading image bytes in judge.grade when complete is set: true, but a harness judge only names the images, each read is a small sandbox read next to a model call, and avoiding it means changing artifacts()'s return shape for a cost I haven't measured.
  • Moving the judge swap from cmd_behavioral into the engine: fair, and there is one caller today. It fits with the sandbox-leg unification that docs/harnesses.md already lists as next.

@johnl-amd

johnl-amd commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator

Review — needs work

Full review of the whole change (base 7a8c00b9ea153c5efc5679f60d7a5e9df0deb056 → head af6f408c3758ab85351525ec578751f7a587b957, a single commit).
Builds on #33 and #34 as stated and is part of the broader goal in #4 ("Add support for Codex agent harness", open, not claimed closed here) — this PR covers Copilot CLI's judge and room-check paths, not the rest of that issue; no closure is claimed.

Blocking

  • The PR description's testing section claims a test that does not exist — PR description ("Testing" section), contradicted by tests/test_harness_cli.py (classes TestABehavioralRunOnCopilot vs TestTheRoomOnCopilot), skillscope/cli.py:837.
    The description says the behavioral-command tests cover "...a negative verdict, a failing judge, and the room check with and without a stray skill." The room-check-with-a-stray-skill tests (test_a_skill_nobody_staged_is_named_as_an_extra, test_a_cli_that_cannot_say_leaves_the_room_unverified_and_says_so) live in TestTheRoomOnCopilot, whose run_routing() helper calls cli.cmd_routing, not cmd_behavioral. Confirmed in the code that the behavioral path never passes room_names to no_sandbox.harness_solver (skillscope/cli.py:837 omits the kwarg entirely, so skillscope/engine/no_sandbox.py:497's if room_names: block is always false for a behavioral run) — the room check genuinely never runs for behavioral. TestABehavioralRunOnCopilot has no assertion touching visible_skills/extra_skills/room at all.
    Confidence 88 · logic · Fix: narrow the description to say the room check was tested for routing only, or add a behavioral-path room-check test if that coverage is actually wanted.

  • Harness.complete() can raise a raw, undocumented NotImplementedError instead of the CompletionError it promises — skillscope/harness/base.py:240-293 (complete), skillscope/harness/claude_code.py:46-52 (ClaudeCode.command raising NotImplementedError when spec.tool_free).
    complete()'s only exception handling around self.host_command(spec) is except subprocess.TimeoutExpired; nothing catches command() raising NotImplementedError. Confirmed by execution: harness.get("claude-code").complete("hi", None) raises NotImplementedError, not CompletionError, even though CompletionError's docstring and complete()'s own documentation describe only "it failed, or printed no answer" as the failure contract. Currently unreachable from any shipped caller only because cli.cmd_behavioral happens to gate harness_completer(...) behind subject.name != harnesses.DEFAULT — nothing in Harness.complete() itself enforces that, so any future caller of the public method on ClaudeCode inherits an undocumented exception type.
    Confidence 80 · architectural · Fix: either catch NotImplementedError in complete() and re-raise as CompletionError, or document the extra exception type explicitly.

  • Two of Copilot.room()'s four new tests pass unchanged even with Copilot.room() deleted outright, and the returncode != 0 guard they're meant to cover is never actually exercised — tests/test_harness_hooks.py:295 (test_a_cli_that_cannot_say_leaves_the_room_unverified), :299 (test_output_that_is_not_a_list_leaves_the_room_unverified), skillscope/harness/copilot.py:178-180.
    Confirmed by removing Copilot.room() entirely and rerunning TestWhichSkillsASessionWouldLoad: only test_what_the_cli_lists_minus_its_own_skills fails; both "unverified" tests still pass, because they assert only that the result is None, which the inherited Harness.room() default also always returns — they prove nothing about Copilot's own implementation. Separately, test_a_cli_that_cannot_say_leaves_the_room_unverified's STUB_SKILLS_FAIL path makes the stub exit 1 with empty stdout, so json.loads("") raises a JSONDecodeError caught by the broad except on line 178 before the proc.returncode != 0 check on line 180 is ever reached — confirmed by deleting that half of the condition and rerunning the suite: all four tests still pass.
    Confidence 88 · mechanical · Fix: have the two "unverified" tests assert the stub was actually invoked (e.g. via a recorded call), and add a case where the stub exits non-zero while still printing syntactically valid JSON, to actually exercise the returncode != 0 branch.

  • A test claiming to validate _isolate_advice's "the advice is true" property never calls _isolate_advice — tests/test_harness_cli.py:286-292 (TestTheAdviceForAContaminatedRoom.test_those_variables_are_the_ones_that_isolate_the_room).
    The test body only calls the pre-existing harness.get("copilot").credentials and subject.can_isolate_room(...); it never calls routing._isolate_advice or routing._other_harness, the two functions this PR introduces. It would pass identically with this entire PR reverted. (The adjacent test_another_harness_names_every_variable_it_can_authenticate_from does drive _isolate_advice and names the same variables, so the underlying claim is covered elsewhere — but this specific test does not do what its docstring says.)
    Confidence 80 · mechanical · Fix: rewrite it to drive _isolate_advice's actual output back through can_isolate_room end-to-end, or delete it as redundant with the sibling test.

  • The message_limit override that fixes the described truncation bug is exercised but never verified for correctness — skillscope/cli.py:818-841 (cmd_behavioral's message_limit computation), skillscope/engine/behavioral.py:126 (message_limit=message_limit or message_limit_for(model)).
    TestABehavioralRunOnCopilot runs with --harness copilot, so the override branch executes without crashing, but no test asserts the built Task's message_limit value, and no scenario in the suite has enough transcript messages to distinguish "override applied" from "override silently skipped" (only message_limit_for itself, in isolation, is unit-tested at tests/test_skillscope.py:2333,2341 — unrelated to this wiring). Reverting just this override (e.g. hard-coding message_limit=None into the call) would not fail any test, which is exactly the "most common defect" pattern this skill's test-quality rule targets.
    Confidence 82 · logic · Fix: add a test that asserts the resolved message_limit reaching build_task/the inspect Task, or a scenario with a transcript long enough to fail under the placeholder model's 6-message limit and pass under the override.

  • The judge's image-artifact-naming branch has zero test coverage — skillscope/engine/judge.py:297-304 (if complete is not None: ... if images: ...).
    Exhaustive grep of tests/ for anything related to image artifacts combined with a complete=-driven judge (image artifact, ContentImage, artifacts() returns nothing. No behavioral test scenario produces an image artifact while also using the complete-based judge, so this specific text-fallback behavior — introduced by this PR for exactly the harnesses this PR newly enables — is never exercised.
    Confidence 85 · logic · Fix: add a case (real or stubbed) with an image artifact graded through complete=..., asserting the fallback note text reaches the question.

Non-blocking

  • Copilot.room() has no deadline/budget integration, unlike complete() and preflight() introduced alongside it — skillscope/harness/copilot.py:167-186.
    room() hardcodes timeout=60 with no reference to deadline.active(), while the new complete() (skillscope/harness/base.py:255-262) and the pre-existing preflight() both clamp to the ambient command deadline. A near-exhausted per-command budget can be overrun by up to 60s by a room check nothing bounds.
    Confidence 70 · architectural · Fix: apply the same deadline clamp/refuse pattern complete()/preflight() use.

  • The new harness.room() call in the solver bypasses the subprocess concurrency limiter protecting the main CLI invocation right below it — skillscope/engine/no_sandbox.py:497-512 vs. :529 (_subprocess_concurrency()).
    listed = await asyncio.to_thread(harness.room, workspace, env) runs unconditionally outside _subprocess_concurrency()'s scope. For Copilot, room() spawns a real subprocess.run(["copilot", "skill", "list", "--json"], ...), so every concurrently-running case now spawns one extra, unbudgeted subprocess alongside the limiter-protected main call — the exact kind of "second, unaccounted pool" the limiter's own docstring says it exists to avoid.
    Confidence 70 · architectural · Fix: wrap the harness.room(...) call in the same _subprocess_concurrency() context used for the main CLI invocation.

  • docs/usage.md's Copilot paragraph reads, without qualification, as if the room check applies to behavioral too — docs/usage.md:279-289.
    The paragraph states "Both routing and behavioral run on it [Copilot]... Copilot reports its skills through copilot skill list --json... so the room check runs once per case" in one unbroken passage, with nothing scoping the room-check sentence to routing only. As established above, the room check never runs for behavioral. The PR description itself is correctly scoped ("the report no longer says the room was not verified" — the routing report), so this is a doc-wording gap rather than a change-scope misrepresentation.
    Confidence 72 · mechanical · Fix: scope that sentence explicitly to routing.

Decisions for the author

  • A systemic judge outage is reported per-expectation rather than surfaced once at the run level — tradeoff.
    judge.grade's except Exception as exc: return False, f"judge failed: {exc}" (skillscope/engine/judge.py:308) is deliberate and well-reasoned for an isolated failure (confirmed it does not abort the case or the run). But if the harness's CLI itself is systemically broken (e.g. bad credential for every call), every judged expectation across every case will independently report "judge failed: ...", with no single clear top-level signal that the judge itself — not the agent — is the problem. Confirm that reading a report full of "judge failed" lines is an acceptable way to discover that, versus wanting one early, aggregated signal.

Positive signals

  • complete() and room() reuse the exact line-by-line json.loads + per-run normalizer() convention already used for real case output in no_sandbox.py, with no new race or inconsistency in how the "last matching result event wins" logic is applied.
  • ClaudeCode.command()'s refusal of tool_free is a deliberate, well-commented "refuse rather than silently build an unsafe command" choice, and is itself tested (test_claude_code_cannot_be_asked_this_way).
  • JUDGE_EXCLUDED_TOOLS's enumerated tool list is correctly understood as defense-in-depth, not the sole safeguard: the comment and the code confirm that because --allow-all is omitted and the run is non-interactive, any tool not on the exclusion list is still denied by default — so a future Copilot CLI tool category missing from the hardcoded list would not silently become available to the judge.
  • test_a_judge_that_fails_is_reported_against_the_expectation and test_a_negative_verdict_fails_that_expectation_and_the_case are a genuinely well-designed pair: they cleanly isolate "judge failed" from "judge disagreed" using distinct stub signals and distinct detail-string prefixes, and the former explicitly confirms the deterministic logs_contain check still passes.
  • The message_limit fix itself (deriving the limit from the harness's real model rather than inspect's no-provider placeholder) is the right fix for the bug the PR describes, even though its test coverage is incomplete (see Blocking).

Deployment notes

None beyond what the PR's own docs changes cover: this enables behavioral runs on a new harness (Copilot CLI) in CI for any repo that opts into --harness copilot; no default-harness (Claude Code) behavior changes, and the reusable GitHub Actions workflow is untouched.

What this covered

Every file in the diff (docs/harnesses.md, docs/usage.md, skillscope/cli.py, skillscope/engine/behavioral.py, skillscope/engine/judge.py, skillscope/engine/no_sandbox.py, skillscope/engine/scorers.py, skillscope/harness/__init__.py, skillscope/harness/base.py, skillscope/harness/claude_code.py, skillscope/harness/copilot.py, skillscope/routing.py, tests/test_harness_cli.py, tests/test_harness_hooks.py) was read completely at head af6f408c3758ab85351525ec578751f7a587b957, by three independent fan-out passes grouped by coupling (harness contract + its hooks tests; judge/scoring engine; CLI + routing + integration tests), plus this driver's own consolidation pass, which read across all groups to resolve contradictions between them (the fan-out passes initially disagreed about whether the "judge failed" branch and the room-check store-keys block were tested; both were re-verified directly against source and resolved as noted above) and to investigate the Step 7 design questions above. Several findings were confirmed by actually running the test suite with targeted reverts/deletions (noted inline), not only by reading.

History/prior-changes passes: git log/git blame were run against every changed file; this PR is a single isolated commit directly on top of main with no relevant prior history in the touched regions. A deeper "prior review comments on earlier PRs touching these files" search was not performed beyond that — judged not to add signal here given the recency and isolation of the change.

No CLAUDE.md/AGENTS.md exists in this repository, so the agent-instruction-adherence pass is not applicable. CI on the reviewed commit is fully green (8/8 checks). Not reconciled against any discussion on this PR — this is a fresh, blind pass at the current head; a prior automated review exists on this PR at an older commit, but its content was deliberately not read as input to this review.


🤖 Automated review by an agent.
Model claude-sonnet-5[1m] · Not a human review. Reply here if a finding is wrong.

@volen-silo
volen-silo force-pushed the harness-judge branch 2 times, most recently from aebcced to f1d5958 Compare November 10, 2026 16:06
@volen-silo

Copy link
Copy Markdown
Collaborator Author

Thanks, this was a careful read. Pushed (the commit is rebuilt, so the hash changed). Taking the findings in order.

1. The description claimed a room-check test for behavioral. You are right and it was my error: behavioral never passes room_names, so it never runs the room check. I narrowed the claim in the description, the commit message and docs/usage.md (the Copilot paragraph now says a routing run asks and behavioral does not). I did not add the check to behavioral: its report has nowhere to show the result, and that is a separate change if it is wanted.

2. complete() raising NotImplementedError. Fixed: a harness that refuses the tool-free command now gives a CompletionError. The test puts a stub claude on PATH first, since without that the missing-executable error answers before the refusal is reached.

3. Weak room() tests. You are right that both "unverified" tests passed with Copilot.room() deleted. The stub now records that it was asked, and the tests assert it was asked once. A new test has the stub exit non-zero while printing valid JSON, which is the only case that reaches the returncode != 0 guard. I checked by deleting room() and by deleting that condition: each now fails a test.

4. The _isolate_advice test that never called it. Rewritten: it reads the variable names out of the advice sentence and feeds each one back through can_isolate_room, so a name in the text that the harness does not read fails.

5. message_limit. A test now spies on build_task and asserts the limit that reaches the task is the real model's, not the placeholder's. Dropping the override fails it.

6. The image note. Two tests drive judge.grade with a stub complete: with an image the question names it and embeds no image data, and with none it says nothing about one.

Non-blocking, all taken: room() now stops if the command's deadline is spent, as complete() and preflight() do, and the test asserts the CLI is not started; the room check now runs inside the subprocess limiter, with a test that fails without it; the docs/usage.md wording is scoped to routing.

Decision, systemic judge outage: I would leave it as is for now. A broken credential already fails the preflight before any case runs; the per-expectation lines are for a judge that breaks partway. An aggregate line is a fair addition, but it is a reporting change, so I'd do it separately.

A correction to the description. I wrote earlier that the timing test TestTheHostLegActuallyKillsTheProcess.test_the_work_after_the_decision_never_happens "passes on its own". That was wrong for my machine: it fails 4 times in 5 even alone, at 1.4 to 2.4 seconds against its 1.4 second bound, and also on a clean main. It passes in CI on both runners and in WSL, so I read it as a bound that is too tight for that host. I have fixed the description.

Local runs, with the numbers: Windows, 650 tests, all pass except that one. WSL (Ubuntu, Python 3.12), 650 tests pass once git's global commit.gpgsign is kept out of the tests; with it set, four TestWhatABranchChanged tests that commit in a temporary repository fail on git commit.

@johnl-amd johnl-amd left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review — needs work

Full review of the whole change (base 7a8c00b9…head f1d59585d8e09ff1bd5a1e5521ad7444b53cd47b, +837/-86 across 14 files — note: the PR was force-pushed after this review was assigned; the originally-given head af6f408c no longer exists on the PR (it was superseded twice, most recently to a commit with byte-identical tree content), so this review covers the current head, which this run confirmed has the same diff), split across four independent passes (harness contract: base.py/claude_code.py/copilot.py; behavioral/judge wiring: judge.py/behavioral.py/scorers.py/cli.py; room-check reporting: no_sandbox.py/routing.py; and history/prior-changes/code-comments), each blind to the others, plus my own consolidation, design review and verification (full test suite run: 650/650 pass; targeted mutation testing on the behavioral and judge tests). Builds on #33 and #34 (merged); part of #4 (open, "Add support for Codex agent harness"), which this PR does not close and does not claim to.

Blocking

  • Copilot.room() can leak an orphaned subprocess on timeout — skillscope/harness/copilot.py:180 (the bare subprocess.run(..., timeout=timeout) inside room())
    This codebase built _run_bounded/_kill_tree (skillscope/harness/base.py:76) specifically because "a CLI installed with npm is a shim that starts the real program, so killing the shim leaves the program running with the output pipe open" — and both preflight() and the new complete() route through it for exactly that reason. The new room() method instead calls bare subprocess.run(..., timeout=timeout), whose TimeoutExpired path only kills the immediate child (confirmed: no start_new_session/process-group handling here, unlike _run_bounded). On a real npm-shimmed copilot CLI that hangs, this leaves the actual program running in the background holding the session's credentials after the 60s-or-less budget expires and the room check reports "unverified." The code's own exception guard (except (OSError, subprocess.SubprocessError, ValueError, TypeError)) catches TimeoutExpired fine and returns None correctly — the bug is the leaked process, not a crash.
    Confidence 80 · architectural · Fix: route room()'s subprocess call through _run_bounded/_kill_tree the way preflight() and complete() already do.

  • The harness-registry contract test does not enforce that every harness safely handles RunSpec.tool_free — tests/test_harness.py:153 (TestEveryRegisteredHarnessMeetsTheContract), contract defined in skillscope/harness/base.py:121 (RunSpec.tool_free) and base.py:240 (Harness.complete)
    This class is the repo's own generic enforcement mechanism — it already loops for name in harness.names() to verify every registered harness meets the room-check and event-translation parts of the contract. tool_free is a new, safety-critical part of that same contract (the judge "never acts on the machine it is reading about"), but nothing in this class — or anywhere reached for every registered harness — checks that a harness's command() refuses or safely restricts tool_free=True. Today's two harnesses both happen to handle it correctly (verified: ClaudeCode.command raises, Copilot.command passes --excluded-tools), but a future harness that silently ignores spec.tool_free would pass every existing test and grant the judge full tool access with no test catching it — exactly the registry-wide gap this test class exists to prevent for the rest of the contract.
    Confidence 80 · architectural · Fix: add a loop over harness.names() to this test class asserting each harness either raises on tool_free=True or that its resulting command excludes risk-granting flags.

Non-blocking

  • Image artifacts are read and base64-encoded even when they are never shown to the judge — skillscope/engine/judge.py:260 (artifacts(paths)), :293-296 (unconditional ContentImage/_data_uri construction), :298-306 (the complete is not None branch)
    artifacts() unconditionally reads every image's bytes from the sandbox and _data_uri base64-encodes them into ContentImage objects before the harness-judge branch is reached; that branch only ever reads content[0].text (confirmed by tracing: images is used solely for its path names in the "cannot be shown" note). This work runs on every judged expectation of every case graded through a non-default harness.
    Confidence 85 · logic · Fix: skip building the ContentImage list (or skip reading image bytes in artifacts()) when complete is set, since that path never attaches them.

  • New except Exception as exc: catch omits this codebase's established # noqa: BLE001 convention — skillscope/engine/judge.py:308
    Every other place in this codebase that catches-and-binds a bare Exception (sandbox.py:87, judge.py:193, judge.py:201, models.py:158) carries a # noqa: BLE001 -- <reason> tag explaining the broad catch; this new one (# the CLI failed or printed no answer, no tag) is the only one added by this diff that breaks that pattern.
    Confidence 75 · mechanical · Fix: add the # noqa: BLE001 -- ... tag consistent with the rest of the file.

  • Stale comment in routing.py's summarize() now describes only one of two sources that fill visible_skills — skillscope/routing.py:461-466 (unchanged by this diff, but made stale by it)
    The comment says visible_skills "is populated from the CLI's session-init event, which only the host leg can read." no_sandbox.py's own (correctly updated) module comment explains that Harness.room() is now a second, independent source for a harness with no init event (Copilot) — but this comment in routing.py, in a file this PR does touch, was not updated to match, and a reader of routing.py alone is misled about where the data comes from.
    Confidence 80 · mechanical · Fix: update the comment to mention Harness.room() as the second source, matching no_sandbox.py's wording.

  • Pre-existing room-check ambiguity now also applies to Copilot's room check (blast radius enlarged by this PR) — skillscope/routing.py:467 (checked = [o for o in outcomes if o.visible_skills])
    An empty visible_skills list is treated as "the check didn't run" (excluded from checked, hence from room_checked_cases and from skills_missing_from_session) — but a harness that did run the check and found the staged skill genuinely absent from the room (the worst-case contamination outcome) produces the same empty list, and that case is silently dropped from the aggregate warning instead of surfacing it. This logic predates this PR (it already applied to the claude-code-no-sandbox init-event path) and this diff does not touch it, but it now also governs Copilot's new Harness.room() path, so the same gap reaches a second harness. Per-case visible_skills/extra_skills are still correct in the raw cases JSON; only the aggregate summary undercounts.
    Confidence 70 · logic, pre-existing/blast-radius-enlarged · Fix: distinguish "checked and found nothing visible" from "could not check" at the source (e.g. a sentinel other than []), or special-case an empty result where room_names was non-empty.

Decisions for the author

  • The judge and the agent now share the same CLI, credential and model (for a harness with no API of its own) — tradeoff
    For Claude Code, the judge is a separate Anthropic inspect-model call; for a harness like Copilot, the judge is now the same binary/credential/model that produced the run, just invoked a second time with no tools. This is the only way to judge a harness with no model API of its own, and it's exactly what the PR sets out to do, but it does mean the verdict comes from the same underlying model evaluating its own run rather than an independent one. Worth confirming this tradeoff (no model diversity in grading) is acceptable rather than a gap to close later.

Positive signals

  • ClaudeCode.command() raises NotImplementedError for tool_free=True rather than silently building a permissions-skipped command — explicit refusal over implicit risk, exactly where a quieter bug would have been easy to ship.
  • Copilot.command()'s judge path is defense-in-depth: it both omits --allow-all and passes --excluded-tools, rather than relying on exclusion alone to keep the judge from acting on the machine.
  • The new Harness.room() check is scoped strictly through the existing room_names parameter rather than a global flag — confirmed by grep of every harness_solver call site that it reaches only the routing leg, leaving behavioral runs byte-for-byte unaffected.

Deployment notes

None. No migration, no breaking change to the existing Claude Code path or the reusable workflow (confirmed: no diff under .github/, action.yml, bootstrap/, or examples/), and workflows that pass no --harness are unaffected.

What this covered

Every file in the diff (docs/harnesses.md, docs/usage.md, skillscope/cli.py, skillscope/engine/behavioral.py, skillscope/engine/judge.py, skillscope/engine/no_sandbox.py, skillscope/engine/scorers.py, skillscope/harness/__init__.py, skillscope/harness/base.py, skillscope/harness/claude_code.py, skillscope/harness/copilot.py, skillscope/routing.py, tests/test_harness_cli.py, tests/test_harness_hooks.py) read in full, plus the callers/consumers of every changed identifier (complete, room, CompletionError, tool_free, message_limit_for, NO_PROVIDER_MODEL, VISIBLE_SKILLS_KEY/EXTRA_SKILLS_KEY, _subprocess_concurrency) and the harness-registry contract test (tests/test_harness.py). Verified by running: the full suite (650 tests, all pass, materialized from the PR tree into a scratch venv without touching the shared clone's working tree) and targeted mutation tests (reverting the judge's try/except, forcing a null message limit) that confirmed two of the new tests genuinely fail on a plausible revert. No GitHub check run exists for this commit (statusCheckRollup is empty) — not treated as a pass, stated as not run.

Did not run: agent-instruction-file adherence (no CLAUDE.md/AGENTS.md in this repo, so this reduces to the universal/domain standards already applied above).
Not reconciled against any discussion on the PR — this review did not read PR comments.


Automated review by an agent.

@johnl-amd

johnl-amd commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

Review — needs work

Full review of the whole change (14 files, base 7a8c00b9ea153c5efc5679f60d7a5e9df0deb056 → head f1d59585d8e09ff1bd5a1e5521ad7444b53cd47b), split into three independent file-group passes (harness contract; judge/behavioral grading pipeline; room-check & routing report) plus consolidation and design review.
Builds on #33 and #34, both confirmed merged. Part of #4 (open, "Add support for Codex agent harness"); this PR implements Copilot CLI's judge and room-check paths toward that goal and explicitly does not claim to close it or add Codex.

Blocking

  • docs/usage.md inverts which Claude Code leg can read the CLI's session-init event. docs/usage.md (the rewritten Copilot-comparison paragraph), contradicted by skillscope/engine/no_sandbox.py's own comment and the unchanged warning text in skillscope/routing.py.
    The new sentence reads "On Claude Code the host leg cannot read the CLI's session-init event, so it has no way to notice such contamination or report it." This is backwards. no_sandbox.py (which implements the host leg, claude-code-no-sandbox) says of the same two signals: "Only this leg can answer -- claude-code drives the CLI through inspect_swe, which surfaces neither." routing.py's own (unchanged) report text agrees: "which only claude-code-no-sandbox can see -- claude-code drives the CLI through inspect_swe, which does not surface that event." It is the sandboxed leg (claude-code, via inspect_swe) that cannot see the init event; the host leg is exactly the one that can, since it reads the CLI's own stream-json line by line. This sentence predates this PR, but the PR rewrote this exact paragraph to add the Copilot comparison without correcting it, so a reader now gets an actively wrong account of which leg is safer right where the two harnesses are being contrasted.
    Confidence 85 · mechanical (doc accuracy) · Fix: swap "host" for "sandboxed"/claude-code in that sentence.

  • Copilot.room() spawns its subprocess without the project's own process-tree-kill protection, reintroducing a previously-fixed bug class. skillscope/harness/copilot.py (room()), compare with skillscope/harness/base.py (preflight(), complete(), _run_bounded/_kill_tree).
    room() calls subprocess.run([...,"skill","list","--json"], ..., timeout=timeout) directly. base.py's own docstring documents why this is unsafe for an npm-installed CLI: subprocess.run(timeout=) only kills the process it launched, and a shim process (common for npm installs) leaves the real program running with the output pipe open, so the wait after the "kill" lasts until the program ends on its own. That is exactly the bug preflight()/complete() avoid by running through _run_bounded, which starts the process in its own session/group and kills the whole tree on timeout — room() is the one new subprocess call in this PR that didn't get the same treatment. It does correctly check deadline.active()/bound.remaining() before starting (so it won't start with no time left), but nothing bounds an in-flight hang once started. No test exercises this: tests/test_harness_hooks.py's room-check tests have no STUB_HANG-equivalent case, unlike the sibling tests for complete()/preflight(), and the stub's skill list branch returns before it could even reach such a case without modification.
    Confidence 75 · mechanical (resource safety) · Fix: route room()'s subprocess through _run_bounded, and add a hang/timeout test alongside the existing complete()/preflight() ones.

  • A test asserting "every tool is excluded" checks only 5 of 8. tests/test_harness_hooks.py (test_nothing_is_allowed_and_every_tool_is_excluded), vs. skillscope/harness/copilot.py's JUDGE_EXCLUDED_TOOLS = "shell,write,read,glob,grep,web_fetch,skill,task".
    The test asserts only shell, write, read, web_fetch, skill are present in the --excluded-tools= argument, silently omitting glob, grep, and task from the check — so a future edit that dropped any of those three from the constant would not be caught, despite the test's own name claiming full coverage. This is not a live security hole today: the primary gate is that --allow-all is omitted for a tool-free run, so a tool missing from the exclusion list is still denied for lack of interactive approval (confirmed against the no_trigger.jsonl recorded fixture, where a glob call is denied this way). But a test whose name claims a branch/condition it does not actually check is worse than no test, because it stops the next reviewer from noticing the gap.
    Confidence 85 · mechanical (test coverage) · Fix: assert against the full JUDGE_EXCLUDED_TOOLS set (e.g. iterate it directly) rather than a hand-picked subset.

Non-blocking

  • skillscope/engine/judge.py's grade() wraps the harness-CLI judge call in a bare except Exception as exc: ("the CLI failed or printed no answer"), which would also swallow a genuine programming bug in a future harness's complete/normalize implementation and report it as a routine per-expectation judge failure rather than surfacing it. Mitigated by precedent: the same module already uses an equally broad catch elsewhere (artifacts(), with an explicit noqa), so this may be deliberate house style rather than an oversight. Confidence 70 · logic.
  • skillscope/routing.py's new _isolate_advice catches except (Exception, SystemExit) around harnesses.get(...).credentials, but harnesses.get() (skillscope/harness/__init__.py) only ever raises SystemExit for an unknown name — the bare Exception arm is pure extra defensiveness that would silently swallow an unrelated bug (e.g. a future AttributeError) and fall back to generic, less-useful advice text instead of surfacing it. Confidence 80 · mechanical.
  • _isolate_advice's non-"other harness" branch hardcodes ANTHROPIC_API_KEY only, omitting ANTHROPIC_AUTH_TOKEN even though ClaudeCode.credentials lists both and the branch immediately above demonstrates the dynamic harnesses.get(...).credentials lookup that would include it. Confidence 70 · mechanical.
  • The new room-check path (no_sandbox.py's if room_names: block, feeding engine/routing.py's reader and routing.py's summarize()) cannot distinguish "the harness answered and reported zero visible skills" from "the check never ran" — both end up as an empty visible_skills list, and summarize()'s checked = [o for o in outcomes if o.visible_skills] treats both as unchecked. A harness whose staging failed completely would get the generic "room was not verified" note instead of the louder "skills missing from session" warning the feature exists to produce. This conflation is a pre-existing, acknowledged limitation of the older init-event signal (noted in that signal's own docstring) that the new room() signal inherits rather than resolves. Confidence 70 · architectural.

Decisions for the author

  • tradeoff — Judging a non-Claude-Code harness through its own CLI ties the judge's model and reasoning quality to the same CLI/model family that produced the run, rather than an independent judge model. That's the necessary price of running behavioral with no Anthropic credential at all, but it's an additional axis beyond the existing "scores aren't comparable across harnesses" caveat in the docs — worth confirming that caveat is understood to now also cover judgment strictness, not only the agent's own behavior.

Positive signals

  • Harness.complete() reuses host_command/host_env/normalizer() rather than duplicating per-harness CLI-invocation logic, so a harness that can already run a case gets judge support "for free."
  • Defense-in-depth on the judge's tool-free command: omitting --allow-all (the actual, primary gate — confirmed via the recorded fixture) and passing an explicit --excluded-tools list is belt-and-suspenders, so a future Copilot tool category missing from the hardcoded list would still be denied by default.
  • ClaudeCode.command()'s refusal of tool_free (fail-closed rather than silently building an under-permissioned-but-still-unsafe command) is tested from two independent angles: the direct command() call, and end-to-end through complete().
  • TestABehavioralRunOnCopilot's message-limit test genuinely proves the override reaches the task (asserts the real model's limit, and explicitly asserts it is not the placeholder's), rather than merely exercising the code path without checking the value.

Deployment notes

None beyond what the docs changes already describe: this enables behavioral runs on Copilot CLI with no Anthropic credential; the default harness (Claude Code) and the reusable GitHub Actions workflow surface (.github/, action.yml, bootstrap/, examples/) are untouched by this diff.

What this covered

All 14 changed files were read completely at head f1d59585d8e09ff1bd5a1e5521ad7444b53cd47b by three independent background passes grouped by coupling (harness contract + its hooks tests; judge/scorer/behavioral/cli wiring + its tests; room-check/no_sandbox + routing report), each of which also fetched and cross-checked unchanged files it depended on (skillscope/engine/routing.py, skillscope/harness/__init__.py, skillscope/engine/verify.py) via the GitHub API. Findings were consolidated, contradictions between passes resolved against source, and a short design-review pass was run over the consolidated results. CI on this commit is fully green (8/8 checks). Content was fetched read-only via the GitHub API (file contents at this SHA, diff via the PR API) rather than a local git fetch, since no local git repository was available in this environment; nothing was checked out and no git state was modified.

Did not run: agent-instruction-adherence (no CLAUDE.md/AGENTS.md exists in this repository). History/prior-changes were checked only lightly (each pass did a light list_commits pass over its own files); no deep search of prior review comments on these specific files elsewhere was performed. Not reconciled against any discussion on this PR — this is a fresh, blind pass at the current head. Two earlier automated reviews exist on this PR, but at older heads (66bffe4..., af6f408c...) that have since been superseded by fixes; their content was not read until after this review's findings were already settled, solely to check for a duplicate at the current head (none exists) and to confirm the head had not moved.


🤖 Automated review by an agent.
Model claude-sonnet-5[1m] · Not a human review. Reply here if a finding is wrong.

@volen-silo

Copy link
Copy Markdown
Collaborator Author

Thanks, two reviews landed on f1d5958, so this answers both. Pushed (the commit is rebuilt, hash 5695d3e).

Blocking, fixed

  • docs/usage.md had the legs backwards. You are right: the host leg reads the init event and the sandboxed leg never sees it. I rewrote the paragraph from what no_sandbox.py and engine/routing.py do (the host solver is handed room_names). One thing I noticed and did not change: the docstring of require_isolated_room in engine/routing.py still says neither leg gets the init event. It predates this PR and says the opposite of the code, so it wants its own fix.
  • Copilot.room() leaked a process on timeout. It now goes through _run_bounded like preflight() and complete(). The new test starts a skill list that sleeps for 30 seconds behind the npm-style shim stub_cli puts on PATH, with the room timeout set to 1.5 seconds. It requires the call to return None in under 15 seconds. I checked that putting subprocess.run back makes it fail.
  • The "every tool is excluded" test checked 5 of 8. It now compares the argument with JUDGE_EXCLUDED_TOOLS and asserts all eight. Dropping task from the constant fails it.
  • tool_free was not part of the registry-wide contract test. Added: for every registered harness, building the tool-free command either raises or contains none of --allow-all, --allow-all-tools, --yolo, or a skip-permissions flag. Making ClaudeCode.command ignore tool_free fails it.

Non-blocking, all taken

  • Image bytes. I said earlier I would leave this, and I have changed my mind: two reviews raised it, and it turned out to be small. artifacts() takes read_images, and a harness judge passes false, so the images are named and never read. A test asserts that.
  • # noqa: BLE001: added to the judge's catch, with the reason.
  • _isolate_advice: the Exception arm is gone and only SystemExit is caught, which is all harnesses.get raises. The Claude Code branch now reads its variables from credentials as well, so it names ANTHROPIC_AUTH_TOKEN too.
  • Stale comment in routing.py: it now names both sources of visible_skills.

Not changed: an empty visible_skills is still read as "the check did not run", so a room that was checked and found to hold none of the staged skills is left out of the aggregate warning. You are right that this predates the PR and that the Copilot path now inherits it. The per-case values are correct in the JSON. A sentinel for "checked, nothing visible" changes the report schema, so I would do it on its own.

Decision, a shared judge: I added a sentence to docs/usage.md saying that for a harness judged through its own CLI, how strictly expectations were judged is not comparable across harnesses either, since the verdict comes from the same model family as the run.

Local runs: Windows 652 tests pass, and WSL (Ubuntu, Python 3.12, git's global config kept out) 652 pass. The Windows run needed one caveat: the timing test I mentioned before passed in that run but fails most of the time there, and is described in the PR body.

@rominf rominf left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Good direction; nothing blocking from me. There's a test gap and two things to coordinate with other PRs.

Direction: this is the right next step for the harness contract. behavioral on Copilot no longer needs an Anthropic credential. The judge stays tool-free (no --allow-all, an explicit --excluded-tools list). Claude Code's path and the reusable workflow are untouched. The size fits the value, since most of the +911 is tests. The full suite passes locally at 5695d3e (652 tests, Linux, Python 3.12).

On the latest AgentHub review: it ran on f1d5958, and 5695d3e addresses everything it raised (the room() leak, image reads, noqa, the stale comment, the judge-model doc sentence). A few corrections:

  • Its #2 "blocking" item (a registry-wide tool_free test) was overstated. Both registered harnesses already handled the flag correctly, so the test hardens against a future harness and shouldn't have blocked the merge. Still good to have it now.
  • Its statement that no CI had run on f1d5958 is wrong. All 8 checks had finished green about 16 minutes before it posted.
  • The # noqa: BLE001 point is a nit. CI's ruff only selects F401, so nothing enforces that tag.
  • I agree the empty-visible_skills ambiguity can be a follow-up, but it should land before or together with #22. If Copilot's skill list shows none of the staged skills, skills_missing_from_session stays empty, so #22's gate would not fire in exactly that worst case.

Before merge: nothing from my side.

Optional / follow-ups:

  • The new hang test (test_a_cli_that_hangs_is_stopped_and_leaves_nothing_running) only checks the process-tree kill on Windows. On POSIX the stub_cli shim execs the interpreter, so there is no grandchild process to leak. I put room() back on plain subprocess.run and the test still passed on Linux. If the POSIX stub started a child instead of exec'ing, the Linux runners would catch a regression too.
  • A judge failure (judge.py:322) returns False, so for unexpected_behavior a Copilot CLI hiccup is scored as if the prohibited behavior happened. #26 fixes this class of bug on the legacy path and explicitly leaves engine/judge.py for later. This PR adds one more site to that follow-up.
  • Merge order: this PR conflicts with #29 (engine/behavioral.py) and #35 (cli.py, engine/behavioral.py, docs/usage.md), while both of those merge cleanly into main today. Whichever lands second has to rebase, so it's worth agreeing on the order.

@johnl-amd johnl-amd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

atml__file_read__/tmp/review_body.md

@johnl-amd johnl-amd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review — needs work

Full review of the whole change, 7a8c00b9...5695d3e1 (base main...PR head), split into three
independent passes grouped by coupling (harness contract + judge; behavioral/CLI/no_sandbox
wiring; routing room-check reporting + docs), each reading its files completely at the PR head,
plus a driver pass that independently re-read the full diff to verify and consolidate.

Builds on #33 and #34 (both merged, confirmed); informationally "part of #4" (adding a further
agent harness), which it does not claim to close and does not need to — #4 itself stays open for
a future harness (e.g. Codex), and this PR's own "Not in this PR" section says so explicitly.

Blocking

  • A new, run-verified guard test can never fail: it mocks a function the code never calls —
    tests/test_harness_hooks.py:347-358 (test_a_command_with_no_time_left_does_not_ask)
    The test patches copilot_module.subprocess.run, but Copilot.room() reaches
    base._run_bounded(), which calls subprocess.Popen (skillscope/harness/base.py:85), never
    subprocess.run. Verified by deleting the real guard (if bound.remaining() <= 0: return None
    in Copilot.room()) and re-running the full suite: it stays green (652/652); patching
    subprocess.Popen instead shows the guard really is exercised when present and really is
    bypassed when removed. Confidence 100 · logic/test-quality · Fix: patch
    base.subprocess.Popen (or Copilot.room's call to _run_bounded directly) instead of
    .run.

  • Harness.complete()'s new deadline-exhaustion branch has no test at all —
    skillscope/harness/base.py:255-260
    if bound is not None: ... if leftover <= 0: raise CompletionError(...). Verified by deleting
    the whole block and re-running the full suite: zero failures. This is new behaviour this PR
    introduces (the judge refusing to start a completion call when the command's own deadline has
    already elapsed), with no test driving it — the sibling branch in room() has an (currently
    broken, see above) test attempt; this one has none. Confidence 100 · correctness/test-coverage ·
    Fix: add a test mirroring the room() one, once its mock target is corrected.

  • judge.artifacts(..., read_images=False)'s real body is never executed by any test —
    skillscope/engine/judge.py:196-199
    The only test referencing read_images replaces judge.artifacts wholesale with
    mock.AsyncMock and asserts only that the caller passed the kwarg; the real function body
    (if not read_images: images.append((path, b"")); continue) never runs under test. Verified by
    deleting this branch from the real artifacts() and re-running the full suite: zero failures.
    New behaviour this PR introduces, untested. Confidence 100 · correctness/test-coverage · Fix:
    add a direct unit test calling judge.artifacts([<image path>], read_images=False) against a
    real/stub sandbox, asserting it returns (path, b"") without invoking sandbox().read_file.

  • The judge's own harness-CLI subprocess calls are not bounded by the run's shared subprocess
    limiter, unlike every sibling subprocess call this PR adds
    —
    skillscope/engine/judge.py:222-234 (harness_completer) vs.
    skillscope/engine/no_sandbox.py:501-502 and :530-534
    no_sandbox._subprocess_concurrency() exists, by its own docstring, "so this driver stays
    inside its budget" rather than opening a second, unaccounted pool of real OS processes. The new
    room check (no_sandbox.py:501-502) and the main agent stream (:530-534) both wrap their
    asyncio.to_thread(...) subprocess call in it. harness_completer's
    asyncio.to_thread(harness.complete, prompt, model) — spawning the identical kind of real,
    blocking CLI subprocess, once per judged expectation, for every case — does not; judge.py
    never imports or calls _subprocess_concurrency (confirmed by inspection and grep). A
    behavioral run on a non-default harness with several concurrent cases, each with multiple
    judged expectations, can spawn an effectively unbounded number of simultaneous harness-CLI
    processes purely for judging, exactly the failure mode the limiter exists to prevent for the
    agent run and the room check one function away. Confidence 85 · architectural
    (resource/concurrency correctness; inconsistent application of a pattern this same PR
    establishes) · Fix: wrap the asyncio.to_thread(harness.complete, ...) call in
    no_sandbox._subprocess_concurrency() the same way room() is wrapped.

  • Two report-warning strings stay Claude-Code-specific even though this PR makes the
    surrounding logic harness-neutral, and are reachable (and now covered by a passing test) on a
    non-Claude harness
    — skillscope/routing.py:693-699 and :700-712
    The "extras" warning's remedy sentence was correctly generalized to
    {_isolate_advice(summary)}, but the rest of the same sentence still says the stray skills
    "come from the runner's own config (usually ~/.claude/skills)" — Claude Code's path, not
    Copilot's. The sibling "missing skills" warning says "the CLI did not report these installed
    skills at session init," but Copilot has no session-init event; its room check is the new
    side-channel copilot skill list --json call, and routing.py's own missing computation
    fires identically regardless of which mechanism populated visible_skills. This PR's own test
    TestTheRoomOnCopilot.test_a_skill_nobody_staged_is_named_as_an_extra confirms the extras
    warning renders on a Copilot run, without checking this specific wording — so a Copilot report
    will tell a reader to go look at ~/.claude/skills, which is simply the wrong place for that
    harness. Confidence 85 · logic/documentation-accuracy (inconsistent application of the
    _other_harness/_isolate_advice pattern this diff itself introduces) · Fix: route both
    strings through a harness-aware helper the way the remedy sentence already is (name the actual
    mechanism, e.g. "at session start" / "a harness that does not say", instead of "session init";
    describe where a harness's own stray skills come from per-harness instead of hardcoding
    ~/.claude/skills).

Non-blocking

  • A module docstring was not updated for what this PR moves onto the harness contract —
    skillscope/harness/base.py:12-15. It still reads "Everything else — staging, the stop rule,
    usage, grading — stays in the engine, once, for every harness," but this PR gives the harness a
    real (if narrow) role in grading via complete(). Debatable — the engine still decides
    whether/how to grade and interprets the verdict, the harness only supplies a raw completion
    primitive — hence non-blocking rather than blocking. Confidence 70 · mechanical/doc-accuracy ·
    Fix: a one-clause update acknowledging complete()'s role.

Decisions for the author

  • Self-judging is a deliberate, disclosed tradeoff, not a defect — for a harness judged
    through its own CLI, the verdict now comes from the same model family (and the same run's own
    credential) that produced the run, rather than from an independent Anthropic model; the PR
    description and docs/usage.md both say this plainly. The upside is the PR's whole point: zero
    model credential needed to run graded behavioral. The downside is a strictly weaker judge in
    principle — a harness's own CLI judging its own run is not an adversarial, independent check the
    way the Claude-Code/inspect-model path is. Worth confirming this asymmetry in judge trust across
    harnesses is acceptable for however these scores get used (e.g. CI gating), since nothing in the
    report currently flags which kind of judge produced a given verdict beyond the harness field
    already in the metadata.
  • The judge is text-only for a non-default harness, by design — an image artifact is named in
    the question rather than shown (judge.py's artifacts(..., read_images=complete is None) and
    the PR description both say so explicitly). For a skill whose expected_behavior/
    unexpected_behavior genuinely hinges on image content, this is a real, disclosed capability gap
    versus the Claude-Code judge path, not an oversight. Confirm that's an acceptable scope limit for
    now rather than something a reader of a report should be warned about per-case.
  • The tool-free safety test is a denylist of known permissive flags, not a structural
    guarantee
    — tests/test_harness.py's
    test_no_harness_hands_the_judge_a_permission_it_was_not_asked_for scans a built command for a
    fixed set of flag names/substrings (--allow-all, --yolo, --dangerously-skip-permissions,
    anything containing skip-permissions). That's reasonable coverage of today's two harnesses, but
    a future harness (per #4) with a differently-named escape hatch would pass this test while still
    handing the judge the run of the machine. Not a defect in this PR — just worth a note in
    docs/harnesses.md's "adding a harness" checklist that a new harness's tool_free handling
    needs a human look, not only this test's blessing.

Positive signals

  • The harness contract cleanly separates "cannot build a safe tool-free command at all"
    (ClaudeCode.command raising NotImplementedError for tool_free) from "builds one safely"
    (Copilot.command dropping --allow-all and adding --excluded-tools=...), and a single
    contract test runs that check once per registered harness rather than once per harness's author
    remembering to add it.
  • The new complete()/room() subprocess calls reuse the existing _run_bounded/_kill_tree
    process-tree-kill machinery rather than a fresh ad hoc timeout, and the hang-handling tests
    assert on actual elapsed wall-clock time, not just on the exception type.
  • Judge-failure isolation is real: a judge that fails or prints no answer is reported against only
    that expectation (judge failed: ...) rather than aborting the whole case, and this exact
    property (one judged expectation failing, the deterministic one still passing) has a direct,
    well-targeted end-to-end test.
  • The deliberate removal of the old behavioral hard-refusal, and its now-obsolete test, leaves no
    dangling references anywhere in the tree (confirmed by search).

Deployment notes

  • No behaviour changes for an existing Claude Code-only setup: the default harness and its judge
    path are unchanged, and the reusable workflow passes no --harness.
  • A repo that opts into --harness copilot for behavioral will now see judge calls billed
    through Copilot CLI the same way agent calls already are — disclosed in docs/usage.md, worth
    restating here since it changes per-run cost, not just per-run behaviour.
  • Until the concurrency-limiter gap above is fixed, a CI setup running behavioral on a
    non-default harness with many concurrent cases should expect judge-call subprocess concurrency to
    be effectively unbounded.

What this covered

Every file in the diff, read completely at the PR head (5695d3e1d0e4ebb0a66f33b90a342ab683bc5d32)
against the merge-base (7a8c00b9ea153c5efc5679f60d7a5e9df0deb056), fetched read-only and never
checked out. Three independent passes by coupling: harness contract + judge
(skillscope/harness/{base,claude_code,copilot,__init__}.py, skillscope/engine/{judge,scorers}.py,
tests/test_harness.py, tests/test_harness_hooks.py); behavioral/CLI/no_sandbox wiring
(skillscope/engine/behavioral.py, skillscope/cli.py, skillscope/engine/no_sandbox.py,
tests/test_harness_cli.py); routing room-check reporting + docs (skillscope/routing.py,
docs/harnesses.md, docs/usage.md, tests/test_harness_cli.py) — plus a driver pass reading the
full diff directly for consolidation, impact analysis and design review. One pass additionally
verified its three findings by extracting the PR tree into a separate scratch copy and
running/mutating the real test suite (652 tests); the other two verified by code tracing only, no
test execution — noted per finding.

Agent-instruction-adherence pass skipped: no CLAUDE.md or AGENTS.md exists anywhere in this
repository. History and code-comment passes were folded into each file-group worker's reading
rather than run as separate sweeps. "Prior changes" was limited to reading the two prior PRs (#33,
#34) this one builds on for continuity, not a search for review comments on these specific lines.
CI: all 8 checks on the head commit (unit tests on Linux/Windows × Python 3.10/3.12, sandbox
machinery on both OSes, unused-imports, action-against-a-throwaway-repo) are green. Not reconciled
against this PR's own comment thread — that discussion was deliberately not read, per the review
method's blind-review rule.

Not a human review. This is an automated code review; please verify findings independently before acting on them.

@johnl-amd

Copy link
Copy Markdown
Collaborator

Correction: the review comment above that reads only atml__file_read__/tmp/review_body.md was posted in error (a tool-call mistake on my end, not a code finding). Please disregard that one — the full review is the one immediately below it, titled "Review — needs work".

@volen-silo

Copy link
Copy Markdown
Collaborator Author

Thanks, both reviews. Pushed (rebuilt commit, hash beadcf2).

The automated review on 5695d3e

  • The no-time-left test mocked the wrong call. Correct, and my error: I moved room() onto _run_bounded after writing it, so it patched subprocess.run while the code uses Popen. It now patches Popen, and deleting the guard fails it.
  • complete()'s deadline branch had no test. Added one: with no time left it raises CompletionError and the CLI is never started. Deleting the branch fails it.
  • artifacts(read_images=False) was never run for real. Two tests now run the real function against a stub sandbox: with read_images=False the image comes back as (path, b"") and is never read, and with True the bytes are read.
  • The judge's CLI calls were outside the limiter. Fixed: harness_completer runs each call inside _subprocess_concurrency(), and a test checks it.
  • Two report strings were Claude-specific. The extras warning now says "the runner's own config for this harness" and keeps ~/.claude/skills only for Claude Code. The missing-skills warning says "did not list these installed skills for the session" for another harness, and keeps "at session init" for Claude Code. Tests cover the Copilot wording, and Claude Code's is unchanged.
  • Non-blocking, the base.py docstring: updated to say a harness supplies complete.
  • Decisions: the shared-judge and text-only caveats are already in docs/usage.md. For the denylist-only safety test I added a note to the "adding a harness" checklist in docs/harnesses.md: the registry test only knows the permission flags it lists, so a new harness's tool_free handling needs a human look too.

@rominf, your review

Local runs: Windows 657 tests pass and WSL 657 pass (git's global config kept out). On Windows, test_the_work_after_the_decision_never_happens is inconsistent: it passed in the last full run but failed three of three alone. It passes in CI and in WSL, so I still read it as a bound that is too tight for that host.

@johnl-amd johnl-amd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review — needs work

Full review of the whole change (base 7a8c00b9…head beadcf24, single commit). Implements the Copilot-CLI judge/room-check work from issue #4 via #33/#34; the description says "part of #4" and does not claim to close it, which matches — #4 stays open (Codex, container leg).

Blocking

  • --excluded-tools names a tool that does not exist; the shell-execution tool is powershell, not shell — skillscope/harness/copilot.py:59 (JUDGE_EXCLUDED_TOOLS = "shell,write,read,glob,grep,web_fetch,skill,task"), used at copilot.py:152.
    This list is the mechanism the PR's own safety claim rests on ("the judge reads evidence, it never acts on the machine it is reading about"). The project's own recorded ground truth — tests/fixtures/harness/copilot/trigger.jsonl and no_trigger.jsonl, the same fixtures this module's docstring cites as the source of truth for CLI 1.0.105 — shows the real tool name for shell execution is "powershell" ("toolName": "powershell", recorded on a Windows run); "shell" never appears as a toolName anywhere in either fixture. Three other entries in the list (grep, glob, skill) do match real recorded toolNames exactly, which suggests the list is meant to match literal tool names rather than a generic capability category — making "shell" look like a plain miss rather than an intentional abstraction.
    Mitigating factor, and why this isn't scored higher: copilot.py:144-146's own comment states the primary protection is "nothing is pre-approved either, so a tool that is not on this list is denied in a non-interactive run" — i.e. the code's own stated model is that any unlisted tool is still denied by the absence of --allow-all plus --no-ask-user. If that external-CLI behavior is accurate, this is latent/redundant rather than exploitable; if some tool class is auto-approved without --allow-all (plausible, since five of the eight excluded entries are read-only tools an interactive CLI might reasonably not gate behind approval), the typo is a live gap for exactly the most dangerous tool in the list. Nothing in this repo can settle which is true — it depends on Copilot CLI's own undocumented approval semantics — and no test asserts the exclusion list's entries against anything resembling the real tool-name vocabulary; the only contract test (tests/test_harness.py::test_no_harness_hands_the_judge_a_permission_it_was_not_asked_for) checks for the absence of granting flags, never the presence/correctness of exclusions.
    Confidence 80 · architectural · Fix: rename shell → powershell (and audit whether other platforms need their own shell-tool name), and add a test that diffs JUDGE_EXCLUDED_TOOLS against the tool names the recorded fixtures actually use, so a future CLI version's renamed/added tool is caught rather than silently unexcluded.

  • A room check that legitimately finds nothing is reported identically to a room check that never ran — and Copilot's new room() makes the worst case reachable — skillscope/routing.py:468 (checked = [o for o in outcomes if o.visible_skills]), skillscope/routing.py:693-706 and :727-746 (new harness-aware wording), skillscope/engine/routing.py:507 and :600-604 (pre-existing _room_report/_outcomes comments stating the same empty-means-unchecked rule), skillscope/engine/no_sandbox.py:497-513 (new room() call site), skillscope/harness/copilot.py:170-203 (room() can return []).
    summarize()'s checked list is a truthiness test on visible_skills, so a genuinely-empty list (the agent's room legitimately contained none of the staged skills — exactly the contamination failure the whole room check exists to catch) is indistinguishable from "nobody could ask," and both are excluded from checked. This was a narrow, perhaps-unreachable edge case under Claude Code alone (its host leg's init event essentially never reports zero skills unless something is already catastrophically broken). This PR's new Copilot.room() makes the empty-list outcome a realistic, ordinary result: skill list --json succeeding but returning zero of the staged, non-builtin skills is precisely what "the skill failed to register" looks like. In that exact case the case's visible_skills is set to [] (no_sandbox.py:505-509), checked drops it, and render_markdown (because _other_harness(summary) is true) prints the new wording: "the room was not verified... this run's harness could not say" (routing.py:735-738) — asserting a specific, false cause, and silently suppressing the "did not list these installed skills" warning (routing.py:693-706) that would otherwise have fired. The pre-existing Claude-Code-only wording this PR left untouched (routing.py:744-749) is carefully hedged ("absence of them is not evidence...") precisely to survive this ambiguity; the new non-Claude-Code wording drops that hedge and asserts the more specific, sometimes-wrong explanation in the one new case that makes the ambiguity common rather than vanishing.
    Confidence 85 · architectural · Fix: distinguish "asked and got zero" from "could not ask" structurally (e.g. a sentinel rather than reusing []/None-via-list-emptiness through VISIBLE_SKILLS_KEY), so the harness-aware "could not say" wording is only used when it's actually true.

Non-blocking

  • skillscope/harness/copilot.py:170-203 — room()'s parsing of the skill list --json schema (name/source/"builtin") has no real recorded fixture backing it, unlike every other behavioral claim this module makes elsewhere (its own docstring leans on CLI 1.0.105 recordings as ground truth throughout); the only coverage is synthetic JSON in tests/test_harness_hooks.py. Worth a real skill list recording before trusting the shape in production.
  • skillscope/harness/copilot.py:199-203 — a truthy-but-non-string name in the skill list (e.g. a number) passes the entry.get("name") truthiness check and is silently str()-coerced into a garbage skill name rather than being dropped; untested (only well-formed entries are exercised in test_what_the_cli_lists_minus_its_own_skills). Low impact — pollutes a report line, nothing crashes.
  • skillscope/engine/no_sandbox.py:497 (if room_names:) — the room-check dispatch (a thread-pool hop plus a _subprocess_concurrency() acquisition) now runs on every routing case for every harness, including Claude Code, whose room() is the base-class no-op returning None. Harmless, pure overhead; gating on "does this harness override room()" would avoid it.
  • skillscope/engine/no_sandbox.py:504-513 — case-insensitive name matching (.lower()) for the room check is implemented but no fixture exercises an actual case mismatch between a staged skill's name and what the CLI reports; all current fixtures match case exactly.
  • skillscope/cli.py:813-845 — no preflight-style check exercises the judge's tool_free path before any case runs; a structurally broken judge wiring on a non-default harness (e.g. Finding 1 above, if it turns out to be live) is now discovered per-expectation across however many cases the run was going to do, rather than upfront the way the deleted refusal effectively guaranteed. Failures are still correctly surfaced (judge failed: ... per expectation, not swallowed, doesn't sink the case's deterministic checks) — this is a cost/design question, not a correctness bug.

Decisions for the author

  • Strengthen tool_free's contract test, or leave it as a documented block-list limitation — tradeoff. The only guard against a harness handing the judge real permissions is tests/test_harness.py::test_no_harness_hands_the_judge_a_permission_it_was_not_asked_for, which checks for the absence of a fixed list of known granting-flag strings; a differently-named escape hatch, or (per Finding 1) an incorrect exclusion entry, would pass it silently. docs/harnesses.md:131-135 already discloses this exact limitation in its own words ("a harness with a differently named escape hatch would pass it... needs a human look as well"), so this is not a fresh gap — just worth the author confirming that documented acceptance still covers the sharper case Finding 1 surfaces (a named-but-wrong exclusion, not just a missing one).
  • Whether to add an upfront judge-path health check (extend preflight, or call Harness.complete once before the run) rather than discover a broken judge expectation-by-expectation — non-blocking-improvement, ties to the non-blocking preflight item above. The current behavior is safe (no silent pass, no crash) but can burn a full multi-case run's compute before the first judge call ever succeeds or fails.

Positive signals

  • judge.harness_completer's asyncio.to_thread(...) call is placed inside no_sandbox._subprocess_concurrency() so each judge question is counted against the same subprocess budget as the agent's own calls, rather than opening a second, uncoordinated pool next to it — verified by tracing both the production code and tests/test_harness_hooks.py::TestTheJudgeCountsAgainstTheSubprocessLimit.
  • The deadline/timeout plumbing for both Harness.complete (COMPLETE_TIMEOUT_S, clipped to deadline.active()) and Copilot.room() (ROOM_TIMEOUT_S, same clipping) consistently reuses the existing _run_bounded/Deadline contract instead of introducing a parallel bounding mechanism, and both are exercised by real hang/no-time-left tests rather than asserted only in prose.

Deployment notes

None beyond what the description already states: no workflow-facing files changed (.github/, action.yml, bootstrap/, examples/ untouched, confirmed by diff), so an existing caller of the reusable workflow sees no behavior change unless it explicitly passes --harness copilot --command behavioral, which previously errored and now runs.

What this covered

Read completely, at pr/39 (beadcf2) against origin/main (7a8c00b): skillscope/cli.py, skillscope/engine/behavioral.py, skillscope/engine/judge.py, skillscope/engine/no_sandbox.py, skillscope/engine/scorers.py, skillscope/harness/__init__.py, skillscope/harness/base.py, skillscope/harness/claude_code.py, skillscope/harness/copilot.py, skillscope/routing.py, tests/test_harness.py, tests/test_harness_cli.py, tests/test_harness_hooks.py, docs/harnesses.md, docs/usage.md, plus surrounding/cross-referenced code (skillscope/engine/routing.py, skillscope/deadline.py, skillscope/agent.py, both recorded Copilot fixtures) and git history on the touched files. Three independent fan-out passes (harness/judge contract; CLI + behavioral-engine wiring; routing-report + docs) each read their full file set, were verified against the source, and both headline findings above were independently re-traced by hand against the actual fixtures and code before inclusion. CI (gh pr view --json statusCheckRollup) shows all checks green for this commit (unit tests on ubuntu/windows × Python 3.10/3.12, sandbox machinery, unused imports, action smoke test).

Did not run: agent-instruction adherence (no CLAUDE.md/AGENTS.md in this repository); the actual test suite (CI already reports it green for this exact commit, so re-deriving that was not needed); the real Copilot CLI (not installed here — the powershell-vs-shell and approval-semantics question in Finding 1 could only be settled against a real CLI, which this review could not run).

Not reconciled against any prior review discussion on this PR — this is a first blind pass at this head commit, scoped deliberately to avoid any earlier round's comments or conclusions.


Not a human review. This is an automated code review; please verify findings independently before acting on them.

@volen-silo

Copy link
Copy Markdown
Collaborator Author

Thanks. Pushed (rebuilt commit, hash 05400f0).

1. The exclusion list named a tool that does not exist. You were right, and it was worse than one wrong name. I ran the real CLI (1.0.105) with the judge's command. With the old list the model saw and tried powershell; the CLI's shell tool is powershell, with read_powershell, stop_powershell, list_powershell, view, create and edit beside it, so shell, write and read matched nothing. Adding powershell,bash to the list removed the call, so the typo was live. Only the permission check, which a non-interactive run cannot answer, stood between the judge and the tool.

  • Fix: the judge now uses --available-tools with one name the CLI does not have, which is an allowlist and doesn't depend on spelling the CLI's own names. Against the real CLI the model then sees no tool and calls none (an empty value is ignored by the CLI, so it has to be a name). A real judge question through the new command still answers.
  • Tests: the tests assert the flag, assert there is no --excluded-tools, and check the name is not one that appears in the recorded runs. Dropping the flag, or naming powershell, fails them.

2. The "room not verified" note named one cause that is sometimes false. Agreed that the non-Claude wording asserted "could not say" when a CLI that answered and listed none of the staged skills reads the same. The note now says either the harness could not say or it answered and listed none of the staged skills, and that the two read the same here. A test covers the case. I did not add the structural sentinel for "asked and got zero", because it changes the report schema; as rominf said, it should land before or with #22.

Non-blocking

  • Real recording: added a real copilot skill list --json recording (no model call) with the username and paths replaced, and a test that parses it. Its shape matches the parser. It also has an enabled field the parser does not read.
  • Non-string names: now dropped, with a test.
  • Case mismatch: a test covers a name the CLI spells in another case.
  • room_names dispatch for Claude Code: I'd leave it. It is one thread hop per case, and gating it on whether a harness overrides room() adds a branch for no change in result.
  • Upfront judge check: the preflight runs the agent's command, not the judge's. Extending it to ask one judge question first would catch a broken judge wiring before a long run, at the cost of one more billed prompt every time. I'd leave it as a follow-up and say so.

Local runs: Windows 661 tests pass (the last full run; test_the_work_after_the_decision_never_happens also fails alone on a clean checkout there, as before) and WSL 661 pass. I used about six billed Copilot requests on Sonnet for the real-CLI checks.

@johnl-amd johnl-amd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Automated review · review-code · head 05400f08

Review — needs work

Full review of the whole change (base 7a8c00b9…head 05400f08, +1153/-106 across 17 files), split across three independent fan-out workers grouped by coupling (harness contract — base.py/claude_code.py/copilot.py; judge/scorer/behavioral wiring; routing report rendering + docs), each blind to the others and to this PR's existing comment thread. Part of #4 ("Add support for a Codex agent harness" — still open, assigned to this PR's author), building on #33 and #34 (merged). The description's stated scope (Copilot-backed judge via Harness.complete, a room() check wired into routing only, a message-limit fix, and an explicit "not in this PR" list excluding .github//action.yml/bootstrap//Codex) matches the diff.

Blocking

  • The new "room was not verified" note claims both warnings above it cannot fire together, but extra_skills_in_session can fire at the same time — a self-contradicting report — skillscope/routing.py:468-472 (summarize()), :732-741 (_other_harness note)
    summarize() restricts missing (skills_missing_from_session) to cases where visible_skills is non-empty (checked = [o for o in outcomes if o.visible_skills]), but computes extras over every outcome regardless of whether anything was checked. So a case where room() answered but named none of the staged skills — only a stray one — has visible_skills == [] (so room_checked_cases == 0) while extra_skills is still non-empty, meaning extra_skills_in_session and the new _other_harness note ("So the two warnings above cannot fire") can both be true in the same render. The PR's own test, tests/test_harness_cli.py::TestTheRoomOnCopilot::test_the_warnings_do_not_send_a_copilot_reader_to_claude_codes_folders, builds exactly this case and asserts both the extra-skill warning text and the "listed none of the staged skills" note text appear in the same rendered markdown, without catching that the note's own claim is then false.
    Confidence 82 · logic · Fix: narrow the note's claim to what it actually governs (the missing-skill warning only), or extend the checked gating/wording so the note and the extras warning can't both claim to be the complete picture.

Non-blocking

  • "Two things still assume Claude Code" introduces a three-item list — docs/harnesses.md:139-151
    This PR merges the old "the judge" and "the room check" bullets into one ("Claude Code's own judge") and adds the lead-in "Two things still assume Claude Code:", but the pre-existing "the sandbox leg" and ".claude/skills" bullets are both still there — three items under a sentence that says two.
    Confidence 92 · documentation · Fix: say "Three things" (or trim the list to two).

Decisions for the author

  • complete()'s result loop trusts the default normalize() to return a dict-shaped event, with no guard — non-blocking-improvement. The base class's default normalize is the identity function with no isinstance(event, dict) check, unlike Copilot.normalize(), which explicitly guards against a non-object JSON line. No currently-registered harness can reach this today (ClaudeCode.command() refuses tool_free outright, and Copilot.normalize() already guards), so it's a latent gap at the base-class seam rather than a live bug — worth a defensive guard before a future harness relies on the inherited default.
  • message_limit=message_limit or message_limit_for(model) uses truthiness, not is None — non-blocking-improvement, skillscope/engine/behavioral.py. Harmless today (the only caller passes 120 or 6, always truthy), but a future legitimately-falsy override would be silently discarded.
  • judge.grade() silently drops grader whenever complete is also supplied — non-blocking-improvement, skillscope/engine/judge.py. No current caller passes both, so this is inert, but nothing guards against the combination either.
  • _subprocess_concurrency()'s nullcontext() fallback now gates two call sites instead of one — tradeoff. Both the judge and the new room check rely on the same private inspect hook to stay under the subprocess limit; if that hook ever moves, both silently degrade to unlimited concurrency rather than erroring. This is an explicit, already-documented tradeoff from before this PR — it now just covers a second call site.

Positive signals

  • judge.grade()'s try/except around await complete(question) is scoped tightly to that one call, so a judge failure is never confused with a bug elsewhere in evidence-assembly or prompt construction.
  • Both new concurrency-bounded call sites (the judge's complete, and the room check) are proven — not just asserted — to run inside the subprocess limiter, via tests that track real entry/exit ordering rather than mocking the call away.
  • The switch from a tool denylist to an allowlist naming a nonexistent tool for Copilot's tool-free command is backed by a concrete, previously-real failure mode (a remembered-wrong tool name) and a test checking the sentinel name against every tool name seen in real recordings.
  • A test round-trips the variable names extracted from the generated isolation-advice sentence back through the actual isolation check, proving the advice is really true rather than just pattern-matching the string.
  • The removed behavioral-refusal guard has no stale remnants — the preflight branch and both docs files were updated in lockstep, with no leftover "behavioral is refused" text found anywhere.

Deployment notes

None beyond what the description already states: the reusable workflow, action.yml, and bootstrap/ are untouched, and Claude Code's own judge/report path is unchanged.

What this covered

Three independent fan-out workers, each reading their full assigned scope completely: (1) harness contract — skillscope/harness/base.py, claude_code.py, copilot.py, __init__.py, their tests and fixtures; (2) judge/scorer/behavioral wiring — skillscope/engine/judge.py, scorers.py, behavioral.py, no_sandbox.py, cli.py, plus related tests; (3) routing report rendering + docs — skillscope/routing.py, docs/harnesses.md, docs/usage.md, tests/test_harness_cli.py. Each worker also read relevant context files (deadline.py, agent.py, engine/routing.py, engine/models.py, etc.) and the full unified diff. Base 7a8c00b9ea153c5efc5679f60d7a5e9df0deb056…head 05400f08e88f9a9db63dd4cfcc8f48d4c1fa8653. All 8 CI checks on this head report success (unit tests on ubuntu/windows × Python 3.10/3.12, sandbox machinery on both OSes, unused-imports, action-against-a-throwaway-repo).

Did not run: this reviewing context did not execute the test suite directly; findings rest on the fan-out workers' direct tracing of the code paths, cross-referenced against the PR's own test assertions, plus the CI result above taken at face value. Agent-instruction adherence not applicable (no CLAUDE.md/AGENTS.md in this repository). History and prior-changes passes were not run as separate passes.

Not reconciled against this PR's existing review thread or comments — this pass was kept blind to them by design.


Not a human review. This is an automated code review; please verify findings independently before acting on them.

@johnl-amd johnl-amd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review — needs work

Full review of the whole change (no narrowing — this head has not been reviewed before).
Builds on #33/#34 (merged) and states it is "Part of #4" (an open, general "add more harnesses" issue it does not claim to close).

Blocking

  • Copilot.room() ignores the real enabled field from skill list --json — skillscope/harness/copilot.py:205-212
    The shipped fixture recorded from the real CLI (tests/fixtures/harness/copilot/skill_list.json) carries "enabled": true on every entry, but the visibility filter only checks isinstance, a non-empty name, and source != "builtin" — it never reads enabled. A skill the CLI reports as enabled: false would still be counted as part of the visible room (or as a contaminating "extra"), the opposite of what the method's own docstring promises ("the skills a session would be given, if known"). No fixture or test exercises an enabled: false entry in either direction, so the gap is currently unguarded as well as unfixed. Confidence 70 · logic · Fix: honor entry.get("enabled", True) in the filter, and add a fixture entry with enabled: false to pin the behavior.

  • No test asserts an ordinary (non-judge) Copilot command omits --available-tools — skillscope/harness/copilot.py:157-158, tests/test_harness_hooks.py
    The gating (if spec.tool_free: cmd.append(f"--available-tools={JUDGE_TOOLS}")) is new in this PR, but only the tool_free=True side is pinned by a test. Verified by mutation: removing the if guard so the flag is always appended left all 84 tests in tests/test_harness.py + tests/test_harness_hooks.py green. If this condition were ever inverted or dropped, every ordinary agentic Copilot run would silently be crippled to zero available tools, undetected by the suite. Confidence 90 · mechanical/test-coverage · Fix: add a test building a default (tool_free=False) command and asserting --available-tools is absent.

  • No test asserts behavioral never triggers the room check — skillscope/cli.py:833-842 (cmd_behavioral), tests/test_harness_cli.py (TestABehavioralRunOnCopilot)
    cmd_behavioral correctly never passes room_names into no_sandbox.harness_solver, so the room check is skipped — but nothing asserts this. Verified by mutation: patching cmd_behavioral to pass room_names=["rocm-doctor"] (mirroring what cmd_routing does) left all 45 tests in the file green; the stub already records one room-*.json file per skill list invocation, and the existing test filters those files out without ever asserting their count is zero. Confidence 90 · mechanical/test-coverage · Fix: assert zero room-check recordings in the behavioral integration test.

  • docs/harnesses.md miscounts its own new list — docs/harnesses.md:141-151
    New text (confirmed via diff against the base commit, which had no count and four bullets) reads "Two things still assume Claude Code:" and then lists three: the Claude Code judge/tool_free refusal, the inspect_swe sandbox leg, and .claude/skills as the routing inspection path. The PR correctly dropped the old "room check" bullet (now generalized) and merged two bullets into one, but never updated the leading count to match. Confidence 85 · mechanical · Fix: change "Two things" to "Three things" (or merge further to actually leave two).

Non-blocking

None.

Decisions for the author

  • Judging a harness through its own CLI/credential trades grading independence for credential-free operation — tradeoff
    Previously every behavioral judge call went to a separate Anthropic model regardless of which harness produced the run. Now, for a non-Claude-Code harness, the same CLI and credential that ran the case also grades it (via tool_free, text-only, no tools). This is clearly the point of the PR (no model credential needed, matching what routing already allowed) and tool_free keeps the judge from acting on the machine it reads about, but it does mean a harness's own blind spots or biases are no longer checked by an independent model. Worth the author confirming this trade was made deliberately and not just as a side effect of removing the refusal.

  • The "is this the default harness?" check gains another scattered site — non-blocking-improvement
    PR #34's review flagged this check as independently duplicated across ~7 places and asked (non-blocking) for a single accessor; the author deferred it pending "the sandbox-leg unification docs/harnesses.md already says is next." This PR adds one more instance at skillscope/cli.py:819 (cmd_behavioral's if subject.name != harnesses.DEFAULT: branch). Each site remains individually correct; flagging only because the deferred cleanup keeps growing in scope.

  • Deadline-remaining clamp logic is now triplicated — non-blocking-improvement
    The same ~5-line "check active bound, compute leftover, degrade if ≤0, clamp timeout" block appears in the pre-existing preflight() and is copy-pasted into the two new methods complete() and room() in skillscope/harness/base.py / copilot.py, rather than being extracted once. Confirmed real (not dead) by mutation: removing either new copy breaks its own dedicated test.

  • judge.grade()'s grader parameter is silently ignored whenever complete is also supplied — non-blocking-improvement
    skillscope/engine/judge.py — a caller passing both would have grader dropped with no warning. No caller currently passes grader= (grepped the whole repo), so this is unreachable today, but worth an explicit guard before a future caller trips on it.

Positive signals

  • tests/test_harness.py's test_no_harness_hands_the_judge_a_permission_it_was_not_asked_for iterates every registered harness and asserts no permission-granting flag appears in its tool_free command — a real forward-compatible safety net for harnesses added later, not just today's two.
  • The hung-process test actually inspects /proc/<pid>/stat to confirm a killed process tree is really gone, rather than trusting that the Python call merely returned.
  • judge.grade()'s failure handling was checked to matter, not just read: removing the except Exception block around the judge call breaks the dedicated failing-judge test rather than passing vacuously.
  • The claim that a judge call and the agent's own subprocess share one concurrency limiter was checked against the actual installed inspect_ai library source, not assumed.
  • Deleting the old blanket refusal for non-Claude-Code behavioral runs is matched by new, specific replacement coverage (all three expectation kinds graded, a failing judge, a negative verdict) rather than just lifting the restriction.

Deployment notes

  • This PR lets a repo run graded behavioral evaluations on a harness other than Claude Code (e.g. Copilot CLI) with no Anthropic credential at all — only the harness's own CI token. Adopters should understand the judge is then the same CLI/credential as the agent under test (see the tradeoff above), not an independent model.
  • Confirmed via the file list: nothing under .github/, action.yml, bootstrap/, or examples/ changes, and the Claude Code default path is untouched — existing workflows that pass no --harness are unaffected.

What this covered

Every one of the 17 changed files read completely at the PR head (05400f0) against the base (7a8c00b), split across four coupled-file passes — harness contract + its tests (harness/__init__.py, base.py, claude_code.py, copilot.py, test_harness.py, test_harness_hooks.py, fixtures); judge/behavioral/scoring (engine/judge.py, behavioral.py, scorers.py, no_sandbox.py, shared integration tests); CLI/routing (cli.py, routing.py, shared integration tests); and docs (docs/usage.md, docs/harnesses.md) cross-checked against the full source — plus a dedicated history pass (git log/git blame on every touched file) and a prior-changes pass against PR #33, PR #34 and issue #4. I additionally verified directly: an OSError-escape path a worker flagged in complete() turns out to be fully caught by the broad except Exception already wrapping its only call site in judge.grade(), so it was dropped rather than reported; and issue #4's current state (open, general multi-harness tracking issue, not closed by this PR).

CI on the reviewed commit: all 8 checks passed (unit tests on 4 OS/Python combinations, sandbox-machinery tests on 2 OSes, unused-imports lint, action-against-a-throwaway-repo). Agent-instruction-adherence pass: this repository has no CLAUDE.md/AGENTS.md, so that pass has no subject. Not reconciled against any discussion or prior review round on this PR — this report was produced blind from the code and the PR's own description only.


Not a human review. This is an automated code review; please verify findings independently before acting on them.

@volen-silo

Copy link
Copy Markdown
Collaborator Author

Thanks, both reviews on 05400f0. Pushed (rebuilt commit, hash 7ce5bf9).

Blocking, all fixed

  • The note and the extras warning could both be true. Correct: the extras warning is computed over every case and the missing-skills warning only over cases that were checked. The note now speaks only for the warning that cannot fire ("the missing-skills warning above cannot fire: its absence is not evidence the agent saw the room"), and the test asserts that and that it no longer says "the two warnings".
  • enabled was ignored. room() now drops a skill the CLI reports as enabled: false and keeps one that has no such field. The recorded fixture stays a real recording, so the false case is covered with a constructed list.
  • No test that the agent's own command omits --available-tools. Added: the default command carries --allow-all and no --available-tools. Making the flag unconditional fails it.
  • No test that behavioral never asks for the room. Added: the stub records every skill list, and the behavioral run must leave none. Passing room_names in cmd_behavioral fails it.
  • "Two things" over three bullets in docs/harnesses.md: now "Three things".

Decisions and the smaller items

  • message_limit truthiness: now is not None, and a test passes a limit of 0 and gets 0.
  • grade() with both grader and complete: it now raises ValueError instead of dropping one silently, with a test.
  • complete() and a non-object line: the loop skips a line that is not a JSON object before it reaches the translator, with a test using a translator written like the default one.
  • Left for later: the deadline clamp that now appears in three places, and the "is this the default harness" check, which the review also notes was deferred in Add GitHub Copilot CLI as a harness, with routing that needs no model credential #34. Both are refactors that belong with the sandbox-leg unification.
  • Judge independence and the nullcontext() fallback: these are the documented tradeoffs, and nothing changes for them here.

Local runs: Windows 666 tests pass and WSL 666 pass (git's global config kept out). Each fix was mutation-checked.

johnl-amd added a commit that referenced this pull request Nov 12, 2026
`--from-logs`, `--run-id` and `--unlabelled-logs` rebuilt a comparison from
logs already on disk. The feature is sound and will return, but it arrived
welded to the command it reads for, and reviewers asked for the smaller thing
first: one PR that runs two halves and reports what differed.

Most of the complexity it carried was its own. A log directory is shared with
`behavioral` and `routing`, is never pruned, and holds every run anybody has
done in it, so the rebuild needed a run id to pair on, a rule for picking one
run per skill, a flag for logs written before the id existed, and a way to
report a side it could not answer for. None of that is needed to run two
halves and compare them.

`STALENESS_RUN_KEY` goes with it. Nothing read it once `outcomes_from_log`
was gone, and a key written for a reader that does not exist is the kind of
dead weight this round of review was about. Removing it also narrows the
`build_task`/`run` keyword surface that #39 touches.

`test_the_default_harness_is_not_refused` is fixed rather than deleted. It
passed `--from-logs`, which returned before the harness guard was reached, so
it asserted the absence of a refusal that could not have fired and would have
gone on passing had the guard been written to refuse the default too.

staleness.py 1314 -> 975 lines.
johnl-amd added a commit that referenced this pull request Nov 12, 2026
The command measured the thing both reviewers said was its strongest signal
and then threw it away. On a two-case skill the graded half finished in 2m23s
while the baseline was still going half an hour later, and the report said
nothing about it: both halves' cases passed, so every verdict read
`no_signal`.

The verdicts cannot express this. A skill whose cases pass either way can
still be the difference between two minutes and half an hour, and that is
worth knowing whatever the table says. So the report now leads with it.

Billing a half needs a scope rather than a subtraction. `usage.snapshot()`
returns the module global itself rather than a copy, so the obvious idiom --
hold a snapshot, subtract afterwards -- subtracts the totals from themselves
and reports both halves as free. That produces plausible zeros rather than an
error, which is the kind of bug that survives a reading. `usage.leg()` swaps
the global instead and folds it back on the way out, so engines recording
from deep inside the call reach the right half without knowing a half exists,
and `snapshot()` still answers for the whole command. Every other report's
meta is unchanged.

Nothing is added to `_run_behavioral`, deliberately: #39 expands that same
block in `cmd_behavioral`, and this work sits entirely in `cmd_staleness`,
which #39 does not touch.

Cost will usually read `n/a`, and the caveat says why rather than letting a
blank pass for a zero. Dollars arrive with a run's final event, which a half
the bound stopped never reaches -- so the half that spent *more* is the
likelier one to show nothing, and a reader taking that for zero reads the
result backwards. Tokens and wall clock are reliable; a stopped half's row
carries a `+` and is named as a floor.

A graded half slower than its baseline is printed plainly rather than
suppressed. A skill that costs the agent time is something its author wants
to know.
@johnl-amd

Copy link
Copy Markdown
Collaborator

Review — no blocking findings

Full review of the whole change (17 files, +1220/-106), base 7a8c00b9ea153c5efc5679f60d7a5e9df0deb056, head 7ce5bf907207cf9d61aab6d077e2c96b717e05f3.
Builds on #33 and #34 (merged). Part of #4 (open); this PR does not claim to close it — only that behavioral now runs on Copilot CLI with no model credential, the way routing already does, which it does.

Blocking

None

Non-blocking

  • require_isolated_room's rationale is stale given the room-check mechanisms now available — skillscope/engine/routing.py:126-128,164-165 (pre-existing, untouched by this PR, but on the same topic this PR extends).
    The docstring and refusal message say "Neither leg in this module gets that event … so the same contamination would be invisible" / "this leg cannot see that happen or say so in the report." That was already inaccurate before this PR — the host leg's _record_room already reads Claude Code's init event — and this PR adds a second, independent detection path (Harness.room(), used by Copilot) that makes the claim more clearly wrong. The function's actual behavior (refuse without an isolating credential) is unaffected and still correct; only the explanatory text is out of date.
    Confidence 70 · documentation · Fix: update the docstring/error text to say detection is now possible on this leg for some harnesses, while isolation is still required regardless (catching contamination after the fact doesn't prevent it from having run).

Decisions for the author

None

Positive signals

  • Copilot.room()'s broad exception catch correctly folds in subprocess.TimeoutExpired from _run_bounded's hang path; the hang test goes as far as polling /proc/<pid>/stat on Linux to prove the child process is actually reaped, not just that the call returned in time.
  • The judge's text-only path (complete given) never leaks image bytes or a data-URI: artifacts(paths, read_images=False) never reads the image, and grade() only appends ContentImage when complete is None — both directly tested, including that artifacts is called with read_images=False in that path.
  • The new per-case room check in no_sandbox.harness_solver runs after install_skill(), against the same workspace/env the real case command uses, and is correctly accounted against the same subprocess concurrency limiter as the main run rather than a second, unbounded pool — confirmed by a test that watches the limiter during the call.
  • --available-tools=skillscope-no-tools (an allowlist naming a tool that doesn't exist, chosen specifically because a denylist has to spell the CLI's real tool names) is cross-checked against real recorded CLI output to confirm it genuinely leaves the model with no callable tool.

Deployment notes

None

What this covered

Read the whole diff (7a8c00b9e...7ce5bf9) and, for every changed file, the complete file at the PR head via git show rather than only the changed hunks — including the unchanged surrounding code in skillscope/harness/base.py, skillscope/engine/no_sandbox.py, skillscope/routing.py, and skillscope/engine/routing.py needed to judge the change in context. Three independent passes ran in parallel, grouped by coupling: (1) the harness contract (base.py, copilot.py, claude_code.py, harness/__init__.py + their tests), (2) the judge/scorer/behavioral wiring (judge.py, scorers.py, behavioral.py, the cli.py behavioral path + their tests), (3) the room-check wiring and reporting (no_sandbox.py, routing.py, engine/routing.py, the docs + their tests). Every candidate finding was verified against the source before scoring. CI on the head commit is green (7 checks, all SUCCESS).

Did not run: agent-instruction adherence (no CLAUDE.md/AGENTS.md in this repository). History and prior-changes were not run as dedicated passes beyond confirming via git log/git diff that skillscope/engine/routing.py (home of the one non-blocking finding above) is untouched by this PR.
Not reconciled against any discussion on this pull request — this review was conducted blind; the PR's own description was read only after findings were settled, per the review method, and matched what the diff actually does.


🤖 Automated review by an agent.
Model claude-sonnet-5[1m] · Not a human review. Reply here if a finding is wrong.

@johnl-amd

Copy link
Copy Markdown
Collaborator

Review — no blocking findings

Full review of the whole change (single commit, 17 files, 7a8c00b9ea…7ce5bf9072).
Implements part of #4 (harness-agnostic behavioral judging and a room check for Copilot CLI); does not add Codex support or a container leg for a non-Claude harness, both of which the description explicitly scopes out.

Blocking

None

Non-blocking

None

Decisions for the author

  • A harness's own CLI judges its own run — tradeoff
    The judge for a subprocess harness (Copilot) now asks the same CLI, credential and model that produced the run, rather than an independent Anthropic model. That's the only way to grade behavioral without requiring an Anthropic credential on every repo, but it trades away judge independence: a model that misjudges its own transcript isn't checked by a second opinion. The docs already say this plainly (docs/usage.md: "the verdict comes from the same model family that produced the run, not from an independent one"), so this reads as a deliberate, disclosed choice rather than an oversight. Confirm that's still the intended tradeoff as more harnesses are added this way.
  • The "no tools" guarantee for Copilot's judge rests on one CLI version's undocumented behavior — non-blocking-improvement
    --available-tools=skillscope-no-tools (skillscope/harness/copilot.py) works because CLI 1.0.105 treats an unrecognized allowlist entry as "offer nothing," which the in-code comment and docs/harnesses.md's contract-test section both honestly flag as unverifiable for a future CLI build. It's mitigated in depth today — nothing is pre-approved and --no-ask-user is set regardless, and a test asserts the allowlisted name is never one of the CLI's real tool names from recorded sessions — so a version change would have to break two independent guards at once to actually let the judge act on the machine. Still, there's no runtime self-check or periodic CI verification against the live CLI that would catch a behavior change proactively; worth considering as this mechanism is reused for future harnesses.
  • "The harness couldn't say" and "the harness said the room is empty" render identically — tradeoff
    Harness.room() returning None versus [] collapse to the same "room not verified" / no-missing-skills-warning state in routing.render_markdown. This is the same ambiguity Claude Code's init-event path already had before this PR (documented accurately, not introduced here), now extended faithfully to a second harness. Worth asking whether it's still the right collapse now that a second, real implementation exercises the "truthfully empty" side, or whether the two cases are common enough in practice to warrant distinguishing in the report.

Positive signals

  • Harness.complete()'s timeout and deadline handling deliberately mirrors the existing preflight() pattern rather than inventing a new one, and is tested the same way (hang, missing CLI, no-time-left, non-JSON line).
  • The --available-tools allowlist choice over a denylist is backed by a documented investigation (denylist spelled shell where the real tool is powershell, verified against CLI 1.0.105) rather than an assumption, and a test pins the allowlisted name against real recorded sessions to confirm it names no actual tool.
  • The message-limit fix (handing inspect the harness's real model for the limit rather than the no-provider placeholder) is exactly the kind of thing an end-to-end test should catch, and test_the_run_is_held_to_the_limit_a_real_model_gets explicitly pins it against the placeholder's test-sized limit rather than merely checking the happy path.
  • routing._isolate_advice generalizes what was a hardcoded "set ANTHROPIC_API_KEY" message into one driven by each harness's own credentials tuple, and a test (test_setting_what_the_advice_names_really_isolates_the_room) round-trips the advice text back through can_isolate_room to verify the named variables actually work, not just that they're named.
  • Fixing the test stub shim from exec-based to not (tests/test_harness_hooks.py) is a genuine incidental bug fix: the old shim collapsed into the CLI's own process, which would have let the new process-group-kill paths (room-check and judge timeouts) pass without ever really exercising the kill.

Deployment notes

  • behavioral now runs on copilot with no Anthropic credential, each judged expectation billed as one real CLI call against the harness's own credential — a cost/spend consideration for anyone opting into --harness copilot --command behavioral, beyond what routing already cost.
  • No files under .github/, action.yml, bootstrap/ or examples/ change, and the default harness (Claude Code) and its judge path are unchanged, so a workflow that passes no --harness is unaffected.

What this covered

Every file in the diff, read completely, fanned out across five independent workers grouped by coupling (judge wiring: cli.py/engine/behavioral.py/engine/judge.py/engine/scorers.py; harness contract: harness/__init__.py/harness/base.py/harness/claude_code.py/harness/copilot.py; room-check and report rendering: engine/no_sandbox.py/routing.py; docs: docs/harnesses.md/docs/usage.md; tests: all four test files and the two fixtures), plus the surrounding code each needed (callers of harness_solver, message_limit_for, engine_models.resolve, NO_PROVIDER_MODEL). All five workers returned; two candidate findings from workers about missing test coverage were independently re-verified against the actual test files and refuted — the coverage exists (TestABehavioralRunOnCopilot, TestTheRoomOnCopilot, TestTheAdviceForAContaminatedRoom, TestWhichSkillsASessionWouldLoad) — and dropped rather than reported. The tests worker additionally ran the full suite from a read-only git archive of the PR head (666 tests passed) and mutation-tested the riskiest branches (the builtin/enabled skill filters, the tool-free/--allow-all leak guard, Claude Code's refusal), each caught by a dedicated test. CI on the reviewed commit (7ce5bf9072) is all green across every check. Comment accuracy and doc-vs-code accuracy were checked as part of the code and docs passes rather than as separate dedicated passes.

Did not run: agent-instruction adherence (no CLAUDE.md/AGENTS.md in this repository); history (git log/blame) as a dedicated pass beyond what individual workers consulted for context; prior-changes (review comments on earlier PRs touching these files) — not checked. Not reconciled against this PR's own review discussion, which was deliberately not read for this pass.


🤖 Automated review by an agent.
Model claude-sonnet-5[1m] · Not a human review. Reply here if a finding is wrong.

@johnl-amd johnl-amd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Automated review · review-code · head 7ce5bf90

Review — no blocking findings

Full review of the whole change: every file the diff touches, read completely at the head commit, split across two independent fan-out workers (harness-contract files: harness/base.py, harness/__init__.py, harness/claude_code.py, harness/copilot.py, tests/test_harness_hooks.py, fixtures; and engine/CLI/routing/docs files: engine/judge.py, engine/scorers.py, engine/behavioral.py, engine/no_sandbox.py, cli.py, routing.py, tests/test_harness.py, tests/test_harness_cli.py, docs/harnesses.md, docs/usage.md), plus my own direct reading of every primary file and cross-checks of the call sites between them.

Builds on #33/#34 (already merged) and is part of #4 (expanding skillscope beyond Claude Code); it implements the Copilot-CLI judge and room-check piece of that, not the rest — a sandboxed leg for other harnesses and Codex support are explicitly out of scope per the PR's own "Not in this PR" section, and issue #4 stays open.

Blocking

None

Non-blocking

  • Copilot.command()'s tool-free guarantee can be reopened by a future caller's extra_flags — skillscope/harness/copilot.py:143-164 (esp. line 163: cmd += list(spec.extra_flags)).
    extra_flags is appended unconditionally after the tool-free (--available-tools=...) or full-access (--allow-all) flags are built, so nothing in Harness, RunSpec, or Copilot.command() stops a future caller from constructing RunSpec(tool_free=True, extra_flags=(...)) with a flag that re-grants permissions or overrides --available-tools. No current call site does this — Harness.complete() builds its RunSpec with no extra_flags — so there's no live exploit today, only a latent contract gap. ClaudeCode.command() is immune since it refuses tool_free outright.
    Confidence 70 · architectural · Fix: have Copilot.command() ignore/reject extra_flags when spec.tool_free is set, or assert the two are never combined in the contract test.

  • The judge-tool-collision regression test only checks names that happen to appear in two recorded fixtures, not the fuller list the code's own comment calls out as risky — skillscope/harness/copilot.py:56-65 (comment naming read_powershell, view, create, edit as real CLI tools besides powershell, which is what the old denylist missed); tests/test_harness_hooks.py:259 (test_the_one_tool_it_names_is_not_a_tool_the_cli_has derives its "real tool names" set only from trigger.jsonl/no_trigger.jsonl, which only exercise grep, powershell, skill, glob). A future rename of JUDGE_TOOLS into a collision with one of the other named tools would not be caught by this test.
    Confidence 70 · mechanical · Fix: assert directly against a short hard-coded list of the tool names the comment names, independent of what the two recordings happen to contain.

  • judge.grade()'s per-expectation failure handling wraps only the complete() call; an exception earlier in the same function still aborts the whole case — skillscope/engine/judge.py:195 (target = await tools.resolve(path) inside artifacts(), unguarded) and :280 (artifacts() called before the try/except at :329, which catches only the complete(question) call); skillscope/engine/scorers.py's score() has no try/except around its judge.grade() loop either. If tools.resolve() (or anything else in grade() before the guarded call) raises — for either the inspect-model or harness-complete path — the exception propagates out of the scorer and the whole sample errors, losing the deterministic logs_contain/files_exist checks already computed plus any expectation graded earlier in the loop. This is pre-existing (the same code ran unguarded before this PR), not a regression, but it sits right beside the new per-expectation failure handling this PR adds and is easy to mistake for being covered by it.
    Confidence 70 · logic · Fix: wrap the whole body of grade() (from after the ListingFailed check onward) in the same per-expectation try/except, or at minimum guard artifacts()'s tools.resolve() call.

  • Harness.room() has no engine-level timeout backstop; it relies entirely on each implementation self-bounding — skillscope/engine/no_sandbox.py:497 (if room_names: async with _subprocess_concurrency(): listed = await asyncio.to_thread(harness.room, workspace, env), no asyncio.wait_for/deadline wrapper), contrasted with the main agent run a few lines below, which is always killed via _stream_until/_terminate regardless of harness. This is safe today only because Copilot.room() happens to self-bound (deadline.active() plus _run_bounded's process-tree kill); a future harness's room() that forgot to self-bound would block the sample's worker thread (and any subprocess it started) with no backstop beyond the sample's own time_limit eventually abandoning — not killing — that thread.
    Confidence 70 · architectural · Fix: wrap the call in a deadline-aware asyncio.wait_for, or state explicitly in the Harness.room() contract (and test it) that an implementation must self-bound.

  • No test asserts the judge's subprocess call shares the global subprocess limiter on the behavioral path, only on routing's room check — tests/test_harness_cli.py:407 (test_the_check_is_counted_against_the_subprocess_limit, patches Harness.room to prove it runs inside _subprocess_concurrency()) has no counterpart in TestABehavioralRunOnCopilot (tests/test_harness_cli.py:487) for Harness.complete/judge.harness_completer. The implementation is correct — harness_completer() already wraps complete() in the same limiter — but a regression that un-wrapped it would not be caught by the current suite.
    Confidence 70 · mechanical · Fix: add a test mirroring the routing one, patching Harness.complete and asserting it runs inside the limiter.

Decisions for the author

  • A judge that grades itself — tradeoff. For a harness other than Claude Code, the expectation judge is the same CLI, credential and model family that produced the run, not an independent model. This is already disclosed in docs/usage.md ("the verdict comes from the same model family that produced the run, not from an independent one"), so it reads as a deliberate, accepted tradeoff rather than an oversight — flagging only so it's confirmed as such rather than something to fix.
  • A judge-infrastructure failure always grades as a failed check, regardless of requirement polarity — tradeoff. judge.grade()'s documented default is that absence of evidence counts as satisfying a must_happen=False ("must not have done this") requirement, but a CompletionError from complete() (CLI down, hung, no credential) always returns False/failed either way, never the polarity-aware default. There's a reasonable case for this as-is — defaulting an infra failure to "pass" could let a broken judge silently wave through a violated safety constraint — so this reads as an intentional choice rather than a bug; worth the author confirming that reading is correct.

Positive signals

  • The allowlist-over-denylist choice for the judge's tool-free command is backed by a documented, concrete failure case (a remembered-wrong tool name — shell vs the CLI's actual powershell — left the denylist porous) and is defended by both a per-harness test and a generic cross-harness contract test (test_no_harness_hands_the_judge_a_permission_it_was_not_asked_for).
  • Process-tree-kill tests are genuinely non-mocked: they inspect /proc/<pid>/stat on Linux to confirm a hung CLI's child process actually dies, rather than only asserting that subprocess.run returned.
  • The message_limit override uses is not None rather than truthiness, correctly preserving a deliberately-set 0, and is exercised by a test for exactly that edge case.
  • Removal of the old "behavioral refused on any non-default harness" path is clean — both the SystemExit branch and the test asserting it are fully gone from the head commit, with no dangling references left behind.

Deployment notes

None. The reusable GitHub Actions workflow, action.yml, bootstrap/ and examples/ are untouched, and neither graded command's default behavior (Claude Code) changes; adopting the new --harness copilot path for behavioral is opt-in.

What this covered

Every file in the diff (7a8c00b9ea153c5efc5679f60d7a5e9df0deb056…7ce5bf907207cf9d61aab6d077e2c96b717e05f3), read completely at the head commit: skillscope/harness/{base,__init__,claude_code,copilot}.py, skillscope/engine/{judge,scorers,behavioral,no_sandbox}.py, skillscope/cli.py, skillscope/routing.py, docs/{harnesses,usage}.md, tests/test_harness{,_cli,_hooks}.py, and the two Copilot fixtures — split across two independent fan-out workers plus my own direct read, with call sites cross-checked against source rather than taken on description (e.g. confirmed directly that room_names is never threaded into cmd_behavioral's solver call, that engine/routing.py passes it unconditionally for cmd_routing, and that the removed refusal path and its test leave no trace at head). CI: all 8 checks (unit tests on Ubuntu/Windows × Python 3.10/3.12, sandbox machinery both OSes, unused-imports, throwaway-repo action test) report SUCCESS for this exact head commit.

Did not run: no project-level agent-instruction file exists in this repo (checked), so agent-instruction-adherence is not applicable; the "prior review comments on these files" pass could not be done without reading this PR's own threads, which the method excludes pre-findings, and no other channel to earlier reviews of these files was available; did not execute the test suite directly, relying instead on CI's own green run plus direct reading of the new tests' assertions.

Not reconciled against any discussion on this PR — conducted blind to its comment threads and any prior review bodies, per the method.


Not a human review. This is an automated code review; please verify findings independently before acting on them.

Behavioral runs on a harness other than Claude Code were refused, because the LLM
judge that grades `expected_behavior` and `unexpected_behavior` called an
Anthropic model directly whatever agent produced the run. A harness is now judged
by its own CLI, so no model credential is needed, and the refusal is gone.

`Harness.complete(prompt, model)` asks the model one question with no tools and
returns its answer. The default runs the harness's own command with a new
`RunSpec.tool_free` and reads the answer through `normalizer()`, the way a case's
closing answer is read. For Copilot CLI that command grants nothing and offers the
model no tool, with `--available-tools` naming one that does not exist: the judge
reads evidence, it never acts on the machine it is reading about. It is an
allowlist because a denylist depends on spelling the CLI's own tool names, and
`shell` is not one (its shell tool is `powershell`), which a real run showed
leaving the tool on offer. Claude Code's command refuses
`tool_free` rather than run a question with permissions skipped, and `complete`
reports that refusal as a `CompletionError`; its judge stays its inspect model. Copilot's tool-free command refuses extra flags too,
since one appended after the allowlist could grant a tool back.

`judge.grade` takes an optional `complete`, passed through the scorer and the
behavioral runner, and uses it in place of the inspect model. The default is the
existing path, unchanged. A judge that fails or prints no answer is reported
against that expectation (`judge failed: ...`) like a sandbox that could not be
listed, so one failure does not end the case or hide the deterministic checks. The
judge sees text only: an image artifact is named in the question, not read or attached.
Each judge call starts a real process, so it is counted against the same subprocess
limit as the agent's own, once per judged expectation.

Copilot CLI does not announce the skills it loaded, so `Harness.room(workspace,
env)` asks for them; for Copilot that is `copilot skill list --json`, which costs no
model call. A `routing` run asks once per case, off the event loop, inside the
subprocess limiter and within the command's deadline, with a hung CLI stopped along
with its children, and records what was visible and
what was extra, so a routing report no longer says the room was not verified. The
`behavioral` command does not run the room check. The CLI's built-in skills are in
every session and are not counted as extras, and a skill the CLI reports as switched
off is not in the room. The two report messages that named
Claude Code's credential and its `inspect_swe` leg now say what is true for the
harness that ran, and the contaminated-room advice names the harness's own
credential variables.

Behavioral on a subprocess harness had a bug found by the end-to-end test: inspect
was handed a no-provider model, whose message limit is a test value of 6, so a
single real run exceeded it and lost its transcript. The limit now comes from the
model the harness would run, not from inspect's placeholder.

New tests cover `complete` and `room` against a stub executable (the answer,
nothing allowed, a throwaway home, a refusal, no answer, a hang, a missing CLI, a
harness that refuses the tool-free command, a CLI that was asked and could not
answer, a failing exit status with valid output, no time left), a recording of `copilot skill list --json` parsed as it is, the image note in
the judge's question and the artifacts read behind it, a completion with no time left,
the judge inside the limiter, the report wording for each harness, the advice and the variables it names, the limiter around the
room check, a hung `skill list`, every harness's tool-free command, and the message
limit that reaches the task. They run the real
`behavioral` command with the stub replaying the recorded session and answering the
judge: all three kinds of expectation graded, the judge asked with nothing allowed
and shown what the agent did, a negative verdict, and a failing judge. The behavioral command is
asserted not to ask for the room and to run each judge question inside the limiter, and the agent's own command not to carry the judge's
allowlist. Each deliberate break of a fix turns a test red. One test from amd#34 is removed,
`test_behavioral_while_its_judge_is_not_available`, because it asserted the refusal
this change deletes.

Signed-off-by: Eugene Volen <[email protected]>
@volen-silo

Copy link
Copy Markdown
Collaborator Author

Thanks, no blockers on 7ce5bf9. I took the cheap ones. Pushed (rebuilt commit, hash e9c3ea1).

Taken

  • extra_flags could reopen the tool-free command. Copilot.command() now refuses a tool-free spec that carries extra flags, as Claude Code's command refuses tool_free, so nothing appended after --available-tools can grant a tool back. A test passes --allow-all and expects the refusal. Another checks the agent's own command still takes extra flags. Removing the guard fails the first.
  • The collision test only knew the recorded tools. It now also checks the judge's name against the tool names a real CLI 1.0.105 session reported when asked which it could see, hard-coded. Naming the judge's tool view fails it.
  • No test that the judge is under the limiter on the behavioral path. Added in TestABehavioralRunOnCopilot: each judge question must run inside _subprocess_concurrency(). Taking the completer out of the limiter fails it.
  • Harness.room() has no engine-level backstop. I stated the contract instead of adding a timeout: the room() docstring and the docs/harnesses.md row now say an implementation bounds its own run, stopping at the command's deadline and killing what it started, and that the engine puts no timeout around it.

Left alone

  • judge.grade() failing outside complete(). Agreed it is real and predates this PR, so I'd rather not widen the scorer's error handling here: it changes which failures lose a case's deterministic checks. It is a candidate for its own change.
  • Judge grading itself, and an infrastructure failure grading as failed regardless of polarity: both are deliberate and already disclosed, so nothing changes.

Local runs: Windows 670 tests pass, and WSL 670 pass (git's global config kept out). Each fix was mutation-checked.

@johnl-amd johnl-amd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review — needs work

Full review of the whole change (17 files, +1287/-106, 1 commit), split across three independent workers grouped by coupling: judge/scorers/behavioral/sandbox; the harness interface core (base/__init__/claude_code/copilot plus their fixtures and tests); and routing/CLI/room-check integration plus its tests. Each read its full file group completely at the head commit, and several verified specific claims by running or mutating the real test suite against an extracted copy of the repository (never the shared working tree, never checked out).

Part of #4 ("Add support for Codex agent harness") — informational only, not claimed closed. #4 asks broadly for other harnesses; this change adds Copilot CLI support for judged/behavioral grading and a harness-agnostic room check, which is a step toward that goal rather than the Codex support #4 specifically names. The PR's own description matches the diff with no scope mismatch found.

Blocking

  • The room check's new isolation guarantees (which directory and environment the subprocess sees) are not verified by any test — skillscope/harness/copilot.py (room(), the cwd=workspace/env=dict(env) call), tests/test_harness_hooks.py (TestWhichSkillsASessionWouldLoad)
    Every test in this class calls the helper with the same fixed tempfile.gettempdir()/dict(os.environ) regardless of what isolation the call is meant to exercise. Confirmed by two independent mutations run against the real suite: changing cwd=workspace to cwd=os.getcwd() left all 57 tests in the file green; separately changing env=dict(env) to env=dict(os.environ) (i.e. ignoring the caller-supplied, isolated environment entirely) also left all 57 green. In production (no_sandbox.py), room() is called with a deliberately isolated environment (e.g. a throwaway COPILOT_HOME) specifically so the check reflects the isolated room, not the real one — a future change that silently broke that isolation would report on the wrong room with nothing here to catch it. The sibling complete() function in the same file already has an equivalent test for exactly this (test_it_runs_in_a_throwaway_home_and_workspace, asserting the stub recorded the expected home/cwd); room() has no counterpart.
    Confidence 90 (two independent mutations, each executed against the real suite) · logic/test-coverage · Fix: add an assertion mirroring complete()'s own isolation test — have the stub record the cwd/env it was actually invoked with and assert room() passed through the specific isolated values it was given.

  • Judge calls made through the new harness-CLI completion path are real, billable subprocess invocations that never reach the run's own usage/cost accounting, and nothing tests this — skillscope/engine/judge.py (harness_completer), skillscope/harness/base.py (Harness.complete())
    The agent's own run records every token/cost-relevant stream event via usage.record_stream_event(event) (the only call site is the agent's run loop in no_sandbox.py, confirmed by a repo-wide search — it is never called from harness_completer/Harness.complete()). Each judged expectation (expected_behavior/unexpected_behavior) therefore starts a second, independent CLI subprocess whose cost is silently absent from the run's reported totals, scaling with however many judged expectations a case declares — and specifically relevant for a harness whose own module already treats this kind of call as a billing concern. No test in the new Copilot-behavioral test class asserts anything about usage/token/cost totals for this path.
    Confidence 72 (directly traced call graph plus a repo-wide grep confirming the single existing caller; not checked against a real billing signal) · architectural · Fix: either have the judge's completion path record its own subprocess usage the same way the agent's run does, or explicitly document that judge-call cost is excluded from the run's reported totals, and add a test pinning whichever is intended.

Non-blocking

  • The module docstring in judge.py overclaims what the judge can see, for a path this PR adds — skillscope/engine/judge.py (module docstring, near the top) vs. the function-level docstring on grade() and the artifacts(paths, read_images=complete is None) logic this PR introduces
    The module docstring still states unconditionally that "images are attached" as evidence for the judge. This PR makes that true only on the default (inspect-model) path; when a harness-CLI judge (complete) is used, images are deliberately only named, never attached — which the newer, more precise grade() docstring and the code both already say correctly. The older, broader claim just wasn't narrowed to the path it still describes.
    Confidence 75 · mechanical (code comments) · Fix: scope the module docstring's claim to the default judge path, matching grade()'s own caveat.

  • A new function hardcodes the default harness's name instead of the constant its own neighbor uses — skillscope/routing.py, _other_harness (harness != "claude-code") vs. _isolate_advice a few lines below it, which does from . import harness as harnesses; ... or harnesses.DEFAULT
    Both functions are new in this PR and need the same fact — which harness is the default — and only one of them gets it from the shared constant; the other repeats the literal by hand. Harmless today since the values are equal, but every comparable comparison elsewhere in the codebase (confirmed in cli.py) goes through harnesses.DEFAULT, so nothing keeps _other_harness in step with it if the default ever changes, silently flipping which report-wording branch fires.
    Confidence 70 · mechanical · Fix: have _other_harness compare against harnesses.DEFAULT too (a deferred import, exactly as _isolate_advice already does safely in the same module).

Decisions for the author

  • The judge's tool-denial allowlist can't be fully verified by this repo's own tests — tradeoff. --available-tools naming one nonexistent tool is asserted at the argv-string level, but whether the real CLI actually offers no tool for an unrecognized allowlist value (versus silently falling back to "everything") can only be confirmed by a live run — the PR's own new docs already disclose this honestly ("a list of known flags, not a guarantee"). Flagged for visibility, not as an unacknowledged gap.
  • Two sibling timeout constants introduced by this PR are bound differently — non-blocking-improvement. COMPLETE_TIMEOUT_S (harness/base.py) is captured as a function's default argument at class-definition time; ROOM_TIMEOUT_S (harness/copilot.py, added in the same change) is read fresh from the module namespace on every call, which is exactly why an existing test can shrink it via mock.patch.object. The same approach would silently fail to shrink COMPLETE_TIMEOUT_S. Not a live bug — nothing currently tries to patch it — just an inconsistency between two constants added together.

Positive signals

  • Tool-freedom for the judge is enforced where it has to be: each harness's own command-builder refuses or strips any tool-granting flag when asked for a tool-free command, rather than the judge/scorer code assembling flags itself — and this is specifically tested for both harnesses, including that a flag which could grant a tool back is rejected.
  • The message-limit fix reuses the exact pattern already established in engine/routing.py (compute the limit from the harness's real resolved model rather than the placeholder used for grading plumbing) instead of inventing a second mechanism, and a dedicated test covers the message_limit=0 edge case the two call sites must handle identically.
  • The "room check couldn't answer" vs. "room check answered zero skills" distinction is carried faithfully end-to-end (None vs. an empty list, through to the rendered report), documented in code comments in three separate places, and specifically tested with wording that doesn't send a reader of one harness's report toward another harness's remediation steps.

Deployment notes

  • All 8 CI checks are green on the reviewed head commit. GitHub reports the PR as mergeable but currently blocked on required review/approval, not a merge conflict.
  • This PR is what first lets the behavioral grader run on a harness with no Anthropic model credential at all. Per the first Blocking finding, a repo adopting this should not yet expect judge-call cost to appear in the run's own reported usage/cost totals.
  • The judge's tool-denial for a non-default harness rests on an allowlist the CLI itself does not contractually guarantee (see Decisions for the author) — worth a live smoke-test before relying on it for anything security-sensitive.

What this covered

Every file in the diff read completely at the head commit by three independent workers grouped by coupling, plus the unchanged surrounding code each needed to judge its group in place (harness dispatch, the subprocess concurrency limiter, the deadline singleton, and the relevant documentation). Several claims above were verified by actually running or mutating the test suite against an extracted copy of the repository. CI was checked directly via the GitHub API. The PR's own description was read last, against the findings already reached, and found to match the diff's actual scope.

Did not run: agent-instruction adherence (no CLAUDE.md/AGENTS.md exists anywhere in this repository). This repository's own existing discussion on this pull request was not read, consistent with reviewing the code blind; this report is not reconciled against any prior comment or review thread on it.


Automated code review — not a human review; please verify findings independently before acting on them.

@johnl-amd

Copy link
Copy Markdown
Collaborator

Review — needs work

Full review of the whole change, base 7a8c00b9ea153c5efc5679f60d7a5e9df0deb056 → head e9c3ea141609af02fe152a1111125b5fd7f309a5 (this is the commit pushed after the prior review round on 7ce5bf9; none of that round's discussion was read for this pass, per a blind-review method).
Builds on #33/#34 (merged) and is part of open issue #4. Judges a behavioral run through whichever harness CLI produced it (adding a tool-free complete() and a room() skill-list check), rather than always calling an Anthropic model directly. The description's claims — scope, the removed test, the message-limit fix, and what is explicitly out of scope — all check out against the diff.

Blocking

  • The new permission-safety test doesn't check for the mechanism that actually provides the protection, only for the absence of an unrelated blocklist — tests/test_harness.py:291-309 (test_no_harness_hands_the_judge_a_permission_it_was_not_asked_for), vs. the real guard at skillscope/harness/copilot.py:158 (--available-tools={JUDGE_TOOLS}).
    The test only asserts that flags like --allow-all/--dangerously-skip-permissions/anything containing "skip-permissions" are absent from the tool-free command. It never asserts that the actual allowlist flag is present. Verified by removing the --available-tools=... token from the built command and re-running: the test still passes. This is exactly the behavior this PR introduces (replacing a prior, buggy denylist approach), so the gap is in coverage for new code, not a pre-existing one.
    Confidence 100 (verified by direct mutation) · mechanical · Fix: assert the allowlist flag (or equivalent per-harness restriction) is actually present in the tool-free command, not just that a fixed blocklist is absent.

  • Harness.room() has no coverage in the generic, per-harness contract suite, even though this PR added the fixtures for exactly that purpose — tests/test_harness.py (TestEveryRegisteredHarnessMeetsTheContract), whose own docstring states every registered harness is held to the same bar without bespoke tests; the new tests/fixtures/harness/copilot/skill_list.json and expected.json's new "skill_list" key sit inside that class's own fixture directory, but are only read by a Copilot-specific test elsewhere.
    Verified by patching Copilot.room() to return an obviously-wrong result (one that ignores the "exclude builtin/disabled skills" contract entirely) and re-running the whole contract suite: all tests still pass.
    Confidence 100 (verified by direct mutation) · architectural · Fix: add a room()-focused case to TestEveryRegisteredHarnessMeetsTheContract that exercises the filtering contract generically, the way the suite already does for stream-event canonicalization.

Non-blocking

  • Harness.room()'s call site has no exception guard, unlike a None return — skillscope/engine/no_sandbox.py:501-513. The contract documents None as "cannot say," but nothing stops an implementation from raising instead, and an uncaught exception there would currently abort the whole case rather than degrade to "unverified." Not a live bug — both registered harnesses already catch everything internally — but a gap a future harness could fall into silently.
    Confidence 70 · architectural · Fix: wrap the call in a try/except that treats an unexpected exception the same as a None return.
  • A pre-existing asymmetry in the "room not verified" explanatory note stands out now that this PR adds a more precise version for other harnesses right next to it — skillscope/routing.py (render_markdown's final note). For a non-default harness, the note now correctly says the silence could mean either "could not say" or "said none of the staged skills were found," which read the same. The Claude-Code branch, unchanged by this PR, only gives the first explanation. Pre-existing, not introduced here, but worth bringing in line now that the contrast is visible.
    Confidence 70 · logic · Fix: add the same "or it answered and reported none" framing to the Claude Code branch.
  • A new test for naming credential variables only checks one of Claude Code's two — tests/test_harness_cli.py:277-278 (test_claude_code_names_its_variable) vs. the equivalent Copilot test a few lines below, which loops over every credential name. Only ANTHROPIC_API_KEY is asserted; ANTHROPIC_AUTH_TOKEN isn't, so a regression that only names the first credential would pass here and only be caught by the Copilot-side test.
    Confidence 85 · mechanical · Fix: assert both credential names, or loop over the harness's own credentials tuple the way the Copilot test does.

Decisions for the author

None.

Positive signals

  • The tool-free Copilot command was checked against the real CLI (per the description) and is defended in depth: no --allow-all, an explicit allowlist naming nothing, and a refusal of any caller-supplied extra flag that could re-grant a tool — each independently confirmed in the code.
  • TestABehavioralRunOnCopilot is a genuine end-to-end test (real command → stubbed subprocess) covering the judge-via-harness path, the subprocess concurrency limiter, a failing judge, and a negative verdict together, replacing the deleted refusal test with real coverage of the new path.
  • The message-limit fix (deriving the limit from the model the harness would actually run, not a test placeholder) is a real, independently-useful bug fix bundled with this change, and is covered by a dedicated test.
  • Both harnesses fail loudly (NotImplementedError) rather than silently approximating a safe tool-free command when one isn't implemented, which is the safer default to pick.

Deployment notes

None — this is additive for the default harness (unaffected call paths keep their prior behavior byte-for-byte); the new judge and room-check paths only engage for a non-default harness, which no existing workflow currently selects.

What this covered

Every changed file read completely at e9c3ea1, against the 7a8c00b9 base: skillscope/harness/{base,__init__,claude_code,copilot}.py, skillscope/engine/{judge,scorers,behavioral,no_sandbox}.py, skillscope/routing.py, skillscope/cli.py, tests/test_harness.py, tests/test_harness_cli.py, the new Copilot fixtures, and docs/usage.md/docs/harnesses.md, plus the surrounding code each touches (engine/routing.py's outcome handling, no_sandbox.py's room-check wiring, harness/base.py's process-group kill logic). Run as three independent, blind passes grouped by coupling (harness contract layer; judge/scorer/behavioral/CLI wiring; routing and room-check wiring), each of which ran the real test suite (670 tests, green) and verified its findings by direct mutation where practical, consolidated here with duplicate/overlapping observations merged and each surviving finding re-checked against the cited code before scoring.

Did not run: agent-instruction adherence (no CLAUDE.md/AGENTS.md in this repository). History and prior-review passes were done via ordinary git log reading within each file group rather than as separate dedicated passes. Not reconciled against this PR's own review discussion — the earlier rounds' comments were deliberately not read; this is a fresh blind pass over the current head.


🤖 Automated review.
Model claude-sonnet-5[1m] · Not a human review. Reply here if a finding is wrong.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent-hub-reviewed agent-hub has reviewed this

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants