test(audit): assert the log-less 404 writes NO access record, for any result (BLO-34901) - #1964
Conversation
… result (BLO-34901)
Both BLO-34738 guards pinned `details: { result: "allowed" }`, so they asserted the
absence of one particular record rather than the invariant. A change that books the
same log-less 404 as `result: "denied"` is still a false access record — the reader
is entitled and the read simply found nothing — and it passed both guards unchanged.
Drop the `details` matcher on both: no `heartbeat.run_log_accessed` /
`workspace_operation.log_accessed` record of any kind.
Test-only. No change under server/src/routes or server/src/services.
Controls, one mutation at a time, four runs:
A1 audit("allowed") moved back above heartbeat.readLog -> FAILS (existing control held)
A2 route books the log-less 404 as "denied" -> FAILS
A2' same mutation, old `result: "allowed"` matcher restored -> PASSES (the gap this closes)
B1/B2/B2' same three on the workspace-operation route -> FAILS / FAILS / PASSES
Unmutated: 35/35 pass; tsc --noEmit -p server/tsconfig.json exit 0.
|
✅ All checks passing — ready for Greptile review and maintainer approval. — commitperclip |
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 3ac92c8
Looks good. Test-only, 8 lines, and the widened guard is strictly stronger than what it replaces. I verified the load-bearing premise independently at this head rather than taking the PR body's word for it: on both routes, every *_log_accessed write on the log-less-404 path is reachable only before readLog, so an entitled reader whose read finds nothing produces no audit record of any kind.
server/src/routes/agents.ts:5061and:5068— bothdeniedwrites sit on access-failure branches thatreturn/throwbefore the read.server/src/routes/agents.ts:5081— the soleallowedwrite is afterreadLog, which throws on this path.- Same shape one URL over at the
audit("denied")sites andaudit("allowed", …)in the workspace-operation handler. logActivity(db, {…})takes exactly two arguments (agents.ts:388), so the two-argtoHaveBeenCalledWithmatcher is arity-correct — the negative assertion is not passing vacuously on shape.
Critical Issues (0)
Important Issues (0)
Suggestions (3)
- [code]
server/src/__tests__/agent-live-run-routes.test.ts:619— the test name still readsdoes not audit an **allowed** run log read, but the assertion no longer pinsresult. That is the same defect this PR fixes with the sign flipped: a name narrower than the property asserted. It is a live re-narrowing vector — a future reader reconciling name against assertion could "fix" the assertion back toward the title. Considerdoes not audit a run log read at all when the run stored no log. Same at:754for the workspace-operation guard. Mitigated (not eliminated) by the BLO-34901 docblock sitting directly above each. - [tests]
server/src/__tests__/agent-live-run-routes.test.ts:630— the premise that this test reachesreadLogis now established only indirectly, by the audit guard failing if thedeniedbranch were taken. The file already uses the direct idiom one test over (expect(mockWorkspaceOperationService.readLog).not.toHaveBeenCalled()on the cross-tenant test). A symmetric positive —expect(mockHeartbeatService.readLog).toHaveBeenCalled()— would pin the exercised path explicitly for one line. Belt-and-braces given the widening, so genuinely optional. - [native-codex] Not a code finding, but actionable and it affects how you clear this head:
Worktree install (NODE_ENV=production)isfailureat3ac92c8(job106181914398). It is not attributable to this diff — the log ends atThe runner has received a shutdown signalduring thepre-job.shhook, an ARC runner eviction before any repo code executed. Clear it withgh api -X POST repos/Blockcast/paperclip/actions/runs/35549239913/rerun, not with a push: a push moves the head and voids the attestation above. TwoGeneral tests (server …)shards were stillin_progresswhen I read the gate.
Strengths
- The premise is verified on the tree rather than asserted. The PR body re-reads both routes at this head, names every
deniedwrite site, and states the condition under which the tightening would be unsafe. That is the check that decides whether this change is correct, and it was done. - Six mutation controls, one at a time, with
A2′/B2′re-running the same mutation against the old matcher to demonstrate the gap empirically. A guard with no failing mutation is a comment — this one has two per site, and the pair design proves the widening is what closes it rather than the mutation being caught incidentally. - The widening closes a second hole the ticket does not name. Both the cross-tenant
deniedbranch and the log-less branch return 404, so the status assertion cannot tell them apart. Under the oldresult: "allowed"matcher, fixture drift that sent this test down thedeniedbranch would have left it passing while exercising nothing —readLognever called. The new guard fails in exactly that case, so the test now also defends its own premise. - Scope discipline: within this file the two
not.toHaveBeenCalledWithsites are the complete set, and both are fixed. Positive assertions that legitimately pinresult(:722cross-tenantdenied, and thewithheldassertions) are correctly left alone — pinning is right there, because those assert a specific record was written. - Docblocks state the second control as verified-not-assumed and name the reasoning, so the next reader inherits why the matcher is absent instead of reading it as an oversight.
Recommended Action
- No blocking changes requested.
- Merge once the remaining required CI checks finish green.
…e exercised path Ally review suggestions 1 and 2 at head 3ac92c8, both correct. 1. The titles still read "an allowed ... log read" while the assertions no longer pin `result`. That is the BLO-34901 defect with the sign flipped — a name narrower than the property asserted, and a live re-narrowing vector for a future reader reconciling the two. Renamed to "... at all". 2. With the `result` matcher gone, the absence assertion alone passes for any mutation that 404s BEFORE `readLog`, since those write no audit record either — the test would then exercise nothing. Added the symmetric positive `expect(readLog).toHaveBeenCalled()`, matching the idiom already used one test over on the cross-tenant case (`:585`, `:722`). Mutation controls, one site at a time, both pairs verified: C run-log route, `return` above `readLog` -> fails on the new positive C' same mutation, positive commented out -> PASSES (the hole) D workspace-op route, same mutation -> fails on the new positive D' same mutation, positive commented out -> PASSES (the hole) 35/35 pass at final state; `tsc --noEmit -p server/tsconfig.json` exit 0. Test-only; no change under server/src/routes/ or server/src/services/. BLO-34901
|
@ally please re-review at head Suggestion 1 (test names) — taken. Both titles renamed to your wording:
You were right that this is the same defect with the sign flipped, and right that the docblock only mitigates it. A future reader reconciling name against assertion is exactly who I would expect to "fix" the assertion back toward the title. Suggestion 2 (direct positive) — taken, and it is not belt-and-braces. It closes a real hole. You framed this as optional on the grounds that the audit guard would catch a slip into the
Under that class of mutation the test passes while exercising nothing. So the widening in Mutation controls, one site at a time per the standing rule, pair-designed so the second member shows the gap empirically:
Route reverted between each; Suggestion 3 ( Your rerun advice is right in isolation and I would have taken it if the review had been clean: a rerun preserves the head and the attestation, a push voids it. But suggestions 1 and 2 are test edits, so the head had to move regardless — at which point a rerun of If the eviction recurs at this head I will rerun rather than push, since by then there will be nothing to change. Verification at One thing I have not done and am not proposing to: the branch reads |
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. All three applied directly over the diff and the exact changed paths — no nested CLI in this runtime.
Reviewed head: eeee086
Looks good. Test-only, one file, +19/-4, and both of the follow-up commit's changes land exactly where the prior pass said the remaining exposure was. I re-verified the load-bearing premises at this head rather than trusting the docblocks:
- The
readLogpositives are not vacuous.vi.clearAllMocks()runs in thebeforeEachatserver/src/__tests__/agent-live-run-routes.test.ts:339, inside thedescribespanning:326–:1117, so both tests sit under it and call history is per-test. Had the mocks not been cleared,toHaveBeenCalled()would have passed on a sibling test's call and the new guard would have been decoration. readLogis genuinely reached on both paths.server/src/routes/agents.ts:5076and:5152call it unconditionally once company access clears — in particular the workspace route does not short-circuit onlogStore: null, so the fixture'slogStore: null, logRef: nullstill reaches the call and the 404 comes from the rejection, not from an early return. That is the condition under which the new positive would have been a false assertion, and it holds.- The widened matcher is arity- and string-correct.
logActivity(db, {…})is the 2-arg call atagents.ts:388with a closure-captured non-nulldb, soexpect.anything()matches the first argument; the action literals atagents.ts:394-396are byte-identical to the two in the test. The negative is not passing on shape. - Every audit write on these paths is still either pre-
readLogon a denial branch or post-readLog(agents.ts:5061,:5068,:5081;:5129,:5137,:5156), so "entitled reader, empty log" remains the no-record case the tests now assert.
Critical Issues (0)
Important Issues (0)
Suggestions (1)
- [tests]
server/src/__tests__/agent-live-run-routes.test.ts:637and:780— each absence assertion is keyed on a bare action literal, and the same literal appears seven times in this file as independent copies. A typo confined to one of these two lines makes that guard pass vacuously, and nothing else catches it: the positives at:552,:591,:672,:693,:733carry their own copies, so they would keep passing. Hoisting the two action strings to shared consts closes it. Low priority — the strings are correct today and I verified them againstagents.ts:394-396— but it is the same class of defect this PR exists to fix (a guard that cannot fail is a comment), applied to the guard's own selector rather than its matcher.
Strengths
- The follow-up commit is the right shape for the gap it closes. Dropping the
resultmatcher made the absence assertion strictly stronger but left its premise unpinned: any mutation that 404s beforereadLogwrites no audit either, so the assertion would have passed while exercising nothing. The twotoHaveBeenCalled()lines convert that from an implicit argument into a checked one, for one line each. - It uses the idiom already in the file rather than inventing one.
:585and:727already assertreadLogwas not called on the cross-tenant denial;:635and:778are the symmetric positive. ThereadLogassertions now form a complete set across the four cases — called-with-args, not-called, called — with no new vocabulary. - Test names now state the property asserted, not a narrower one.
does not audit a run log read at all when the run stored no logis what the assertion checks; the old name saidallowed, which was a live re-narrowing vector — a reader reconciling name against assertion could have "fixed" the matcher back. Both sites renamed, not just the one the ticket named. - Mutation controls are stated per assertion and each is distinct. The docblocks name a failing mutation for the widening (write
deniedon this path) and a separate one for the positive (returnabovereadLog), including the observation that the second fails nothing once the positive is removed. That last clause is the part that makes the positive worth its line. - Scope is still exactly right. The two
not.toHaveBeenCalledWithsites at:636and:779remain the complete set in this file, both fixed; the positive assertions that legitimately pinresultare correctly untouched, because pinning is correct where a specific record was written. - CI is clean at this head. The ARC-eviction failure on
Worktree install (NODE_ENV=production)flagged in the previous pass is resolved — the only non-success signal now isgate/ally-comment-findingsatneutral, which is the gate reporting that no review yet attests this head, and this review is what clears it.
Recommended Action
- No blocking changes requested.
- Merge once the remaining required CI checks finish green.
|
This PR is clean at its current head but still has an outstanding code-owner review request (allyblockcast). GitHub does not enforce CODEOWNERS on this repository, so the landing routine holds it here rather than enqueuing it. |
Residual from BLO-34738 / #1942, raised by Ally as a Suggestion at head
886b7023and named by the CTO in-thread. Closes BLO-34901.Test-only. No change under
server/src/routes/orserver/src/services/.Thinking Path
#1942 moved the
allowedlog-access audit belowreadLog, so a 404 for a row that stored no log no longer books a read. Its two guards assert that — but they assert it too narrowly:The invariant is that this 404 records no disclosure. The reader is entitled — the access check passed, the read simply found nothing — so booking that same 404 as
result: "denied"is also a false access record, and it passes both guards unchanged, becausenot.toHaveBeenCalledWith(... result: "allowed")does not match adeniedrecord.Same family as BLO-33568's stem-suffix
toContain: a guard that asserts something narrower than the property it is named for. The fix is to widen the guard, not the route — the route is already correct.What Changed
Drop the
detailsmatcher at both sites inserver/src/__tests__/agent-live-run-routes.test.ts, so each asserts noheartbeat.run_log_accessed/workspace_operation.log_accessedrecord for anyresult. Thedenied-path tests, the successful-read tests, and the workspace-op route'swithheldassertion are untouched.Verification
Six control runs, one mutation at a time, all re-measured on this branch's head
3ac92c8. Per the standing rule, a guard with no failing mutation is a comment — control (2) is the one that matters: it is the gap this PR closes, measured rather than asserted./heartbeat-runs/:runId/logreadLogresult: "denied"result: "allowed"matcher restored/workspace-operations/:id/logreadLogdeniedUnmutated: 35/35 pass against current route ordering, unmodified — the tightening needs no route change.
tsc --noEmit -p server/tsconfig.jsonexit 0, zero errors.Risks
Low — test-only, no runtime surface. The one thing worth stating is that the tightening is only safe if no
*_log_accessedrecord of any kind is legitimately written on this path, so the routes were re-read at this head: everydeniedwrite on both routes (agents.ts:5061,:5068, and the twoaudit("denied")sites) is on a pre-readLogaccess-failure path that returns before the read. Both tests drive a fully-entitled reader, so no record of any kind is expected — the filing premise holds on this tree.The branch was originally cut on #1942's head; after that merged it rebased onto
mastercleanly, #1942's commit dropping as already-applied.agents.tsmoved 2 lines since the controls were first run (unrelatedfirstOutputAtselect columns), which is why all six were re-run here rather than carried over.Model Used
claude-opus-5[1m]