Skip to content

fix(eval-runner): harden fixture isolation with parent-held digests - #101

Closed
Haven2026 wants to merge 3 commits into
bmad-code-org:mainfrom
Haven2026:codex/harden-eval-fixtures
Closed

fix(eval-runner): harden fixture isolation with parent-held digests#101
Haven2026 wants to merge 3 commits into
bmad-code-org:mainfrom
Haven2026:codex/harden-eval-fixtures

Conversation

@Haven2026

@Haven2026 Haven2026 commented Jul 24, 2026

Copy link
Copy Markdown

Summary

Hardens bmad-eval-runner fixture staging so eval evidence cannot be trusted after path escape, symlink escape, or same-UID mutation.

Changes

  • Split containment/attestation into fixture_integrity.py
  • Reject absolute/.. sources; contain sources to trusted roots; block destination symlink escape
  • Keep immutable fixture bytes in the parent process until after adapter exit
  • Persist fixture-evidence/ only post-adapter; reject pre-existing evidence paths
  • Trust boundary is parent-held artifact_digest, not chmod 0400 (same UID can still rewrite)
  • Re-validate snapshot+manifest against that digest before results are accepted
  • Exclusive run-directory creation; fail-closed CLI exit on integrity/exception
  • Adversarial tests (path/symlink/mutation/manifest forge/digest tamper/exclusive dir)

Test plan

  • python -m pytest skills/bmad-eval-runner/scripts/tests/ -q → 23 passed

Context

Addresses fixture isolation gaps tracked downstream as haven-studio/cexing#5361 (Product Experience Audit graduation blocker).

Summary by CodeRabbit

  • New Features

    • Added trusted fixture evidence snapshots and manifests to evaluation runs.
    • Added fixture integrity verification to detect tampering, unsafe paths, symlink escapes, and post-run mutations.
    • Evaluation results now include fixture evidence digests.
  • Bug Fixes

    • Grading failures now produce explicit errors instead of default verdicts.
    • Run directories are protected against collisions.
    • Evaluation commands fail closed when integrity or execution errors occur.
  • Tests

    • Added coverage for fixture safety, tampering detection, evidence validation, and run-directory exclusivity.

Split fixture containment into fixture_integrity, re-validate evidence
with an adapter-unreachable digest, exclusive run dirs, and fail-closed
CLI exits. chmod is no longer treated as the immutability boundary.
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The eval runner adds trusted fixture resolution, staging verification, immutable evidence snapshots, manifest validation, and fixture digests. It rejects unsafe paths and mutations, records explicit integrity failures, prevents run-directory collisions, and documents trusted evidence usage for grading.

Changes

Fixture integrity pipeline

Layer / File(s) Summary
Fixture resolution and staging
skills/bmad-eval-runner/scripts/fixture_integrity.py, skills/bmad-eval-runner/scripts/run_evals.py, skills/bmad-eval-runner/references/eval-format.md, skills/bmad-eval-runner/scripts/tests/test_fixture_integrity.py
Fixture paths are limited to trusted relative sources, symlink escapes are rejected, and staged content is checked against recorded hashes.
Evidence persistence and case attestation
skills/bmad-eval-runner/scripts/fixture_integrity.py, skills/bmad-eval-runner/scripts/run_evals.py, skills/bmad-eval-runner/SKILL.md, skills/bmad-eval-runner/scripts/tests/test_fixture_integrity.py
Fixture snapshots and manifests are persisted and validated, with evidence digests recorded for adapter, skipped, and missing-adapter cases.
Runner failure and run-directory controls
skills/bmad-eval-runner/scripts/run_evals.py, skills/bmad-eval-runner/scripts/tests/test_fixture_integrity.py
Run IDs include random entropy, run directories are exclusive, integrity failures are surfaced explicitly, and failed statuses produce exit code 1.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers: bmadcode

Poem

A rabbit guards the fixture gate,
Hashes hop in, paths stay straight.
Snapshots rest beneath the run,
Tampered files are swiftly shunned.
“Fail closed!” the bunny sings,
While trusted evidence grows wings.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately captures the main change: stronger fixture isolation with parent-held digests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
skills/bmad-eval-runner/scripts/run_evals.py (1)

