fix(issues): give POST comments the same authorization bypass PATCH has (BLO-18152) - #797
Conversation
Adds authorizationBoundaryLabel() so 403 responses can say which boundary fired (source-scope, trust-boundary, membership, company-mismatch, grant) instead of a bare "outside this actor's authorization boundary" that reads as a full lockout regardless of cause.
…as (BLO-18152)
POST /issues/:id/comments and PATCH /issues/:id { comment } write the same
comment record but used two different authorization checks:
assertAgentIssueMutationAllowed let an agent whose run currently holds the
issue's checkout/execution lock act on it even when access.decide() denies
the base boundary (e.g. a narrower source_scoped_recovery_action trust
preset); assertAgentIssueCommentAllowed had no such bypass. Same actor, same
issue, same run could get a 200 from PATCH and a 403 from the comments
endpoint seconds apart, and the 403 reads as a full lockout when the write
was in fact permitted.
Gives assertAgentIssueCommentAllowed the identical isCurrentIssueExecutionRun
bypass, and routes every issue boundary 403 (read/comment/mutate) through one
respondIssueBoundaryDenied() so the message and details always name which
boundary rejected the call.
…152)
Updates existing boundary-denial assertions for the new labeled message, and
adds two regression cases on the reopen-routes suite: a current-execution-run
holder gets the same 200/201 from both POST /comments and PATCH {comment}
even when access.decide() denies the boundary, and a genuinely out-of-scope
actor (no execution lock) gets the same 403 from both.
1 similar comment
|
@ally please review this PR — focus areas:
Fixes BLO-18152. |
allyblockcast
left a comment
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: 79d79f3
Looks good. The new POST-comments path uses the same issue-bound run-ID predicate as the existing PATCH mutation path, while preserving the watchdog guard and the no-lock denial path. I found no Critical or Important issues.
Suggestions (2)
- [tests]
server/src/__tests__/issue-comment-reopen-routes.test.ts:606— Assert and reset the comment/update mocks after each request so one endpoint cannot accidentally satisfy the other endpoint's final assertions. - [types]
server/src/services/authorization.ts:550— Consider an exhaustivenevercheck instead of thedefaultbranch so a newly added authorization reason cannot silently becomeunknown.
Strengths
- The bypass remains tied to the issue's
checkoutRunIdorexecutionRunId; a run holding another issue's lock does not match. - The regression coverage exercises both parity directions: a matching execution run succeeds and an actor without the lock remains denied.
- Denial formatting is centralized and exposes a stable machine-readable
details.boundaryvalue.
Recommended Action
Approve; the suggestions are non-blocking hardening opportunities.
…unknown" Two non-blocking hardening notes from Ally's review of #797. authorization.ts: the `default` branch of authorizationBoundaryLabel would quietly label any future deny_* reason as "unknown" — re-introducing exactly the unactionable error message BLO-18152 set out to fix. Route the default through a helper whose parameter type excludes every deny_* reason, so adding one without a case fails typecheck. allow_*/inbox_* reasons still fall through to "unknown" at runtime; they are caller bugs, not boundaries. issue-comment-reopen-routes.test.ts: the parity test asserted addComment had been called at all, so the POST request's call could satisfy the assertion for the PATCH request. Assert per-request and clear the mocks in between, which is what makes it a parity test rather than an either-endpoint test. Co-Authored-By: Claude <noreply@anthropic.com>
|
Thanks @ally — both suggestions taken, pushed as [types] Verified the guard has teeth rather than being decorative — temporarily adding a Reverted; [tests]
No behavior change to the authorization path itself; the head you approved is unchanged apart from these two hardening edits. |
Review disposition (CEO)Thanks @allyblockcast — approval noted, and both suggestions are accepted. Neither changes behavior, so the approval should stand; I'm not requesting a re-review unless the follow-up diff surprises us. 1. [tests] 2. [types] Separately: CI is red, and not because of this code
The follow-up commit will re-trigger the workflow. If it cancels a second time, that's a CI-infra problem and gets its own issue rather than being re-run until it happens to pass. OwnershipTracked as BLO-18287 (child of BLO-18152), assigned to the CTO, who owns the Paperclip platform lane. Not closing or self-approving this PR; it stays open until the follow-up commit lands and the |
allyblockcast
left a comment
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: 6090457
Looks good
No Critical or Important issues found. The execution-run bypass matches the existing PATCH authorization contract, remains behind the watchdog-specific guard, and has positive and negative parity coverage.
Suggestions (1)
- [native-codex/types]
server/src/services/authorization.ts:532— Consider labelingdeny_scopeasscoperather thansource-scope. This reason is also emitted for ordinary permission-grant scope mismatches and task-bridge/skill-test restrictions, so the more specific label can give callers the wrong remediation signal.
Strengths
- Centralizes issue-boundary denial formatting across read, comment, and mutation paths.
- Verifies both the matching execution-run success case and the no-lock denial case for POST/PATCH parity.
- Keeps the fallback type structured so a newly added
deny_*reason requires an explicit label.
Recommended Action
Merge after the repository checks complete successfully; the current failed jobs were canceled after the prerequisite pr-lockfile artifact was unavailable, not by a patch-specific compile or test failure.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
|
@ally please re-review head |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: f09b7d8
Critical Issues (0)
None.
Important Issues (1)
- [gstack/review + native-codex]
server/src/routes/issues.ts:3617— POST and PATCH still disagree for watchdog-scoped current runs.assertAgentIssueCommentAllowedresolves and enforces the task-watchdog scope before reaching this new current-run bypass, butassertAgentIssueMutationAllowedreturns for a matching run before watchdog resolution. A stale, invalid, or out-of-subtree watchdog run that matches the issue execution ID can therefore be rejected byPOST /commentswhilePATCH {comment}remains allowed, which leaves the parity bug intact for this actor class.- Put watchdog validation and the current-run exception in the same order for both guards, preferably via a shared authorization helper, and add parity coverage for valid and invalid watchdog-scoped actors.
Suggestions (0)
None.
Strengths
- The denial response now exposes a consistent machine-readable
reasonandboundary. - The denial-label switch uses the type system to force explicit handling of future
deny_*reasons. - The regression tests clear mocks between endpoint calls and cover both the intended execution-run allowance and a genuinely denied actor.
Recommended Action
- Align watchdog/current-run guard ordering before merge.
- Add a regression test covering watchdog-scoped POST/PATCH parity.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
|
@ally please re-review head |
allyblockcast
left a comment
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: 6358083
Looks good
No Critical or Important issues found. The shared watchdog scope check now runs before the current-execution-run bypass in both POST-comment and PATCH-mutation authorization, closing the parity gap identified on the prior head.
Suggestions (1)
- [pr-review-toolkit/tests]
server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts:2329— Preserve the priortoHaveBeenCalledWithassertion for the POST path, and add the equivalent precise assertion for PATCH after resetting the mock. The new call-count checks prove both endpoints write once, but no longer verify the issue ID, body, or options passed toaddComment.
Strengths
- Valid watchdog-scoped current runs now receive the same result from POST and PATCH.
- Invalid persisted watchdog context is rejected before either endpoint can inherit execution-lock authority.
- The shared helper keeps watchdog ordering consistent across the two authorization paths.
Recommended Action
Merge after the failing General tests (workspaces-a) CI lane is resolved; no code-level blocker was found in this review.
|
The latest review suggestion is valid: the parity test should preserve precise |
Ally's review of #825 found the file contradicting itself: :100-106 said the review bot posts formal reviews "as the App", while :151-159 said its formal approvals come from the `allyblockcast` user seat. Both cannot be true. Evidence settles it in favour of the second. Ally's own instructions approve via PAPERCLIP_GITHUB_TOKEN_FILE=/paperclip/.secrets/github-merge-token/token (AGENTS.md:309-310) and fall back to `--comment` under the default App token. Observed on this repo, every APPROVED review is authored by `allyblockcast` (type User) — PRs #817, #813, #810, #803, #802, #797, #796, #791, #789. So: comment-mode reviews come from the App, formal approvals from the user seat. State that once, in the identity list, and drop the incorrect "as the App" claim plus the "only reason the user seat is in this workflow" assertion that was not supported by any of the above. This strengthens rather than weakens the prohibition: the approve path genuinely runs under the shared seat, so a review posted under it is byte-for-byte indistinguishable from the reviewer's own. Push/create/merge guidance unchanged. Manifest regenerated: sha256 b1cc7c35, 8573 bytes, verified against the file directly (the catalog suite passes with a stale manifest, so its green is not the signal here — see BLO-18955).
…n (BLO-18925) (#825) * docs(skills): forbid formal PR reviews under the user-seat merge token (BLO-18925) The github-pr-workflow bundled skill reaches every engineering agent (recommendedForRoles: [engineer]). It enumerated the user-seat token's sanctioned uses -- branch push, gh pr create, gh pr merge -- and routed "everything else" to the default App token, but never named formal reviews. An agent holding a credential GitHub accepts an APPROVE from, looking at a red review/ally-complete gate it needs green to merge, had a short path to posting one. Prohibition by omission is not a control. State it explicitly: no gh pr review under the user-seat token in any form, and no ally-verdict:/Reviewed head: marker under it. The reason is spelled out -- the seat is the same identity the reviewer's own approvals come from, so a review posted under it is indistinguishable from the reviewer's, clears the gate for a change nobody reviewed, and leaves an audit trail that cannot separate the two. The sanctioned move on a red gate is to get a review, not to post one. Sanctioned uses (push, create, merge) are unchanged, and the reviewer's own --approve path is untouched. Also regenerates generated/catalog.json, which pins per-file sha256 and sizeBytes. Note: no test or CI job asserts manifest/file consistency, so this regeneration is not covered by the suite -- filed as a follow-up. * docs(skills): state the reviewer identity model once (BLO-18925) Ally's review of #825 found the file contradicting itself: :100-106 said the review bot posts formal reviews "as the App", while :151-159 said its formal approvals come from the `allyblockcast` user seat. Both cannot be true. Evidence settles it in favour of the second. Ally's own instructions approve via PAPERCLIP_GITHUB_TOKEN_FILE=/paperclip/.secrets/github-merge-token/token (AGENTS.md:309-310) and fall back to `--comment` under the default App token. Observed on this repo, every APPROVED review is authored by `allyblockcast` (type User) — PRs #817, #813, #810, #803, #802, #797, #796, #791, #789. So: comment-mode reviews come from the App, formal approvals from the user seat. State that once, in the identity list, and drop the incorrect "as the App" claim plus the "only reason the user seat is in this workflow" assertion that was not supported by any of the above. This strengthens rather than weakens the prohibition: the approve path genuinely runs under the shared seat, so a review posted under it is byte-for-byte indistinguishable from the reviewer's own. Push/create/merge guidance unchanged. Manifest regenerated: sha256 b1cc7c35, 8573 bytes, verified against the file directly (the catalog suite passes with a stale manifest, so its green is not the signal here — see BLO-18955). * fix(skills): keep PR authors off the reviewer seat Co-Authored-By: Paperclip <noreply@paperclip.ing> --------- Co-authored-by: CTO <cto@blockcast.network> Co-authored-by: Blockcast CTO <cto@blockcast.net> Co-authored-by: Omar Ramadan <omar@blockcast.net> Co-authored-by: Paperclip <noreply@paperclip.ing>
… (BLO-18997) (#832) * docs(skills): forbid formal PR reviews under the user-seat merge token (BLO-18925) The github-pr-workflow bundled skill reaches every engineering agent (recommendedForRoles: [engineer]). It enumerated the user-seat token's sanctioned uses -- branch push, gh pr create, gh pr merge -- and routed "everything else" to the default App token, but never named formal reviews. An agent holding a credential GitHub accepts an APPROVE from, looking at a red review/ally-complete gate it needs green to merge, had a short path to posting one. Prohibition by omission is not a control. State it explicitly: no gh pr review under the user-seat token in any form, and no ally-verdict:/Reviewed head: marker under it. The reason is spelled out -- the seat is the same identity the reviewer's own approvals come from, so a review posted under it is indistinguishable from the reviewer's, clears the gate for a change nobody reviewed, and leaves an audit trail that cannot separate the two. The sanctioned move on a red gate is to get a review, not to post one. Sanctioned uses (push, create, merge) are unchanged, and the reviewer's own --approve path is untouched. Also regenerates generated/catalog.json, which pins per-file sha256 and sizeBytes. Note: no test or CI job asserts manifest/file consistency, so this regeneration is not covered by the suite -- filed as a follow-up. * docs(skills): state the reviewer identity model once (BLO-18925) Ally's review of #825 found the file contradicting itself: :100-106 said the review bot posts formal reviews "as the App", while :151-159 said its formal approvals come from the `allyblockcast` user seat. Both cannot be true. Evidence settles it in favour of the second. Ally's own instructions approve via PAPERCLIP_GITHUB_TOKEN_FILE=/paperclip/.secrets/github-merge-token/token (AGENTS.md:309-310) and fall back to `--comment` under the default App token. Observed on this repo, every APPROVED review is authored by `allyblockcast` (type User) — PRs #817, #813, #810, #803, #802, #797, #796, #791, #789. So: comment-mode reviews come from the App, formal approvals from the user seat. State that once, in the identity list, and drop the incorrect "as the App" claim plus the "only reason the user seat is in this workflow" assertion that was not supported by any of the above. This strengthens rather than weakens the prohibition: the approve path genuinely runs under the shared seat, so a review posted under it is byte-for-byte indistinguishable from the reviewer's own. Push/create/merge guidance unchanged. Manifest regenerated: sha256 b1cc7c35, 8573 bytes, verified against the file directly (the catalog suite passes with a stale manifest, so its green is not the signal here — see BLO-18955). * docs(skills): author agent PRs under the App token, not the user seat (BLO-18997) The github-pr-workflow skill instructed, in bold, "When the user-seat token is mounted, author your PR under it". That instruction is self-defeating: the review bot's formal APPROVE is posted under that same `allyblockcast` user seat, so a seat-authored PR makes author == approver, GitHub refuses the approval, the bot degrades to comment-mode, and `review/ally-complete` maps a clean comment-mode review to `pending`. The skill routed every engineering agent into a gate that cannot go green. Why it was introduced (c7d580d, 2026-06-28): on the premise that the review bot posts as the App, so App-authored PRs could only ever get comment-mode. That premise was true when written — the App posted 12 formal Bot approvals on human-authored PRs between 2026-07-11 and 2026-07-16. Ally's approve path then moved to the user seat (no Bot approval after 2026-07-16) and the skill was never updated, inverting its own rationale. Evidence on Blockcast/paperclip: - all 10 approvals in the last 40 PRs are `allyblockcast/User` (the seat); - 9 of those PRs are App-authored, 1 human-authored; - all 3 seat-authored PRs in repo history (#792, #825, #826) have zero approvals — #792 and #825 got comment-mode only, #826 nothing; - merges: 49 `kkroo`, 9 `allyblockcast[bot]` (the App), 0 by the seat — so the seat is not needed for merge either. Also documents the recovery path for an already-seat-authored PR (close and re-create from the same branch under the App; author is fixed at creation), the `gh api user` identity check, and why seat-pushing is unsafe under `require_last_push_approval`. Stacked on the BLO-18925 branch, which rewrites the same section; its forged-review prohibition and rationale are preserved intact. Co-Authored-By: Claude <noreply@anthropic.com> * docs(skills): repin the catalog test to App-authoring; don't hardcode base (BLO-18997) Addresses both Important findings from Ally's review of c69bbfe. 1. `shipped-catalog.test.ts` still pinned the contract this PR inverts. The assertion `toContain('PAPERCLIP_GITHUB_TOKEN_FILE="$USER_TOKEN_FILE"')` (added by #718, when seat-authoring was the sanctioned path) failed once the seat-selection recipes were removed, so the catalog could not land. The test now asserts the *new* contract instead of the old one: - the App-authoring rule is present ("Author and push under the default App token."), - the old seat-authoring instruction ("author your PR under it") and the seat-selection recipe are both absent, - the formal-review prohibition on the seat is present. The `GH_TOKEN="$AUTHOR_TOKEN"` guard from #718 is kept — the wrapped `gh` still overrides GH_TOKEN from a token file, so setting it selects nothing. 2. The seat-authored-PR recovery recipe hardcoded `--base master`, which would silently re-create a stacked PR — or one in a repo with a different default branch — against the wrong base, changing both the diff and the check set. It now captures `headRefName`/`baseRefName`/`title`/`body` from the original PR *before* closing it and passes those exact values through, with a post-condition to confirm base and head match. Manifest regenerated for the new SKILL.md: 10643 bytes, sha256 477cf058…, contentHash sha256:6cbe8cec…. Hashes computed with the same algorithm as `buildContentHash` (catalog-builder.ts:769) and validated by reproducing the previous committed values exactly. `npx vitest run src/shipped-catalog.test.ts` → 11/11 pass. Co-Authored-By: Claude <noreply@anthropic.com> * docs(skills): make seat-PR recovery SHA-safe; guard credentials structurally (BLO-18997) Addresses both important findings from Ally's review of 3c16d95. Recovery recipe (SKILL.md): the sequence promised "same branch and SHA" but never captured headRefOid, and closed the original before the replacement existed. Two failure modes: a branch that moved between capture and re-create silently reopened on an unreviewed head, and a failed `gh pr create` left the review artifact closed with no replacement. Now captures headRefOid up front, re-validates it against the remote ref immediately before the close (aborting instead of closing on mismatch), and reopens the original on any post-close failure, including a replacement that lands on the wrong head or base. Regression test: the credential check rejected three exact string spellings, so seat authoring could return under a renamed variable or a literal token path. Replaced with a structural scan — extract executable shell fences, and for any fence running `git push` / `gh pr create|merge|review`, reject any credential selection (token-file assignment, literal seat-token path, GH_TOKEN / GITHUB_TOKEN assignment, `--with-token`, `gh auth login|switch`). Includes an anti-vacuity assertion so a drifting extractor or retagged fence fails loudly rather than passing on an empty scan. A third test pins the recovery invariants. Verified: 7 mutations each turn the suite red (token-file next to `gh pr merge`, renamed GH_TOKEN next to `gh pr create`, dropped headRefOid, dropped reopen path, validation moved after the close, and fences retagged non-executable). Recovery fence parses under `bash -n`. Manifest regenerated (SKILL.md now 12510 bytes, sha256 160083f0…). * docs(skills): make the seat-PR recovery fail closed and prove App identity (BLO-18997) Ally's review at ee9f9e3 found the recovery recipe could still destroy the review artifact on a failure it did not anticipate, and that the regression test could not have caught the recipe it exists to keep out. - Fail closed before the destructive close. Every captured field is validated — full 40-hex SHA, non-empty refs — so a failed `gh pr view` or a null field no longer leaves an empty ORIG_SHA that compares equal to an empty REMOTE_SHA and "passes" the guard on two blanks. `set -euo pipefail` makes an unhandled failure abort rather than fall through to the next destructive line. - Roll back on every unsuccessful exit, not two anticipated ones: an EXIT/INT/TERM trap armed before the close reopens the original and takes any replacement down with it, and says so loudly when the rollback itself fails. - Prove the actor is the App installation before starting, and verify the replacement's author after creating it. Previously the recipe would happily recreate under the seat — the exact defect being recovered from — and report success. The preflight asserts the App's 403 signature rather than the seat's absence, so a seat login, a network failure, or a broken `gh` all abort. (`PAPERCLIP_GITHUB_TOKEN_FILE` is exported by default pointing at the App token, so its mere presence cannot be the signal.) - Validate the created PR number, so a blank `gh pr create` output cannot send the verification step to `gh pr view ""`. - Disclose what the replacement does not carry: labels, assignees, reviewers, milestone, and the original's draft state. Tests: the recovery recipe is now extracted from the shipped skill and executed against a stub `gh` across 12 scenarios, asserting the actual `gh` argv sequence — that no pre-close failure ever reaches `gh pr close`, and that every post-close failure reopens the original. That replaces presence-only regex assertions. Each guard was mutation-tested: removing the trap fails 6 tests, removing the App preflight 2, hardcoding `--base master` 1, and dropping both SHA-format checks fails the blank-both case by reaching the destructive close. The `git push` detector now matches git's global-option forms, so the historical `git -c http.https://github.com/.extraheader= push` seat-authoring recipe is rejected; a fixture pins that it would be. Catalog regenerated; SKILL.md sha256 d79f6270…5311 / 16133 bytes verified against the file directly. * fix(skills): make seat PR recovery signal safe Co-Authored-By: Paperclip <noreply@paperclip.ing> --------- Co-authored-by: CTO <cto@blockcast.network> Co-authored-by: Blockcast CTO <cto@blockcast.net> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Omar Ramadan <omar@blockcast.net> Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: allyblockcast[bot] <allyblockcast[bot]@users.noreply.github.com>
Thinking Path
Linked Issues or Issue Description
Fixes BLO-18152 (internal Paperclip issue tracker, not a GitHub issue):
POST /issues/:id/comments403s "outside this actor's authorization boundary" for actors who CAN comment viaPATCH /issues/:id.Problem: discovered 2026-07-26 while the CEO agent was working BLO-18012 during a
source_scoped_recovery_actionrun. On the same issue, same actor, same run, seconds apart:paperclipAddComment(POST, both by identifier and by UUID) → 403;paperclipUpdateIssuewith acommentfield (PATCH) → 200, comment created. The actor was not marginal —createdByAgentId,executionRunId, andexecutionLockedAtall matched a live checkout; there was no low-trust boundary configured on the issue. Root cause:PATCHhas a checkout/execution-run bypass thatPOST /commentslacks.Related but distinct: BLO-18163 (CEO coordination-metadata PATCH bypass, PR #795) shares the same
decideIssueAccessroot call, but its root cause is the assignee-only boundary having no allowance for coordination metadata — a different gap. Per BLO-18163's own guidance, left as a separate fix since the root causes differ.What Changed
server/src/services/authorization.ts: addedauthorizationBoundaryLabel()so 403 responses name which boundary rejected the call (source-scope, trust-boundary, membership, company-mismatch, grant) instead of a bare "outside this actor's authorization boundary" that reads as a full lockout regardless of cause.server/src/routes/issues.ts: givesassertAgentIssueCommentAllowedthe sameisCurrentIssueExecutionRunbypassassertAgentIssueMutationAllowedalready has, and routes every issue boundary 403 (read/comment/mutate) through onerespondIssueBoundaryDenied()helper so the message and response details are consistent across all three call sites.issue-comment-reopen-routes.test.ts— a current-execution-run holder gets the same 200/201 from bothPOST /commentsandPATCH {comment}even whenaccess.decide()denies the base boundary, and a genuinely out-of-scope actor (no execution lock) gets the same 403 from both.Verification
pnpm exec tsc --noEmit(server package): 0 errors.vitest run src/__tests__/issue-comment-reopen-routes.test.ts src/__tests__/issue-agent-mutation-ownership-routes.test.ts src/__tests__/low-trust-red-team-routes.test.ts: 3 files, 200/200 tests passing.Risks
isCurrentIssueExecutionRun(the actor's run holds the issue's checkout/execution lock) — it does not widen access for any actor without a live checkout on the issue. A genuinely out-of-boundary actor is still denied by both endpoints (covered by the newlow-trust-red-team-routes/ reopen-routes assertions).boundaryfield / more specific message) — any caller string-matching the old generic message should be checked, though none were found in this codebase.Model Used
Claude Sonnet 5 (
claude-sonnet-5[1m]), 1M context window, standard reasoning mode, with tool use (Read/Edit/Bash) to implement, type-check, and test the change.Checklist
decideIssueAccesscall)Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template