Skip to content

feat(evals): Karpathy spike driver for /recce-verify v1 eval (DRC-3586) - #37

Merged
even-wei merged 4 commits into
feature/drc-3584-sandbox-profilesfrom
feature/drc-3586-karpathy-spike
May 29, 2026
Merged

even-wei merged 4 commits into
feature/drc-3584-sandbox-profilesfrom
feature/drc-3586-karpathy-spike

Conversation

@even-wei

Copy link
Copy Markdown

Stacked on #36 (DRC-3584 sandbox profiles + DRC-3430 Recce-aware strip). The driver invokes those sandbox profiles per cell, so the base branch must merge first; this PR rebases onto main automatically once #36 lands.

Summary

Closes DRC-3586. Single-file Python driver at evals/agent-blind-spots/spike-driver/driver.py (~290 lines, stdlib only) that:

  • Dispatches up to 6 fixtures × 2 agents × 2 tiers = 24 cells via subprocess to claude --print and codex exec.
  • Stamps the Tier-0 / Tier-1 sandbox profile from PR feat(evals): Tier-0/Tier-1 sandbox profiles + Recce-aware strip (DRC-3584, DRC-3430) #36 into each fixture worktree before invoking the agent.
  • Captures each transcript under runs/<date>/spike-driver/transcripts/.
  • Runs a Claude-as-judge pass on each transcript with the locked RUBRIC.md → JSON verdict on the three rubric axes (catch, primary evidence tier, counterfactual delta).
  • Writes verdicts.csv, summary.md, and re-judgeable cells.json per run.

This is the spike, not the durable harness. If the spike's stability + sandbox checks pass, the next ticket (DRC-3587) ports to Inspect AI.

Two stability modes (both optional)

Mode Flag What it gives you
Self-consistency --judge-stability Double-judges each transcript; reports per-axis agreement. Floor: 80% per axis (catch / tier / delta). Below = judge too noisy to replace hand-grading at N=6.
Judge vs human --baseline-dir <path> Compares judge's Tier-0 catch verdict to DRC-3585's manual baseline once it lands. This is the canonical check from the ticket; self-consistency is the proxy until 3585 produces baselines.

Without either, the driver still produces the full 24-cell verdict matrix — useful for inspecting agent behaviour even before the judge is trusted.

Graceful degradation

  • CLI missing: if claude or codex isn't on PATH, the relevant cells skip with a recorded error rather than crashing. Lets you run claude-only or codex-only without flags.
  • Per-fixture worktree missing: cells whose .tmp/sources/<slug>/ isn't built record an error pointing at build_fixtures.sh. No silent half-runs.
  • --no-run: re-judges existing transcripts in --run-dir without re-running agents. Cheap iteration on judge-prompt changes.