588-603: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

resolve_fixtures runs outside the try/except it's meant to be caught by — a bad fixture path crashes the whole run.

resolve_fixtures(...) is invoked in the main thread as an argument inside the pool.submit(...) dict comprehension (lines 589-593), not inside the callable executed by the worker. Any FixtureError it raises (absolute path, .. traversal, symlink escape, missing fixture, duplicate destination) propagates straight out of main(), never reaching the new except FixtureError/except Exception handlers at lines 600-606. The result: one case with an unsafe "files" entry aborts the entire run — no execution-summary.json is written and every other case's already-computed result is discarded — instead of surfacing a per-case fixture-integrity-error as the rest of this PR is designed to guarantee. No test in test_fixture_integrity.py exercises this path (all adversarial-path tests call resolve_fixtures directly rather than through main()).

🐛 Proposed fix — resolve fixtures inside the submitted callable
+    def _run_job(c, case_dir, adapter, timeout, config_name, config_skill):
+        fixtures = resolve_fixtures(c.get("files", []), project_root,
+                                     cases_file.parent)
+        return run_case(c, case_dir, run_dir, adapter, timeout, config_name,
+                         config_skill, fixtures)
+
     with ThreadPoolExecutor(max_workers=max(1, args.workers)) as pool:
         fut_to_case = {
-            pool.submit(run_case, c, case_dir, run_dir, adapter,
-                        int(c.get("timeout", args.timeout)), config_name,
-                        config_skill,
-                        resolve_fixtures(c.get("files", []), project_root,
-                                         cases_file.parent)): c
+            pool.submit(_run_job, c, case_dir, adapter,
+                        int(c.get("timeout", args.timeout)), config_name,
+                        config_skill): c
             for config_name, c, case_dir, config_skill in jobs
         }
         for fut in as_completed(fut_to_case):
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@skills/bmad-eval-runner/scripts/run_evals.py` around lines 588 - 603, Move
the resolve_fixtures call out of the pool.submit argument construction and into
the submitted worker flow, such as run_case or a wrapper around it, so
FixtureError is raised while processing each future. Preserve the existing
per-case FixtureError handling around fut.result() and ensure an invalid fixture
produces a fixture-integrity-error result without aborting other cases or
preventing execution-summary.json generation.
🧹 Nitpick comments (1)
skills/bmad-eval-runner/scripts/tests/test_fixture_integrity.py (1)

112-147: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add CLI-level coverage for an unsafe files entry in the cases file.

Every adversarial-path test here calls resolve_fixtures directly or mutates the source only after a successful resolve. None exercises main() with a case whose "files" entry is itself unsafe (absolute/../missing) — exactly the scenario affected by the resolve_fixtures/pool.submit ordering issue flagged in run_evals.py. Adding a test like the following would pin the expected fixture-integrity-error status through the full CLI path once that's fixed:

def test_cli_fails_when_case_files_contain_traversal(tmp_path):
    project = tmp_path / "project"
    project.mkdir()
    cases = project / "cases.json"
    cases.write_text(json.dumps({"cases": [{
        "id": "traversal",
        "input": "test",
        "rubric": [],
        "files": ["../outside.txt"],
    }]}), encoding="utf-8")
    skill = tmp_path / "skill"
    skill.mkdir()
    (skill / "SKILL.md").write_text("# test", encoding="utf-8")
    output = tmp_path / "output"

    exit_code = run_evals.main([
        "--cases", str(cases),
        "--skill-path", str(skill),
        "--project-root", str(project),
        "--output-dir", str(output),
        "--workers", "1",
        "--quiet",
    ])

    assert exit_code == 1
    summary_path = next(output.glob("*/execution-summary.json"))
    summary = json.loads(summary_path.read_text(encoding="utf-8"))
    assert summary["results"][0]["status"] == "fixture-integrity-error"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@skills/bmad-eval-runner/scripts/tests/test_fixture_integrity.py` around lines
