test(harness): pin that a headless run denies and audits approval (closes #207) - #212
Conversation
Closes arthurpanhku#207. First slice of arthurpanhku#119. `src/harness/run.ts:240` holds a governance invariant with no test: // A headless surface has no human approval channel. requestApproval: async () => false, There was no `tests/harness/` at all, so `executeHarnessRun` was reached only indirectly. If someone made that auto-approve, nothing would notice -- and the failure mode is silent auto-approval in exactly the configuration with no human watching. Five cases drive `executeHarnessRun` directly with `runAgentTurn` stubbed, so no provider, network or agent loop is involved: - a run using no approval-gated tool completes normally - the hook denies - the denial settles rather than waiting on a channel that will never answer, which is the difference between a failed run and a hung one - the denial reaches the audit chain as `{type: 'approval', toolName, approved: false}` -- driven through the real `ToolRegistry`, because "denied but unrecorded" must not pass - the file is not written, since a denial that still mutated the workspace would make the audit entry describe something that happened Bypass-proof as the issue asks: changing line 240 to `async () => true` turns 4 of the 5 red, and the happy path stays green.
arthurpanhku
left a comment
There was a problem hiding this comment.
Thank you for this — the core of it is right, and the write-up is unusually careful. One test needs a change before merge, and it is the one whose stated purpose does not survive checking.
What holds up
I reproduced your mutation check rather than taking it on trust. Changing src/harness/run.ts:240 to async () => true gives 4 failed | 1 passed, exactly as you reported, with the happy path staying green. That is the right shape and it means the invariant is genuinely pinned.
Going past the issue's ask — the audit assertion and the "file was not written" case — was the right instinct. "Denied but unrecorded" and "denied but it happened anyway" are the two ways this could fail while still returning false, and neither would have been caught by asserting on the hook alone. Full suite 520 → 525 on my run, tsc --noEmit clean, no regressions.
Your note about edit_file failing on zod validation before reaching the approval gate is a genuinely useful finding, and worth having in the file as it is.
The change I am asking for
Test 3, resolves the denial promptly rather than waiting for input, contains an assertion that cannot fail.
const settled = await Promise.race([
requestApproval('apv_test', 'write_file', {}).then(() => 'settled'),
Promise.resolve('pending'),
].map(p => Promise.resolve(p)));
// ...
expect(settled).toBeDefined();Promise.resolve('pending') is already settled, so the race resolves to 'pending' essentially always, and toBeDefined() passes for either branch. I ran that block in isolation against a hook that never resolves — precisely the hang it claims to detect:
settled = "pending"
expect(settled).toBeDefined() would PASS
So the hang is not caught by this construct. What actually catches it is the next line, await expect(requestApproval(...)).resolves.toBe(false), which duplicates test 2 and fails via Vitest's 5s timeout. I confirmed that separately: with requestApproval replaced by a never-resolving promise, all four tests fail with Test timed out in 5000ms — the protection is real, but it comes from the timeout, not from this test.
The test therefore passes for a reason other than the one its name and comment give. In a governance test that matters more than usual: this repository's own standard is PCP-1 §8 T-2 — "A suite whose tests still pass after the control is deleted proves nothing, and this profile treats it as unimplemented." An assertion that can never fail is the same problem one step further along.
Either fix is fine by me:
- Drop the race block, keep the
resolves.toBe(false)line, and rewrite the comment to say what is true — a hang is caught by the test timeout. Honest and smaller. - Actually assert promptness with fake timers:
vi.useFakeTimers(), assert the promise settles without advancing the clock. More work, and it delivers what the name promises.
I would take either, and (1) with an accurate comment is not a lesser answer.
Two notes, neither blocking
- The "8 pre-existing failures" baseline does not reproduce. On
origin/mainat8749913I get 520 passed, 0 failed (one unrelated unhandledEPIPEfromtests/mcp/stdio.test.tsteardown, which does not fail the run). Your 8 are almost certainly environmental — missing optional scanner binaries would be my guess. Nothing wrong with your PR; just be aware your no-regression evidence rested on a different baseline than CI's. I checked it against a clean one and it holds. runAgentTurnis fully mocked, so tests 4 and 5 are reallyToolRegistryexercised with the harness's hook rather than the harness end to end. Correct for a first slice and consistent with #207's scope — worth stating so nobody later reads this file as end-to-end coverage.
On the process note
Not claiming the issue first is fine and needs no apology. Nobody else had it in flight.
Push the change and I will re-review. Note also that the repo's own CI has not run here — fork PRs need a maintainer to approve the workflow, so everything above comes from running the suite locally against your branch.
Generated by Claude Code
|
Reviewed and verified independently. Merging. What I checkedThe bypass proof reproduces. Changed Clean on this machine: full suite The invariant is anchored where it should be. Driving case 4 through the real Your note about One nit, for a follow-up rather than this PRIn "resolves the denial promptly", the So the test does test what its name says, just by a different mechanism than the code implies. Not worth holding the merge over, and given you flagged silent vacuity yourself elsewhere in this PR I expect you'd rather know. Dropping the race and leaving an explicit ProcessNo need to have claimed the issue first — CONTRIBUTING doesn't ask for it and nobody had this in flight. Thanks for the thorough write-up; the mutation result in the PR body is what made this quick to verify. Generated by Claude Code |
`git log` on main carries commits from two people the contributors table does not: Samran Asif (@webdevsamran), whose #211 pinned the EG-4/EG-5/EG-6 egress assertions, and @dchaudhari7177, whose #213 and #212 pinned the URL rejections and that a headless run denies and audits approval. Both tables now list them, in both languages. Names and handles are taken from the commits and pull requests themselves rather than guessed: the git author name where one is given, the login otherwise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015f83ci81a7cKYf253ckwTb
`git log` on main carries commits from two people the contributors table does not: Samran Asif (@webdevsamran), whose #211 pinned the EG-4/EG-5/EG-6 egress assertions, and @dchaudhari7177, whose #213 and #212 pinned the URL rejections and that a headless run denies and audits approval. Both tables now list them, in both languages. Names and handles are taken from the commits and pull requests themselves rather than guessed: the git author name where one is given, the login otherwise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015f83ci81a7cKYf253ckwTb
Closes #207. First slice of #119.
Bypass-proof check — done by hand, as asked
Changed
src/harness/run.ts:240toasync () => trueand re-ran:Restored → 5 passed. The happy-path case correctly stays green under the mutation, which is what tells you the other four are testing the invariant and not just the plumbing.
The five cases
exitCode 0.requestApprovalhookexecuteHarnessRunhands down returnsfalse.ToolRegistrywith the harness's own hook, asserting{type: 'approval', toolName: 'write_file', approved: false}lands on the sink. Per the acceptance criteria, "denied but unrecorded" must not pass, so this deliberately does not assert on the hook's return value alone.Notes on the approach
runAgentTurnis stubbed viavi.mock+vi.hoisted, so no network, no provider, no agent loop — per "not in scope". The test captures the hooks objectexecuteHarnessRunpasses down and exercises it directly.write_file, notedit_file. I started withedit_fileand it failed for an interesting reason: zod schema validation runs before the approval gate inToolRegistry.run, so a malformed input rejects with a schema error and never reaches the thing under test.write_filewith a valid{filePath, content}is the smallest input that actually gets there. Worth knowing if anyone extends this — a test that "passes" on a schema error would be silently vacuous.tests/harness/is new, as the issue notes.Verification
npx vitest run tests/harness/run.test.ts— 5 passedmainon this machine gives 8 failed | 498 passed | 14 skipped — the same 8 pre-existing failures, +5 from this file. No regressions.Out of scope, as stated
The full
UnattendedPermissionModematrix and the tamper-evident-chain assertions stay in #119.Process note
The issue says to comment to claim, and I didn't — CONTRIBUTING doesn't require it and the work was already done by the time I'd read the issue through. Happy to follow claim-first on anything further here; if someone else has this in flight, say so and I'll close this.