Codex-under-sandbox check (DRC-3586 acceptance #3)

After a run, grep transcripts/*_codex_*.txt for:

  • mcp__recce__* references in Tier-0 → leak (MCP table is empty in runner-configs/codex/tier-0/config.toml).
  • recce shell calls in Tier-0 → exit was non-zero (PATH scrub).
  • Reads of ../README.md or ../../RUBRIC.md → leak (cwd separation).
  • dbt shell calls in either tier → exit was non-zero (PATH scrub).

spike-driver/README.md documents the grep recipe.

What's in here

evals/agent-blind-spots/spike-driver/
├── README.md      # usage, prereqs, output layout, stability check rationale
└── driver.py      # ~290-line stdlib-only Python driver

No changes to runner-configs/, ENFORCEMENT.md, RUBRIC.md, build_fixtures.sh, or fixtures/. The spike consumes what #36 produced; it does not modify the contract.

Verification

  • python3 -m py_compile evals/agent-blind-spots/spike-driver/driver.py → ok.
  • python3 driver.py --help → renders full usage block.
  • End-to-end smoke run (--smoke) requires claude on PATH + a built fixture worktree; recipe documented in spike-driver/README.md. Not executed in this background job — first smoke run is part of operator acceptance for this PR.

Non-goals (explicit, per ticket)

  • Durable harness with resume / parallel dispatchDRC-3587 (Inspect AI port).
  • Gap-report generator across 6 fixturesDRC-3405 (eval run).
  • Auto-iteration on prompt / skill changes — closed-loop optimization overfits at N=6 (explicit non-goal of the spike per RUBRIC.md framing).

Linear chain

DRC-3430 + DRC-3584  ← PR #36 (in review)
        ↓ blocks
DRC-3585  Lock rubric via 6×2×2 manual run         ← Backlog (canonical predecessor)
        ↓ blocks
DRC-3586  Karpathy-style spike (~200-line driver)  ← THIS PR
        ↓ blocks
DRC-3587  Port to Inspect AI durable harness       ← Backlog (gated on spike outcome)
        ↓ blocks
DRC-3405  Run eval + produce ranked gap report     ← Todo

Note: per Linear, DRC-3586 is blocked-by DRC-3585. Shipping the driver code first is a pragmatic move — it can be reviewed and merged independently, and the --baseline-dir flag plugs in once 3585's manual baseline lands. Self-consistency mode (--judge-stability) is useful immediately.

🤖 Generated with Claude Code

even-wei added 3 commits May 29, 2026 17:34
Single-file Python driver at evals/agent-blind-spots/spike-driver/. Dispatches
up to 6 fixtures x 2 agents x 2 tiers = 24 cells, captures each agent's
transcript, runs a Claude-as-judge pass per transcript to produce a three-axis
verdict (catch / tier / delta), and writes a CSV + Markdown summary under
runs/<date>/spike-driver/.

Stability checks (both optional, both produce judge-quality signal):
- --judge-stability: double-judges each transcript, reports per-axis
  self-consistency (catch / tier / delta). Floor 80%.
- --baseline-dir <path>: compares judge verdicts against DRC-3585 manual
  baseline once it lands (judge-vs-human catch agreement on Tier-0 cells).

Graceful degradation:
- Skips an agent's cells if its CLI is not on PATH; codex commonly absent
  in lighter dev envs (recipe in runner-configs/codex/tier-{0,1}/README.md).
- --no-run mode re-judges existing transcripts without re-running agents.

Sandbox profile integration (from DRC-3584):
- Claude Code cells stamp tier-N claude-overlay/ into the fixture worktree
  before invoking `claude --print`, with a neutered CLAUDE_CONFIG_DIR per
  cell and scrubbed warehouse env.
- Codex cells use `codex exec --sandbox=<read-only|workspace-write>
  --ask-for-approval=never --config <tier-N/config.toml>` plus PATH scrub.

Non-goals for this spike (deferred):
- Durable harness with resume / parallel dispatch -> DRC-3587 Inspect AI port.
- Gap-report generator across 6 fixtures -> DRC-3405.
- Auto-iteration on prompt / skill changes (closed-loop optimization overfits
  at N=6 -- explicit non-goal of the spike).

Stacks on PR #36 (DRC-3584 + DRC-3430). When #36 merges to main, this PR
should be rebased onto main; GitHub auto-retargets the base branch.

Signed-off-by: even-wei <evenwei@infuseai.io>
The agent's cwd is .tmp/sources/<id>/ (the per-fixture standalone repo),
but the frozen Tier-0 inputs (diff.patch, artifacts/{manifest, compiled,
catalog}*) live at fixtures/<id>/, outside cwd. Without staging:

- Claude Code reaches them via absolute paths (Read tool isn't cwd-anchored),
  but the prompt doesn't tell the agent where to look.
- Codex Tier-0 read-only sandbox blocks reads outside cwd entirely, so the
  agent literally cannot reach the artifacts.

Driver now symlinks fixtures/<id>/{diff.patch, artifacts} into a
_eval_inputs/ subdir under cwd before each cell. Prompt updated to point
at _eval_inputs/. Works under both agent sandboxes.

Verified locally:
  >>> stage_inputs(SOURCES_DIR / 'pr1-fix-clv', 'pr1-fix-clv')
  >>> sorted(p.name for p in (SOURCES_DIR / 'pr1-fix-clv' / '_eval_inputs').iterdir())
  ['artifacts', 'diff.patch']

Signed-off-by: even-wei <evenwei@infuseai.io>
ENFORCEMENT.md recipe step 3 sets CLAUDE_CONFIG_DIR to a fresh mktemp dir
to neuter user-level ~/.claude/settings.json. That step also strips the
Claude Code auth state, so a child `claude --print` invoked by the spike
driver fails with "Not logged in" and no transcript ever lands.

For unattended runs, the load-bearing enforcement is the project-level
.claude/settings.json overlay (stamped per cell) plus the PreToolUse hook
(deny-tier-N.py); a user-level `permissions.allow` cannot bypass an
exit-2 hook. Skip the CLAUDE_CONFIG_DIR override by default.

For paranoid mode (e.g. when running the eval on a machine with risky
~/.claude/ contents), set RECCE_EVAL_STRICT_CONFIG=1 to enable the
override. The operator is responsible for preseeding auth under the
per-cell _claude_cfg dir.

Verified end-to-end smoke run after this fix:
  uv run driver.py --smoke --agents claude --tiers 0
    [run]   pr1-fix-clv · claude · tier-0
    [judge] pr1-fix-clv · claude · tier-0
  Agent VERDICT: catch · request-changes
  Judge verdict: {catch, tier-0, same}  (delta=same expected with no T1 paired)

Signed-off-by: even-wei <evenwei@infuseai.io>
@even-wei

Copy link
Copy Markdown
Author

Smoke validation — first end-to-end run produces a verdict ✓

Ran the driver from this session against the worktree's prebuilt fixtures (symlinked from the sibling DRC-3584 worktree's .tmp/ to save rebuild time):

uv run evals/agent-blind-spots/spike-driver/driver.py --smoke --agents claude --tiers 0
[run]   pr1-fix-clv · claude · tier-0
[judge] pr1-fix-clv · claude · tier-0
[output] verdicts: runs/2026-05-29/spike-driver/verdicts.csv
[output] summary:  runs/2026-05-29/spike-driver/summary.md

Agent transcript ending:

VERDICT: catch · request-changes

Judge verdict:

{"catch": "catch", "tier": "0", "delta": "same"}

Driver returned 0 across the cell. Three driver gaps surfaced and were fixed inline:

  1. Frozen Tier-0 inputs not reachable from cwd (c8952c4). fixtures/<id>/artifacts/ lives outside the agent's cwd at .tmp/sources/<id>/. Added stage_inputs() to symlink fixtures/<id>/{diff.patch, artifacts} into _eval_inputs/ under the cwd, and updated the prompt to point at that path. Works for both Claude Code (which doesn't anchor Read on cwd) and Codex (which does — its Tier-0 read-only sandbox would otherwise reject absolute paths to artifacts).

  2. CLAUDE_CONFIG_DIR=$(mktemp -d) from ENFORCEMENT.md recipe step 3 stripped auth (95a1bee). First smoke produced "Not logged in · Please run /login" in the transcript. The override is incompatible with unattended runs. Now skipped by default; opt back in via RECCE_EVAL_STRICT_CONFIG=1 (operator must preseed auth under the per-cell _claude_cfg dir). Documented the rationale in spike-driver/README.md § "Auth note".

  3. (No third gap — the rest worked out of the box.)

What this proves vs. what's still open

DRC-3586 acceptance Status after smoke
1. Single end-to-end run produces complete per-fixture scoring artifacts ✅ proven on 1 cell — driver structure / staging / judge call / CSV-write all functional
2. Judge stability check vs DRC-3585 manual baseline (≥80% per axis) ⏸ blocked on DRC-3585 producing the baseline. Run --judge-stability for self-consistency in the meantime — single-cell self-consistency is meaningless.
3. Codex-under-sandbox check ⏸ codex CLI is present on this machine but not exercised in this smoke. Next step: smoke with --agents codex --tiers 0.
4. Driver code committed to spike-driver/
5. Spike write-up ⏸ pending after full 24-cell run

Recommended next runs (operator)

# Codex smoke under Tier-0 sandbox (verifies acceptance #3)
uv run evals/agent-blind-spots/spike-driver/driver.py --smoke --agents codex --tiers 0

# Full smoke matrix (4 cells: 1 fixture × 2 agents × 2 tiers)
uv run evals/agent-blind-spots/spike-driver/driver.py --smoke --judge-stability

# Full 24-cell run after DRC-3585's baseline lands
uv run evals/agent-blind-spots/spike-driver/driver.py \
    --baseline-dir evals/agent-blind-spots/fixtures/

The agent's first transcript is at runs/2026-05-29/spike-driver/transcripts/pr1-fix-clv_claude_t0.txt — worth a quick read to sanity-check the prompt-shape and verdict structure before scaling to 24 cells.

@even-wei

even-wei commented May 29, 2026

Copy link
Copy Markdown
Author

Code Review: PR #37 (iter-2)

SHA 40282ee · Verdict GO · Incremental (vs prior review at 95a1bee)

Reviewed only commit 40282ee since the prior review. All three iter-1 ISSUEs are resolved cleanly; no new findings.

Verification of iter-1 fixes

  1. VERDICT_TAIL_RE removedgrep -n VERDICT_TAIL_RE driver.py returns no matches. import re legitimately retained (used at scrub_path L128 and parse_baseline_dir L304).

  2. .gitignore surgical — verified via git check-ignore -v:

    • runs/*/spike-driver/transcripts/sample.txt → ignored (L54)
    • runs/*/spike-driver/_claude_cfg/foo → ignored (L55)
    • runs/*/spike-driver/verdicts.csv → NOT ignored
    • runs/*/spike-driver/summary.md → NOT ignored
    • runs/*/spike-driver/cells.json → NOT ignored
      Matches the intent: transient outputs ignored, baseline-trackable artifacts preserved.
  3. stage_inputs() raises — behavioral test of all three branches passes:

    • src_root missing → FileNotFoundError: fixture dir missing: ... (L147).
    • src_root exists but no diff.patch / artifacts/FileNotFoundError: no Tier-0 inputs under ... (L163–166), with dst.rmdir() cleanup so _eval_inputs/ is not left orphaned.
    • Happy path with diff.patch only → symlinks created.
      Caller at L244–248 catches and records into cell.error; the summary writer (L363) surfaces it as the cell status.

Quality checks

  • python3 -m py_compile evals/agent-blind-spots/spike-driver/driver.py — ok.
  • ruff check evals/agent-blind-spots/spike-driver/driver.py — all checks passed.

Notes

  • NOTE-level findings 4–7 from iter-1 (judge env inheritance, same-day run-dir collision, broken-symlink in overlay, no tests) were accepted as-is and remain unchanged. Spike framing per evals/agent-blind-spots/RUBRIC.md is calibrated for non-adversarial code agents; durable harness work tracked in DRC-3587.
  • Pre-existing edge case at L154 (dst.mkdir() would fail if dst is a regular file rather than a dir/symlink) is not introduced by this commit and not raised as a finding — the path is only reachable through build_fixtures.sh output, which doesn't produce regular files at that name.

This iter-2 confirms iter-1 fixes resolved the original 3 ISSUEs. Ready to merge once the base PR #36 lands.

@even-wei even-wei left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

NO-GO at 95a1bee — 3 issues, 4 notes. Full review: #37 (comment)

1. Drop dead VERDICT_TAIL_RE regex. Defined in driver.py:99 but never
   used; agent verdict parsing is fully handled by judge_cell()
   downstream. Removed alongside the regex line.

2. Add surgical .gitignore entries for spike-driver runtime outputs:
     evals/agent-blind-spots/runs/*/spike-driver/transcripts/
     evals/agent-blind-spots/runs/*/spike-driver/_claude_cfg/
   These are the genuinely-transient subdirs. verdicts.csv,
   summary.md, cells.json under runs/<date>/spike-driver/ are
   intentionally NOT ignored — they may be committed manually as
   run baselines for cross-iteration tracking.

3. stage_inputs() now raises FileNotFoundError when no Tier-0
   inputs (neither diff.patch nor artifacts/) could be staged under
   the fixture's source dir. run_cell() catches and records into
   cell.error so the cell shows up as failed in the matrix instead
   of silently feeding the agent an empty _eval_inputs/ and letting
   it review with no inputs.

Verified:
  python3 -m py_compile spike-driver/driver.py  -> ok
  stage_inputs(.../pr1-fix-clv, 'pr1-fix-clv')  -> happy path
  stage_inputs(/tmp/dne, 'does-not-exist')      -> raises FileNotFoundError

NOTE-level findings 4-7 from the review are accepted as-is per the
spike framing (judge inherits parent env intentionally for auth;
same-day run dirs are an operator concern; broken-symlink in
overlay copy is rare and surfaced via run_cell's general except;
no tests is consistent with the Karpathy-spike scope and the
DRC-3587 follow-up which adds Inspect AI test harness).

Signed-off-by: even-wei <evenwei@infuseai.io>
@even-wei

Copy link
Copy Markdown
Author

pr-cycle iteration 1 — review addressed

Thanks for the clean pass. All three ISSUEs fixed in 40282ee; NOTE-level findings accepted as-is per the spike framing (rationale below).

ISSUEs — fixed

# File Fix
1 driver.py:99 (dead VERDICT_TAIL_RE) Removed. The regex was a vestige of an earlier design that parsed the agent's tail line; judge_cell() is now the only verdict source.
2 .gitignore (missing runs/ patterns) Added surgical entries for runs/*/spike-driver/transcripts/ + runs/*/spike-driver/_claude_cfg/. The verdicts.csv / summary.md / cells.json under runs/<date>/spike-driver/ are intentionally not ignored — they may be committed manually as run baselines, which is how cross-iteration tracking works once 3585 lands.
3 driver.py:144 (`stage_inputs` silently empties) Now raises `FileNotFoundError` if neither `diff.patch` nor `artifacts/` could be staged. `run_cell()` catches and records `cell.error` so the matrix shows the cell as failed instead of the agent reviewing with no inputs. Verified: `stage_inputs(/tmp/dne, 'does-not-exist')` → raises with a clear message pointing at `build_fixtures.sh`.

NOTEs — accepted as-is

  • Judge inherits unscrubbed parent env (`driver.py:264`). Intentional — the judge needs auth and Recce isn't relevant to it. Scrubbing the agent env (cells under `run_claude`/`run_codex`) is the contract; the judge is an independent subprocess.
  • Same-day runs share `runs//spike-driver/`. Operator concern. If running multiple full matrices same-day, prefix `--run-dir runs/-iter2/spike-driver/`.
  • `shutil.rmtree(overlay_dst)` on broken symlink. Rare in practice (overlay_dst is reset every cell). If this becomes a real failure mode, we'll add a targeted handler.
  • No tests. Consistent with the Karpathy-spike framing (~290 lines, single file, throwaway-shaped). The Inspect AI port in DRC-3587 is the durable harness — that's where pytest coverage belongs.

Quality checks

Check Result
`python3 -m py_compile spike-driver/driver.py` ok
`stage_inputs(.../pr1-fix-clv)` happy path ok
`stage_inputs(/tmp/dne, 'does-not-exist')` raises FileNotFoundError with actionable message
`ruff check` (default) clean (no project ruff config)

Closing out iteration 1. Ready for next pass or merge.

@even-wei even-wei left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

iter-2 verdict: GO at 40282ee. All three iter-1 ISSUEs resolved; no new findings. See updated review comment for details.

@even-wei
even-wei merged commit 1d3c95d into feature/drc-3584-sandbox-profiles May 29, 2026
@even-wei
even-wei deleted the feature/drc-3586-karpathy-spike branch May 29, 2026 10:22
even-wei pushed a commit that referenced this pull request Jun 3, 2026
…3584, DRC-3430) (#36)

* feat(evals): Tier-0/Tier-1 sandbox profiles + Recce-aware strip (DRC-3584, DRC-3430)

Make the /recce-verify v1 eval's Tier-0 / Tier-1 split enforceable
rather than aspirational, so the lens-3 counterfactual delta (Tier-0
verdict → Tier-1 verdict) actually isolates Recce's contribution.

DRC-3584 — sandbox profile templates under runner-configs/:
- Claude Code: per-tier .claude/{settings.json, hooks/deny-tier-N.sh}.
  permissions.deny + a PreToolUse hook (belt-and-suspenders per open
  Claude Code issue #6699). Tier 0 denies Recce CLI, Recce MCP namespaces
  (mcp__recce__*, mcp__plugin_recce_*), /recce-* skills, dbt subcommands
  that regenerate frozen Tier-0 inputs, and direct SQL clients. Tier 1
  allows Recce CLI + MCP but keeps dbt regen and raw SQL clients denied.
- Codex: per-tier README + config.toml. Process sandbox flag
  (--sandbox=read-only / workspace-write) + an empty (Tier 0) or
  templated (Tier 1) mcp_servers table + a PATH scrub recipe to drop
  recce/dbt binaries.
- ENFORCEMENT.md: per-agent recipes, the agent-view-restriction folded
  from PR #28 follow-up, what's mechanically enforced vs. contract-only,
  and the recording-in-baseline requirement.
- RUBRIC.md: adds the agent-view-restriction paragraph pointing at
  ENFORCEMENT.md.
- templates/tier-0-baseline.md: adds the required "Sandbox profile
  used" block in Notes; missing block disqualifies the baseline.
- runs/2026-05-28/sandbox-verification.md: synthetic hook unit tests
  (Tier-0 6 cells, Tier-1 4 cells) all pass; one end-to-end claude
  --print × Tier-0 confirms the PreToolUse hook fires on a real agent
  attempt at `recce check`. CC × Tier-1, Codex × Tier-0, Codex × Tier-1
  cells deferred to the operator with recipes.

DRC-3430 (bundled because the DRC-3584 verify step is contaminated
without it) — build_fixtures.sh:
- Strip .github/prompts/, .github/workflows/recce-*.yml,
  .github/workflows/claude.yml, recce.yml from each per-fixture source
  tree right after the head-SHA checkout.
- Belt-and-suspenders post-strip grep for
  mcp__recce__|recce.yml|RECCE_API_TOKEN with --exclude-dir=.git.
- Initial run surfaced claude.yml (a "Claude Code + Recce MCP" reviewer
  workflow that wasn't on the original strip list); now stripped. All
  six fixtures rebuild cleanly with empty post-strip grep.
- fixtures/README.md documents the strip list and rationale.

gitignore exception added so the runner-configs/**/.claude/ templates
can be committed under the existing repo-wide .claude/ ignore.

Signed-off-by: even-wei <evenwei@infuseai.io>

* fix(evals): address PR #36 review — Python hooks + expanded strip (DRC-3584, DRC-3430)

Review found 6 BLOCKERs + 4 ISSUEs + 3 NOTEs against the v1 sandbox
profiles. Every blocker was an exit-0 from the case-glob hook on an
input the rubric said must be exit-2. This commit collapses them.

Hook rewrite — Python with shlex tokenization (B1+B2+B3+B4+B5+I10):
- New `deny-tier-{0,1}.py` (replacing `.sh`). Splits commands on shell
  metacharacters (`;`, `&`, `|`, `()`, newline) plus `$()` and backtick
  substitutions, shlex-parses each segment, basenames the executable
  (so `/usr/local/bin/recce` is caught), and recurses into `sh -c` /
  `bash -lc` / `zsh -c` arguments. Skill match is now case-insensitive.
  MCP namespace regex broadened to `^mcp__(plugin_)?recce(_|-|$)` so
  `mcp__recce_dev__*` and future variants are covered.
- Tier-0 flipped to a positive allowlist (git/grep/jq/file-read +
  POSIX text utilities). The legitimate Tier-0 surface is tiny per
  RUBRIC.md; allowlist is safer than chasing bypass shapes.
- Tier-1 stays a denylist but the matching is principled. dbt's
  banned subcommands are detected anywhere after `dbt`, so
  `dbt --debug parse` and `dbt --profiles-dir /tmp parse` are caught.
- `runs/2026-05-28/sandbox-verification.md` grew a Bypass attempts
  table with every reviewer shape; all rows pass exit-2 against v2.

ENFORCEMENT.md fix (B6):
- Corrected the false claim that the hook blocks
  `Bash(cat ../../<host-path>)`. The hook does not gate Read/Grep/Glob
  and `cat` is in the Tier-0 allowlist. Spoiler-path protection comes
  from cwd separation alone — the recipe's `cd "${FIXTURE_DIR}"` step
  is therefore not optional. Document says so now.
- Reframed the `permissions.deny` + hook relationship: the hook is the
  load-bearing layer, not a backup. `permissions.deny` is
  documentation that mirrors the documented surface, not the full
  bypass coverage.

Gitignore restructure (I8):
- Renamed `runner-configs/claude-code/tier-{0,1}/.claude/` →
  `claude-overlay/`. Per gitignore docs, you can't re-include a file
  under an excluded directory, so the v1 broad un-ignore would have
  let arbitrary files under `.claude/` slip through. Renaming
  sidesteps the `.claude/` ignore rule entirely; the eval runner now
  copies `claude-overlay/` → `.claude/` (one extra arg to `cp -r`).
  Gitignore exception removed.

Strip + leak-grep expansion (I9+N11):
- Strip list grew with `.devcontainer/`, `.github/mcp_config.json`,
  `.github/workflows/{recce_*.yml, recce_*.yaml, dbt_base.yml,
  dbt-build-pr.yml, dbt-build-base.yml}` — surfaced by the broadened
  leak grep on the first re-build.
- Leak grep broadened from `mcp__recce__|recce\.yml|RECCE_API_TOKEN`
  to case-insensitive `mcp__recce|recce\.yml|RECCE_API_TOKEN|recce`
  with `profiles.yml` whitelisted (`role: RECCE` is the Snowflake
  role name, not Recce-the-tool priming, and dbt needs the file).
- `printf '  %s\n' ${leak_hits}` (unquoted) replaced with
  `sed 's/^/  /' <<< "${leak_hits}"` — robust against whitespace in
  filenames.

Other doc fixes (N12, N13):
- Tier-1 hook header notes Recce MCP is intentionally not gated.
- `runner-configs/README.md` calls out that `permissions.deny` and
  the hook now intentionally cover different surfaces.
- Maintenance section updated for the v2 layout.

Build verified end-to-end after every change. The Bypass attempts
table in `runs/2026-05-28/sandbox-verification.md` covers all 16
hook unit tests plus 1 end-to-end claude --print cell — every row
green.

Signed-off-by: even-wei <evenwei@infuseai.io>

* fix(evals): close cycle-review bypasses (Tier-1 dbt flag-with-value + exec wrappers)

The pr-cycle reviewer found 2 new BLOCKERs in v2 that the prior review
missed. Both are static hook-logic bugs; v3 fixes them with two small
changes plus a regex tweak.

BLOCKER 1 — Tier-1 dbt-flag-with-value bypass (`deny-tier-1.py`):
  `has_denied_dbt_subcommand` skipped `-`-prefixed tokens but never
  consumed the value following a value-bearing flag, so the value
  became the "first positional" and the real subcommand was never
  inspected. Eight verified shapes:
    dbt --target dev parse        → was exit 0, now exit 2
    dbt --profiles-dir /tmp parse → was exit 0, now exit 2
    dbt --project-dir /x run      → was exit 0, now exit 2
    dbt --vars "x: 1" parse       → was exit 0, now exit 2
    dbt --log-format json parse   → was exit 0, now exit 2
    dbt --log-level debug parse   → was exit 0, now exit 2
    dbt --printer-width 80 parse  → was exit 0, now exit 2
    dbt --profile myprof parse    → was exit 0, now exit 2

  Fix: scan EVERY token after `dbt` for a banned subcommand, not just
  the "first positional". False positives require the agent to pass a
  literal banned-subcommand name as a flag value (e.g. `--target parse`),
  which is perverse and would deserve denial anyway since Tier-1
  warehouse access is supposed to be mediated through Recce MCP.

  Also expanded DBT_DENIED_SUBCOMMANDS with `run-operation`, `debug`,
  `source` — all hit the warehouse and were missing from the prior list.

BLOCKER 2 — exec-wrapper bypass (both hooks):
  `xargs recce` / `find -exec recce` / `time recce` / `nohup recce`
  spawned a denied binary as a child process; the hook only inspected
  the wrapper's head token and let the call through. Tier-0's positive
  allowlist let `xargs` and `find` pass; Tier-1's denylist saw a
  non-DENIED_BINS head and stopped looking.

  Fix: new EXEC_WRAPPERS set covers
    xargs, time, nice, nohup, setsid, parallel, exec, timeout, watch,
    ionice, chrt, stdbuf
  plus `find` (which needs special handling because its wrapped command
  lives after `-exec`/`-execdir`). For each wrapper at the head of a
  segment, walk the args for the first executable-shaped token and
  recurse-check from there. Heuristic `looks_like_executable` skips
  numeric positionals (timeout duration, chrt priority), flags, and
  placeholders (`{}`, `[]`).

Regex fix (split_segments):
  `(?<!\\)[;&|()\n]+` — negative lookbehind on backslash so escaped
  separators (`find -exec ... \;`) stay attached to the same segment.
  Without this, `find -exec dbt parse \;` would split at the `;` and
  shlex would error on the trailing backslash; the malformed-payload
  path would silently allow.

Verification:
  All v3 cells exit-2 in red-team testing. Bypass attempts table in
  `runs/2026-05-28/sandbox-verification.md` grew from 16 to 38 rows
  covering every reviewer-named bypass (B1-B6, I7, cycle B1, cycle B2)
  plus counter-claim regressions (Recce/MCP/dbt-list allowed at T1,
  benign xargs/find at T0). Build re-verified across all 6 fixtures —
  the broader leak grep + expanded strip still pass clean.

Closes the cycle review's BLOCKER 1 and BLOCKER 2. Addresses ISSUE 3
(per-tier disambiguation now in the table) and ISSUE 4 (collapsed by
BLOCKER 1's fix).

Signed-off-by: even-wei <evenwei@infuseai.io>

* fix(evals): close iter-2 bypasses (eval / \$() smuggling / dbt clone+retry)

The pr-cycle iter-2 review surfaced three more bypass classes at
Tier-1 — all real, none caught by v3. v4 closes them.

1. `eval` shell-builtin smuggling
   `eval dbt run` / `eval "dbt run"` were exit 0 because `eval` was
   not in SHELL_WRAPPERS. Fix: added `eval` to Tier-0's SHELL_WRAPPERS
   (banned outright since the positive allowlist has no legit reason
   for a shell) and added a dedicated `eval ARGS...` branch in
   Tier-1's check_tokens that joins the args and recurses into the
   resulting command — same threat surface as `sh -c`, different
   syntactic shape.

2. `$()` / backtick substitution at command-head position
   `$(echo dbt) run`, `` `echo dbt` run ``, `$(printf %s dbt) parse`
   evaluate to `dbt run` / `dbt parse` at bash runtime; the
   substitution provides the head binary, which the static
   check_tokens path can't see because the substitution's content
   has been stripped from the outer segment. Fix: new
   `check_substitution_at_head` in Tier-1 pre-walks each segment for
   `$(...)` / backtick at head and, if the substitution payload
   contains a denied binary name, denies the outer command. For dbt,
   it also combines payload tail + outer args to decide whether a
   denied subcommand is present (so `$(echo dbt)` alone is still
   allowed — bare dbt is discovery-only).

   Segmentation for this pre-check uses `(?<!\\)[;&|\n]+` (no
   parens) so `$(...)` stays intact. The main split_segments still
   includes parens because it pre-extracts substitutions before
   splitting.

3. Missing dbt subcommands `clone` and `retry`
   `dbt clone` (dbt-core ≥1.6) materialises cloned models in the
   warehouse. `dbt retry` re-executes the prior failed command, so
   it inherits any deny semantics of that prior command. Both added
   to DBT_DENIED_SUBCOMMANDS.

False-positive sanity (all exit 0):
  git log --grep=\$(echo psql)      — $() at non-head position
  echo \$(date)                     — benign substitution
  \$(which python) script.py        — non-denied binary
  \$(echo grep) -rn foo .           — non-denied binary
  \$(echo dbt)                      — dbt alone (allowed)
  echo \$(echo dbt) is good         — dbt in non-head $()

All v3 / v2 regressions still pass. Bypass attempts table in
`runs/2026-05-28/sandbox-verification.md` grew with the iter-2 cells
plus the new false-positive counter-claims. Build re-verified across
all 6 fixtures.

Signed-off-by: even-wei <evenwei@infuseai.io>

* refactor(evals): hooks → bashlex AST parser (DRC-3584 v5, closes iter-3 bypasses)

The pr-cycle iter-3 review surfaced 9 more bypass classes that the
shlex+regex hooks couldn't model — all real, all rooted in Bash
semantics shlex doesn't represent.

Iter-3 BLOCKERs:
  1. Nested $() — `$(sh -c "$(echo dbt) run")`, `$($(echo dbt) run)`,
     `` `$(echo dbt) run` `` (single-level regex misses nesting)
  2. `eval $(...)` — substitution stripped before eval branch ran
  3. `xargs $(echo dbt)` — wrapper-at-head + substitution-as-arg;
     find_wrapped_command saw no executable-shaped token
  4. `dbt $(echo run)` — substitution supplied the subcommand;
     has_denied_dbt_subcommand only scanned outer literal tokens
  5. Bash ANSI-C $'dbt' / parameter expansion ${a:-dbt} — shlex
     passed through as opaque basenames that didn't equal 'dbt'
  6. `coproc dbt run` — Bash keyword not in any wrapper set
  7. `command dbt run` / `builtin eval dbt run` — Bash command
     modifiers not handled
  8. `! dbt run` — Bash logical-not prefix not matched
  9. `xargs -I {} sh -c "{} parse" dbt` — xargs substitutes dbt into
     {} at runtime; hook saw only the literal {}

Rather than patch each class with another regex, this commit
**replaces the regex/shlex approach with `bashlex` (a real Bash AST
parser)**. Substitutions are walked as a tree; ANSI-C / parameter
expansion are extracted from their AST nodes; Bash keywords (!,
command, builtin) are handled as transparent prefixes; `coproc` is
denied outright (bashlex can't represent it).

New dependency: `bashlex` (pure Python, MIT, well-maintained). Both
hooks fail closed if bashlex isn't installed — better to deny a
legit call than silently allow a bypass. ENFORCEMENT.md + runner-
configs README document the install step (`python3 -m pip install
bashlex`).

Architecture:
  Two passes per command (in walk()):
    1. WALK INTO every $()/backtick substitution as if it were a
       top-level command. Catches `$(sh -c "dbt run")` because the
       inner sh -c "dbt run" is itself denied.
    2. RESOLVE the head word of each command to candidate output
       strings (literal text, ANSI-C decoded via raw-position lookup,
       parameter defaults, substitution payload words). At Tier 0,
       ALL candidates must be in the allowlist — `$(echo dbt) run`
       resolves to ['echo','dbt'] and 'dbt' isn't allowlisted, so
       deny. At Tier 1, any candidate matching a denied binary
       (with denied subcommand visible anywhere in args, including
       substitution payloads) denies.

  Exec wrappers (xargs/find -exec/time/nohup/etc.) now scan EVERY
  wrapped-command word's candidates for a denied basename — exec
  wrappers supply args from stdin/find-output, so "bare dbt is
  allowed" reasoning doesn't apply under a wrapper.

  Shell wrappers (sh -c, bash -lc, ...) reparse the LITERAL word
  text of the -c arg (not its substitution candidates) so the
  inner command structure stays intact. Catches nested $(sh -c
  "$(echo dbt) run").

Verification: 51 cells across both tiers. Every iter-1/iter-2/iter-3
BLOCKER closed. False-positive sanity for benign $() at non-head,
$(echo grep), $(which python), echo $(date), command grep, find
without -exec, xargs grep, etc. — all allow correctly.

Build re-verified across all 6 fixtures.

Closes the iter-3 9 BLOCKER classes. The reviewer's recommended
follow-up (pytest-parameterized over the bypass matrix) is filed as
an out-of-PR improvement.

Signed-off-by: even-wei <evenwei@infuseai.io>

* fix(evals): close iter-4 BLOCKERs (process sub / CompoundNode / brace / xargs-chain / \$0)

Iter-4 cycle review found 7 BLOCKERs + 5 ISSUEs against v5's bashlex
hooks — all rooted in AST-walker gaps inside the bashlex code (not
in bashlex itself). v6 closes them.

BLOCKERs:

1+7. **Process substitution `<(...)` / `>(...)`** — `_walk_substitutions`
   only handled `commandsubstitution` kind. Now also handles
   `processsubstitution` (same `.command` attribute). Catches
   `diff <(dbt run) /etc/hosts` (head-position) AND
   `echo x 2> >(dbt parse)` (process sub inside a RedirectNode —
   `walk()` now also iterates `kind == 'redirect'` parts and walks
   their `.output` word's substitutions).

2. **CompoundNode.list** — bashlex's subshell `(...)` and group
   `{ ...; }` store children in `.list`, not `.parts`. The walker's
   descent path now tries `.list` first then `.parts`, so
   `(dbt parse)` and `{ dbt parse; }` are no longer silently
   allowed.

3+4. **Brace expansion** — bashlex returns `{dbt,bash}` and
   `{parse,run}` as literal `word` text with no `parts`. New
   `_expand_brace_literal()` regex decodes `prefix{a,b,c}suffix`
   into candidate list (`['prefix.a.suffix', 'prefix.b.suffix',
   ...]`). Used by `resolve_word` for both head and args.

   Multi-candidate head fix: `{dbt,bash} run` resolves to
   `{'dbt','bash'}`. Before v6, the shell-wrapper branch fired on
   `bash` and returned without checking the dbt arm. Now an
   eager-deny up front checks dbt+args and direct DENIED_BINS
   BEFORE the shell-wrapper / eval / exec-wrapper branches —
   any candidate matching a denied shape denies regardless of
   what other candidates are present.

5. **xargs → sh -c chain** — `echo dbt | xargs -I {} sh -c "{} parse"`
   exec-wrapper found `sh` as wrapped, but `sh` isn't in DENIED_BINS
   so the wrapper-scan exited without escalating. v6 adds: if the
   wrapped binary IS a shell wrapper, find the `-c` arg; if the
   xargs `-I` placeholder appears inside the shell body,
   conservatively deny (placeholder substitution from stdin is
   dynamic — can't be statically resolved, and the pattern has very
   narrow legit use). Otherwise re-parse the literal -c text.

6. **`$0` head** — bashlex parses `$0` as ParameterNode(value='0').
   `resolve_word`'s parameter branch only handled `:-`/`:=` defaults.
   Now also handles `0`, `BASH`, `SHELL`, `BASH_SOURCE` — these
   resolve at runtime to a shell name; we surface them as the
   candidate `"sh"` so the shell-wrapper branch fires conservatively.

ISSUEs:

8. **coproc regex inside string literals** — pre-parse `\bcoproc\b`
   match was firing on `echo coproc`, `echo 'no coproc here'`, etc.
   Removed the pre-parse check; rely on bashlex's NotImplementedError
   raised when the actual `coproc` keyword is present. Error message
   is inspected for `'coproc'` to differentiate from other
   unsupported constructs.

9. **NotImplementedError fail-closed contradicted docstring** — bashlex
   raises NotImplementedError on legit `$((arith))`, `select`, etc.
   v5 denied all such cases. v6 only denies if the error message
   mentions `coproc`; other unsupported constructs fall through
   (the rest of the sandbox catches denied binaries).

10. **`_looks_like_executable`** — added `[[`, `]]`, `;;`, `&&`, `||`
    to the skip set.

11. **ANSI-C decoder** — switched from `re.sub(r"\\(.)", r"\1", inner)`
    to `codecs.decode(inner, 'unicode_escape')` for proper bash
    escape semantics. Falls back to the naive regex on decode error.

12. **Reparse fallback** — already scans every token, no change
    needed.

Verification: 49 cells across both tiers + iter-1/2/3/4 regressions
+ false-positive sanity. All green. Build re-verified across all 6
fixtures.

Closes the iter-4 cycle's 7 BLOCKERs and 5 ISSUEs.

Signed-off-by: even-wei <evenwei@infuseai.io>

* docs(evals): add non-adversarial threat-model section to ENFORCEMENT.md

Document the v1 enforcement contract explicitly: hooks are calibrated
for a non-adversarial code agent (RUBRIC.md's stated subject), not a
red-teamer actively evading the sandbox. Catalogue the seven classes
of static-Bash-analysis-unreachable bypass (bashlex NIE, heredoc-body,
pipeline string flow, stdin-supplied subcommands, trap evaluation,
interpreter shell-out, variable-flow loops/assignments) as accepted
out-of-scope for v1, with rationale and the follow-up trigger
(non-adversarial agent reaching one in practice).

Reframes the iter-3/4/5 cycle-reviewer BLOCKER inflation as adversarial
review against a non-adversarial artifact; closes that loop on the
documentation layer rather than the code layer.

Signed-off-by: even-wei <evenwei@infuseai.io>

* feat(recce): opt-in PostHog telemetry scaffolding for /recce-verify (DRC-3597) (#38)

Foundation for the L3 funnel signal proposed in the 2026-05-29 project
rethink. Tells the project whether real-world agent users reach for
/recce-verify, complete it, and convert downstream -- the production
complement to the L1 offline eval (DRC-3405) and L2 in-driver trace
metrics (DRC-3586).

What ships:

- plugins/recce/hooks/scripts/telemetry.sh
  Event emitter. Fires recce_verify.* events to PostHog via curl
  fire-and-forget. Off by default; opt in via RECCE_TELEMETRY_OPT_IN=1
  or ~/.recce/config.yml `telemetry_opt_in: true`. Anonymous stable
  installation ID at ~/.recce/installation-id (UUID4). Failure modes
  are silent and non-blocking by construction.

- plugins/recce/hooks/scripts/test-telemetry.sh
  Audit script that exercises every opt-in / opt-out short-circuit
  without firing network traffic. Currently 4/4 pass on default-off,
  opt-in + no key, opt-in + DISABLED bypass, and missing-event-name
  edge case.

- plugins/recce/hooks/scripts/README-telemetry.md
  Documents the event schema (skill_invoked, tier_degraded, tool_call,
  verdict_emitted, session_completed), opt-in mechanism, per-event
  wiring recommendations, plugin-maintainer responsibilities (PostHog
  key), and audit script usage.

What's NOT in this PR (deferred to follow-up):

- Auto-wiring in hooks.json (which PostToolUse / Stop hooks fire which
  events; needs design decision on session-scoping)
- Inline emit calls in plugins/recce/skills/recce-verify/SKILL.md
- The actual PostHog project key (plugin maintainer fills in at release)
- Recce Cloud signup join key (coordinate with Andy when Cloud picks up
  the parameter)

This is independent of the eval chain (DRC-3585 / 3586 / 3587 / 3405)
and can land on its own without disturbing the in-flight eval work.

Signed-off-by: even-wei <evenwei@infuseai.io>

* feat(evals): Karpathy spike driver for /recce-verify v1 eval (DRC-3586) (#37)

* feat(evals): Karpathy spike driver for /recce-verify v1 eval (DRC-3586)

Single-file Python driver at evals/agent-blind-spots/spike-driver/. Dispatches
up to 6 fixtures x 2 agents x 2 tiers = 24 cells, captures each agent's
transcript, runs a Claude-as-judge pass per transcript to produce a three-axis
verdict (catch / tier / delta), and writes a CSV + Markdown summary under
runs/<date>/spike-driver/.

Stability checks (both optional, both produce judge-quality signal):
- --judge-stability: double-judges each transcript, reports per-axis
  self-consistency (catch / tier / delta). Floor 80%.
- --baseline-dir <path>: compares judge verdicts against DRC-3585 manual
  baseline once it lands (judge-vs-human catch agreement on Tier-0 cells).

Graceful degradation:
- Skips an agent's cells if its CLI is not on PATH; codex commonly absent
  in lighter dev envs (recipe in runner-configs/codex/tier-{0,1}/README.md).
- --no-run mode re-judges existing transcripts without re-running agents.

Sandbox profile integration (from DRC-3584):
- Claude Code cells stamp tier-N claude-overlay/ into the fixture worktree
  before invoking `claude --print`, with a neutered CLAUDE_CONFIG_DIR per
  cell and scrubbed warehouse env.
- Codex cells use `codex exec --sandbox=<read-only|workspace-write>
  --ask-for-approval=never --config <tier-N/config.toml>` plus PATH scrub.

Non-goals for this spike (deferred):
- Durable harness with resume / parallel dispatch -> DRC-3587 Inspect AI port.
- Gap-report generator across 6 fixtures -> DRC-3405.
- Auto-iteration on prompt / skill changes (closed-loop optimization overfits
  at N=6 -- explicit non-goal of the spike).

Stacks on PR #36 (DRC-3584 + DRC-3430). When #36 merges to main, this PR
should be rebased onto main; GitHub auto-retargets the base branch.

Signed-off-by: even-wei <evenwei@infuseai.io>

* fix(evals): stage frozen Tier-0 inputs into cwd before agent run

The agent's cwd is .tmp/sources/<id>/ (the per-fixture standalone repo),
but the frozen Tier-0 inputs (diff.patch, artifacts/{manifest, compiled,
catalog}*) live at fixtures/<id>/, outside cwd. Without staging:

- Claude Code reaches them via absolute paths (Read tool isn't cwd-anchored),
  but the prompt doesn't tell the agent where to look.
- Codex Tier-0 read-only sandbox blocks reads outside cwd entirely, so the
  agent literally cannot reach the artifacts.

Driver now symlinks fixtures/<id>/{diff.patch, artifacts} into a
_eval_inputs/ subdir under cwd before each cell. Prompt updated to point
at _eval_inputs/. Works under both agent sandboxes.

Verified locally:
  >>> stage_inputs(SOURCES_DIR / 'pr1-fix-clv', 'pr1-fix-clv')
  >>> sorted(p.name for p in (SOURCES_DIR / 'pr1-fix-clv' / '_eval_inputs').iterdir())
  ['artifacts', 'diff.patch']

Signed-off-by: even-wei <evenwei@infuseai.io>

* fix(evals): inherit CLAUDE_CONFIG_DIR for auth in unattended runs

ENFORCEMENT.md recipe step 3 sets CLAUDE_CONFIG_DIR to a fresh mktemp dir
to neuter user-level ~/.claude/settings.json. That step also strips the
Claude Code auth state, so a child `claude --print` invoked by the spike
driver fails with "Not logged in" and no transcript ever lands.

For unattended runs, the load-bearing enforcement is the project-level
.claude/settings.json overlay (stamped per cell) plus the PreToolUse hook
(deny-tier-N.py); a user-level `permissions.allow` cannot bypass an
exit-2 hook. Skip the CLAUDE_CONFIG_DIR override by default.

For paranoid mode (e.g. when running the eval on a machine with risky
~/.claude/ contents), set RECCE_EVAL_STRICT_CONFIG=1 to enable the
override. The operator is responsible for preseeding auth under the
per-cell _claude_cfg dir.

Verified end-to-end smoke run after this fix:
  uv run driver.py --smoke --agents claude --tiers 0
    [run]   pr1-fix-clv · claude · tier-0
    [judge] pr1-fix-clv · claude · tier-0
  Agent VERDICT: catch · request-changes
  Judge verdict: {catch, tier-0, same}  (delta=same expected with no T1 paired)

Signed-off-by: even-wei <evenwei@infuseai.io>

* fix(evals): address pr-cycle review of #37 — 3 ISSUEs

1. Drop dead VERDICT_TAIL_RE regex. Defined in driver.py:99 but never
   used; agent verdict parsing is fully handled by judge_cell()
   downstream. Removed alongside the regex line.

2. Add surgical .gitignore entries for spike-driver runtime outputs:
     evals/agent-blind-spots/runs/*/spike-driver/transcripts/
     evals/agent-blind-spots/runs/*/spike-driver/_claude_cfg/
   These are the genuinely-transient subdirs. verdicts.csv,
   summary.md, cells.json under runs/<date>/spike-driver/ are
   intentionally NOT ignored — they may be committed manually as
   run baselines for cross-iteration tracking.

3. stage_inputs() now raises FileNotFoundError when no Tier-0
   inputs (neither diff.patch nor artifacts/) could be staged under
   the fixture's source dir. run_cell() catches and records into
   cell.error so the cell shows up as failed in the matrix instead
   of silently feeding the agent an empty _eval_inputs/ and letting
   it review with no inputs.

Verified:
  python3 -m py_compile spike-driver/driver.py  -> ok
  stage_inputs(.../pr1-fix-clv, 'pr1-fix-clv')  -> happy path
  stage_inputs(/tmp/dne, 'does-not-exist')      -> raises FileNotFoundError

NOTE-level findings 4-7 from the review are accepted as-is per the
spike framing (judge inherits parent env intentionally for auth;
same-day run dirs are an operator concern; broken-symlink in
overlay copy is rare and surfaced via run_cell's general except;
no tests is consistent with the Karpathy-spike scope and the
DRC-3587 follow-up which adds Inspect AI test harness).

Signed-off-by: even-wei <evenwei@infuseai.io>

---------

Signed-off-by: even-wei <evenwei@infuseai.io>

* fix(evals): close iter-6 BLOCKER (git history leak) + ISSUE (Tier-1 dbt policy)

Two findings from the pr-cycle iter-6 review of #36, both re-confirming
wcchang1115 (Andy) review comments that remained open.

## BLOCKER: per-fixture git history leaks stripped content

build_fixtures.sh fetched the upstream head SHA into refs/fixture/head
and `rm -rf`'d Recce-aware paths from the working tree. The original
commit (and its tree) was untouched, so a Tier-0 agent allowlisted to
run `git` could recover everything via `git show HEAD:recce.yml`,
`git cat-file -p HEAD^{tree}`, `git log -p`, etc. The working-tree
strip is bypassable by anyone reading git objects.

Fix: after strip + leak-grep, rewrite the per-fixture repo's history
into a single fresh commit whose tree IS the stripped working tree.
Delete .git, `git init`, `git add -A`, `git commit`. No ancestor
references the original head's tree; no other ref / reflog entry
keeps it reachable.

Verified end-to-end on a copy of pr1-fix-clv's source:

  Before:
    git show HEAD:recce.yml -> dumps recce.yml content (leak)
    git ls-tree -r HEAD | grep recce.yml -> recce.yml is listed

  After rewrite:
    git cat-file -e HEAD:recce.yml -> exit 128 (unreachable)
    git cat-file -e HEAD:.github/prompts/system-prompt.md -> unreachable
    git cat-file -e HEAD:.github/workflows/claude.yml -> unreachable
    git cat-file -e HEAD:.github/workflows/recce_ci.yml -> unreachable
    git cat-file -e HEAD:.devcontainer/post-create.sh -> unreachable
    rev-list --all HEAD --count -> 1 (single commit invariant preserved)

Post-rewrite path-leak regex tightened to anchor at path components
(`(^|/)recce\.yml$` etc.) so the generic .devcontainer.json VS Code
config (no Recce content) isn't a false positive.

## ISSUE: Tier-1 dbt subcommand policy divergence

Codex Tier-1 PATH-scrubs the `dbt` binary entirely (no dbt at all);
Claude Code Tier-1 hook denylisted only {run,test,parse,compile,...}
and allowed `dbt list/show/ls/deps/--help/--version`. The lens-3
cross-runner delta would partially measure "Claude Code can call
read-only dbt subcommands but Codex can't" instead of the
Recce-equipped-vs-not-equipped signal.

Fix: at Tier-1, deny ALL dbt invocations regardless of subcommand.
The recce-verify SKILL.md uses `git diff --name-only` + Recce MCP
for model discovery, not `dbt list` — so denying read-only dbt
subcommands costs nothing in capability. The DENIED_BINS check at
line 340 still has the `- {"dbt"}` exclusion so the dbt branch
gives its own specific message; the message is updated to reflect
the broader policy ("not reachable at Tier-1").

Synthetic hook tests (Tier-1):
  dbt list       -> BLOCKED ✓
  dbt show       -> BLOCKED ✓
  dbt --version  -> BLOCKED ✓
  dbt run        -> BLOCKED ✓ (already was)
  dbt parse      -> BLOCKED ✓ (already was)
  git status     -> allowed ✓
  recce list     -> allowed ✓

Signed-off-by: even-wei <evenwei@infuseai.io>

* chore(evals): address iter-7 NOTEs — remove dead Tier-1 dbt code + doc drift

Both findings from pr-cycle iter-7 review of #36 (explicit non-blockers,
author already acknowledged the doc-drift one inline).

NOTE 1: dead code in deny-tier-1.py after the iter-6 ISSUE fix.
The unconditional `if "dbt" in head_names: deny(...)` at line ~340
makes the entire subcommand-discrimination path unreachable. Removed:
  - DBT_DENIED_SUBCOMMANDS frozenset (was lines 64-68)
  - _dbt_args_have_denied() function (was lines 519-527)
  - The `# --- dbt ---` block in walk() (was lines 458-465)

deny-tier-0.py still has its own DBT_DENIED_SUBCOMMANDS — Tier-0
denies the same set + bare `dbt`. The two hook files are kept
separate intentionally; cross-file dedup would require restructuring
the per-tier overlay layout. Comment at line 65-68 documents this.

NOTE 2: doc drift at fixtures/README.md:32 — the prior paragraph
mentioned only the simple `grep -E '...'` belt-and-suspenders and
made no reference to the history rewrite. Updated to describe:
  - Both leak-grep layers (tight identifier + loose `[Rr]ecce`)
  - profiles.yml whitelist rationale
  - The git history rewrite step + post-rewrite path-leak check
  - Why the rewrite matters (Tier-0-allowlisted `git` recoverability)

Verified: py_compile ok, no functional orphan refs (only one comment
mentions the deleted DBT_DENIED_SUBCOMMANDS name as cross-reference
to tier-0). 10/10 synthetic hook tests still pass:
  - dbt list/show/run/parse/--version/deps -> all BLOCKED
  - git status, recce list -> allowed
  - sh -c "dbt parse" -> BLOCKED (shell wrapper)
  - {dbt,bash} run -> BLOCKED (brace expansion)

Signed-off-by: even-wei <evenwei@infuseai.io>

* fix(evals): close iter-8 BLOCKERs + ISSUEs on spike driver + overlays

Address @wcchang1115's re-review at ff46b98 (PR #36).

BLOCKER 1 — overlay-as-spoiler at the agent's cwd. The Tier-0 enforcement
overlay copied to `<fixture>/.claude/` contains Recce-shaped strings
(`mcp__recce__*`, `recce`, `RECCE_API_TOKEN`); `cat`/`ls`/`grep` over
`.claude/` would let a Tier-0 agent read its own enforcement file and
defeat the "agent cannot reach Recce-shaped signals" contract.

  * Tier-0 + Tier-1 `settings.json`: deny `Read/Glob/Grep(.claude/**)`.
    (Project-level deny rules apply even with
    `--dangerously-skip-permissions`; that flag bypasses the interactive
    prompt, not the rule.)
  * Tier-0 hook (`deny-tier-0.py`): new Pass 0 over command words and
    redirect targets. Any argument matching `(^|[/=])\.claude(/|$)` exits
    2 with a "references the Tier-0 enforcement overlay" message.
    Covers `cat .claude/...`, `cat ./.claude/...`, `cat < .claude/...`,
    `ls .claude/`, `find .claude`, `grep -r recce .claude/`, and
    `cat $(echo .claude/...)` — i.e., the non-adversarial shapes.
    Interpreter-shell-out (awk/python `-c`) is intentionally out of
    scope per ENFORCEMENT.md § "Threat model — non-adversarial".

BLOCKER 2 — Codex Tier-0 PATH-regex scrub was a no-op for normal installs.
`grep -v -E '/recce(/|$)|/dbt(/|$)|\.recce'` strips dirs whose path
contains literal `/recce/` or `/dbt/` — NOT dirs containing the binary
named `recce`/`dbt`. So `/opt/homebrew/bin`, `.venv/bin`, `~/.local/bin`
all survived and Codex Tier-0 could still run `recce list` / `dbt list`.

  * New `runner-configs/codex/tier-0/stub-bin/{recce,dbt}` — exit-127
    stubs prepended to PATH so the real binaries are masked regardless
    of which bin dir they live in.
  * `driver.py::codex_tier0_path()` builds the PATH (stub-bin first,
    then the legacy regex scrub as belt-and-suspenders).
  * `driver.py::assert_codex_tier0_masked()` runs `command -v recce|dbt`
    under the final env before the agent launches; if either resolves
    outside `stub-bin/`, the cell errors out instead of contaminating
    the Tier-0 baseline. Verified: regression detected when stub-bin is
    skipped.
  * `codex/tier-0/README.md` updated with the new recipe + pre-flight
    assertion shell snippet.

ISSUE 1 — Tier-0 read/write asymmetry. Codex Tier-0 is OS read-only;
Claude Code Tier-0 had no Write/Edit deny + matcher didn't fire on those
tool kinds + `--dangerously-skip-permissions` was set.

  * Tier-0 `settings.json`: deny `Write`, `Edit`, `MultiEdit`,
    `NotebookEdit`. Deny rules are enforced separately from
    `--dangerously-skip-permissions`.

ISSUE 2 — cells don't reset state between runs.
`fixture_dir = SOURCES_DIR / cell.fixture` was reused across all four
cells per fixture with no `git reset`, so a write from cell N persisted
into cell N+1.

  * New `driver.py::reset_fixture_dir()` runs `git reset --hard HEAD`
    + `git clean -fdx` at the top of `run_cell()`, before
    `stage_inputs()` (whose `_eval_inputs/` symlinks would otherwise be
    cleaned away) and before the overlay copy. Each cell now starts
    from the same committed base.

ISSUE 3 — no model pinning (ENFORCEMENT.md:157).

  * `driver.py`: new `--model` flag (default `claude-opus-4-5`),
    `--no-model` opt-out. The pin is passed to `claude --model` and
    `codex --model` for every agent invocation and to the judge call.
    Cell metadata (`cells.json`, transcript headers) records the
    pinned model so cross-cell comparability is auditable.

Deferred (Andy's Notes, non-blocking for this PR):
  - N1: tier-1 dbt deny-list / docs drift.
  - N2: `_eval_inputs/` symlinks could be written through at Tier-1.
  - N3: `codex/tier-1/config.toml` `command = ""` unvalidated.

Verification:
  - `py_compile` clean on deny-tier-{0,1}.py + driver.py.
  - JSON valid on both `settings.json` files.
  - Synthetic Bash-hook tests: 12/12 `.claude/` access shapes denied;
    8/8 allowlisted commands pass.
  - `codex_tier0_path()` + `assert_codex_tier0_masked()` pre-flight
    works under the new stub-bin recipe and catches regressions if
    stub-bin is dropped.
  - `driver.py --help` shows `--model` / `--no-model`.
Signed-off-by: even-wei <evenwei@infuseai.io>

* fix(evals): close iter-8 BLOCKER (Tier-0 overlay leak via cwd) structurally

Andy's iter-8 review (PR #36, on 80d5ea3) flagged that the Pass 0
overlay-leak guard in deny-tier-0.py only matches the literal `.claude`
substring, so a non-adversarial Tier-0 agent dumps the overlay's Recce
vocabulary via ordinary recursive reads the Bash AST hook can't see
ahead of glob expansion:

    cat .*/settings.json      (glob expands AFTER the hook reads literal)
    grep -r recce .           (recurse descends into .claude/)
    find . -type f -exec cat {} \;
    cat $(find . -type f)

Token-matching is whack-a-mole; the root cause is structural — the
overlay (mcp__recce__*, Bash(recce *), RECCE_API_TOKEN, recce literals
in the hook source) lives in the agent's cwd as <fixture>/.claude/.

Andy's recommended fix (verified on CC v2.1.160): load settings via
`claude --settings <abs-path>` from outside cwd, with the hook command
pointing to an absolute path also outside cwd. The agent's cwd then
becomes a pristine dbt project with no Recce-shaped files — leak is
structurally impossible.

Driver change (spike-driver/driver.py):
* Add render_claude_settings() — reads the template settings.json,
  substitutes ${RUNNER_HOOK_PATH} with the absolute path to the
  tier-N hook script (under runner-configs/), writes the rendered
  file to run_dir/_settings/<fixture>_t<tier>.json.
* run_claude() drops shutil.copytree(overlay_src, fixture/.claude),
  calls render_claude_settings(), and passes
  --settings <rendered-abs-path> to `claude --print`.
* Codex path (run_codex, assert_codex_tier0_masked) unchanged —
  Codex uses --config <toml> + PATH stub-bin masking, separate
  enforcement shape.

Template change (tier-{0,1}/claude-overlay/settings.json):
* `hooks.PreToolUse[].hooks[].command` now uses ${RUNNER_HOOK_PATH}
  placeholder; the runner substitutes at render time.
* Hook matcher unchanged: Bash|Skill|mcp__(plugin_)?recce(_|-).*
* permissions.deny rules unchanged (belt-and-suspenders, even though
  the overlay is no longer in cwd).

Doc updates:
* ENFORCEMENT.md Claude Code recipe: bash example now renders the
  template to a temp path and passes `claude --settings <abs-path>`
  instead of `cp -r claude-overlay/ .claude/`. Same shape for Tier-1.
* runner-configs/README.md: directory map and Quick start updated;
  added the "why not copy" explainer pointing at the leak.

Verification:
* py_compile passes for driver.py, deny-tier-{0,1}.py.
* JSON parse passes for tier-{0,1} settings templates.
* Filesystem check: after reset_fixture_dir() + stage_inputs(),
  find <fixture> -maxdepth 2 -name "*.claude*" returns empty.
  Fixture cwd contains dbt project files + _eval_inputs/ symlink
  only.
* Rendered settings live at run_dir/_settings/, outside fixture cwd.
* Rendered hook command field carries an absolute path that resolves
  to an existing deny-tier-N.py with no ${RUNNER_HOOK_PATH} leakage.
* Andy's 4 bypass shapes (cat .*/settings.json, grep -r Bash(recce .,
  find . -type f -exec grep -l deny-tier {} \;,
  cat $(find . -name deny-tier-0.py)) all return empty / no-such-file
  — overlay simply isn't there.
* Standalone hook invocation (Bash dbt parse → exit 2; Bash ls → exit 0)
  still works when the hook script is executed from outside cwd.
* Codex Tier-0 PATH masking (assert_codex_tier0_masked) is untouched
  and still aborts when recce/dbt resolve outside stub-bin.

Out of scope (per the iter-8 review):
* Codex --ignore-rules / --dangerously-bypass-hook-trust (codex-only,
  separate code path).
* B1 (git history leak) and I2 (Tier-1 dbt policy) — already closed
  in 6f20289.
* 7 adversarial-bypass classes documented in ENFORCEMENT.md §
  "Threat model — non-adversarial code agent".
* The 3 NOTEs Andy explicitly deferred.

Refs: DRC-3584
Signed-off-by: even-wei <evenwei@infuseai.io>

---------

Signed-off-by: even-wei <evenwei@infuseai.io>
even-wei pushed a commit that referenced this pull request Jun 3, 2026
…-3405)

Validation preview from the Karpathy spike driver landed in #37. Runs:
1 fixture (pr1-fix-clv) x 2 agents (claude, codex) x 2 tiers (0, 1)
= 4 cells, with --judge-stability (two judge passes per transcript).

NOT a DRC-3405 deliverable. 3405 wants:
  - 6 fixtures (not 1)
  - Manual baselines from DRC-3585 to anchor lens-3 delta
  - Hand-curated gap report

This commit ships verdicts.csv + summary.md only. cells.json and
transcripts/ stay local (cells.json has absolute paths; transcripts/
is gitignored).

## Headline results

| Cell | Catch | Evidence | Delta | Status |
|---|---|---|---|---|
| claude · tier-0 | catch | 0 | same | ok |
| claude · tier-1 | catch | 1c | improvement | ok |
| codex · tier-0 | miss | 0 | same | rc=2 (driver bug) |
| codex · tier-1 | miss | 0 | same | rc=2 (driver bug) |

Claude moved from Tier-0 (evidence=0, no delta) to Tier-1 (evidence=1c,
improvement). The lens-3 counterfactual is producing real signal on
this fixture -- Recce-equipped agent reached for structured 1c-tier
evidence (current-env queries) that Tier-0 couldn't.

## Judge self-consistency

- catch: 100% (4/4 cells)  -> above 80% bar
- tier:  100% (4/4 cells)  -> above 80% bar
- delta: 75%  (3/4 cells)  -> just below 80% bar

The delta flip on claude x tier-0 (same -> improvement across the two
judge passes) is expected behaviour without a DRC-3585 manual
baseline: the driver passes "baseline_catch=unknown" and the judge
has to guess what delta means. Once 3585 lands, plug --baseline-dir
in and re-judge.

## Findings to action separately

1. Tier-0 LEAK: recce.yml is present in pr1-fix-clv's .tmp/sources/
   tree. The agent cited recce.yml preset names (row_count_diff,
   schema_diff) in its Tier-0 reasoning. build_fixtures.sh strip list
   includes recce.yml (line 292) but the .tmp/sources/ directory is
   stale -- it was built before the strip was added. Mitigation:
   re-run build_fixtures.sh. (Follow-up against DRC-3430.)

2. Codex driver: codex-cli 0.133.0 removed both --ask-for-approval=never
   and --config <file>. Driver invocation in run_codex() fails with
   rc=2 before the agent ever runs. Both codex cells show miss/0/same
   because the judge had nothing useful to score. Driver needs an
   update: CODEX_HOME-based config loading + `-c approval_policy="never"`
   override + a sanity check that the codex schema in our config.toml
   matches the installed codex version. (New ticket needed.)

3. Stale fixture worktrees: more broadly, if anyone re-runs the eval
   they should `rm -rf .tmp/` first to guarantee a fresh strip. Will
   document in the spike-driver README in a follow-up.

Signed-off-by: even-wei <evenwei@infuseai.io>
even-wei pushed a commit that referenced this pull request Jun 3, 2026
…-3405) (#39)

Validation preview from the Karpathy spike driver landed in #37. Runs:
1 fixture (pr1-fix-clv) x 2 agents (claude, codex) x 2 tiers (0, 1)
= 4 cells, with --judge-stability (two judge passes per transcript).

NOT a DRC-3405 deliverable. 3405 wants:
  - 6 fixtures (not 1)
  - Manual baselines from DRC-3585 to anchor lens-3 delta
  - Hand-curated gap report

This commit ships verdicts.csv + summary.md only. cells.json and
transcripts/ stay local (cells.json has absolute paths; transcripts/
is gitignored).

## Headline results

| Cell | Catch | Evidence | Delta | Status |
|---|---|---|---|---|
| claude · tier-0 | catch | 0 | same | ok |
| claude · tier-1 | catch | 1c | improvement | ok |
| codex · tier-0 | miss | 0 | same | rc=2 (driver bug) |
| codex · tier-1 | miss | 0 | same | rc=2 (driver bug) |

Claude moved from Tier-0 (evidence=0, no delta) to Tier-1 (evidence=1c,
improvement). The lens-3 counterfactual is producing real signal on
this fixture -- Recce-equipped agent reached for structured 1c-tier
evidence (current-env queries) that Tier-0 couldn't.

## Judge self-consistency

- catch: 100% (4/4 cells)  -> above 80% bar
- tier:  100% (4/4 cells)  -> above 80% bar
- delta: 75%  (3/4 cells)  -> just below 80% bar

The delta flip on claude x tier-0 (same -> improvement across the two
judge passes) is expected behaviour without a DRC-3585 manual
baseline: the driver passes "baseline_catch=unknown" and the judge
has to guess what delta means. Once 3585 lands, plug --baseline-dir
in and re-judge.

## Findings to action separately

1. Tier-0 LEAK: recce.yml is present in pr1-fix-clv's .tmp/sources/
   tree. The agent cited recce.yml preset names (row_count_diff,
   schema_diff) in its Tier-0 reasoning. build_fixtures.sh strip list
   includes recce.yml (line 292) but the .tmp/sources/ directory is
   stale -- it was built before the strip was added. Mitigation:
   re-run build_fixtures.sh. (Follow-up against DRC-3430.)

2. Codex driver: codex-cli 0.133.0 removed both --ask-for-approval=never
   and --config <file>. Driver invocation in run_codex() fails with
   rc=2 before the agent ever runs. Both codex cells show miss/0/same
   because the judge had nothing useful to score. Driver needs an
   update: CODEX_HOME-based config loading + `-c approval_policy="never"`
   override + a sanity check that the codex schema in our config.toml
   matches the installed codex version. (New ticket needed.)

3. Stale fixture worktrees: more broadly, if anyone re-runs the eval
   they should `rm -rf .tmp/` first to guarantee a fresh strip. Will
   document in the spike-driver README in a follow-up.

Signed-off-by: even-wei <evenwei@infuseai.io>
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