112 - 147, Add CLI-level coverage in the test suite for an unsafe case files
entry by adding a test alongside test_cli_fails_when_adapter_mutates_fixture
that invokes run_evals.main with a traversal path such as "../outside.txt" and
no adapter. Assert the CLI exits with 1 and the generated execution summary
reports fixture-integrity-error for the case.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@skills/bmad-eval-runner/scripts/fixture_integrity.py`:
- Around line 48-55: Update _contained to catch resolution failures from
path.resolve(strict=True), including broken-symlink FileNotFoundError, and
re-raise them as FixtureError while preserving the existing trusted-root escape
handling and label/path context.

---

Outside diff comments:
In `@skills/bmad-eval-runner/scripts/run_evals.py`:
- Around line 588-603: Move the resolve_fixtures call out of the pool.submit
argument construction and into the submitted worker flow, such as run_case or a
wrapper around it, so FixtureError is raised while processing each future.
Preserve the existing per-case FixtureError handling around fut.result() and
ensure an invalid fixture produces a fixture-integrity-error result without
aborting other cases or preventing execution-summary.json generation.

---

Nitpick comments:
In `@skills/bmad-eval-runner/scripts/tests/test_fixture_integrity.py`:
- Around line 112-147: Add CLI-level coverage in the test suite for an unsafe
case files entry by adding a test alongside
test_cli_fails_when_adapter_mutates_fixture that invokes run_evals.main with a
traversal path such as "../outside.txt" and no adapter. Assert the CLI exits
with 1 and the generated execution summary reports fixture-integrity-error for
the case.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8747278e-7cf3-4892-8be7-bbc003e8977a

📥 Commits

Reviewing files that changed from the base of the PR and between e6935f2 and 01aa076.

📒 Files selected for processing (5)
  • skills/bmad-eval-runner/SKILL.md
  • skills/bmad-eval-runner/references/eval-format.md
  • skills/bmad-eval-runner/scripts/fixture_integrity.py
  • skills/bmad-eval-runner/scripts/run_evals.py
  • skills/bmad-eval-runner/scripts/tests/test_fixture_integrity.py

Comment on lines +48 to +55
def _contained(path: Path, root: Path, label: str) -> Path:
root = root.resolve()
resolved = path.resolve(strict=True)
try:
resolved.relative_to(root)
except ValueError as exc:
raise FixtureError(f"{label} escapes trusted root: {path}") from exc
return resolved

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Broken-symlink source raises a bare OSError, not FixtureError.

path.resolve(strict=True) is unguarded, so a source that is a broken/dangling symlink raises FileNotFoundError instead of FixtureError. Callers that key off except FixtureError to classify fixture problems (see run_evals.py) will instead see this bubble up as a generic exception, losing the explicit fixture-integrity-error status the PR is meant to guarantee for every path/symlink hazard.

🛡️ Proposed fix
 def _contained(path: Path, root: Path, label: str) -> Path:
     root = root.resolve()
-    resolved = path.resolve(strict=True)
+    try:
+        resolved = path.resolve(strict=True)
+    except OSError as exc:
+        raise FixtureError(f"{label} cannot be resolved: {path}") from exc
     try:
         resolved.relative_to(root)
     except ValueError as exc:
         raise FixtureError(f"{label} escapes trusted root: {path}") from exc
     return resolved
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _contained(path: Path, root: Path, label: str) -> Path:
root = root.resolve()
resolved = path.resolve(strict=True)
try:
resolved.relative_to(root)
except ValueError as exc:
raise FixtureError(f"{label} escapes trusted root: {path}") from exc
return resolved
def _contained(path: Path, root: Path, label: str) -> Path:
root = root.resolve()
try:
resolved = path.resolve(strict=True)
except OSError as exc:
raise FixtureError(f"{label} cannot be resolved: {path}") from exc
try:
resolved.relative_to(root)
except ValueError as exc:
raise FixtureError(f"{label} escapes trusted root: {path}") from exc
return resolved
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@skills/bmad-eval-runner/scripts/fixture_integrity.py` around lines 48 - 55,
Update _contained to catch resolution failures from path.resolve(strict=True),
including broken-symlink FileNotFoundError, and re-raise them as FixtureError
while preserving the existing trusted-root escape handling and label/path
context.

@Haven2026 Haven2026 closed this by deleting the head repository Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant