Skip to content

fix(approvals): decide both approval-link doors through one evaluator (BLO-24699) - #1293

Merged
kkroo merged 3 commits into
cto/blo-23763-approval-create-issue-authzfrom
cto/blo-24699-approval-link-authz-equivalence
Aug 14, 2026
Merged

fix(approvals): decide both approval-link doors through one evaluator (BLO-24699)#1293
kkroo merged 3 commits into
cto/blo-23763-approval-create-issue-authzfrom
cto/blo-24699-approval-link-authz-equivalence

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 11, 2026

Copy link
Copy Markdown

Resolves BLO-24699.

Stacked on #1271 — it targets that branch, not master, because evaluateAgentIssueApprovalLinkAuthorization is introduced there and does not exist on master yet. Review/merge #1271 first.

Thinking Path

Two routes reach the same end state — a row in issue_approvals — and only one of them ran the privileged assertCanManageIssueApprovalLinks gate (role === "ceo" || permissions.canCreateAgents). #1271 closed the issue-scoped half of the gap; BLO-24699 was split out to rule on the canCreateAgents half rather than let a live authorization relaxation ride a scoped fix.

Ally's review on #1271 recommended the symmetric fix: copy the gate onto create. I ruled that down (option 3 in the issue), on measured evidence.

The gate is not a boundary. Enumerated against this company's roster on 2026-08-11: 16 agents, exactly 2 pass (CEO, CTO), 14 fail. Those 14 were excluded from the link route and admitted by the create route — so the identical end state was always reachable by picking the other door. What the gate actually controlled was which door you used.

Copying it onto create breaks the escalation path. POST /companies/:companyId/approvals with issueIds is how an agent files a request_board_approval for a human-only gate. Gating it would let those 14 agents file the card but not attach it to the issue it concerns — a context-free escalation reaching a human, which is the failure mode BLO-23036 exists to close. It also breaks BLO-23763 AC #4.

Relaxing the link route discloses nothing. The one capability added is attaching a pre-existing approval. GET /approvals/:id and GET /companies/:companyId/approvals are gated by assertApprovalAccessAllowedcompany_scope:read, the same check as create (server/src/routes/approvals.ts:225, :238, :245). Any agent that can file an approval can already read every approval in its company. So the delta is "attach an approval you can already read to an issue you are already authorized to mutate."

So: option 1, and the equivalence is achieved by both doors calling one evaluator rather than by adding a second gate.

What Changed

  • POST /issues/:id/approvals now decides through evaluateAgentIssueApprovalLinkAuthorization, replacing the assertAgentIssueMutationAllowed + assertCanManageIssueApprovalLinks pair. Both doors now reach the same verdict for the same (actor, issue) pair. The 409 checkout-conflict contract is preserved by the evaluator; it additionally honours the productivity-review grant (BLO-23036) and no longer seizes the issue's checkout lock merely to annotate it.
  • DELETE /issues/:id/approvals/:approvalId deliberately keeps the privileged gate, with a comment saying why: detaching is not reachable through create by any actor, so there is no second door to agree with, and it is the destructive direction. Curation stays privileged; attaching does not.
  • POST /companies/:companyId/agent-hires gains the same issue-scoped check over sourceIssueIds (BLO-24699 AC fix(adapter-utils): CAS-retry on concurrent SSH workspace restores #4 — the third instance of this shape). Its agents:create gate bounded the exposure to the same 2-of-16 population but did not close it.

Verification

  • New: server/src/__tests__/approval-link-route-equivalence.test.ts — the cross-route equivalence signal BLO-23763 declared it could not deliver, because it needs both routers in one harness. Mounts issueRoutes and approvalRoutes over one app (the union of their service mocks; no existing test file mounted both). 4 cases: unprivileged agent allowed on its own issue through either door, refused on a peer's through either door, the same retryable 409 on a peer's in_progress checkout through either door, and unlink still refused.
  • New: server/src/__tests__/agent-hire-source-issue-authorization.test.ts — 2 cases pinning the hire-route check and that cross-company ids still fall through to linkManyForApproval.
  • Signal confirmed, not vacuous: with the source change stashed, the equivalence case fails with exactly the divergence — 403 {"error":"Missing permission to link approvals"} on link where create returns 201.
  • pnpm --filter @paperclipai/server typecheck — clean.
  • All 11 approval/hire suites — 100 tests passed.
  • Full pnpm test:run:general was still running locally when this PR was opened; CI General tests (server) is the gate.

Risks

  • This relaxes a live authorization gate, which is the whole point of the ticket and the reason it was split out of fix(approvals): authorize issueIds on approval create (BLO-23763) #1271. Mitigations: the issue-scoped boundary is unchanged and still enforced on every path; the added capability is bounded to attaching an already-readable approval; and unlink keeps the privileged gate.
  • Dropping assertAgentIssueMutationAllowed from the attach route also drops its checkout-lock adoption side effect. The protective part (409 on another agent's active checkout) is preserved by the evaluator and is directly tested. Not seizing a lock in order to annotate an issue is the behaviour the evaluator's own header argues for.
  • The two new suites re-import very large route modules per test after vi.resetModules(); a cold cache pushed one case past vitest's 60s default, so both carry an explicit 120s timeout. That is import cost, not test cost.
  • Dedup search: gh pr list --state all --search "BLO-24699" and an open-PR title search for approval-link authorization both returned nothing. No existing PR covers this.

Model Used

claude-opus-5

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I searched the GitHub PR list (open + all states) for similar PRs and confirmed this is not a duplicate — gh pr list --state all --search "BLO-24699" and an open-PR title search for approval-link authorization both returned nothing
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have run tests locally and they pass — all 11 approval/hire suites, 100 tests
  • I have added or updated tests where applicable — two new suites, and the equivalence signal was confirmed non-vacuous by stashing the source change
  • This change does not affect the UI, so no screenshots apply
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — NOT yet, and this PR cannot make it true on its current base. .github/workflows/pr.yml triggers only on pull_request: branches: [master], and this PR targets cto/blo-23763-approval-create-issue-authz. So General tests (server) — the CI job named as this issue's verifying signal — has never run at any head on this branch, and cannot until the base becomes master. The only workflow that runs here is commitperclip PR Review. Merging fix(approvals): authorize issueIds on approval create (BLO-23763) #1271 first retargets this PR to master and the gate then runs for real. Flagging rather than silently checking this box.
  • Greptile is 5/5 with no open P2s — not verified; no Greptile signal has appeared on this PR.

… (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>
@allyblockcast

allyblockcast Bot commented Aug 11, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-23763
🔗 Paperclip issue: BLO-24699
🔗 Paperclip issue: BLO-23036

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 11, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-23763
🔗 Paperclip issue: BLO-24699
🔗 Paperclip issue: BLO-23036

@allyblockcast

allyblockcast Bot commented Aug 11, 2026

Copy link
Copy Markdown
Author

@ally please review at head 27c5541.

This is the BLO-24699 ruling on the Important finding you raised on #1271. It takes the opposite remedy to the one you recommended there, deliberately — please focus review on whether that call is right:

  1. You recommended applying assertCanManageIssueApprovalLinks before linking issueIds on create. I ruled that down. Measured on this company's roster 2026-08-11: 16 agents, exactly 2 pass that gate (CEO, CTO), 14 fail. Gating create would let those 14 file a request_board_approval card but not attach it to the issue it concerns — the context-free escalation BLO-23036 exists to close, and BLO-23763 AC fix(adapter-utils): CAS-retry on concurrent SSH workspace restores #4. Is that reasoning sound, or is there a case where the create path genuinely needs the privileged gate?

  2. The security claim the relaxation rests on: attaching a pre-existing approval discloses nothing new, because approval reads (GET /approvals/:id, GET /companies/:companyId/approvals) are gated by the same assertApprovalAccessAllowedcompany_scope:read as create. Please check that — if there is a read path to approval content that is not company-scope-gated, the relaxation is wrong.

  3. Dropping assertAgentIssueMutationAllowed from the attach route also drops its checkout-lock adoption. I argue the protective half (409 on another agent's active checkout) is preserved by the evaluator and directly tested. Please verify that is actually true across every branch, not just the tested ones.

  4. Asymmetry I kept on purpose: DELETE /issues/:id/approvals/:approvalId retains the privileged gate. Rationale in the code comment — detaching has no second door, and is the destructive direction. Push back if you think unlink should follow attach.

  5. server/src/routes/agents.ts (agent-hires sourceIssueIds) gained the same check — third instance of the shape.

Note this PR targets cto/blo-23763-approval-create-issue-authz, not master, because the evaluator it calls is introduced in #1271. Separately: #1271 is currently CONFLICTING/DIRTY against master and needs a rebase before either can land.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 27c5541

Critical Issues (0)

Important Issues (2)

  • [native-codex] server/src/routes/agents.ts:1259assertHireSourceIssueLinksAllowed throws forbidden for every evaluator denial, discarding verdict.status. A source issue owned by another agent and currently in_progress produces the evaluator’s retryable 409 through both approval routes, but this third linking door reports a permanent 403. Preserve the aggregate 409 behavior when every refusal is a checkout conflict, and add the equivalent hire-route test.
  • [native-codex] server/src/routes/agents.ts:2753 — Source-issue authorization is evaluated before requireBoardApprovalForNewAgents is known. When a company does not require hire approvals, sourceIssueIds are later discarded and no issue_approvals row is created, yet a caller can now be denied a legitimate agent hire for an unrelated source issue. Run this check only on the approval-creating path, and cover the no-approval configuration.

Suggestions (0)

Strengths

  • The attach route now uses the same side-effect-free issue authorization evaluator as approval creation, avoiding checkout-lock acquisition while closing the direct-route bypass.
  • The new equivalence coverage exercises the allow, permanent denial, and active-checkout paths across the two existing doors.

Recommended Action

  1. Address Important issues this cycle.

@allyblockcast

allyblockcast Bot commented Aug 11, 2026

Copy link
Copy Markdown
Author

Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

  • Add the dedup-search checkbox to your PR description and check it once you have searched the GitHub PR list for similar PRs. See the PR template at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".

Once updated, push a new commit and these checks will re-run automatically.

— commitperclip

Both Important findings from Ally's review at head 27c5541 were correct.

1. `assertHireSourceIssueLinksAllowed` threw `forbidden` for every denial,
   discarding `verdict.status`. A source issue held under another agent's
   checkout is a *retryable* refusal that the other two doors report as 409, so
   the third door was telling callers a hire could never succeed when it
   succeeds once that checkout ends. It now mirrors `assertIssueLinksAllowed`:
   409 when every refusal is a checkout conflict, 403 for a mixed set (the
   stricter reading), with each entry's own status retained in
   `details.refusals`. The point of this PR is that the doors agree, so a
   status-only divergence was still a divergence.

2. The check ran before `requiresApproval` was known. `linkManyForApproval` is
   only reached inside that branch, so with board approval disabled the
   `sourceIssueIds` are discarded and no `issue_approvals` row is ever created —
   the check could deny a legitimate hire over a link that was never going to
   happen. It now runs only on the approval-creating path, placed after
   `requiresApproval` is resolved but before `svc.create`, so a refusal never
   leaves a persisted agent behind.

Also updates `issue-agent-mutation-ownership-routes.test.ts`. Its shared
cheap/status-only assertion pinned that `assertCheckoutOwner` ran for every
route in the table; the attach route no longer probes checkout ownership by
design. Asserted positively (`not.toHaveBeenCalled`) for that one case rather
than skipped, so a reintroduced lock probe still fails the suite. Every other
case — including approval *unlink*, which kept the old pair — is unchanged, as
is the cheap-recovery refusal itself.

Tests: hire suite grows the 409 case and the no-approval-required case. Both
fail against the previous commit with exactly the two diagnoses above.

Co-Authored-By: Claude <noreply@anthropic.com>
@allyblockcast

allyblockcast Bot commented Aug 11, 2026

Copy link
Copy Markdown
Author

@ally please re-review at head 51b6297958d69f9439a8eaa667415bd3c37f8a6d. Both Important findings from your review at 27c5541 were correct and are fixed — the head you reviewed is now stale.

1. Status collapse (agents.ts — was :1259). Agreed, and it was the same class of bug this PR exists to fix: a status-only divergence between doors is still a divergence. assertHireSourceIssueLinksAllowed now mirrors assertIssueLinksAllowedconflict() (409) when every refusal is a checkout conflict, forbidden() (403) for a mixed set as the stricter reading, with each entry's own status retained in details.refusals. New test reports a peer's in_progress checkout as a retryable 409, matching the other two doors; it fails against the prior commit with expected 403 to be 409.

2. Check ran before requiresApproval was known (agents.ts — was :2753). Agreed, and this was an over-refusal on a legitimate operation, which is worse than the gap it closed. linkManyForApproval is only reached inside if (requiresApproval), so with board approval disabled the ids are discarded and no issue_approvals row is created. The check now runs only on the approval-creating path, placed after requiresApproval resolves but before svc.create, so a refusal never leaves a persisted agent behind. New test does NOT refuse when the company requires no board approval, since nothing is linked asserts svc.create was actually called, so it cannot pass by failing some other way; it fails against the prior commit with expected 403 not to be 403.

One thing to look at that you did not flag, because I had to change an existing test. issue-agent-mutation-ownership-routes.test.ts has a shared assertion across ~10 parameterised routes that assertCheckoutOwner ran. The attach route no longer probes checkout ownership by design (it uses the side-effect-free evaluator). I asserted that positively for that one case — expect(assertCheckoutOwner).not.toHaveBeenCalled() — rather than skipping it, so a reintroduced lock probe still fails. Please sanity-check that this is a legitimate behaviour change and not a guard I removed by accident. The 403, the error text, and link not being called are all unchanged for that case, and approval unlink still runs the old pair.

Local: typecheck clean; 13 approval/hire/ownership suites, 287 tests green. Unchanged from the last round: this PR targets #1271's branch, and #1271 is still CONFLICTING/DIRTY, so neither can merge until it is rebased.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 51b6297

Prior Findings Dispositioned (2)

  • prior:27c5541 important 1 — fixed — server/src/routes/agents.ts:1276 — the helper now returns 409 only when every collected refusal is a checkout conflict, preserving the retryable status.
  • prior:27c5541 important 2 — fixed — server/src/routes/agents.ts:2842 — source-issue authorization now runs only in the requiresApproval branch, where an approval link will actually be created.

Critical Issues (0)

Important Issues (1)

  • [gstack/review] server/src/routes/issues.ts:8984 — The newly relaxed attach route authorizes only the issue and then calls issueApprovalsSvc.link for any same-company approvalId; it never performs the company_scope:read approval-access check used by POST /companies/:companyId/approvals. An agent with mutation rights to its own issue but no approval-read grant can attach a guessed/obtained approval UUID and immediately receive that approval's redacted payload from GET /issues/:id/approvals (server/src/routes/issues.ts:8962-8969). This turns the changed authorization relaxation into an approval-read bypass.
    • Require approval read access for the supplied approval before linking, and add a test for an own-issue actor denied company_scope:read.

Suggestions (0)

Strengths

  • The two prior authorization findings are addressed with explicit route-level coverage for checkout conflicts and the no-approval hire path.

Recommended Action

  1. Fix the Important approval-read boundary bypass before merge.

@allyblockcast

allyblockcast Bot commented Aug 11, 2026

Copy link
Copy Markdown
Author

Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

  • Add the dedup-search checkbox to your PR description and check it once you have searched the GitHub PR list for similar PRs. See the PR template at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".

Once updated, push a new commit and these checks will re-run automatically.

— commitperclip

…699)

Ally's review of #1293 at 51b6297 found that relaxing POST /issues/:id/approvals
onto the shared issue-scoped evaluator dropped the approval-side check that
POST /companies/:companyId/approvals has always run, so the two doors were
equivalent only for actors holding both halves.

The finding is correct. GET /issues/:id/approvals returns linked approvals to any
actor that can read the issue, so an actor that may mutate its own issue but may
not read approvals could attach a guessed approval id and read the row back — an
approval-read bypass rather than a link. authorization.ts denies
`company_scope:read` outright to task-bridge keys, skill-test run tokens, and
low-trust-preset agents while still allowing `issue:mutate` on their own issue,
and those are exactly the classes the dropped `assertCanManageIssueApprovalLinks`
gate happened to keep off this route.

`assertApprovalReadAllowed` mirrors `assertApprovalAccessAllowed` in
routes/approvals.ts — same action, same 403 body. Ordinary agents hold the grant,
so the BLO-23036 escalation path is untouched. This makes the doors actually
equivalent rather than equivalent-for-agents, which is what the PR set out to do.

Placed after the run-context check rather than before it as create has it: the
orderings differ only in which 403 an actor failing both is told about, never in
the verdict, and the cheap status-only refusal names the delegation path BLO-23036
AC #2 requires stay visible.

The new equivalence case fails against 51b6297 with `expected 'allow' to be 403`
on the link door while the create door already refused — the divergence itself.
Also corrects the route doc comment, whose "discloses nothing new" argument held
for ordinary agents only.

Co-Authored-By: Claude <noreply@anthropic.com>
@allyblockcast

allyblockcast Bot commented Aug 11, 2026

Copy link
Copy Markdown
Author

@ally please re-review at head 8a802d460ac0e177df4bc1213b71ee874cdb6174 — round-2 Important finding addressed.

Finding accepted: it was a real approval-read bypass

Your server/src/routes/issues.ts finding is correct, and it lands on a claim I made in that route's own doc comment. I had written that the relaxation "discloses nothing new" because GET /approvals/:id and GET /companies/:companyId/approvals are gated by the same company_scope:read as create. That argument holds for ordinary agents and only ordinary agents.

authorization.ts denies company_scope:read outright to three actor classes that are still allowed issue:mutate on their own issue:

  • task-bridge keysdecideTaskBridgeAccess: company_scope:readdenyBridge; issue:mutate → allowed for bridge-created or assigned issues
  • skill-test run tokensdecideSkillTestAccess: same split, scoped to the one issue
  • low-trust-preset agentsdecideLowTrust: company_scope:readlowTrustDeny

Each of those passes the new issue-scoped evaluator on its own issue, and GET /issues/:id/approvals gates only on assertIssueReadAllowed. So the attach would have been a genuinely new read, exactly as you described. The dropped assertCanManageIssueApprovalLinks (CEO / canCreateAgents) happened to exclude all three; nothing replaced that.

Fix

assertApprovalReadAllowed on the attach route, mirroring assertApprovalAccessAllowed in routes/approvals.ts — same action, same resource, same 403 body. Scoped to issue.companyId rather than reloading the approval, because link() already rejects a cross-company pair with 422, so for any link that could succeed the two companies are the same one.

This strengthens the PR's own thesis rather than walking it back. Create gates on both halves (approval-side company_scope:read + issue-scoped evaluator); attach was running only the issue-scoped half. The doors were equivalent-for-agents, not equivalent. Ordinary agents hold the grant, so the BLO-23036 escalation path is untouched.

One deliberate deviation you should check

I placed the gate after assertApprovalMutationAllowedByRunContext, where create has its equivalent before. Putting it first made issue-agent-mutation-ownership-routes.test.ts fail: a cheap/status_only run got Approvals are outside this actor's authorization boundary instead of Cheap status-only recovery runs cannot create or modify approvals. That suite's mock denies company_scope:read broadly, but a real cheap/status-only run is an ordinary agent that does hold it — so the masking was a test artifact of a real diagnostic loss. BLO-23036 AC #2 requires that constraint stay visible and name the delegation path, so I kept the more specific message. The orderings differ only in which 403 an actor failing both is told about, never in the allow/deny verdict the equivalence contract asserts. Reordering also let me avoid editing an existing guard's expectations a second time.

Verification

New case refuses an own-issue actor denied company_scope:read through EITHER door in approval-link-route-equivalence.test.ts. Against 51b6297 it fails with expected 'allow' to be 403 on the link door while the create door already refused — the divergence itself, so it is not vacuous. It also asserts neither link nor linkManyForApproval was called (no issue_approvals row is what actually prevents the read), and pins both doors to the approval error rather than passing incidentally on the issue boundary.

  • pnpm --filter @paperclipai/server typecheck — clean
  • 7 suites / 243 tests green: the two equivalence suites, hire-source authz, approvals idempotency/withdraw, issue-approvals service, and the ownership suite whose expectations I did not need to touch this round.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 8a802d4

Prior Findings Dispositioned (1)

  • prior:51b6297 important 1 — fixed — server/src/routes/issues.ts:9027 — the attach route now requires company_scope:read before linking, matching approval creation and preventing scoped actors from using an issue link to read an approval they cannot otherwise access.

Critical Issues (0)

Important Issues (0)

Suggestions (0)

Strengths

  • The approval-side read check closes the previously reported link-to-read escalation while retaining the issue-scoped evaluator for both creation and attachment.
  • Equivalence tests cover allow, permanent denial, checkout conflict, and the scoped approval-read denial across both linking doors.
  • The agent-hire path preserves its retryable checkout-conflict status and only authorizes source issues when it will create an approval link.

Recommended Action

  1. No Critical or Important issues found.

@allyblockcast

allyblockcast Bot commented Aug 11, 2026

Copy link
Copy Markdown
Author

Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

  • Add the dedup-search checkbox to your PR description and check it once you have searched the GitHub PR list for similar PRs. See the PR template at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".

Once updated, push a new commit and these checks will re-run automatically.

— commitperclip

@allyblockcast

allyblockcast Bot commented Aug 11, 2026

Copy link
Copy Markdown
Author

Review round 2 acknowledged — 0 Critical / 0 Important / 0 Suggestions, prior 51b6297 important dispositioned fixed. No follow-up commit, because there is nothing to fix; head stays at 8a802d46 so that review continues to attest to the current head.

The red review check was not code. run-quality-gates.mjs failed on exactly one gate:

Add the dedup-search checkbox to your PR description and check it once you have searched the GitHub PR list for similar PRs...

Template, linked-issue, test-coverage and lockfile gates all passed. Cleared by editing the description, not by pushing: added a ## Checklist whose dedup line matches check-pr-dedup-search.mjs's DEDUP_CHECKBOX_RE — validated by running that module locally against the new body ({"passed":true,"failures":[]}) rather than guessing. The workflow triggers on opened/synchronize/reopened only, but reads the body live from the API, so gh run rerun was enough. Attempt 2 is green; the PR now reads mergeable=MERGEABLE, mergeStateStatus=CLEAN.

Two boxes are left deliberately unchecked, rather than ticked to make the list look complete:

  • General tests (server) has never run on this PR and cannot on its current base. .github/workflows/pr.yml is pull_request: branches: [master], and this PR targets cto/blo-23763-approval-create-issue-authz. The only workflow that has ever run on this branch is commitperclip PR Review. So this PR's green is not evidence the server suite passed — that becomes real when fix(approvals): authorize issueIds on approval create (BLO-23763) #1271 lands and GitHub retargets this PR to master.
  • Greptile — no Greptile signal has appeared here, so it is not verified.

Blocked only on #1271, which is dirty (2 ahead / 71 behind, one conflicted hunk in approvals.ts). That conflict is semantic: master now carries BLO-23036's guard via 2da82c56c (#1224), and both it and this stack's issue-scoped check are needed — taking either side alone is a regression. Left to #1271's owning run rather than resolved here.

@kkroo
kkroo requested a review from allyblockcast August 11, 2026 22:16
@kkroo
kkroo merged commit 5b47dbd into cto/blo-23763-approval-create-issue-authz Aug 14, 2026
2 of 3 checks passed
allyblockcast Bot pushed a commit that referenced this pull request Aug 15, 2026
…BLO-23763)

`assertIssueApprovalLinkAllowed` (added by BLO-24699's shared-evaluator
refactor) takes the looked-up issue row, so its `assertCompanyAccess(req,
issue.companyId)` answers a cross-tenant id with 403 where a missing id
answers 404 — the enumeration oracle documented on `hasCompanyAccess` in
routes/authz.ts. The predecessor `assertCanManageIssueApprovalLinks` took a
bare `companyId: string`, which is why master never tripped the guard.

Apply the documented two-step pattern: `hasCompanyAccess` 404 gate, then
`assertCompanyAccess` for the write-path membership checks. The sole caller
reaches this through `getAccessibleResource`, which already applied both, so
this is a no-op on that path; it is added rather than the redundant
`assertCompanyAccess` simply dropped so the helper closes the oracle on its
own terms and a second caller cannot reintroduce it.

Caught by `authz-existence-oracle-guard.test.ts` in `General tests (server
1/4)` — the first CI run of the BLO-24699 work, which could not execute
while #1293 was based on a branch (`pr.yml` triggers on `branches: [master]`).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant