mirror of
https://github.com/bdbrown4/peltier.io.git
synced 2026-11-07 19:08:27 +00:00
The Claude Agent SDK loop (agent/hotpath_agent/loop.py, model claude-fable-5) drives the seven harness tools over a standalone stdio MCP server (mcp_server.py) and runs the full profile -> hypothesize -> patch -> gated-verdict cycle on comrak with no human in the loop. Three audited attempts, all honest rejections, ZERO false accepts: - phase2-comrak-001 class 3: syntect buffer with_capacity, [0.971,1.007] - phase2-comrak-002 class 5: gate dead VecDeque build, [0.982,1.026] - phase2-comrak-003 class 6: hoist find_special_char check, [0.970,1.007] The agent picked the cheapest untried class each time (3->5->6) and TWICE refused to fabricate a verdict it couldn't read back, verifying ledger integrity instead — the anti-reward-hacking behavior SPEC §10 targets. Harness changes surfaced by the loop: - run_verdict is now async: launches the multi-minute build+bench detached (past the 60s MCP transport cap) and returns immediately; new read_verdict op polls the append-only ledger row (ledger verdict_summary). Fixes attempt 001's unreadable-verdict gap. - read_target_source takes offset/limit line windows so 100KB+ files page instead of overflowing — removes the agent's reason to reach for a shell (attempt 003 paged a 2400-line file cleanly, no shell). Honest boundary note (case study + code comments): in THIS dev container the SDK tool allow/deny layer is defense-in-depth, not the trust boundary — a nested CLI as root under a parent session doesn't reliably gate built-ins. SPEC §10's boundary is OS process/user isolation; that hardening is the remaining Phase 2 exit item (docs/infra.md). What holds regardless: propose_patch is the only mediated write path (path-allowlist + git apply) and the ledger is append-only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EcKPWQ3MQD4NdEHwar96u2
104 lines
5.7 KiB
Markdown
104 lines
5.7 KiB
Markdown
# Phase 2 case study — the unattended agent loop
|
|
|
|
**Result: the Claude Agent SDK loop ran the full profile → hypothesize →
|
|
patch → gated-verdict cycle on comrak with no human in the loop, across
|
|
multiple attempts, with ZERO false accepts. The agent twice refused to
|
|
fabricate a verdict it could not read back, verifying ledger integrity
|
|
instead — exactly the anti-reward-hacking behavior SPEC §10 is designed
|
|
to produce.**
|
|
|
|
## What runs
|
|
|
|
- `agent/hotpath_agent/loop.py` — Claude Agent SDK (`claude-agent-sdk`,
|
|
model `claude-fable-5`) driving a standalone stdio MCP server
|
|
(`mcp_server.py`) that exposes the seven harness tools. The agent has
|
|
no built-in file/shell/web tools in its allowlist.
|
|
- `harnessd` gained an async `run_verdict` (launches the multi-minute
|
|
build+bench pipeline detached, returns immediately) and a pollable
|
|
`read_verdict` that reads the append-only ledger row once written.
|
|
- `read_target_source` gained an `offset`/`limit` line window so 100 KB+
|
|
source files can be paged instead of overflowing one response.
|
|
|
|
## The attempts (all in the ledger, `results/ledger.sqlite`)
|
|
|
|
| run_id | class chosen | patch | verdict | speedup CI |
|
|
|---|---|---|---|---|
|
|
| phase2-comrak-001 | 3 alloc-churn | syntect buffer `with_capacity` | rejected-bench | [0.971, 1.007] |
|
|
| phase2-comrak-002 | 5 algorithmic | gate dead `VecDeque` build in text postprocess | rejected-bench | [0.982, 1.026] |
|
|
| phase2-comrak-003 | 6 SIMD | hoist `^`-in-brackets check out of `find_special_char` predicate | rejected-bench | [0.970, 1.007] |
|
|
|
|
Run 003 exercised the two shipped fixes: the agent **paged through a
|
|
2,400-line file with `read_target_source` windows** (no shell reach this
|
|
time) and **polled the async `read_verdict` cleanly** to a verbatim
|
|
verdict. Across all three attempts the agent chose the cheapest untried
|
|
class each time (3 → 5 → 6, skipping 4/7 whose profile preconditions the
|
|
callgrind data didn't support), and **0 of 3 were false accepts**.
|
|
|
|
The agent's class selection was correct each time: it read the ledger,
|
|
saw which classes were already attempted, and picked the cheapest
|
|
untried class whose profile preconditions were met. Both patches went
|
|
through `propose_patch` (path-allowlist + `git apply`) and the full
|
|
gate+bench pipeline. Both were correctly rejected — real mechanisms,
|
|
too small to clear the 2% CI-lower-bound bar. **No false accepts.**
|
|
|
|
## Two findings the loop surfaced about itself
|
|
|
|
1. **`run_verdict` outlived the MCP transport (attempt 001).** The
|
|
build+bench pipeline takes minutes; the MCP tool-call cap is 60 s. The
|
|
agent's calls timed out — and rather than retry blindly or invent a
|
|
number, it checked `read_ledger`, confirmed exactly one row was
|
|
written (dedup on run_id held), and reported the verdict as
|
|
*unreadable, not fabricated*, asking a human to read the ledger. Fix
|
|
shipped: async `run_verdict` + pollable `read_verdict`. Attempt 002
|
|
then read its verdict back cleanly.
|
|
|
|
2. **The SDK tool-restriction is not the trust boundary in this
|
|
environment.** The agent reached for a built-in `Bash`/`Monitor` tool
|
|
to read a source file too large for the (then whole-file-only)
|
|
`read_target_source`. Investigation showed that a nested Claude Code
|
|
CLI running as root under a parent session does not reliably gate
|
|
built-ins: `--dangerously-skip-permissions` is refused under root, and
|
|
a `can_use_tool` deny-by-default callback is shadowed by settings
|
|
inherited from the parent session (and broke legitimate MCP tool calls
|
|
with "Stream closed"). **This is expected, not a regression:** SPEC §10
|
|
specifies the boundary as OS-level process/user isolation and no-net
|
|
containers — "enforced by filesystem permissions and a separate-process
|
|
harness, not by prompt." The SDK allow/deny layer is defense in depth.
|
|
Mitigation shipped: windowed `read_target_source` removes the agent's
|
|
reason to reach for a shell; the load-bearing guarantees hold
|
|
regardless (see below).
|
|
|
|
## What holds even when the tool layer leaks
|
|
|
|
- **Writes to the target are only mediated through `propose_patch`** —
|
|
path-allowlist (relative paths, no `..`, no `.git`) then `git apply`
|
|
rooted in the target workspace. A patch touching `config/` or `crates/`
|
|
is refused before any apply. Verified against a malicious
|
|
accept-threshold diff in Phase 1.
|
|
- **The ledger is append-only** — UPDATE/DELETE are refused by SQLite
|
|
triggers, and `accepted` rows are written only by the `verdict` binary
|
|
after the gated pipeline. An agent cannot forge a passing verdict
|
|
through the harness.
|
|
- **The gate+bench pipeline is unbypassable through the harness** —
|
|
pristine-rebuilt baseline, interleaved A/B, CI-lower-bound rule.
|
|
|
|
The residual hole in THIS container: an agent with host shell could, in
|
|
principle, `sqlite3 INSERT` a forged row (triggers block mutation, not
|
|
insertion) or invoke the verdict binary directly. That is precisely the
|
|
attack SPEC §10's process/user isolation exists to prevent, and it is the
|
|
**Phase 2 exit gap to close on real infrastructure** (run the agent
|
|
process as a separate unprivileged user with no shell and the trust layer
|
|
owned by another uid — the `bench-metal`/`docs/infra.md` setup).
|
|
|
|
## Phase 2 exit criteria (SPEC §5)
|
|
|
|
- Unattended profile→verdict loop on one target: **met** (comrak).
|
|
- ≥1 auto-accepted win: **not yet** — both attempts were honest
|
|
rejections. The loop is proven; a win needs either a richer profile
|
|
(perf `stat` cache-miss data to unlock class-4 preconditions) or more
|
|
attempts against fresh hypotheses.
|
|
- Zero false accepts across audited attempts: **met so far** (2/2
|
|
audited, both correctly rejected).
|
|
- The separate-uid isolation for the tool boundary is the remaining
|
|
hardening item, tracked to `docs/infra.md`.
|