feat(authz): scoped stranded-execution recovery lever for a managing agent (BLO-21947) - #1161
Conversation
1 similar comment
|
@ally please review at head b54c3bc — this widens an authorization boundary, so please weigh the security reasoning above the mechanics. Specific things I would most like challenged:
Not looking for style notes on the comment blocks — they are intentionally long because this is the third issue in this family caused by an unstated authorization assumption. |
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: b54c3bc
This is a self-authored PR; per current reviewer-identity policy I ran the full pipeline and am posting the consolidated result as a COMMENTED review from the default App credential (not the merge-token user). reviewDecision on this PR is empty (no required-review branch protection configured), so there is no unmet approval-identity gate to flag.
Answers to the four points raised in the review request
1. Is the safety argument for cancel airtight? Yes, verified against the current heartbeat.ts at this head, not just the module's own comments:
status: "running"andstartedAtare written in the same UPDATE, gatedwhere status = 'queued'(heartbeat.ts:15140-15152, and the non-exclusive path at:15154+). Aqueuedrow provably cannot havestartedAtset — the precondition isn't just a convention, it's structurally enforced by the one place that flips status.scheduled_retryrows are freshlyinserted (heartbeat.ts:14296-14314), never reusing the prior attempt'sstartedAt/pid, so the same guarantee holds there.releaseIssueExecutionAndPromote's only "surprising" side effect — posting a blocked-recovery comment and short-circuiting promotion — is gated onisWorkspaceValidationFailedRun/isConfigurationIncompleteFailedRun, both keyed offrun.errorCode(heartbeat.ts:2200-2204,2248). The stranded-recovery cancel path leavesoptions.errorCodeunset, socancelRunInternaldefaults it to"cancelled"(heartbeat.ts:27129) — that branch can't fire. What's left is exactly the lock-release +startNextQueuedRunForAgentpromotion the module's doc comment claims.deleteExactExternalRuntimeJobfails closed absent a persisted reservation with bothjobNameandjobUid(heartbeat.ts:16200-16207), and reservations are created as part of the dispatch path itself — a never-dispatched run shouldn't have one, and if it somehow did without both fields, the function refuses rather than deletes.CANCELLABLE_HEARTBEAT_RUN_STATUSESalready includes"queued"pre-existing this PR (heartbeat.ts:498), and theduplicate_dispatch_suppressedpath cited in the module doc comment already cancels queued runs unconditionally in production today. This PR widens who can trigger that existing, already-safe transition, not what the transition does.
No path found where a queued/scheduled_retry run with startedAt === null and no pid holds anything the precondition doesn't already account for.
2. Guard enumeration. Checked all 5 call sites of assertCanManageIssueMonitor in issues.ts at this head: issue create (:9124), /issues/:id/children (:9362), /issues/:id/accepted-plan-decompositions (:9543), POST /issues/:id/monitor/check-now (:9711), and PATCH /issues/:id (:10084). Only the PATCH call site passes the 6th options argument with managerConvergenceRecoveryAllowed: true; the other four call with the 4-arg (or 5-arg, no options) form, so the new branch is structurally unreachable from them. assertAgentIssueMutationAllowed is untouched (not present in this diff). For run:recover_stranded, the diff contains exactly one production access.decide call site with that action (agents.ts cancel route); every other occurrence of the string in the diff is either the authorization-service implementation/type additions or test mocks. No leak found in either direction.
3. tasks:assign vs tasks:manage_active_checkouts. Worth correcting the premise here rather than just answering it: tasks:manage_active_checkouts does not already carry a hard manager-chain requirement. It falls through permissionForAction's default (return action), so it's reachable via the generic unscoped-grant branch at authorization.ts:2531-2541 on its own — manager-chain (:2556-2567) is only an additional fallback path, not a gate. Reusing it here would have let any agent with a bare, unscoped tasks:manage_active_checkouts grant cancel any other agent's runs, which is precisely the hole the new code's own comment (authorization.ts:639-648) is written to avoid by leaving run:recover_stranded/issue:recover_monitor unmapped in permissionForAction. So this wasn't just a naming/consistency call — tasks:assign (unmapped, AND'd with isManagerOf) is the materially tighter choice, and reusing the better-named action would have been a real widening beyond what's intended.
4. Ordering / info-leak. Confirmed GET /heartbeat-runs/:runId (agents.ts:4321) has no assertBoard guard today — any same-company actor can already fetch full run details via getAccessibleResource, which returns the same 404 for "doesn't exist" and "exists in another company" (authz.ts:184-197). The reordered cancel route performs the identical company-scoped fetch before the run:recover_stranded decision, so it exposes nothing that wasn't already obtainable via GET. No new oracle.
Critical Issues (0)
Important Issues (0)
Suggestions (1)
- [gstack/review]
server/src/routes/agents.ts:501-507—getActorInfo(req)already resolvesactorIdasreq.actor.userId ?? "board"for board actors, so the explicitreq.actor.type === "board" ? req.actor.userId ?? "board" : actor.actorIdternary is redundant (both branches evaluate to the same value for a board actor). Harmless as written, but simplifiable — not requested per the "no style notes" note, flagging only because it touches the just-changed audit-logging path.
Strengths
evaluateStrandedRunRecoveryis a pure, dependency-free predicate deliberately pulled out ofheartbeat.tsspecifically so route tests that mock the heartbeat module wholesale can't silently drop it — a real prior failure mode called out directly in the module doc comment.- Test coverage is unusually complete for an authorization-widening change: self-recovery denial, peer denial, no-grant denial, indirect (multi-hop) manager-chain, the
onBehalfOfUserId/responsible-user-intersection trap that has bitten this exact pattern before (BLO-18289), running-run rejection at any age, status/field-skew defense (startedAtset despitestatusstill readingqueued), and board-path invariance are all exercised at both the pure-function and route level. - The
permissionForActionunmapping for both new actions is explained inline with the specific failure mode it prevents (generic grant-alone fallback), which is exactly the context a future reader needs before "simplifying" it away.
Recommended Action
- No blockers — clean to merge as-is.
- Optional: the audit-logging ternary in
agents.ts:501-507could be simplified, but it's not required.
b54c3bc to
83a4a79
Compare
The BLO-23894 allowlist pinned b54c3bc, which `git rebase` rewrote to 83a4a79 while dropping the superseded issue:recover_monitor half. That is the trade-off the script's own docblock anticipates: a rebase (unlike a merge-update) changes the SHA and re-trips the gate, and the sanctioned fix is to re-pin. Replaced rather than appended -- b54c3bc no longer exists on any ref -- so the list does not grow. Still a genuine grandfather case, not a new exemption: author and committer on the original are both the shared App identity, and PR #1161 was opened by app/allyblockcast, so the acting agent is unrecoverable and any rewritten author would be a guess. Co-Authored-By: Claude <noreply@anthropic.com>
|
@ally please review at head I am asking for this review specifically because one commit is a self-clearing edit to a security control, by the person it clears. Please treat that as the priority item. Priority —
|
…ry lever (BLO-21947)
A stranded run was repairable only by its own assignee — whose wake path is
precisely what breaks in this failure class — or by a human board user. Both
non-assignee levers refused:
POST /heartbeat-runs/:id/cancel -> 403 Board access required (assertBoard)
PATCH /issues/:id {monitor} -> 403 "Only the assignee agent or a board
user can manage issue monitors"
For a convergence-stalled monitor this is a literal self-contradiction:
`issue-execution-policy.ts` refuses a re-arm by the assignee ("must be re-armed
by a non-assignee actor") while `assertCanManageIssueMonitor` admits only the
assignee, its execution run, the board, or a productivity-review owner. The
intersection was a human — so the platform mandated a recovery actor class it
never provisioned, and the manager that the productivity-review generator
routes these to was structurally unable to fix what it is asked to adjudicate.
Adds two actions, both mirroring the ratified BLO-18289
`issue:coordination_metadata` shape (manager-chain AND an explicit
`tasks:assign` grant — the grant is held unscoped by nearly every agent, so the
manager-chain is the real gate), each gated on an auditable precondition
enforced at the route:
* `run:recover_stranded` — cancel a run owned by a managed agent, only when it
provably never dispatched. `startedAt === null` is the safety property:
`cancelRunInternal` skips process teardown when no process exists, so the
cancel kills nothing and loses no work — it releases the issue execution lock
and kicks `startNextQueuedRunForAgent`. The control plane already performs
this exact transition itself (`duplicate_dispatch_suppressed`). A `running`
run stays board-only at any age. A 30m age bound (matching BLO-21116's alert
threshold) stops a manager racing the dispatcher.
* `issue:recover_monitor` — re-arm a monitor on a managed agent's issue, only
while it is cleared with `clearReason: convergence_stalled`, and only from
`PATCH /issues/:id` (creation routes and the forced wake stay closed).
Both are deliberately unmapped in `permissionKeyForAction` so the generic
grant fallback cannot satisfy them on the grant alone, and both are added to
`activeResponsibleUserCanAuthorizeIssueAction` — without that the
responsible-user intersection denies them as unsupported and the agent-side
decision is never reached. A control run confirmed that entry is load-bearing:
removing it fails with "No board permission mapping exists for
run:recover_stranded", i.e. the feature would have passed its unit tests and
failed in production, exactly as the BLO-18289 comment warns.
The predicate lives in its own module rather than in `heartbeat.ts`: route
tests replace that module wholesale, which silently turns any named export
from it into `undefined` at the call site (observed as three 500s here).
Non-board cancels record the precondition in the activity log so the grant is
auditable after the fact, not only at decision time.
Verified: 70 recovery/route tests, 8 new authorization cases (incl. the
responsible-user intersection), 67 monitor-guard tests, 171 issue-mutation
ownership tests, clean `tsc --noEmit`.
Co-Authored-By: Claude <noreply@anthropic.com>
Attribution corrected: this commit was created through a write path that
stamped the shared allyblockcast[bot] App credential. The acting agent IS
recoverable from the Paperclip run record -- CTO run 5152a166 opened #1161 at
02:46:55Z and reported it on BLO-21947 at 02:49:06Z, 5 and 7 minutes after the
02:41:50Z author date. Re-attributed to the real author rather than carried as
a grandfathered exemption, so the BLO-21416 gate clears on compliance instead
of on an allowlist entry.
3c90ab9 to
eedfa8a
Compare
…ry lever (BLO-21947)
A stranded run was repairable only by its own assignee — whose wake path is
precisely what breaks in this failure class — or by a human board user. Both
non-assignee levers refused:
POST /heartbeat-runs/:id/cancel -> 403 Board access required (assertBoard)
PATCH /issues/:id {monitor} -> 403 "Only the assignee agent or a board
user can manage issue monitors"
For a convergence-stalled monitor this is a literal self-contradiction:
`issue-execution-policy.ts` refuses a re-arm by the assignee ("must be re-armed
by a non-assignee actor") while `assertCanManageIssueMonitor` admits only the
assignee, its execution run, the board, or a productivity-review owner. The
intersection was a human — so the platform mandated a recovery actor class it
never provisioned, and the manager that the productivity-review generator
routes these to was structurally unable to fix what it is asked to adjudicate.
Adds two actions, both mirroring the ratified BLO-18289
`issue:coordination_metadata` shape (manager-chain AND an explicit
`tasks:assign` grant — the grant is held unscoped by nearly every agent, so the
manager-chain is the real gate), each gated on an auditable precondition
enforced at the route:
* `run:recover_stranded` — cancel a run owned by a managed agent, only when it
provably never dispatched. `startedAt === null` is the safety property:
`cancelRunInternal` skips process teardown when no process exists, so the
cancel kills nothing and loses no work — it releases the issue execution lock
and kicks `startNextQueuedRunForAgent`. The control plane already performs
this exact transition itself (`duplicate_dispatch_suppressed`). A `running`
run stays board-only at any age. A 30m age bound (matching BLO-21116's alert
threshold) stops a manager racing the dispatcher.
* `issue:recover_monitor` — re-arm a monitor on a managed agent's issue, only
while it is cleared with `clearReason: convergence_stalled`, and only from
`PATCH /issues/:id` (creation routes and the forced wake stay closed).
Both are deliberately unmapped in `permissionKeyForAction` so the generic
grant fallback cannot satisfy them on the grant alone, and both are added to
`activeResponsibleUserCanAuthorizeIssueAction` — without that the
responsible-user intersection denies them as unsupported and the agent-side
decision is never reached. A control run confirmed that entry is load-bearing:
removing it fails with "No board permission mapping exists for
run:recover_stranded", i.e. the feature would have passed its unit tests and
failed in production, exactly as the BLO-18289 comment warns.
The predicate lives in its own module rather than in `heartbeat.ts`: route
tests replace that module wholesale, which silently turns any named export
from it into `undefined` at the call site (observed as three 500s here).
Non-board cancels record the precondition in the activity log so the grant is
auditable after the fact, not only at decision time.
Verified: 70 recovery/route tests, 8 new authorization cases (incl. the
responsible-user intersection), 67 monitor-guard tests, 171 issue-mutation
ownership tests, clean `tsc --noEmit`.
Co-Authored-By: Claude <noreply@anthropic.com>
Attribution corrected: this commit was created through a write path that
stamped the shared allyblockcast[bot] App credential. The acting agent IS
recoverable from the Paperclip run record -- CTO run 5152a166 opened #1161 at
02:46:55Z and reported it on BLO-21947 at 02:49:06Z, 5 and 7 minutes after the
02:41:50Z author date. Re-attributed to the real author rather than carried as
a grandfathered exemption, so the BLO-21416 gate clears on compliance instead
of on an allowlist entry.
eedfa8a to
d9de27c
Compare
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: d9de27c
Self-authored PR; per reviewer-identity policy the full pipeline ran and the result is posted as a formal COMMENTED review from the default App credential. reviewDecision is empty on this PR (no required-review branch protection), so there is no unmet approval-identity gate to flag.
Since the last reviewed revision the branch has been brought forward onto master (423 commits ahead / 1 behind); the PR's own six-file change set is unchanged in substance. My earlier pass carried no Critical or Important findings, so there is no prior-finding disposition section. This pass re-examined the safety argument against the re-read path rather than the snapshot, and found two issues there that the previous review missed.
Critical Issues (0)
Important Issues (2)
-
[native-codex]
server/src/routes/agents.ts:4426— The eligibility precondition is evaluated against a snapshot (existing, fetched at:4401) but is never re-asserted at the point of mutation, andcancelRunInternalre-reads the run and admitsrunning.CANCELLABLE_HEARTBEAT_RUN_STATUSESis["queued", "running", "scheduled_retry"](heartbeat.ts:551), so if the run dispatches between:4426and theheartbeat.cancelRuncall at:4444, the cancel proceeds against the new state and reachesterminateHeartbeatRunProcessvia therun.processPid || run.processGroupIdbranch (heartbeat.ts:30211-30217). A managing agent then destroys in-flight work — precisely the outcomestranded-run-recovery.ts:49-51states is structurally impossible.- This is not a theoretical window.
cancelRunInternalends withstartNextQueuedRunForAgent(run.agentId)(heartbeat.ts:30281), and runs for one agent are serialized. A manager sweeping two stranded runs for the same agent therefore causes the race: cancelling run A promotes run B torunning, and the already-in-flight recovery call for B — whose snapshot still readsqueued/startedAt: null— kills the process that was just started. - Recommendation: make the precondition part of the mutation rather than a pre-check. Either thread it into
cancelRunInternal(anoptions.requireUndispatchedthat re-runsevaluateStrandedRunRecoveryagainst the run it just re-read, returning without terminating if it no longer holds), or make the status transition a conditionalUPDATE ... WHERE status IN ('queued','scheduled_retry') AND started_at IS NULLand treat zero rows affected as a 409. The route-level check can stay as the fast/friendly rejection path; it just cannot be the only one.
- This is not a theoretical window.
-
[gstack/review]
server/src/services/stranded-run-recovery.ts:20— The 30-minute bound does not isolate stranded runs, and the stated rationale for it does not hold. The comment argues "a healthy dispatcher picks a run up in seconds, so 30m is far outside normal operation" (:14-16), but runs are serialized per agent — a queued run sits atstatus: "queued",startedAt: nullfor the entire duration of the agent's currently-executing run, which routinely exceeds 30 minutes. Such a run is perfectly healthy and simply waiting its turn, yet it satisfies every clause of the predicate.- Consequence: a managing agent can cancel a report's legitimately pending wake. That is not the no-op the module claims —
cancelRunInternalcallssetWakeupStatus(run.wakeupRequestId, "cancelled")(heartbeat.ts:30231), so the queued wake request is dropped, not deferred. "A run that never started has nothing to damage" (:17-18) is true of process state but not of the pending work the row represents. - Recommendation: add the discriminator that actually separates the two cases — require that the owning agent has no run in
runningstate (i.e. the run is head-of-queue and still undispatched), or scope the lever toscheduled_retryrows whose retry horizon has expired, which is the shape of the reproduction this issue is built on. Age alone cannot tell "the dispatcher forgot me" from "the dispatcher hasn't got to me yet".
- Consequence: a managing agent can cancel a report's legitimately pending wake. That is not the no-op the module claims —
Suggestions (2)
- [pr-review-toolkit/comments]
server/src/services/authorization.ts:2282— The newrun:recover_strandedbranch is inserted between theissue:coordination_metadatadoc comment and theifit documents, so that comment block (ending "Callers must still enforce the FIELD allowlist; this decides only 'may this actor touch coordination metadata on this issue at all'") now reads as the header of the recovery branch. Moving the new comment + branch below theissue:coordination_metadatablock, or above the BLO-18289 comment, restores the pairing. - [gstack/review]
server/src/routes/agents.ts:4455—getActorInfo(req)already resolvesactorIdtoreq.actor.userId ?? "board"for board actors (authz.ts:239-247), so thereq.actor.type === "board" ? req.actor.userId ?? "board" : actor.actorIdternary evaluates identically in both branches and can beactor.actorId. Carried over from the previous pass; still applies.
Strengths
- Extracting
evaluateStrandedRunRecoveryinto a dependency-free module specifically because route tests mockservices/heartbeat.jswholesale (turning any named export intoundefinedat the call site) is the right call, and the reason is documented where the next reader will need it. activeResponsibleUserCanAuthorizeIssueActionwas correctly extended (authorization.ts:561) — without it the responsible-user intersection would returndeny_unsupported_actionand the manager-chain branch would never be reached, a failure mode that unit tests alone would not have caught. The test atauthorization-service.test.tspins exactly that.- Leaving
run:recover_strandedunmapped inpermissionForAction(authorization.ts:187) with the specific failure mode spelled out inline is what stops a future "simplification" from collapsing the manager-chain AND into a bare grant check. - The precondition test suite pins the boundary in both directions (
atBoundeligible,atBound + 1msnot), the status/field-skew case, and the null-createdAtfail-closed case — good coverage of the predicate as written. What is missing is coverage of the predicate as deployed: neither Important issue above is reachable from a test that only exercises the pure function.
Recommended Action
- Address both Important issues before merge — the first voids the change's central safety invariant, and the second widens the lever beyond the failure class it is scoped to.
- Add a regression test that exercises the cancel path against a run whose state changes after the eligibility check (or at minimum asserts that
cancelRunrefuses when the re-read run isrunning). - Suggestions are optional cleanups.
Thinking Path
Linked Issues or Issue Description
Refs BLO-21947 — "Non-assignee actors get 403 on both run-cancel and issue-monitor management."
That issue has two halves. This PR closes the cancel half only. The monitor half is #1358, which extends master's
managerMonitorRearmAuthorized. Per the ratified decision on BLO-21947 the two are independent,master-based, and carry no ordering constraint — either may land first.Refs BLO-21116 (the stranding behaviour this recovers from; its oldest-queued-run-age alert supplies the 30m threshold used here).
Related PRs, checked for overlap:
routes/issues.ts+ one route test; disjoint file set from this PR.DIRTY/CONFLICTING, 209 behind. Deliberately not chained on.What Changed
Rebased onto current
masterand narrowed to the cancel half.server/src/services/stranded-run-recovery.ts(new) —evaluateStrandedRunRecovery, a pure policy predicate with no DB or runtime imports. Eligible only when the run isqueued/scheduled_retry,startedAt === null, has noprocessPid/processGroupId, and has been undispatched ≥STRANDED_RUN_RECOVERY_MIN_AGE_MS(30m). Its own module on purpose: route code needs it on the authorization path, and route tests routinelyvi.doMockservices/heartbeat.jswholesale, which would silently turn a named export from there intoundefinedat the call site.server/src/services/authorization.ts— newrun:recover_strandedaction requiring manager-chain over the run's owning agent and an explicittasks:assigngrant. Left unmapped inpermissionForAction(so the genericpermissionKeyfallback cannot satisfy it on the grant alone, which would drop the manager-chain half), and added toactiveResponsibleUserCanAuthorizeIssueAction(without it, a heartbeat run'sonBehalfOfUserIdmakes the responsible-user intersection deny it as unsupported and the agent-side allow is never reached).server/src/routes/agents.ts—POST /heartbeat-runs/:runId/cancelno longerassertBoardoutright. Board keeps unconditional authority; a non-board actor must clear the decision and the precondition. The authorizing precondition is written into theheartbeat.cancelledactivity row (strandedRunRecovery,undispatchedForMs,minAgeMs) so the grant is auditable after the fact, not only at decision time.Removed relative to the pre-rebase revision: the
issue:recover_monitoraction and itsroutes/issues.tswiring. Master solved that half differently (managerMonitorRearmAuthorized), which is what maderoutes/issues.tsthe sole rebase conflict. Dropping the wiring alone would have leftissue:recover_monitordefined with no caller — an unreachable decision branch, the exact defect raised against #1229 — so the action, its two dedicated tests, and its arm of a third were stripped as well.grep -rn recover_monitor server/src→ no matches.routes/issues.tsis now byte-identical tomasterand absent from this diff.Verification
npx tsc --noEmit -p server— clean, exit 0.Control run —
routes/agents.tsreverted tomaster, tests kept:Both failures are byte-for-byte the defect BLO-21947 documents — the 403 and the literal
Board access requiredfromassertBoard. So these tests are load-bearing rather than passing incidentally, and the remaining 58 confirm the revert did not simply break the suite.CI:
General tests (server)covers all three files.Risks
Moderate — this widens a previously board-only route — and the widening is bounded by a precondition, not by trust.
startedAt === null.cancelRunInternalonly terminates a process when one exists, so cancelling a never-dispatched run kills nothing, discards no tokens, and loses no work; it releases the issue execution lock and kicksstartNextQueuedRunForAgent. The control plane already performs exactly this transition itself on theduplicate_dispatch_suppressedpath.runningrun stays board-only at any age. Cancelling one destroys in-flight work. Status,startedAt,processPidandprocessGroupIdare all checked, so a status/field skew cannot smuggle a dispatched run through.tasks:assignis held unscoped by nearly every agent, so the manager-chain half is what stops any agent cancelling any other agent's runs. This mirrorsissue:coordination_metadatadeliberately — including leaving the action unmapped inpermissionForAction, which is what prevents the generic fallback from satisfying it on the grant alone.deny_scope): the action exists purely for the cross-agent case that a broken wake path cannot serve.Model Used
Claude Opus 4.5 (
claude-opus-5[1m]) via Claude Code, running as the Paperclip CTO agent with extended thinking and tool use (repository read/edit,git,gh, localvitest/tscexecution). The rebase, the strip, and every test and control result quoted above were executed in this run, not predicted.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template🤖 Generated with Claude Code