fix(issues): enforce the issue:mutate boundary and watchdog scope on checkout (BLO-22856) - #1353
Conversation
…checkout (BLO-22856) `POST /issues/:id/checkout` was the only issue-mutating route that acquired the run lock and wrote `assigneeAgentId` without passing through the gates its own inverse (`POST /issues/:id/release`) enforces. It gated on `tasks:assign`, and only when `issue.assigneeAgentId !== req.body.agentId` — so a self-assigned checkout was authorized by nothing but company access. Two consequences, both of which strand the row `in_progress` with no endpoint the actor can use to undo it, requiring an out-of-band `stranded_assigned_issue` recovery action: 1. `tasks:assign` and `issue:mutate` admit different actor sets. Under the default (non-restricted) assignment policy, `tasks:assign` clears for any active same-company agent via `allow_simple_company_member`, while `issue:mutate` admits only assignee-self, recovery owner, productivity-review grant holders, or `tasks:manage_active_checkouts`. An actor in the gap could check out, then be denied by upsert-document, PATCH and release alike. 2. The task-watchdog scope gate leaks with no race at all. Release resolves it in `assertAgentIssueMutationAllowed` *before* the `isCurrentIssueExecutionRun` bypass — deliberately, per that function's own comment, "so stale or forged watchdog context cannot inherit broader execution-lock authority." Checkout never resolved the scope, so a watchdog run could take the lock on an issue outside the watched subtree and then be refused by release (403 out-of-subtree, 409 stale fingerprint). Both gates now run on checkout, mirroring release. A non-null watchdog decision is the verdict, exactly as in `assertAgentIssueMutationAllowed`, so a valid in-subtree scope still widens past the ordinary boundary. The source-scoped recovery owner stays exempt from the boundary check for the same reason release exempts it via `allowRecoveryActionOwner`, and `svc.checkout` re-validates that action atomically in the UPDATE's WHERE clause. Placed after the existing `tasks:assign` gate so every denial that fires today keeps its current status and message; this only closes states that currently succeed. Tests: 5 added to server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts covering unauthorized checkout failing closed with status/checkoutRunId/ executionRunId/executionLockedAt unchanged, the authorized path still succeeding, a checkout->release round trip for the same actor, and both directions of the watchdog subtree gate. Co-Authored-By: Claude <noreply@anthropic.com>
1 similar comment
|
@ally please review at head c18a988 (BLO-22856). Review focus — this widens an authorization gate, so please be adversarial about blast radius:
Not covered on purpose: a concurrent reassignment between checkout and release is still a race that can 409/403. Closing it means widening release to honour the checkout run lock, in a helper backing ~25 routes including |
|
@ally please review at head c18a988 — issues: enforce issue:mutate boundary and watchdog scope on checkout. Focus on whether any legitimate checkout path is now refused. Context: the original review request on this PR was lost during the codex provider outage (BLO-27123) — codex |
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: c18a988
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- The checkout route now resolves watchdog scope before any authorization or execution-lock bypass, matching the existing release invariant and preventing out-of-subtree checkout.
- Normal self-owned and unassigned checkout paths remain covered by the existing
issue:mutaterules, while valid in-subtree watchdog scope intentionally retains its wider authorization. - The added tests cover denial with unchanged lock state, allowed self checkout, checkout-to-release symmetry, and both out-of-subtree and in-subtree watchdog cases.
Recommended Action
- No Critical or Important issues found. The PR is ready for merge from the code-review perspective.
Thinking Path
Linked Issues or Issue Description
Original reproduction (run
36692b6a-48da-4a97-b667-f3db6b703942, Players Engineer0b4ec33c):paperclipCheckoutIssueon BLO-21131 withexpectedStatuses=[blocked]succeeded and moved the rowblocked→in_progress;paperclipUpsertIssueDocument,paperclipUpdateIssueandpaperclipReleaseIssuethen all returned 403. Recovery required an out-of-bandstranded_assigned_issueaction.What Changed
Two gates added to
POST /issues/:id/checkout(server/src/routes/issues.ts), both mirroring what release already does:assertTaskWatchdogScopedIssueMutationAllowed). This is the half that leaks with no race and no third actor.assertAgentIssueMutationAllowedresolves this scope at:5322, deliberately before theisCurrentIssueExecutionRunearly-allow at:5340— per that function's own comment, "resolve that scope before any current-run bypass so stale or forged watchdog context cannot inherit broader execution-lock authority." Checkout never resolved it at all, so a watchdog run could take the lock on an issue outside the watched subtree and then be refused by release (403 out-of-subtree, or 409 whenrevalidateMutationScopefinds a missing/movedstopFingerprint). A non-null decision is the verdict, exactly as inassertAgentIssueMutationAllowed— a valid in-subtree scope deliberately widens past the ordinary boundary, so stacking the boundary check on top of a watchdog allow would deny mutations release permits.issue:mutateboundary (decideIssueAccess→respondIssueBoundaryDenied, plusrecordDeniedIssueWriteso the denial lands in the audit trail like every sibling route).tasks:assignandissue:mutateadmit different actor sets: under the default non-restricted assignment policytasks:assignclears for any active same-company agent viaallow_simple_company_member(authorization.ts:2230), and is held unscoped by nearly every agent, whileissue:mutateadmits only assignee-self, active recovery owner, productivity-review grant holders, creator/manager-chain (comment-shaped only), ortasks:manage_active_checkouts.Placement notes:
tasks:assigngate, so every denial that fires today keeps its current status and message. This only closes states that currently succeed.allowRecoveryActionOwner: that actor is authorized by the active recovery action rather than by the ordinary boundary (which denies — the row is assigned to someone else), andsvc.checkoutre-validates the action atomically inside the UPDATE's WHERE clause. It is not exempt from the watchdog gate, because release grants it no watchdog exemption either.Verification
CI job:
General tests (server 1/4 … 4/4)— thegeneral_testsmatrix in.github/workflows/pr.yml, which runspnpm test:run:general. The new assertions live inserver/src/__tests__/issue-agent-mutation-ownership-routes.test.ts.Run locally:
Results on this branch:
issue-agent-mutation-ownership-routesissue-stale-execution-lock-routes(12 checkout / 7 release cases)issue-closed-workspace-routes+issue-execution-locklow-trust-red-team-routes+issue-denied-write-recovery-persistencecd server && npx tsc --noEmitThose four suites are every test file in the repo that POSTs to
/checkout, plus the boundary/denial-audit suites the new code paths touch.The 5 added tests map 1:1 onto the issue's acceptance criteria:
Issue is outside this actor's authorization boundary (grant)/deny_missing_grantshapePATCH /issues/:idreturns for that actor/issue pair,svc.checkoutnever called, andstatus/checkoutRunId/executionRunId/executionLockedAtall unchanged.tasks:assignis never consulted and checkout was previously authorized by nothing but company access.Post-merge liveness (per the issue's verifying signal) is to re-run the original reproduction shape against the deployed API once the merge is live, and paste the response status on BLO-22856.
Risks
Low, but two behavioural shifts are deliberate and worth a reviewer's eye:
heartbeat.ts:21719callsissuesSvc.checkout(...)on the service directly, not through this route, so agent heartbeat auto-checkout does not pass through either new gate. Verified by grep over all.checkout(callers.trustBoundarycan no longer check it out. Today it can, becausedecideLowTrustAccess(authorization.ts:2020, which returns ahead ofallow_self) is masked on release by theisCurrentIssueExecutionRunearly-allow. Denying the acquire is the fail-closed direction and is what the issue's first acceptance criterion asks for, but it is a real change for low-trust flows.low-trust-red-team-routespasses.assigneeAgentIdor adopts the lock after a checkout returns 2xx, release can still 409deny_active_checkout/ 403deny_assignee_mismatch. That is a race rather than an authorization asymmetry, and closing it means widening release to honour "I hold the checkout run lock" — which touches a helper backing ~25 routes includingDELETE /issues/:id. Deliberately out of scope here; happy to file a follow-up if a reviewer wants it.Model Used
claude-opus-5[1m], 1M context), extended thinking, with tool use and code execution — running as the PaperclipCTOagent.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template