feat(criteria): accept glob patterns in criterion path fields - #65
feat(criteria): accept glob patterns in criterion path fields#65jiyangzh wants to merge 1 commit into
Conversation
A criterion `path` is a literal, so a task must hardcode where an artifact lands. When the prompt does not pin that location — a scaffolding tool that creates a wrapper directory the agent names itself — a correct artifact in an unexpected directory scores 0.0 on the path alone, while a sibling criterion that discovers the file reports the run as valid. Resolve `path` through `Sandbox.resolve_files`, which expands a pattern containing `*`, `?`, or `[` against the sandbox root and leaves a literal path untouched. Every path-based criterion inherits it: file_exists, file_contains, file_matches_regex, file_check, json_check, import_check. `file_exists` passes on at least one match. Content reads require exactly one match and otherwise raise, listing every match — an ambiguous pattern is captured as a scored-0.0 result rather than silently grading one arbitrary file. Matches are sorted for determinism and directories are dropped.
|
Claude finished @jiyangzh's task in 1m 31s —— View job 📋 Review Task List
|
uipreliga
left a comment
There was a problem hiding this comment.
Review: coder_eval — pr:65
Scope: pr:65 · branch feat/glob-paths-in-file-criteria · 1e2ca2e · 2026-07-30T17:45Z · workflow variant
Change class: complex — changes the path-resolution semantics of shared Sandbox APIs (file_exists, get_file_content) that every path-based criterion consumes, adding new control flow and a new raise path that can change a task's score
coder_eval is in excellent shape overall (9.4/10 — clean style, strong typing, no security or error-handling defects, well-factored plugin/merge architecture), but the new glob seam in Sandbox.resolve_files (src/coder_eval/sandbox.py:970-1034) is the real risk: globbing is inferred from the presence of *?[ rather than opted into, and expands unfiltered over a sandbox root containing .venv/node_modules/copied starter files, so identical agent output can score differently — fix the two harness defects before trusting any run for grading, and treat the rest as documentation and robustness polish.
Summary
| Axis | Score | 🔴 | 🟠 | 🟡 | 🔵 | Top Issue |
|---|---|---|---|---|---|---|
| 1. Code Quality & Style | 10 / 10 | 0 | 0 | 0 | 0 | — |
| 2. Type Safety | 8.4 / 10 | 0 | 0 | 3 | 1 | Path fields that gained glob semantics (json_check.path/json_schema, classification_match.path) still carry pre-glob Field descriptions (schema SSOT drift) |
| 3. Test Health | 9.9 / 10 | 0 | 0 | 0 | 1 | test_matches_are_sorted is self-referential and passes vacuously on an empty result |
| 4. Security | 10 / 10 | 0 | 0 | 0 | 0 | — |
| 5. Architecture & Design | 9.9 / 10 | 0 | 0 | 0 | 1 | reference_comparison.agent_file is a sandbox-relative path field that bypasses the new seam, so path semantics now silently differ per criterion |
| 6. Error Handling & Resilience | 10 / 10 | 0 | 0 | 0 | 0 | — |
| 7. API Surface & Maintainability | 9.5 / 10 | 0 | 0 | 1 | 0 | Ambiguous-glob ValueError enumerates every match unbounded and is duplicated into details/error (persisted to task.json, injected into judge prompts) |
| 8. Evaluation Harness Quality | 7.5 / 10 | 0 | 2 | 1 | 0 | String-sniffing *?[ in resolve_files silently reinterprets a literal path as a glob (no literal-first fallback, no regression test) |
Overall Score: 9.4 / 10 · Weakest Axis: Evaluation Harness Quality at 7.5 / 10
Totals: 🔴 0 · 🟠 2 · 🟡 5 · 🔵 3 across 8 axes.
Blockers
- [Axis 8] String-sniffing
*?[in resolve_files silently reinterprets a literal path as a glob (no literal-first fallback, no regression test) (src/coder_eval/sandbox.py:990) —resolve_filesclassifies a path as a glob purely by character scan:
if not any(c in path for c in "*?["):
candidate = self.sandbox_dir / path
return [candidate] if candidate.exists() else []
return sorted(p for p in self.sandbox_dir.glob(path) if p.is_file())There is no escape hatch and no author opt-in — any path that contains a metacharacter is routed to Path.glob, where brackets become a character class. Verified on this machine (python 3.14 pathlib, same semantics as 3.13): with report[2024].json (real, content "literal") and report2.json (content "decoy") both present in the sandbox, d.glob("report[2024].json") returns ['report2.json']. So a criterion written as a plain literal path: "report[2024].json" now resolves to exactly one match — the WRONG file — and get_file_content silently grades the decoy with no error and no ambiguity warning. This is precisely the failure the PR's commit message says it prevents ("rather than silently grading one arbitrary file"). The inverse also verified: d.glob("logs[1]") returns [] while (d/'logs[1]').exists() is True, so file_exists flips True->False for an unchanged literal path and the criterion scores 0.0 for identical agent output.
Blast radius is wider than hand-written YAML: orchestration/task_loader.py:429-430 substitutes ${row.<field>} into every string leaf of success_criteria (data["success_criteria"] = [_substitute_row_in_tree(c, row) for c in data["success_criteria"]]), so a dataset row value carrying a [, *, or ? injects glob semantics into a path the task author wrote as a literal, per-row and invisibly.
Fix: make globbing an explicit opt-in rather than inferred from content — e.g. a separate path_glob field, or a glob: true flag on the criterion — or, at minimum, keep the literal-first fallback: if (sandbox_dir / path).exists() treat it as a literal regardless of metacharacters, and only glob when the literal does not exist. Add a regression test with a bracketed literal filename plus a decoy that the character class would match.
2. [Axis 8] Glob resolution walks the whole sandbox root with no ignore list, containment/symlink guard, or bound (src/coder_eval/sandbox.py:994) — return sorted(p for p in self.sandbox_dir.glob(path) if p.is_file()) walks the entire sandbox root with no exclusions. Verified empirically: d.glob("**/*.py") over a tree containing a.py, .hidden/b.py, __pycache__/c.py, .venv/lib/dep.py returns all four — pathlib's glob matches hidden directories too.
That root is not agent-authored territory. Sandbox._setup_virtualenv sets self.venv_dir = self.sandbox_dir / ".venv" (sandbox.py:520) for any task with a python: block (tasks/token_check.yaml:7, tasks/fibonacci_with_template.yaml:15, tasks/inline_starter_example.yaml:12, tasks/python_cli_simulated_judged/echo_simulated_judged.yaml:28, tasks/internal/session_resumption.yaml:20), and template/starter files are copied in before the agent runs. Two score-correctness consequences for IDENTICAL agent output:
- Fairness:
file_existswithpath: "**/*.py"returnsTrueoff.venv/lib/python3.13/site-packages/...or a copied starter file even when the agent wrote nothing.path: "**/*.json"is worse —node_modulesis wall-to-wallpackage.json. - Determinism: a content criterion (
file_contains/file_check/json_check) that resolves to exactly one agent-authored file on a cold sandbox resolves to many once the agent (or a retry, or a flakyuv pip install/npm install) materializes dependencies, andget_file_contentthen raises the ambiguityValueError-> score 0.0. Same deliverable, different score, decided by incidental filesystem state.
The module already imports the mechanism to fix this: from .resources import get_ignore_patterns, should_ignore_path (sandbox.py:18), whose default set includes .venv, __pycache__, node_modules, dist, build, *.pyc (src/coder_eval/resources/__init__.py:73,127). Filter glob matches through should_ignore_path(...) in resolve_files. Note the new test fixture deliberately sidesteps this: Sandbox(SandboxConfig(driver="tempdir", python=None), ...) at tests/test_glob_paths_in_file_criteria.py:26 creates no venv and no template, so the case that changes scores has no coverage.
Non-blocking, but please consider before merge
- [Axis 2] Path fields that gained glob semantics (json_check.path/json_schema, classification_match.path) still carry pre-glob Field descriptions (schema SSOT drift) (
src/coder_eval/models/criteria.py:381) — The PR updated four descriptions (FileExistsCriterion.pathL197,FileContainsCriterion.pathL209,FileMatchesRegexCriterion.pathL282,FileCheckCriterion.pathL413) but left every other field that flows through the sameSandbox.resolve_filesuntouched:
path: str = Field(description="Path to the JSON file (relative to sandbox root)") # L381
json_schema: str | None = Field(default=None, description="Path to JSON Schema file (relative to sandbox root)") # L382and ClassificationMatchCriterion.path at L603:
path: str = Field(description="Path to the file (relative to sandbox) containing the agent's predicted label")All three reach the new code: criteria/json_check.py:72,81 (path) and :144,147 (schema_path), criteria/classification_match.py:56. docs/TASK_DEFINITION_GUIDE.md:564 even advertises json_check as covered, so the doc and the model now contradict each other, and classification_match gained undocumented glob behavior. CLAUDE.md's design principle is "Single Source of Truth: Schema models are the authoritative source for parameter definitions" — update these three descriptions (and note the exactly-one-match rule for the content-reading ones) in the same edit as the other four.
2. [Axis 2] is_file() filtering only on the glob branch: directories match via a literal path but not via a glob, contradicting the new docstring (src/coder_eval/sandbox.py:994) — The docstring promises "directories are dropped so a glob cannot resolve to something unreadable" and the return contract is "Sorted matching files", but only one of the two branches honors it:
if not any(c in path for c in "*?["):
candidate = self.sandbox_dir / path
return [candidate] if candidate.exists() else [] # L990-992: directories kept, unsorted
return sorted(p for p in self.sandbox_dir.glob(path) if p.is_file()) # L994: directories droppedWith a directory build.flow in the sandbox: file_exists("build.flow") → True, file_exists("build.*") → False — the same target, opposite verdicts, decided by whether the string happens to contain a metacharacter. And get_file_content("build.flow") reaches matches[0].read_text(...) (L1023) on a directory and raises IsADirectoryError, which is absent from the Raises: block just added at L1006-1009 (RuntimeError / FileNotFoundError / ValueError). Either apply is_file() uniformly (return [candidate] if candidate.is_file() else []) — which also makes file_exists mean what its name says — or document the split; the current asymmetry makes the list[Path] return type mean "files" or "paths" depending on the argument. Note tests/test_glob_paths_in_file_criteria.py:63 covers only the glob half (test_glob_skips_directories).
3. [Axis 2] Absolute path containing a metacharacter makes file_exists non-total — Path.glob NotImplementedError escapes a documented -> bool API (src/coder_eval/sandbox.py:1034) — file_exists documents a pure boolean contract and previously could not raise:
Returns:
True if at least one file matches, False otherwise
"""
return bool(self.resolve_files(path))but it now delegates to Path.glob, which on Python 3.13.11 raises for non-relative patterns — verified: Path(tmp).glob('/etc/hosts') → NotImplementedError: Non-relative patterns are unsupported. Absolute criterion paths are a supported, in-use shape: the module NOTE at L965-968 says path validation is deliberately skipped, and tasks/byod_smoke_test.yaml:37 (path: /opt/byod_marker) plus tasks/dockerfile_build_example/dockerfile_build_example.yaml:66,73 (/opt/greeting.txt, /opt/secret_check.txt) use them today. A user following the new doc line ("Every path-based criterion ... accepts a glob in path") and writing path: /opt/*.txt gets an exception, not False — inside a criterion it is swallowed by handle_criterion_errors into a 0.0-scored error= result, and at evaluation/judge_context.py:291 the file_exists call sits outside the try that guards the subsequent get_file_content. Either handle the absolute case explicitly in resolve_files (glob from Path(path).anchor with the relative remainder, or return []/raise a typed ValueError with a clear message) and add it to the documented contract, or keep the function total by catching NotImplementedError/ValueError from glob and returning [].
4. [Axis 7] Ambiguous-glob ValueError enumerates every match unbounded and is duplicated into details/error (persisted to task.json, injected into judge prompts) (src/coder_eval/sandbox.py:1020) — The message interpolates the full match list with no cap:
listed = ", ".join(str(p.relative_to(self.sandbox_dir)) for p in matches)
raise ValueError(
f"Pattern '{path}' matches {len(matches)} files — refusing to guess which to grade: {listed}"
)The sandbox venv lives inside the graded root (sandbox.py:520: self.venv_dir = self.sandbox_dir / ".venv"), so a natural author pattern like path: "**/*.json" or "**/*.py" enumerates the whole .venv (and node_modules for JS tasks) — thousands of paths in one string. That string lands verbatim in CriterionResult.error and details (criteria/base.py:80-83), which is persisted into task.json/run.json and rendered by the report writers, and it is injected untruncated into an LLM judge prompt via evaluation/judge_context.py:299 (FileBlock(path=path, content=f"<error reading file: {e}>") — the truncate() call is only on the success branch at line 302). Cap the listing, e.g. show the first 10 sorted matches plus … and {len(matches) - 10} more; the count alone already tells the author the pattern is too broad.
5. [Axis 8] Glob docs section names a nonexistent import_check criterion and omits real glob-inheriting surfaces (classification_match, json_schema, judge artifact paths) (docs/TASK_DEFINITION_GUIDE.md:564) — The new section states: "Every path-based criterion (file_exists, file_contains, file_matches_regex, file_check, json_check, import_check) accepts a glob in path." import_check does not exist anywhere in the repo — grep -rn import_check over *.py/*.md/*.yaml hits only an unrelated example string in .claude/commands/coder-eval-implement-plan.md:124, and there is no criteria/import_check.py. A task author who copies that name gets a Pydantic discriminator failure.
The list is also under-inclusive in the direction that matters: three more surfaces route through the rewritten Sandbox methods and therefore now accept globs with no documentation and no author signal — classification_match (src/coder_eval/criteria/classification_match.py:56 raw = sandbox.get_file_content(criterion.path)), uipath_eval (src/coder_eval/criteria/uipath_eval.py:100), and the judge file lists (src/coder_eval/evaluation/judge_context.py:291,296 sandbox.file_exists(path) / sandbox.get_file_content(path), fed by LLMJudgeCriterion.files at src/coder_eval/models/criteria.py:694 and AgentJudgeCriterion.files at :856). classification_match is the sharpest of these: an ambiguous glob raises ValueError, which handle_criterion_errors converts to a plain CriterionResult (criteria/base.py:80-86) rather than the ClassificationCriterionResult the suite P/R/F1 overlay needs — the row drops out of the confusion matrix instead of scoring.
Finally, the model descriptions the doc claims parity with were only updated for four of the six listed types: JsonCheckCriterion.path still reads description="Path to the JSON file (relative to sandbox root)" (src/coder_eval/models/criteria.py:381), as does json_schema (:382). Fix the list (drop import_check, add the three real inheritors or explicitly scope them out), and update JsonCheckCriterion.path / json_schema / the two files descriptions to match.
Nits
- [Axis 2] resolve_files returns [] for an un-setup sandbox while get_file_content raises RuntimeError for the identical precondition (
src/coder_eval/sandbox.py:987) — The new public API silently absorbs a programming error:
if not self.sandbox_dir:
return []so resolve_files("**/*.flow") on an un-setup() sandbox is indistinguishable from a sandbox where nothing matched, while get_file_content (L1011-1012) raises RuntimeError("Sandbox not set up") for the identical state — two entry points, two contracts, one condition (tests/test_glob_paths_in_file_criteria.py:83-89 pins both). The -> list[Path] signature gives a caller no way to tell the cases apart, and any future consumer that reports "no file matched <pattern>" will mis-report a setup bug as an agent failure. Prefer raising the same RuntimeError from resolve_files and letting file_exists decide how to degrade (this mirrors, rather than extends, the pre-existing lenient file_exists behavior).
2. [Axis 3] test_matches_are_sorted is self-referential and passes vacuously on an empty result (tests/test_glob_paths_in_file_criteria.py:81) — FAILURE: a future edit drops the sorted(...) at src/coder_eval/sandbox.py:994; on a filesystem enumerating a/ before z/ this test still passes, and the determinism guarantee the docstring makes at line 977 ("Matches are sorted so grading is deterministic") ships unguarded. The single assertion is assert sandbox.resolve_files("**/*.flow") == sorted(sandbox.resolve_files("**/*.flow")) — it never states the expected order, so it can only fail if resolve_files stops sorting AND Path.glob's iteration order happens to be reversed on the test machine, making its regression-catching power filesystem-order-dependent. FIX: replace with an explicit expected list, e.g. assert sandbox.resolve_files("**/*.flow") == [sandbox.sandbox_dir / "a" / "first.flow", sandbox.sandbox_dir / "z" / "last.flow"], which is deterministic and documents the sort key.
3. [Axis 5] reference_comparison.agent_file is a sandbox-relative path field that bypasses the new seam, so path semantics now silently differ per criterion (src/coder_eval/models/criteria.py:447) — agent_file: str = Field(description="Path to agent's generated file (relative to sandbox root)") (models/criteria.py:447) reads through raw pathlib, not the seam: agent_path = sandbox.sandbox_dir / criterion.agent_file / if not agent_path.exists(): (criteria/reference_comparison.py:66-67) and agent_code = agent_path.read_text(encoding="utf-8") (:76). Before this PR every sandbox-relative path field behaved identically; now some glob-expand and one does not, with nothing in the schema distinguishing them — a user who tries agent_file: "**/*.py" gets error="Agent file not found: **/*.py" and a 0.0. Fix: either route reference_comparison through the same seam or add a note on the field that it is literal-only. This disappears entirely under finding 1's explicit-opt-in design, where a field either declares glob support or doesn't.
What's Missing
Parallel paths:
- 🟠
sandbox.pyalready owns two "which files count" filters —should_ignore_pathfor template copying (sandbox.py:490-494, patterns inresources/default_ignore_patterns.yaml:.venv,__pycache__,node_modules,dist,build) and the_EXCLUDEDset forcapture_to(sandbox.py:25) — but the newresolve_files(sandbox.py:994) extends neither, so grading globs walk.venv/node_modules/starter files that both sibling paths deliberately exclude. (trigger: src/coder_eval/sandbox.py) (restates: Axis 8: Glob resolution walks the whole sandbox root with no ignore list) - 🟡
reference_comparisonis the one path-based criterion left reading through raw pathlib (criteria/reference_comparison.py:66-76,sandbox.sandbox_dir / criterion.agent_file+.exists()+.read_text()), so after this PRagent_file: "**/*.py"errors withAgent file not foundwhile the identical pattern infile_containsresolves — path semantics now differ per criterion with nothing in the schema signalling it. (trigger: src/coder_eval/sandbox.py) (restates: Axis 5: reference_comparison.agent_file bypasses the new seam) - 🟡 Three surfaces silently inherited glob behavior without any accompanying update:
classification_match(criteria/classification_match.py:56),uipath_eval(criteria/uipath_eval.py:100), and the judge file lists (evaluation/judge_context.py:291,296fed byLLMJudgeCriterion.files/AgentJudgeCriterion.files) — none are documented, none got a field-description update, andclassification_matchonly catchesFileNotFoundError, so the new ambiguityValueErrorescapes. (trigger: docs/TASK_DEFINITION_GUIDE.md) (restates: Axis 8: Glob docs section names a nonexistent import_check and omits real glob-inheriting surfaces)
Display & mapping dicts:
- 🟡 The per-criterion parameter tables in the same guide were not extended for the new capability:
file_check'spathrow (docs/TASK_DEFINITION_GUIDE.md:641) andjson_check'spath/json_schemarows (:694-695) still read "Path to the file (relative to sandbox root)" with no glob or exactly-one-match note, and thellm_judge/agent_judge/classification_matchsections (:821,:882,:959) say nothing at all — a reader who lands on a criterion section instead of the new:562section sees the pre-PR contract. (trigger: docs/TASK_DEFINITION_GUIDE.md) (restates: Axis 2: Path fields that gained glob semantics still carry pre-glob Field descriptions)
Tests:
- 🟠 The new suite covers only 3 of the 6 criteria the docs advertise: there is no glob test for
file_matches_regex(criteria/file_matches_regex.py:43,51) orjson_check(criteria/json_check.py:72,81), and none at all forjson_check.json_schema— a second path field that now globs (criteria/json_check.py:144,147) and whose ambiguityValueErrorwould surface as a schema-load failure rather than a path failure. (trigger: tests/test_glob_paths_in_file_criteria.py) - 🟠 No test pins the behavior of a literal path that happens to contain
[,*, or?(e.g.app/[id]/page.tsx,report[2024].json) — the exact case whereresolve_files(sandbox.py:990) changes an existing task's verdict, and the case a regression test would have to encode before any future literal-first fallback can be added safely. (trigger: tests/test_glob_paths_in_file_criteria.py) (restates: Axis 8: String-sniffing*?[in resolve_files silently reinterprets a literal path as a glob) - 🟡 Zero absolute-path coverage (no
/opt,/etccase) even though in-tree tasks use absolute criterion paths (tasks/byod_smoke_test.yaml:37,tasks/dockerfile_build_example/…:66,73) andPath.globraisesNotImplementedErrorfor non-relative patterns — a test would have caught thatfile_existsstopped being total. (trigger: tests/test_glob_paths_in_file_criteria.py) (restates: Axis 2: Absolute path containing a metacharacter makes file_exists non-total) - 🟡 The fixture constructs
SandboxConfig(driver="tempdir", python=None)(tests/test_glob_paths_in_file_criteria.py:26,84), which creates no.venvand copies no template/starter files — so every glob test runs against a tree containing only agent-written files, and the one configuration that actually changes scores (a venv-bearing task where**/*.pyor**/*.jsonmatches hundreds of dependency files) is never exercised. (trigger: tests/test_glob_paths_in_file_criteria.py) (restates: Axis 8: Glob resolution walks the whole sandbox root with no ignore list) - 🟡 No test covers the judge-context path (
evaluation/judge_context.py:291,296), which now globsllm_judge.files/agent_judge.filesentries: neither the happy case (a pattern resolving to a file) nor the ambiguous case, wherefile_existsreturns True,get_file_contentraises, and the full untruncated match listing is embedded into the judge prompt as<error reading file: …>(judge_context.py:300, notruncate()on that branch). (trigger: tests/test_glob_paths_in_file_criteria.py) - 🔵
test_glob_skips_directories(:63) asserts only the glob half of the directory contract; there is no counterpart asserting what the literal branch does with a directory (file_exists("build.flow") -> True, thenget_file_contentraisingIsADirectoryError), so the branch asymmetry the docstring denies is unpinned in either direction. (trigger: tests/test_glob_paths_in_file_criteria.py) (restates: Axis 2: is_file() filtering only on the glob branch) - 🔵 All
TestGlobThroughCriteriacases call the syncSuccessChecker(...).check(...), but production grading goes exclusively throughcheck_all_async(orchestrator.py:1418,1476,1568) — the async surface CLAUDE.md calls primary has no glob coverage, so a future criterion that overrides_check_impl_asyncdirectly would ship its glob path untested. (trigger: tests/test_glob_paths_in_file_criteria.py)
Downstream consumers:
- 🟡 Nothing downstream records which file a glob resolved to:
file_exists.py:44andfile_contains.py:42interpolatecriterion.path(the pattern) intodetails/error, andjudge_context.py:292,300,302buildsFileBlock(path=path)from the pattern — so a passing**/*.flowcriterion intask.json, the reports, and the judge prompt is indistinguishable from any other, and a grading dispute can't be reconstructed from the run record. Resolving the pattern intodetails("matchedSol/Proj/Proj.flow") is the cheap fix. (trigger: src/coder_eval/sandbox.py) - 🟡
get_file_contentgained a new exception in its contract (ValueErroron ambiguity,sandbox.py:1017-1021) but no caller was audited for it:classification_match.py:56catches onlyFileNotFoundError, so theValueErrordegrades to a plainCriterionResultand the row is filtered out of the suite confusion matrix by theisinstance(r, ClassificationCriterionResult)guard in_classification_aggregate.py:44— a suite's accuracy / P / R / F1 denominators silently shrink instead of scoring the row 0.0. (trigger: src/coder_eval/sandbox.py) (restates: Axis 8: Glob docs section names a nonexistent import_check and omits real glob-inheriting surfaces) - 🔵 The unbounded ambiguity message reaches three sinks with different caps and none was adjusted:
reports.pytruncates at_FAILURE_REASON_MAX_LEN = 240(reports.py:35,861),criteria/base.py:81-82persists it twice (bothdetailsanderror) untruncated intotask.json, andjudge_context.py:300injects it untruncated into an LLM prompt. (trigger: src/coder_eval/sandbox.py) (restates: Axis 7: Ambiguous-glob ValueError enumerates every match unbounded)
Daily/nightly:
- 🟠 The PR rewrites
Sandbox.file_exists/get_file_content— the grading path every task in the nightly suite runs through (68path:declarations acrosstasks/) — with no statement of nightly impact. I verified none of those 68 in-repo paths currently contains*,?, or[, so in-tree nightly scores are unchanged today; that safety argument belongs in the PR body, and the un-audited part (out-of-treecoder-eval-uipathtask suites, plus${row.<field>}substitution injecting metacharacters into criterion paths per-row atorchestration/task_loader.py:430) is where the blast radius actually is. (trigger: src/coder_eval/sandbox.py) - 🟡
Sandboxis public API for out-of-tree plugins (thecoder_eval.pluginsSPI /coder_eval_uipath), and this PR changes its contract in two observable ways —get_file_contentnow raises a newValueErrorand a rewordedFileNotFoundError("No file matches …" instead of the OSError text), andfile_existsis no longer total — with no migration note indocs/and no mention in the commit message that third-party criteria catching onlyFileNotFoundErrorneed updating. (trigger: src/coder_eval/sandbox.py)
Harness & Lint Improvements
Static checks (lint / type):
- [ce-lint] CE032 — no raw sandbox path joins outside the Sandbox seam. New rule
tests/lint/rules/ce032_no_raw_sandbox_path_join.py(aBaseRule, wired intoALL_RULESintests/lint/runner.py), scoped tosrc/coder_eval/criteria/andsrc/coder_eval/evaluation/: forbidsandbox.sandbox_dir / <expr>andPath(sandbox.sandbox_dir, …); all sandbox file access must go throughSandbox.resolve_files/file_exists/get_file_content. Verified feasibility: a grep oversrc/coder_eval/at HEAD returns exactly ONE violation —src/coder_eval/criteria/reference_comparison.py:66(agent_path = sandbox.sandbox_dir / criterion.agent_file) — so the rule lands green after the one fix, with# noqa: CE032available for a deliberate literal-only reader. Prevents:reference_comparison.agent_filebypassing the new glob seam so path semantics silently differ per criterion (models/criteria.py:447 / criteria/reference_comparison.py:66-76), and any future criterion re-introducing a second, divergent path-resolution path. - [ce-lint] CE033 — path fields on criterion models must state their path semantics. Registry-driven check (not an AST
BaseRule; wire like CE030 astests/lint/doc_schema_parity.py-style module +tests/test_custom_lint.py::TestCE033PathFieldSemantics): for every member of theSuccessCriterionunion, everystr/list[str]field whose name matchespath|_file$|^files$|dir|schemamust have aField(description=…)containing the wordgloborliteral. I enumerated the affected set — 9 fields today (FileExistsCriterion.path,FileContainsCriterion.path,FileMatchesRegexCriterion.path,FileCheckCriterion.path,JsonCheckCriterion.path,JsonCheckCriterion.json_schema,ReferenceComparisonCriterion.agent_file,ClassificationMatchCriterion.path,LLMJudgeCriterion.files,AgentJudgeCriterion.files) — small enough to annotate once; the intmax_file_charsfields are excluded by the type filter. This makes the CLAUDE.md "schema models are the authoritative source" rule mechanical instead of aspirational. Prevents: The schema SSOT drift finding:JsonCheckCriterion.path/json_schema(models/criteria.py:381-382) andClassificationMatchCriterion.path(:603) keeping pre-glob descriptions while their code paths (criteria/json_check.py:72,81,144,147; criteria/classification_match.py:56) gained glob semantics; also the undocumented glob behavior of the two judgefilesfields (:694, :856). - [ce-lint] CE034 — criterion type names in docs must exist in
CriterionRegistry. New doc-surface checktests/lint/doc_criterion_names.py+tests/test_custom_lint.py::TestCE034DocCriterionNames(same non-BaseRulewiring as CE027–CE031, since it reasons over Markdown). Algorithm, prototyped and measured: scandocs/**/*.mdfor runs of comma/and/|-separated backticked snake_case tokens; when ≥2 tokens in a run ARE registered criterion types, every other snake_case token in that run must also be registered. Measured onmain: 0 findings across all ofdocs/(no false positives). Run against the PR's sentence it reports exactly['import_check']. Cheap variant/extension: also validate- type: <name>lines inside fenced YAML blocks against the registry (complements CE029, which only checks whole-document examples). Prevents:docs/TASK_DEFINITION_GUIDE.md:564advertising a nonexistentimport_checkcriterion (a task author copying it gets a Pydantic discriminator failure), and the same class of stale/fabricated criterion name in any future doc list. - [ce-lint] CE035 — no self-referential assertions in tests. New rule
tests/lint/rules/ce035_no_self_referential_assert.pywired intotests/lint/runner.py, scoped totests/: flagassert A == Bwhere one side contains anast.Callstructurally identical (byast.dump) to a call on the other side — e.g.assert f(x) == sorted(f(x)),assert sorted(g()) == sorted(g()). No existing gate covers this: ruff's enabled set isE/F/I/N/W/UP/B/SIM/RUF, andB015only fires on a bare comparison statement, never inside anassert;flake8-pytest-styleis not enabled and has no equivalent either. Fix guidance in the message: state the expected value explicitly. Prevents:tests/test_glob_paths_in_file_criteria.py:81(assert resolve_files(p) == sorted(resolve_files(p))), which passes vacuously on an empty result and can only fail if the OS happens to enumerate in reverse — so the documented sort-determinism guarantee (sandbox.py:977) ships effectively unguarded. - [ce-lint] Shared glob helper + CE036 — non-literal glob patterns must go through it. Extract
glob_relative(root: Path, pattern: str) -> Iterator[Path]intosrc/coder_eval/path_utils.py, handling the absolute-pattern case the waysrc/coder_eval/cli/run_helpers.py:88ALREADY does (Path(pattern.anchor).glob(pattern.relative_to(anchor))) — the fix for the absolute-glob crash is literally already in-tree, just not shared. Then addtests/lint/rules/ce036_glob_through_helper.py(wired intests/lint/runner.py) forbidding.glob(<non-literal>)/.rglob(<non-literal>)anywhere insrc/coder_eval/except inside that helper. Migration cost measured: 4 non-literal call sites (sandbox.py:994,cli/run_helpers.py:88,90,agents/codex_agent.py:1802,logging_config.py:373), each either migrated or# noqa: CE036with a reason. Side benefit: the metacharacter/literal policy then lives in one audited place instead of being re-decided per call site. Prevents: The absolute-path finding —file_exists('/opt/*.txt')raisingNotImplementedError: Non-relative patterns are unsupportedout of a documented-> boolAPI (sandbox.py:1034), for a path shape shipped tasks use today (tasks/byod_smoke_test.yaml:37,tasks/dockerfile_build_example/…:66,73) — and it centralizes the*?[sniffing behind one reviewable helper. - [ce-lint] CE037 (low priority) — uniform
self.sandbox_dirprecondition insandbox.py. Rule: anif not self.sandbox_dir:guard whose body returns a falsy default instead ofraise RuntimeError("Sandbox not set up")is a violation, requiring an explicit# noqa: CE037 — <why lenient>. Honest cost/benefit note: this is NOT green today —file_exists(sandbox.py:998) andlist_files(:1012) are pre-existing lenient guards, so adopting it means one policy decision plus two annotated exceptions. Its value is exactly that: it converts an accidental split contract into a declared one. Prevents: The finding thatresolve_filesreturns[]for an un-setup()sandbox whileget_file_contentraisesRuntimeErrorfor the identical precondition (sandbox.py:987 vs :1011) — an un-setup bug that a future "no file matched<pattern>" message would mis-report as an agent failure.
Harness improvements (not statically reachable):
- Adversarial-filename round-trip test for
resolve_files. Intests/test_glob_paths_in_file_criteria.py, add a parametrized fixture that creates real files whose names contain glob metacharacters —report[2024].jsonalongside a decoyreport2.json,app/[id]/page.tsx(a Next.js dynamic route),logs[1],what?.txt,star*.md— and assert the invariant: for every file created,file_exists(<its own relative path>)isTrueandget_file_content(<same>)returns that file's own content (never a decoy's). Why not static: The defect is pathlib's runtime matching semantics against real on-disk names; no AST rule can know that[2024]in a YAML string will be reinterpreted as a character class, and the decoy-grading path needs a filesystem to exist at all. Prevents: The high-severity literal-reinterpretation finding at sandbox.py:990 — a plain literalpathcontaining[/*/?silently grades the wrong file (one match, so the ambiguity guard never trips) or flipsTrue→False; amplified per-row by${row.*}substitution at orchestration/task_loader.py:430. - Literal-vs-glob branch equivalence test (directories + error contract). Assert that for the same target the two
resolve_filesbranches agree: with a directorybuild.flowpresent,file_exists("build.flow")andfile_exists("build.*")must return the same value, andget_file_contenton a directory must raise one of the documented exceptions in theRaises:block (sandbox.py:1006-1009), not a bareIsADirectoryError. Why not static: It is a behavioral equivalence between two code paths over real inode types (file vs. directory); a lint rule cannot tell that.exists()and.is_file()diverge only for directories. Prevents: The literal/globis_file()asymmetry finding (sandbox.py:990-994) where the docstring's "directories are dropped" and "Returns: Sorted matching files" contract is honored by only one branch — currently covered on the glob side only (test_glob_skips_directories, tests/…:63). - Realistic sandbox fixture for the glob tests (venv / node_modules / template files) + a scoring-determinism assertion. The current fixtures use
SandboxConfig(driver="tempdir", python=None)(tests/test_glob_paths_in_file_criteria.py:26,84), which materializes no.venvand no starter files — precisely the state in which the defect cannot appear. Add a fixture withpython:set (or a synthetic.venv/lib/.../dep.py,node_modules/pkg/package.json,__pycache__/, plus copied starter files) and assert: (a)file_exists("**/*.py")isFalsewhen the agent wrote nothing; (b) afile_contains/json_checkcriterion scores identically before and after dependencies are installed into the same sandbox. Why not static: Requires a materialized dependency tree and two grading passes over changing filesystem state — the whole point is that the score changes for identical agent output depending on incidental sandbox contents. Prevents: The high-severity unfiltered-glob finding (sandbox.py:994 walks the graded root with noshould_ignore_pathfilter, whileself.venv_dir = self.sandbox_dir / ".venv"lives inside it, sandbox.py:520) — both the fairness half (falseTrueoff.venv/starter files) and the determinism half (a content criterion flipping to the ambiguityValueError→ 0.0 after auv pip install). - Cap
CriterionResult.error/detailsat the single sink, with a size-budget test. Truncate incriteria/base.py::_failed_result(reuseevaluation/judge_context.truncate) and add a test asserting noerror/detailsstring persisted totask.jsonexceeds a documented budget; separately cap the listing insandbox.py:1018-1020to the first ~10 sorted matches plus… and N more. Why not static: I prototyped the source-side lint ("unbounded", ".join(...)interpolated into araise") and it flags 7 existing sites insrc/, all of which join over closed sets (schema field names, variant ids) and are legitimately bounded. Distinguishing an FS-derived collection from a closed one needs runtime knowledge, so a lint rule here would be pure false-positive noise — the sink cap is the correct enforcement point. Prevents: The unbounded ambiguous-glob message finding:**/*.jsonover a sandbox containing.venv/node_modulesenumerates thousands of paths into one string, stored twice intask.json(criteria/base.py:81-82) and injected untruncated into an LLM-judge prompt viaevaluation/judge_context.py:300. - Absolute-path coverage in the glob tests.
tests/test_glob_paths_in_file_criteria.pycontains no absolute-path case (zero occurrences of/opt,/etc, or "absolute"), yet shipped tasks use absolute criterion paths and the module deliberately skips path validation (NOTE at sandbox.py:965-968). Add cases for an absolute literal (must stay total →False) and an absolute glob (must return[]or raise a typed, documented error — neverNotImplementedError). Why not static: Totality of a-> boolfunction against a third-party library's runtime exception is not expressible in the type system; pyright seesPath.globas non-raising. Prevents: The absolute-glob finding at sandbox.py:1034, where a documented-legal input degrades into an opaqueerror="NotImplementedError: Non-relative patterns are unsupported"0.0-scored result at both the criterion and judge-context call sites. - Judge-prompt golden test for the file-block error branch. Add a test that builds a judge context where
get_file_contentraises (ambiguous glob), and assert the resultingFileBlockcontent is bounded by the samemax_file_charsbudget as the success branch — todayevaluation/judge_context.py:300interpolates the raw exception while only line 302 truncates. Why not static: A lint rule requiring "both branches calltruncate" is too narrow to justify a CE number and would not generalize; the real assertion is about the rendered prompt, which needs the context builder to actually run. Prevents: The judge-prompt half of the unbounded-error finding, and any future error string reaching a paid LLM call untruncated.
Top 5 Priority Actions
- Restore literal-first resolution in
resolve_files(src/coder_eval/sandbox.py:990): a plain path containing[,*, or?— e.g. a Next.js routeapp/[id]/page.tsx, or a${row.<field>}value substituted per-row at src/coder_eval/orchestration/task_loader.py:430 — is silently reinterpreted as a pattern, reproducibly grading a decoy file or flippingfile_existsTrue→False for unchanged agent output; make globbing an explicit opt-in (glob: true/path_glob) or fall back to the literal when(sandbox_dir / path).exists(), plus a bracketed-literal-with-decoy regression test. - Filter glob matches through the already-imported
should_ignore_path(src/coder_eval/sandbox.py:994; import at :18, patterns in src/coder_eval/resources/default_ignore_patterns.yaml):**/*.py/**/*.jsoncurrently match.venv(created at sandbox.py:520 for any task with apython:block) andnode_modules, sofile_existspasses on files the agent never wrote, and a content criterion that resolved to one file on a cold sandbox hits the ambiguityValueError→ 0.0 once dependencies materialize. - Make
classification_matchglob-safe (src/coder_eval/criteria/classification_match.py:56): it catches onlyFileNotFoundError, so an ambiguous-globValueErrorescapes to@handle_criterion_errorsand yields a plainCriterionResult(src/coder_eval/criteria/base.py:116-117, 77-83) instead of aClassificationCriterionResult, which theisinstancefilter at src/coder_eval/criteria/_classification_aggregate.py:44 drops entirely — silently removing the row from suite P/R/F1 rather than scoring it. - Harden the
resolve_filescontract (src/coder_eval/sandbox.py:987-1020): applyis_file()on the literal branch too (990-992) so directories don't passfile_existsonly when the path lacks a metacharacter, keep the function total for absolute globs like/opt/*.txt(documented-legal per tasks/byod_smoke_test.yaml:37, butPath.globraisesNotImplementedError→ opaque 0.0error=result at :1034 and src/coder_eval/evaluation/judge_context.py:291), raise instead of returning[]on an un-setup sandbox (:987), and cap the ambiguity message's match listing (:1018-1020) which is persisted twice into task.json and injected untruncated into judge prompts (judge_context.py:300). - Fix the doc/schema single-source-of-truth drift: docs/TASK_DEFINITION_GUIDE.md:564 advertises a nonexistent
import_checkcriterion (a copy-paste yields a Pydantic discriminator failure), omits the real new glob inheritors (classification_match,uipath_eval,json_schema, and thellm_judge/agent_judgefileslists), and steers authors toward broad**/*globs with no.venv/node_modulescaveat, whileJsonCheckCriterion.path/json_schema(src/coder_eval/models/criteria.py:381-382),ClassificationMatchCriterion.path(:603), and the literal-onlyReferenceComparisonCriterion.agent_file(:447) still carry pre-glob descriptions; also replace the vacuous self-referential sort assertion at tests/test_glob_paths_in_file_criteria.py:81 with an explicit expected ordering and add venv/absolute-path coverage.
Stats: 0 🔴 · 2 🟠 · 5 🟡 · 3 🔵 across 8 axes reviewed.
bai-uipath
left a comment
There was a problem hiding this comment.
Right seam to put this on, and requiring exactly one match for content reads is the correct call over max/min-over-files, which would invent a scoring rule and let a staged fixture satisfy a check. One thing needs to land before merge; the rest are optional.
Major
The glob runs unfiltered over the sandbox root at grading time. pathlib doesn't skip dotdirs, and the skills post_run prune of node_modules/.venv runs in teardown, after evaluation, so a **/*.json pattern matches vendored files and grades or hard-fails on them; **/*.flow works today only because the extension is rare, which makes this latent rather than absent. Fix: filter the glob branch through the existing get_ignore_patterns/should_ignore_path helpers in the resources module (already used for the template copy), passing the sandbox-relative path since they match on any path component. Worth a line in the guide that dist and build are in that default set, with ignore_patterns: ["!dist"] as the escape hatch.
Fix if you agree, otherwise lgtm
- The follow-up migration should glob away only the segment the prompt leaves free. The failure is an unknown wrapper prefix, not an unknown filename, so
**/<Name>.flowdrops the prefix and stays unique; a blanket**/*.flowturns exactly-one into a hard 0.0 on any task carrying a second flow file. - Nothing records which file was graded. With exactly-one semantics the message is most of the feature: put the resolved path in
detailson the passing path too, and cap the ambiguity list at ~10 with+N moresince it persists into task.json. - Minor: try the literal path first and only fall back to globbing, so a real filename containing
[or?isn't silently reinterpreted;import_checkin the guide isn't a criterion type, whilejson_checkandclassification_matchinherited the behavior and still carry pre-glob field descriptions;reference_comparison's agent file reads the sandbox directly and bypasses the new seam; the sortedness test asserts againstsorted()of its own output.

Problem
A criterion
pathis a literal, so a task must hardcode where an artifact lands. When the prompt does not pin that location, a correct artifact scores 0.0 on the path alone.Real case from the UiPath/skills smoke gate.
uip maestro flow init <Name>scaffolds a wrapper solution directory whose name the prompt never specifies. The agent choseInvoiceApprovalSolution/, the task assertedInvoiceApproval/InvoiceApproval/InvoiceApproval.flow:run_command— validate the discovered flow"Status": "Valid"file_exists— hardcoded pathfile_contains— hardcoded pathTask scored 0.375 on a valid artifact and failed the ≥95% pass-rate gate. The workaround was a per-repo helper script that globs and shells out — one more thing to maintain, and it only fixes the tasks that adopt it.
Change
pathresolves through a newSandbox.resolve_files. A pattern containing*,?, or[expands against the sandbox root; a literal path is untouched.Because every path-based criterion already routed through
sandbox.file_exists/sandbox.get_file_content, they all inherit this from one change point:file_exists,file_contains,file_matches_regex,file_check,json_check,import_check.Semantics:
file_existspasses on at least one match.ValueErrorlisting every match —ValueErroris not in_ESCALATING_EXCEPTIONS, sohandle_criterion_errorscaptures it as a scored-0.0 result with the message. An ambiguous pattern is reported, never silently resolved to one arbitrary file.pathbehaves exactly as before — no existing task changes meaning.Verification
tests/test_glob_paths_in_file_criteria.py— unit coverage ofresolve_files(literal, glob hit, glob miss, ambiguous, directory-skipping, sort order, unset sandbox) plus end-to-end coverage throughSuccessCheckerforfile_exists/file_contains/file_check, including the ambiguous-glob 0.0-with-message path.ANTHROPIC_API_KEY, Windows-only).ruff format --check,ruff check,pyright(0 errors), and the custom-lint suite (166 passed) all clean.Follow-up in UiPath/skills
Once this releases and the
tests/.coder-eval-versionpin moves off0.8.8, the interim helper script is deleted and ~19 task files with hardcoded<Name>/<Name>/<Name>.flowpaths collapse topath: "**/*.flow".