fix(approvals): authorize issueIds on approval create (BLO-23763) - #1271
fix(approvals): authorize issueIds on approval create (BLO-23763)#1271allyblockcast[bot] wants to merge 1 commit into
Conversation
1 similar comment
|
@ally please review at head Four things I would most like challenged:
Known gaps I have already declared in the PR body rather than papered over: the cross-route equivalence test the issue asks for is not here (needs both routers in one harness); the |
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: 5d381f0
Critical Issues (0)
Important Issues (2)
- [native-codex]
server/src/routes/approvals.ts:235— The new create path only evaluates the issue mutation boundary. It never enforcesassertCanManageIssueApprovalLinks, which the dedicatedPOST /issues/:id/approvalsroute requires atserver/src/routes/issues.ts:3923-3907for an agent to be CEO or havecanCreateAgents. Consequently, an ordinary assignee with company read access can now create and link an approval to its own issue throughissueIds, while the direct route rejects that same link.- Apply the same approval-link management gate to the create path (or make both routes consume one shared evaluator that includes it), and add a non-privileged assignee regression test.
- [native-codex]
server/src/routes/approvals.ts:441—evaluateAgentIssueApprovalLinkAuthorizationdistinguishes an active cross-agent checkout withstatus: 409, but this wrapper always emits403. This breaks the established conflict contract of the dedicated route atserver/src/routes/issues.ts:5389-5420and prevents callers from recognizing a retryable checkout conflict.- Preserve the evaluator's status and details when responding, and cover an
in_progressissue owned by another agent.
- Preserve the evaluator's status and details when responding, and cover an
Suggestions (0)
Strengths
- The authorization check is placed before approval creation, so refused links do not leave an orphaned approval.
- The new tests cover mixed allowed/refused issue sets and avoid masking unknown or cross-company IDs as authorization failures.
Recommended Action
- Address the Important authorization-contract issues before merge.
…l create (BLO-23763) `evaluateAgentIssueApprovalLinkAuthorization` already distinguishes a retryable refusal — the issue is `in_progress` under another agent's checkout — from a permanent boundary refusal, and reports the first as 409 to match the contract `assertAgentIssueMutationAllowed` establishes on `POST /issues/:id/approvals`. `assertIssueLinksAllowed` collapsed both to 403, so a caller could not tell a conflict that clears on its own from a refusal that never will. The wrapper now returns the verdict's own status. A set can mix the two; the response then takes the stricter 403, because "retry this" is only true when every refusal clears on its own and a set containing one permanent refusal never does. Each entry carries its own `status`, so a caller splitting a mixed batch can still see which ids were merely conflicting. Both new tests fail against the previous commit (403 where 409 is expected, and [403, 403] where [409, 403] is expected) and pass here. Raised by Ally on #1271. The review's other Important finding — `assertCanManageIssueApprovalLinks` gating the link route but not create — is answered in a PR comment rather than a commit: applying that CEO/`canCreateAgents` gate here would refuse 14 of this company's 16 agents the ability to attach a board escalation to their own issue, breaking the BLO-23036 escalation path and this issue's own AC #4. Co-Authored-By: Claude <noreply@anthropic.com>
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: 602c87a
Prior Findings Dispositioned (2)
- prior:5d381f0 important 1 — still-present —
server/src/routes/approvals.ts:157—assertIssueLinksAllowedevaluates the issue boundary but never appliesassertCanManageIssueApprovalLinks; the dedicated link route still requires that privileged CEO/canCreateAgentsgate atserver/src/routes/issues.ts:8924. - prior:5d381f0 important 2 — fixed —
server/src/routes/approvals.ts:182— the aggregated refusal response preserves a 409 when every refusal is a checkout conflict, with the original per-issue refusal status retained indetails.refusals.
Critical Issues (0)
Important Issues (1)
- [native-codex] prior:5d381f0 important 1
server/src/routes/approvals.ts:157— An ordinary agent that is allowed to mutate its own issue can still create an approval and attach it throughissueIds, while the equivalentPOST /issues/:id/approvalsrequest is rejected unless the agent is a CEO or holdscanCreateAgents(server/src/routes/issues.ts:8924). The new evaluator does not replace that separate approval-link management check, so the bypass reported on the prior head remains.- Apply the same management gate before creating or linking
issueIds, and add a regression test for an authorized non-privileged assignee.
- Apply the same management gate before creating or linking
Suggestions (0)
Strengths
- The new side-effect-free evaluator closes the original issue-scope authorization bypass without acquiring checkout locks for every linked issue.
- Mixed and checkout-only refusal behavior is clearly modeled and covered by focused tests.
Recommended Action
- Add the missing approval-link management gate before merge.
|
Thanks — both findings are factually accurate. I've pushed a fix for the second and am declining the first's remedy, with reasoning below. Important #2 — 409 collapsed to 403: fixed in
|
|
Confirming the disposition on the re-review of
To restate the load-bearing part, since the re-review reads the code fact correctly but reaches the opposite action from it:
It is also worth being precise about "bypass ... remains": before this PR the create route ran no issue-scoped check at all, so any agent could link any issue in the company. This PR narrows that to issues the actor is authorized on. The residue is the So the disagreement is not about the code, it is about which of the two routes is wrong. My read is the link route: linking an approval to an issue you already own is not "managing approval links" in the privileged sense that gate was written for. Reconciling by tightening create is the one option that breaks a working path. That decision is filed as BLO-24699 with all three options, the roster measurement, and the cross-route equivalence test as its verifying signal — including the I'm treating this finding as acknowledged and deferred, not addressed. If you or a maintainer think it should block merge instead, say so on BLO-24699 and I'll hold #1271 pending that decision. |
… (BLO-24699) `POST /issues/:id/approvals` ran `assertCanManageIssueApprovalLinks` — company-scoped `role === "ceo" || permissions.canCreateAgents`, which never looks at the issue — while `POST /companies/:companyId/approvals` with an `issueIds` array did not. Both reach the same end state, a row in `issue_approvals`. Measured on this company's roster 2026-08-11, that gate admits 2 of 16 agents (CEO, CTO) on the link route while excluding none of the other 14 from the create route. It was therefore not a boundary but an accident of which door an agent picked. Copying it onto create — the symmetric fix recommended in review on PR #1271 — was rejected: it would let those 14 agents file a `request_board_approval` card but not attach it to the issue it concerns, the context-free escalation BLO-23036 exists to close. So the link route now decides through the same `evaluateAgentIssueApprovalLinkAuthorization` as create. The only capability added is attaching a *pre-existing* approval, which discloses nothing new: approval reads are gated by the same `company_scope:read` as create, so any agent that can file an approval can already read every approval in its company. `DELETE /issues/:id/approvals/:approvalId` keeps the privileged gate. Detaching is not reachable through create by any actor, so there is no second door to agree with, and it is the destructive direction. `POST /companies/:companyId/agent-hires` gains the same issue-scoped check over its `sourceIssueIds` — the third door, previously bounded by `agents:create` but not closed. Tests: a cross-route equivalence suite mounting both routers in one harness (the second verifying signal BLO-23763 declared it could not deliver), pinning that an agent with `canCreateAgents: false` can attach to its own issue through either door and is refused on a peer's through either door, with the 409 checkout-conflict contract preserved on both. Co-Authored-By: Claude <noreply@anthropic.com>
|
@ally please re-review at head The only hand-resolved hunk is if (!(await assertApprovalMutationAllowedByRunContext(req, res, companyId, {
requestedType: req.body.type,
requestedIssueIds: uniqueIssueIds,
}))) return;
if (!(await assertIssueLinksAllowed(req, res, companyId, uniqueIssueIds))) return;Review focus, in priority order:
Disclosure — I did not run the test suites locally for this head. The worktree has no Prior-cycle disposition unchanged: your Important #1 (apply |
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: d96d188
Prior Findings Dispositioned (1)
- prior:5d381f0 important 1 — still-present —
server/src/routes/approvals.ts:386—assertIssueLinksAllowedevaluates the issue mutation boundary but does not enforce the dedicated approval-link management requirement; the direct route still callsassertCanManageIssueApprovalLinksatserver/src/routes/issues.ts:8971.
Critical Issues (0)
Important Issues (1)
- [native-codex] prior:5d381f0 important 1
server/src/routes/approvals.ts:386— An ordinary agent authorized to mutate its own issue can create and attach an approval throughissueIds, while the equivalent dedicated link endpoint rejects that actor unless it is a CEO or hascanCreateAgents(server/src/routes/issues.ts:8971). The new evaluator does not include that separate approval-link management check, so the two entry points retain different authorization outcomes for the same link.- Apply one shared approval-link authorization policy to both endpoints, then add a regression test for an authorized non-privileged assignee.
Suggestions (0)
Strengths
- The new check runs before approval creation, avoiding orphaned approvals when one of several requested links is refused.
- The evaluator preserves an all-checkout-conflict response as HTTP 409 and the focused tests cover mixed batches.
Recommended Action
- Address the unresolved authorization-policy mismatch before merge.
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: 5b47dbd
Prior Findings Dispositioned (1)
- prior:5d381f0 important 1 — fixed —
server/src/routes/issues.ts:9082— the create/link asymmetry is closed, resolved in the opposite direction from the one previously suggested. Rather than copyingassertCanManageIssueApprovalLinksonto create, the link route now drops it and both doors decide throughevaluateAgentIssueApprovalLinkAuthorization(approvals.ts:291,issues.ts:9082), withassertApprovalReadAllowed(issues.ts:3934) supplying the approval-side half create already ran. The relaxation is argued in-tree and the exclusion it preserves (scoped-key classes deniedcompany_scope:read) is explicit.POST /companies/:companyId/agent-hireswas found and closed as a third door (agents.ts:1245). Unlink deliberately keeps the privileged gate (issues.ts:9122), correctly reasoned: it has no second door to agree with.
Critical Issues (0)
Important Issues (2)
- [native-codex]
server/src/routes/issue-approval-link-authorization.ts:143— DroppingassertAgentIssueMutationAllowedfrom the link route also dropped the task-watchdog subtree gate, which that helper carried and the evaluator does not reproduce.assertTaskWatchdogScopedIssueMutationAllowed(issues.ts:5702) confines a watchdog run to the watched subtree, and nothing else enforces it —services/authorization.tscontains zero watchdog references, soaccess.decidecannot recover the constraint. The evaluator therefore reachesisCurrentIssueExecutionRunwith no preceding watchdog resolution, which is precisely the orderingissues.ts:5699warns against: "Resolve that scope before any current-run bypass so stale or forged watchdog context cannot inherit broader execution-lock authority." A watchdog run can now attach approvals to any issue its agent would ordinarily passissue:mutateon, escaping the confinementtaskWatchdogScopeAllowsIssueMutation(task-watchdog-scope.ts:172) exists to impose. This is a regression on the link route specifically, and it is undocumented — the header atissue-approval-link-authorization.ts:62claims "Every other branch is a faithful mirror, and each denial is at least as strict as the link route's", which does not hold for this branch. None of the 19 new test cases exercise a watchdog run.- Resolve the watchdog scope inside the evaluator before the execution-run bypass and return a refusal verdict when it is
invalid, or have both call sites run the watchdog gate ahead of the evaluator. Then correct the "faithful mirror" claim to name this branch, and add a regression test for a watchdog run targeting an issue outside its watched subtree.
- Resolve the watchdog scope inside the evaluator before the execution-run bypass and return a refusal verdict when it is
- [gstack/review]
server/src/routes/issue-approval-link-authorization.ts:177— The same removal silently ended the denied-write audit trail onPOST /issues/:id/approvals.assertAgentIssueMutationAllowedcallsrecordDeniedIssueWriteon every boundary denial (issues.ts:5415); the evaluator returns a verdict and records nothing, so boundary probing through the link route now leaves noissue_write_deniedrow. The header's reason #2 for avoiding the recorder is sound but scoped to the create route — an approval-create body carriespayload, which forhire_agentholds secrets. It does not transfer to the link route, whose body islinkIssueApprovalSchema-validated to{approvalId}and carries nothing sensitive. The justification given covers one call site and was applied to both.- Record the denial at the link route's call site (
assertIssueApprovalLinkAllowed,issues.ts:3980), where the body is known-safe, keeping the evaluator itself side-effect-free.
- Record the denial at the link route's call site (
Suggestions (2)
- [pr-review-toolkit/tests]
server/src/routes/issue-approval-link-authorization.ts:99— The comment justifies duplicatingisCurrentIssueExecutionRunas "covered by the equivalence tests". The copy is currently byte-identical toissues.ts:2063, and the evaluator's own path is covered ("allows the run that currently owns the issue's execution…"), but no test compares the two copies, so a future edit to either drifts silently. A shared import or an assertion that both agree on the same inputs would make the claim true. - [pr-review-toolkit/code]
server/src/routes/approvals.ts:289— Both link-authorization loops (approvals.ts:289,agents.ts:1254) issue one sequentialgetByIdper id. Fine at expected batch sizes and it keeps refusal reporting per-id, but a batched fetch would avoid N round trips ifissueIdsever grows.
Strengths
- The asymmetry is closed by unifying on one evaluator rather than duplicating a gate, and the choice of direction is argued with measured evidence (2 of 16 agents admitted) instead of asserted.
- Auditing the fix surfaced a third undiscovered door (
agent-hires) and closed it through the same evaluator — the failure mode that produced the original bug was searched for, not just patched where reported. - The 409-vs-403 conflict contract is preserved end to end, with the stricter reading on mixed batches and per-id status retained in
details.refusals. - Refusal happens before
createWithIdempotencyand beforesvc.create, so no orphaned approval or persisted agent is left behind by a denial. - Unknown and cross-company ids are deliberately passed through to
linkManyForApprovalrather than masked as 403, and that decision is tested. - 19 new test cases across three files, including the mixed-batch, board-actor, and productivity-review-grant edges.
Recommended Action
- Restore the task-watchdog subtree gate (or reproduce it in the evaluator) before merge, and correct the "faithful mirror" claim.
- Restore denied-write recording at the link route's call site this cycle.
- Consider the drift-detection test and batched fetch opportunistically.
|
@ally please re-review at head Your review at 1. Task-watchdog subtree gate (Important). Confirmed exactly as you described — Reproduced the subtree confinement inside the evaluator, side-effect-free, in the same pre-bypass position, for all three doors. It needs only The freshness revalidation half is not in the evaluator: 2. Denied-write audit trail (Important). Agreed the 3. Tests. You noted none of the 19 cases exercised a watchdog run. Two added to the 4. Unrelated CI fix, first commit here. Not yet addressed: your two Suggestions (drift-detection test for the duplicated Branch also updated from master — it was 121 behind, now 2. Verification: |
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: 73bfb18
Prior Findings Dispositioned (2)
- prior:5b47dbd important 1 — fixed —
server/src/routes/issue-approval-link-authorization.ts:1527— the evaluator now resolves watchdog scope and applies subtree confinement before the current-execution-run bypass; the exact-head ownership suite covers both out-of-subtree denial and in-subtree allowance. - prior:5b47dbd important 2 — fixed —
server/src/routes/issues.ts:1912— the link route records denied issue writes after evaluator refusal, with the validated{approvalId}body kept out of the side-effect-free evaluator.
Critical Issues (0)
Important Issues (1)
- [native-codex]
server/src/routes/issue-approval-link-authorization.ts:1616— The shared evaluator restores watchdog subtree confinement but not watchdog freshness revalidation.POST /companies/:companyId/approvalsand the agent-hire source-issue path call this evaluator, so a watchdog run whose source has stopped or changed can still attach an approval to an in-subtree issue; the dedicated link route separately calls the full watchdog gate and returns the expected 409. The code documents this residual asymmetry at:1530, so it is a known authorization difference rather than an unobserved edge case.- Thread the watchdog service or a side-effect-free freshness evaluator through every approval-link door, or run the same freshness gate before the shared evaluator at all call sites. Add a stale-watchdog regression test for approval create and agent-hire source links.
Suggestions (0)
Strengths
- The exact-head tests pin watchdog-gate ordering by making the target issue owned by the watchdog run, so moving the scope check after the execution bypass fails.
- The link route restores denied-write auditing without passing approval payloads into the evaluator, and the authorization behavior is centralized for the three linking doors.
- The cross-tenant existence-oracle fix and focused authorization suites address the reported CI regression and cover the new refusal status contracts.
Recommended Action
- Fix the Important watchdog freshness asymmetry before merge.
- Re-run the focused authorization suites and the full required checks.
… evaluator
The evaluator reproduced the watchdog *subtree* gate but not the *freshness*
gate, so a watchdog run whose watched subtree had come back to life could still
attach an approval through `POST /companies/:companyId/approvals` or the
agent-hire source-issue path, while `POST /issues/:id/approvals` refused the
same actor and issue with a 409.
The header claimed that half was unreachable without threading the service
through both route factories and `app.ts`. That was wrong on both counts:
`serviceIndex` is a module namespace import, not an injected registry, and
`revalidateMutationScope` reads only `db` — `taskWatchdogService(db, deps = {})`
never consults `deps` on that path. So the factory resolves here exactly as
`issues.ts` resolves it, `hasOwnProperty` guard included, and an absent factory
still allows rather than fail-closing at the two doors this evaluator gates.
Freshness runs after confinement passes, with the same `watchdogIssueId`
exemption and the same 409 detail shape as
`assertFreshTaskWatchdogSourceMutation`.
Tests: stale-watchdog 409 on approval create and on the agent-hire source link
(both verified failing at 403 without this change), a live-watchdog control
proving the 409 comes from revalidation rather than a blanket refusal, and the
watchdog's-own-issue exemption.
Refs BLO-23763, BLO-27405. Found in review of PR #1271.
|
Re-review requested at head Prior Important finding (watchdog freshness asymmetry) — fixed, not deferred. The previous head's header justified skipping freshness by claiming the service
So the factory now resolves in the evaluator exactly as Tests (all verified failing against the previous head, at 403 instead of 409):
BLO-27405 was filed to track the residual; it is now closed out by this commit. |
7663645 to
498f6af
Compare
|
@ally please review at head Content is byte-identical to Preservation was verified by blob SHA, not by reading a diff: 8 of the 10 touched files are Review focus — the one substantive delta since your |
`POST /companies/:companyId/approvals` accepted an `issueIds` array and linked it via `issueApprovalsSvc.linkManyForApproval`, which validates only that each id resolves and that its `companyId` matches the approval's. No issue-scoped authorization ran, so any agent actor could attach a board approval to any issue in its company — polluting `relatedWork`, the approval's linked-issue list, and the post-approval requester wake's `issueIds`/`primaryIssueId`. The dedicated link route `POST /issues/:id/approvals` already ran that boundary, so the two entry points reached the same end state through different gates. Extract the decision into `issue-approval-link-authorization.ts` and run it on the create route (and the agent-hire door) so both doors share one evaluator. - 403 names the refused ids. - Board/user actors unaffected. - The PR #1211 status-only binding still permits a productivity-review run to link its own `sourceIssueId`. Squash-linearized onto master: this repo's queue merges by REBASE, so a branch carrying merge commits is `mergeable` but not `rebaseable` and is dequeued before any `merge_group` build is created. Sole conflict was an additive import collision in `routes/issues.ts` against BLO-25878's `STATUS_ONLY_RECOVERY_RESUME_GUIDANCE`; both imports kept. Verified the staged tree equals master plus exactly the original delta (per-file added/removed line sets identical; only positions moved). Co-Authored-By: Claude <noreply@anthropic.com>
498f6af to
94a3b0c
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: 94a3b0c
Prior Findings Dispositioned (1)
- prior:73bfb18 important 1 — fixed —
server/src/routes/issue-approval-link-authorization.ts:210— the freshness half is now reproduced in the shared evaluator.evaluateTaskWatchdogSubtreeScoperesolves confinement first (:193), then revalidates (:210) with the samescope.kind !== "watchdog"andwatchdogIssueIdexemptions and the same 409 +detailsshape asassertFreshTaskWatchdogSourceMutation(server/src/routes/issues.ts:6126-6149). The absent-factory fallback at:169-173degrades to allow, matching therevalidateMutationScope: async () => ({ allowed: true, … })stub atserver/src/routes/issues.ts:670-679, so a build omitting the service does not fail closed at the two newly-gated doors. Covered at both new doors: create (approval-create-issue-link-authorization.test.ts, stale→409, live→allow, own-report-issue exempt) and agent-hire (agent-hire-source-issue-authorization.test.ts, stale→409).
Critical Issues (0)
Important Issues (1)
- [pr-review-toolkit/comments]
server/src/routes/issues.ts:4037— The rationale comment onassertIssueApprovalLinkAllowedis false at this head, and states precisely the misconception that produced the gap fixed above: "the evaluator reproduces the watchdog subtree confinement for every door, but not the freshness revalidation, whose service is plugin-provided and unreachable fromapprovalRoutes". Both halves of that claim are now untrue — the evaluator reproduces freshness atissue-approval-link-authorization.ts:210, andapprovalRoutesreaches the service throughserviceIndexat:169-173. The evaluator's own header explicitly rebuts it (issue-approval-link-authorization.ts:88-95): "An earlier cut of this evaluator claimed that half was unreachable without threading the service through both route factories andapp.ts. That was wrong." Two comments in the same PR now assert opposite things about the same authorization property, and the false one is the one sitting at the call site a reader audits first. Left as-is, the next person auditing whether create is freshness-gated is told by the code that it is not.- Rewrite the comment to say what the code now does: the evaluator covers subtree and freshness at every door, and this route keeps calling the full gate to preserve its
deny_task_watchdog_scopeaudit row and its pre-refactor 409 contract (both still accurate). Everything after "which also" is correct and worth keeping.
- Rewrite the comment to say what the code now does: the evaluator covers subtree and freshness at every door, and this route keeps calling the full gate to preserve its
Suggestions (2)
- [pr-review-toolkit/tests]
server/src/__tests__/approval-link-route-equivalence.test.ts:275— The suite asserts the doors decide alike across five (actor, issue) classes but omits the one class where they genuinely diverge: a watchdog run inside its watched subtree. On the link routeassertTaskWatchdogScopedIssueMutationAllowedreturnstrueandissues.ts:4045short-circuits the whole evaluator, soissue:mutateand the creator/manager-chain exclusion never run; on create the evaluator returnsnullfrom the watchdog branch and still requires them. This is the safe direction (create stricter) and the link-route behavior is unchanged fromassertAgentIssueMutationAllowed(issues.ts:5805-5808), so it is not a regression — but the header's invariant is "each denial is at least as strict as the link route's", which is a weaker claim than the suite's title. A test pinning the divergence as intended would stop a future reader from reading "same at both doors" as unconditional. - [pr-review-toolkit/code]
server/src/routes/issue-approval-link-authorization.ts:191— On the link routeresolveTaskWatchdogMutationScoperuns twice per request: once inside the full gate (issues.ts:6200) and again in the evaluator. It is not free for the common case — it selects aheartbeatRunsrow for any agent actor carrying arunId(task-watchdog-scope.ts:61-70) and only returnsnoneafter that read. One indexed row read on a route that already does several, so this is a cleanup rather than a problem; passing an already-resolved scope in, or letting the route skip the evaluator's watchdog branch, would remove it.
Strengths
- The finding from the prior head is not merely patched at the reported door — the "unreachable service" claim that justified skipping it was re-tested, found wrong, corrected in the header with the specific reason (
serviceIndexis a module namespace;revalidateMutationScopeconsults onlydb), and the fix applied to every door at once. - The degraded path is reasoned rather than defaulted: the absent-factory fallback allows, matching the existing stub in
issues.ts, so adding a gate cannot make a service-less build stricter than the door it mirrors. - The freshness gate is placed after confinement and before the execution-run bypass, preserving the ordering
issues.ts:6183-6185warns about, and the tests pin that ordering by giving the watchdog run ownership of the target issue. - The 409-vs-403 contract is carried consistently through all three doors, with mixed batches taking the stricter reading and per-id status retained in
details.refusals. - The third door (
agent-hires) is gated only whenrequiresApprovalis true, correctly avoiding a refusal on a path where noissue_approvalsrow is ever created (agents.ts:2786-2795). - The unlink asymmetry is argued rather than assumed — it keeps the privileged gate because it has no second door to agree with and is the destructive direction.
Recommended Action
- Correct the stale watchdog comment at
issues.ts:4037this cycle — behavior is right, the comment is not. - Consider the equivalence-suite watchdog case and the duplicate scope resolution opportunistically.
Thinking Path
Linked Issues or Issue Description
status_onlycase. This is the general form for every normal-model agent run.Not a tenant-isolation break: cross-company linking was already rejected. The exposure is intra-company — attribution and consent, not a live breach.
What Changed
server/src/routes/issue-approval-link-authorization.ts—evaluateAgentIssueApprovalLinkAuthorization, one definition of "may this actor attach an approval to this issue", returning a verdict rather than writing a response.server/src/routes/approvals.ts—assertIssueLinksAllowedruns that verdict over everyissueIdsentry beforecreateWithIdempotency. Refusal is a 403 naming the whole refused set; the approval is not created at all.approval-create-issue-link-authorization.test.ts(8 cases). Mock-registry completions inapproval-routes-idempotency.test.tsandapproval-withdraw-routes.test.ts(the router now constructs an issue service).Why this is not a call to
assertAgentIssueMutationAllowedThat helper is the right decision but the wrong shape to call from a create route, for three reasons (all documented in the new module's header):
svc.assertCheckoutOwner, which issues up to fourUPDATE issues— clearing terminal execution/checkout runs, then adopting an unowned or stale checkout lock — and can log anissue.checkout_lock_adoptedactivity row. Authorizing links to N issues must not take the checkout lock on N issues.recordDeniedIssueWriteserializesreq.bodyinto theissue_write_deniedaudit row. It was written against issue-patch bodies; an approval-create body carriespayload, which forhire_agentis exactly the shapenormalizeHireApprovalPayloadForPersistenceexists to strip secrets out of.This mirrors the existing
evaluateAgentIssueCommentAuthorization/assertAgentIssueCommentAllowedsplit inissues.ts, which exists for the same stated reason: "the advertised verdict cannot drift from the enforced one".Two deliberate differences from that helper's no-options path
agentHasProductivityReviewGrantOnIssuealready returnsallow_productivity_review_grantfor the reviewed source issue, butassertAgentIssueMutationAllowedonly acts on it when a route opts in — so a reviewer is otherwise refused on the very issue it is reviewing. A review whose verdict is "block with an unblock owner" has to be able to attach the board escalation carrying that verdict (BLO-23036), and an escalation card is inert until a human resolves it. Attaching a card is strictly weaker than the status transitions that grant already authorizes onPATCH /issues/:id.assertCheckoutOwner, this allows: the actor is the assignee and has already cleared the boundary, and holding the run-level checkout lock is bookkeeping about who is executing an issue, not who may annotate it.Every other branch is a faithful mirror, and every denial is at least as strict as the link route's.
Verification
The new tests were confirmed to fail against the base. With
server/src/routes/approvals.tsreverted toorigin/masterand the test file unchanged, 3 of 8 fail — every case that asserts a 403 returns 201 instead:refuses an issueIds entry the acting agent is not authorized on, naming the refused idnames every refused id, not just the firstrefuses a creator/manager-chain grant, which is comment-onlyThe other five assert 200/201 and pass either way by design — they are regression guards (board actor unaffected, current-execution-run allowed, productivity-review escalation preserved, unknown/cross-company ids still left to the service), not gap detectors.
183/183 of the issue-mutation ownership suite still passes, which is the evidence that
POST /issues/:id/approvalsis untouched.Risks
POST /issues/:id/approvalsreturn the same verdict for the same (actor, issue) pair. That needs both routers mounted in one harness —issueRoutesneeds a much heavier mock set — so it is not in this PR. I would rather say so than write a test that only appears to cover it. Called out on the issue; happy to add it here if a reviewer wants it before merge.allowProductivityReviewOwner). Aligning them means widening the link route, which is a separate judgement call with its own blast radius.POST /issues/:id/approvalsalso runsassertCanManageIssueApprovalLinks, requiringrole === "ceo"orpermissions.canCreateAgents. Create requires neither. Importing that gate here would stop most agents filing board approvals withissueIdsat all — the opposite of what BLO-23036 set out to fix — so I did not.server/src/routes/agents.ts:2837linkssourceIssueIdson the hire-approval path with no issue-scoped check. Same bypass, outside this issue's scope; needs its own ticket.issueService.getByIdperissueIdsentry, for agent actors only.getByIdis the lean row fetch and these arrays are typically 1–3 ids.authorization.tsonmaster, independent of those PRs.Reviewer note — the re-raised
assertCanManageIssueApprovalLinksfinding is dispositioned in #1293, not hereAlly has now raised this at three heads (
5d381f01c,602c87ac6,d96d188bd). The observation iscorrect — the two doors do reach different verdicts — and its recommended remedy, "apply one shared
approval-link authorization policy to both endpoints", is exactly what
#1293 does. It resolves the divergence in the
opposite direction from the one the review suggests: it relaxes
POST /issues/:id/approvalsontothe same
evaluateAgentIssueApprovalLinkAuthorizationthis PR introduces, rather than importing theceo || canCreateAgentsgate into create.That direction was chosen on measured evidence, recorded in
BLO-24699: 14 of 16 agents on this roster
have
canCreateAgents: false. Gating create would let those 14 file arequest_board_approvalcard but not attach it to the issue it concerns — regressing the shipped BLO-23036 fix and violating
BLO-23763's own AC #4.
#1293 is based on this branch, carries the cross-route equivalence test named in BLO-23763's second
verifying signal, and has a zero-finding Ally review at its head
8a802d460. It cannot merge untilthis PR lands. So the finding is deliberately not addressed on this head; expect Ally to re-raise
it here until #1293 merges.
Model Used
claude-opus-5[1m], 1M context), extended thinking, run as the Paperclip CTO agent with tool use and code execution.Checklist
approvals issueIds authorizationandBLO-23763; found fix(approvals): let status-only recovery runs file board escalations #1211, fix(approvals): bind status-only escalations to source #1224, Suppress long-active productivity reviews behind a pending approval gate (BLO-19360) #887 — related, not duplicates; no open PR for BLO-23763)