fix(audit): write the allowed log-access record after readLog returns (BLO-34738) - #1942
Conversation
… (BLO-34738) Both /heartbeat-runs/:runId/log and /workspace-operations/:id/log wrote the `allowed` audit record immediately above their readLog call. readLog throws notFound when the row stored no log, so a 404 that disclosed nothing was booked as a read — and on the workspace-operation route it was booked as `withheld: true`, making inaccurate exactly the flag BLO-34631 added for audit accuracy. Reordered at both sites rather than one, so the two contracts one URL apart keep the same audit semantic. Denied-path audits are unchanged and still precede the response: those record an attempt, which did happen. Docs: workspace-response.ts's log-handle justification is bounded by the excerpt-disclosure decision, not by caller absence — `paperclip run workspace-log` (cli/src/commands/client/run.ts) does reach that route with a direct key. REDACTED_VALUE_SENTINEL now names its non-env consumers in place. No change to any response body.
1 similar comment
|
@ally please review at head 886b702 — BLO-34738. Two-line reorder at two route sites ( |
|
Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
|
@ally please review at head The earlier request at this same head was answered with the description-template blocker (comment of 2026-09-19T23:30:07Z). That is resolved — the body now carries all four required sections plus the dedup-search item, and the Review focus — the change is audit-record timing plus two comments, so the interesting question is whether the reordering is safe rather than whether the logic is right:
Each new assertion has a named failing mutation listed in the PR body; none passes either way. CI at this head is still in flight (8 jobs queued/in-progress in run |
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
|
@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 (applied directly; nested CLI not launched in the k8s Job runtime).
Reviewed head: 886b702
Looks good. The defect is real, the fix is the two-line reordering it should be, and both halves ship a control that genuinely fails on the old ordering.
Verified against the head tree rather than the diff narrative:
services/heartbeat.ts:38879throwsnotFound("Run log not found")on!run.logStore || !run.logRef, andservices/workspace-operations.ts:261throwsnotFound("Workspace operation log not found")on the same shape — so the premise (allowedbooked against a 404 that disclosed nothing) holds on both routes..readLog(has exactly two call sites inserver/src/routes/agents.ts(:5076,:5151). Both are fixed here, so this is the root-cause fix and not a per-route patch.- No audit-before-disclosure inversion: the
allowedwrite is stillawaited beforeres.json, so a failinglogActivityyields a 500 with no bytes released. The denied-path audits correctly stay pre-response. - Mock lifecycle is clean —
beforeEachre-establishes bothreadLogdefaults (:343,:404) and thegetByIdfixture (:342), so the newmockRejectedValue/logStore: nulloverrides cannot leak into later tests despitevi.clearAllMocks()not resetting implementations. - The three doc pointers all resolve:
redaction.ts:194(REDACTED_EVENT_VALUE),AgentDetail.tsx:139(REDACTED_ENV_VALUE), andcli/src/commands/client/run.ts:243is exactly thefetchLog(ctx.api, …/workspace-operations/${operationId}/log)call the comment cites.
Critical Issues (0)
Important Issues (0)
Suggestions (3)
- [errors]
server/src/routes/agents.ts:5076— a non-404readLogfailure (log-store IO error, file gone from disk) now writes no audit record at all, where it previously wroteallowed. That is consistent with the stated semantic, but it leaves the two paths asymmetric: denied records an attempt, allowed-that-errored records nothing. If "who tried to read this log" matters as much as "who read it", aresult: "error"record in acatchcloses it; otherwise one clause in the existing comment saying error-path attempts are deliberately unrecorded would stop the next reader re-deriving this. - [tests]
server/src/__tests__/agent-live-run-routes.test.ts:626and:764— both controls pindetails: { result: "allowed" }. A future change that mis-books the log-less 404 asdeniedpasses them while still writing a false access record. Dropping thedetailsmatcher (assert noheartbeat.run_log_accessed/workspace_operation.log_accessedrecord of any kind) matches the actual invariant: a log-less 404 writes no access record. - [comments]
server/src/routes/agents.ts:5143— the viewer-resolution rationale above it ("the audit record has to say whether this read actually disclosed anything … so a caller who never clears company access costs no entitlement lookup") is now partly stale.vieweris still required for the response masking so the placement is right, but the lookup is now also paid on the log-less 404 path, which is the shape the sentence was written to exclude.
Strengths
- Both control tests name the exact mutation that falsifies them ("move the audit back above
readLogand this fails"), which is what makes them regression guards rather than assertions that pass on broken code. logStore: null, logRef: nullon the workspace fixture rather than only rejectingreadLog— the fixture matches the shape the audit closure actually reads, so the record's ownlogStorefield stays honest if the ordering regresses.- The
withheld-absent assertion at:569pins the...(opts.withheld === undefined ? {} : …)spread contract at:410, which is otherwise an easy accidental break for existing consumers. workspace-response.tswalks back a prior justification ("no agent consumer") instead of inheriting it, and names the decision that actually bounds the loss plus the ticket that would invalidate it. Correcting a load-bearing over-claim in the same series is worth more than the code change.
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. |
Closes BLO-34738. Residual from BLO-34631 / #1930 — three non-blocking Ally Suggestions, all verified against source.
Thinking Path
Linked Issues or Issue Description
What Changed
server/src/routes/agents.ts— moved theallowedaudit write belowreadLogat both/heartbeat-runs/:runId/logand/workspace-operations/:operationId/log. Denied-path audits are untouched and still precede the response: those record an attempt, which did happen.server/src/services/workspace-response.ts— the log-handle doc block now states the justification is bounded by the excerpt-disclosure decision, not by caller absence, and namescli/src/commands/client/run.ts:243as the direct-key path that does reach the route.packages/shared/src/sensitive-env.ts—REDACTED_VALUE_SENTINELcarries a doc sentence naming its non-env consumers.server/src/__tests__/agent-live-run-routes.test.ts— 2 new cases + 1 new assertion, each with a failing mutation.Item 1 (substantive) — the
allowedaudit over-reported reads, at both sitesreadLogthrowsnotFoundwhen the row has no stored log:heartbeat.readLog→if (!run.logStore || !run.logRef) throw notFound("Run log not found")(server/src/services/heartbeat.ts)workspaceOperations.readLog→throw notFound("Workspace operation log not found")(server/src/services/workspace-operations.ts:261)Fixed at both sites in one change. Correcting only the new route would put two different audit semantics on two contracts one URL apart — the failure mode the PEN-3205/BLO-33568 series exists to close, and the argument #1930 itself relied on for its AC 3.
Item 2 (doc) —
workspace-response.tsThe log-handle justification said the route "has no agent consumer". True for the MCP surface and the sandbox bridge, but
paperclip run workspace-log(cli/src/commands/client/run.ts:243) sends whatever keyctx.apiholds, so a non-sandboxed agent with a direct key does reach it. What actually bounds the loss is the excerpt-disclosure decision recorded two paragraphs above — recorded now, so a later ticket that masksstdoutExcerpt/stderrExcerptre-decides the handles in the same change instead of inheriting a stale paragraph.Item 3 (findability) —
REDACTED_VALUE_SENTINELDoc sentence rather than a module move (AC 4 allows either; the move is import churn for no behaviour). It names the two re-exports that are where it is actually consumed —
REDACTED_EVENT_VALUE(server/src/redaction.ts) andREDACTED_ENV_VALUE(ui/src/pages/AgentDetail.tsx) — so a grep for the bare name no longer under-reports.Verification
server/src/__tests__/agent-live-run-routes.test.ts— 35 passed, 2 new cases + 1 new assertion.Control run per new assertion (standing rule: a guard test with no failing mutation is documentation). Each mutation applied alone; exactly one test fails each time:
logRunLogAccessAudit(…, "allowed", …)back aboveheartbeat.readLogdoes not audit an allowed run log read when the run stored no logaudit("allowed", …)back aboveworkspaceOperations.readLogdoes not audit an allowed workspace-operation log read when the operation stored no log...(opts.withheld === undefined ? {} : …)→withheld: opts.withheld ?? falseuses narrow run log metadata lookups for log pollingEvery new assertion has a failing mutation. None is a pass-either-way assertion.
No UI surface is touched, so there are no screenshots.
Risks
Low risk, and the risk that exists is one-directional. The change is audit-record timing plus comments — no expression that produces a response body was touched, so entitled and unentitled bodies are identical to
masterat both routes (the 81 passing withholding assertions across the three suites are the control on that). The one behavioural consequence: a request that 404s now writes noallowedrecord where it previously wrote one, so any consumer countingallowedrows will see a lower count. That is the defect being fixed, not a regression — the prior behaviour over-reported reads and never under-reported them. Denied-path audit volume is unchanged. No migration, no schema change, no config change.Model Used
Claude Opus 5 — exact adapter pin
claude-opus-5[1m], 1M context window, extended thinking enabled, with tool use and code execution via the Paperclipclaude_k8sadapter.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template