Skip to content

fix(seidroid-review): gate the guard's admission on the caller's mode and identity - #93

Merged
bdchatham merged 3 commits into
feat/seidroid-reviewfrom
fix/guard-admission-parity
Sep 6, 2026
Merged

fix(seidroid-review): gate the guard's admission on the caller's mode and identity#93
bdchatham merged 3 commits into
feat/seidroid-reviewfrom
fix/guard-admission-parity

Conversation

@bdchatham

@bdchatham bdchatham commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Three defects in the guard's Admit the request step, shipped together because
all three edit that one step. PLT-1169, PLT-1160 and PLT-1149.

  • PLT-1169 — the skip-label check keyed on $COMMAND, the guard's own parse
    of the comment body. inputs.mode decides whether a review runs. The two
    grammars differ, and both directions of the divergence are reachable. The
    guard admitted a labelled pull request whose body reads as a close, and it
    refused a teardown whose body reads as a review. Both checks now gate on
    $MODE. The guard also drops a command output that nothing reads, which
    leaves one grammar in the guard.
  • PLT-1160 — the label read used the App token alone, so ai: skip-review
    did nothing for a caller with no App. It now reads under GATE_TOKEN, and it
    fails closed.
  • PLT-1149allowed-team defaulted to empty, which skipped the only team
    gate on a comment-triggered review. It now defaults to
    sei-protocol/sei-core, and an empty value denies.

Review round two adds one fix and one wording change:

  • The team check no longer gates mode: close. It did, on the base as well
    as on the first revision of this PR. Its two neighbours exempt a teardown on
    purpose, and it now does too.
  • The refusal a no-App caller meets on the comment path names its cause, its
    fix, and what still works.
    The same fact now sits in the allowed-team
    input description and the SEIDROID_APP_ID secret description.

Review round three fixes three more:

  • The review job's comment matched the old gating. It said a comment close
    goes through the same team gate a review does. The exemption made that false.
  • pull-requests: read is now load-bearing, and the PR body did not say so.
    See "The rollout dependency" below.
  • The label refusal named a route that cannot work. It told a no-App caller
    to retry with @seidroid review, which the team check refuses first.

Which checks gate a close

Every check in Admit the request, tested rather than reasoned about. The
close-* rows of the table below are the evidence.

# check gates a close? correct?
1 the command parsed at all ($PARSED) yes yes — keep. It answers "was this comment a command", not "which command". The guard's whole-line grammar is the documented access control. A caller's contains() filter is only a pre-filter. close-not-a-command refuses prose that quotes the command, and should.
2 draft only on a pairing the job if blocks the job condition pairs pull_request with mode: review, so EVENT_NAME=pull_request implies MODE=review. close-auto-draft refuses, and is unreachable while that condition holds.
3 team membership was yes — now no the defect. Fixed here. close-team-nonmember pins the widening: the same actor and state that team-explicit-nonmember refuses for a review now reaches a close.
4 fork origin no ($MODE != close) already correct, from #89
5 skip-review label no ($MODE != close) correct after PLT-1169
6 once-per-PR verdict only on a pairing the job if blocks same argument as the draft check; close-auto-prior-verdict is unreachable while it holds

Exactly one check gated a teardown and should not. Rows 2 and 6 are not defects
but they are a standing dependency: they refuse a close on the pull_request
path, and only the job if keeps that pairing from arising. Anyone who widens
that condition has to revisit both.

This is a pre-existing defect, not one PLT-1149 introduced. On the base,
close-team-nonmember, close-team-malformed and close-team-read-fails all
refuse the teardown. Both existing callers set allowed-team, so both carry
the defect today. PLT-1149's default would have extended it to callers that omit
the input.

What the exemption widens. A close is now available to any collaborator the
job condition admits — OWNER, MEMBER or COLLABORATOR, non-bot — rather than to
the team alone. A close destroys a sandbox and nothing else. The alternative is
a pod holding reserved cpu and memory with no path to reclaiming it.

The rollout dependency: pull-requests: read is now load-bearing

On the automatic path with no App, the label check used to make zero API
calls. The old [ -n "${GH_TOKEN:-}" ] short-circuited, and the fork check reads
repository ids off the event payload. This PR makes that check always issue a
repos/{owner}/{repo}/pulls/{n} read, and refuse when it fails.

A caller that takes GitHub's default GITHUB_TOKEN permissions therefore loses
every automatic review on a private repository, where the old code reviewed
fine. That default grants contents, packages and metadata read, and no
pull-requests. My earlier caller analysis checked allowed-team and the App
secrets. It did not check permissions:, which this check now hard-depends on.

I re-checked both callers directly against the GitHub API rather than from
memory:

caller job mode permissions: covers pull-requests: read?
sei-load all three review, close, close contents: read, pull-requests: write, checks: write, issues: write yes — write subsumes read
sei-internal-skills all three review, close, close contents: read, pull-requests: write, checks: write yes

Neither breaks. Only the guard's permissions: comment implied the
requirement before. This PR states it there in as many words: the grant is
load-bearing rather than declared, and a caller on the default token reviews
nothing on a private repository.

One adjacent observation, pre-existing and not from this PR:
sei-internal-skills grants no issues: scope at any call site, while the guard
job declares issues: read. Per the docs a called workflow may only downgrade,
and the docs do not say what happens when it asks for more. Either GitHub errors
that job on the next pin bump, or it downgrades to none and the gate's comment
read fails open with a warning. Worth resolving before that caller bumps.

Posture decision for the label check: fails closed

A read that does not answer refuses the review. Three facts weighted, in order:

  1. The neighbour above it already fails closed on the same read. The fork
    check that landed in fix(seidroid-review): refuse an explicit re-review on a fork-originated pull request #89 reads repos/{owner}/{repo}/pulls/{n} under
    GATE_TOKEN and refuses when it cannot place the pull request. A label check
    that admits on that same failed read would give two answers to one API error.
  2. The costs are asymmetric, as PLT-1160 frames them. A refusal costs one
    review, and the notice names both fixes. Admitting costs the label its whole
    meaning, on the one pull request whose author asked for no review.
  3. A teardown is never affected. mode: close skips the check, so a failed
    label read can never strand a sandbox — the failure that has no other
    recovery.

The once-per-PR gate below still fails open, and its comment now says so against
this one rather than agreeing with it.

What GitHub's documentation actually says

Read from docs.github.com, API version 2022-11-28:

endpoint fine-grained permission covered by the guard's grants
GET /repos/{owner}/{repo}/pulls/{pull_number} at least one of "Pull requests" read or "Contents" read yes — the job grants pull-requests: read
GET /repos/{owner}/{repo}/issues/{issue_number}/labels at least one of "Issues" read or "Pull requests" read yes, though the guard reads labels off the pulls endpoint and never calls this one
GET /orgs/{org}/teams/{slug}/memberships/{user} "Members" organization permissions (read) no — the workflow permissions: key has no members scope, so a GITHUB_TOKEN cannot carry it

The third row is why the team check keeps GH_TOKEN and gains no fallback: the
App identity is the only identity that can answer it. The GATE_TOKEN comment
now records that as the documented reason rather than an assertion.

One rule I could not fully confirm: the reusable-workflow reference states that
"the GITHUB_TOKEN permissions passed from the caller workflow can be only
downgraded (not elevated) by the called workflow." It does not say what happens
when a called workflow requests more than the caller granted. Nothing here ran
on a GitHub runner, so I did not test it.

Verification

Nothing in this PR ran on a GitHub runner. A harness reads the parse and
Admit the request steps out of the YAML with PyYAML — jobs.guard.steps[]
and runs each under bash. A gh stub on PATH serves fixture JSON through
the step's own --jq filter and the real jq. The harness resolves every
${{ }} in both steps' env: blocks from the workflow file. It hard-errors
on an expression it does not know
, so it cannot quietly stop modelling the
step it tests. Input defaults come from the file's own workflow_call.inputs,
so a case that omits an input models a caller that omits it.

The base moved three times: 5f5fd78 to 5d06528 mid-task, then 41ee3ff
(#90), then 30f5c09 (#95). Every case below comes from a fresh extraction of
the rebased file.

#95 moved driver-version to v0.15.0. Its hunks land at lines 99, 106 and
1035+; the first hunk in this diff is at 109. The guard job is byte-identical
between 41ee3ff and 30f5c09, dumped and diffed the same way as before.

The rebase onto 41ee3ff reported no conflict, so I checked it rather than
trusted it. The guard job is byte-identical between 5d06528 and
41ee3ff — dumped and diffed. #90's hunks land at lines 1151+ and 2292+, clear
of every hunk in this diff. #90 also hoisted FINDING_MARKER into the workflow
env: block. The harness now exports every workflow env key rather than the
one it used to name, so a later hoist reaches these scripts the way it does on a
runner.

admit as the step wrote it, base 5d06528 against this branch:

| case                         | scenario                                                    | base   | this PR |
|------------------------------|-------------------------------------------------------------|--------|--------|
| divergence-labelled          | body parses close, caller sends review, labelled            | true   | false  |
| divergence-bare              | same body, no label                                         | true   | true   |
| divergence-fork              | same body, fork-originated                                  | false  | false  |
| close-labelled               | teardown, labelled                                          | true   | true   |
| close-label-read-fails       | teardown, pulls read fails                                  | true   | true   |
| close-body-review            | body parses review, caller sends close, labelled            | false  | true   |
| comment-app-labelled         | App set, labelled                                           | false  | false  |
| comment-app-bare             | App set, no label                                           | true   | true   |
| comment-app-read-fails       | App set, pulls read fails                                   | false  | false  |
| comment-noapp-labelled       | no App, labelled, comment path                              | true   | false  |
| comment-noapp-bare           | no App, no label, comment path                              | true   | false  |
| comment-halfapp-bare         | half a credential, comment path                             | false  | false  |
| team-omitted-member          | omitted, sei-core member                                    | true   | true   |
| team-omitted-nonmember       | omitted, not an active member                               | true   | false  |
| team-omitted-unknown         | omitted, membership unreadable                              | true   | false  |
| team-explicit-empty          | caller passes allowed-team: ''                              | true   | false  |
| team-explicit-member         | existing caller, sei-core member                            | true   | true   |
| team-explicit-nonmember      | existing caller, not a member                               | false  | false  |
| team-malformed               | allowed-team with no slash                                  | false  | false  |
| not-a-command                | prose that mentions the command                             | false  | false  |
| auto-noapp-labelled          | no App, labelled                                            | true   | false  |
| auto-noapp-bare              | no App, no label                                            | true   | true   |
| auto-app-labelled            | App set, labelled                                           | false  | false  |
| auto-app-bare                | App set, no label                                           | true   | true   |
| auto-halfapp-labelled        | half a credential, labelled                                 | false  | false  |
| auto-halfapp-bare            | half a credential, no label                                 | false  | true   |
| auto-noapp-read-fails        | caller grants no pull-requests: read                        | true   | false  |
| auto-team-set-noapp          | team set, automatic path skips it                           | true   | true   |
| auto-draft                   | draft                                                       | false  | false  |
| auto-fork                    | fork-originated                                             | false  | false  |
| auto-prior-verdict           | a verdict already stands                                    | false  | false  |
| auto-standing-block          | a block from this workflow stands                           | true   | true   |
| label-other                  | a different label                                           | true   | true   |
| label-superstring            | a label the skip label is a prefix of                       | true   | true   |
| label-key-absent             | the payload carries no labels key                           | true   | true   |
| label-input-empty            | skip-review-label passed empty                              | true   | true   |
| close-team-omitted-noapp     | close: team omitted, no App                                 | true   | true   |
| close-team-nonmember         | close: commander not on the team, team set                  | false  | true   |
| close-nonmember-team-default | close: commander not on the team, team defaulted            | true   | true   |
| close-team-empty             | close: allowed-team passed empty                            | true   | true   |
| close-team-malformed         | close: allowed-team with no slash                           | false  | true   |
| close-team-read-fails        | close: membership read fails                                | false  | true   |
| close-fork                   | close: fork-originated                                      | true   | true   |
| close-not-a-command          | close: prose, the guard's grammar refuses                   | false  | false  |
| close-auto-draft             | close on pull_request, draft (job `if` blocks this pairing) | false  | false  |
| close-auto-prior-verdict     | close on pull_request, prior verdict (job `if` blocks this) | false  | false  |

Thirteen verdicts change. Each one is a ticket or a review finding asking for it:

case change why
divergence-labelled admit → deny PLT-1169, the reachable bypass
close-body-review deny → admit PLT-1169 in the other direction: the guard refused a teardown whose body reads as a review
auto-noapp-labelled admit → deny PLT-1160, the label now bites with no App
auto-noapp-read-fails admit → deny PLT-1160, the fail-closed posture
auto-halfapp-bare deny → admit the label now reads under github.token, so the half-credential refusal loses its premise
team-omitted-nonmember admit → deny PLT-1149, the default
team-omitted-unknown admit → deny PLT-1149, an unreadable membership
team-explicit-empty admit → deny PLT-1149, empty denies
comment-noapp-labelled admit → deny the team check refuses first — see below
comment-noapp-bare admit → deny the team check refuses first — see below
close-team-nonmember deny → admit review finding: the team check stranded a teardown
close-team-malformed deny → admit same
close-team-read-fails deny → admit same

A no-App caller on the comment path: accepted, and the refusal says why

The automatic pull_request path is unaffected. A reader will assume that
half broke, so it goes first. auto-noapp-bare admits. auto-noapp-labelled
refuses on the label. No team check runs on that path at all. A no-App caller
keeps automatic reviews, and after this round keeps @seidroid review close.

The comment path is what changes. allowed-team is non-empty by default. The
team check needs "Members" organization read, and only the App token carries it.
A caller with no App therefore meets a refusal when it asks for a review by
comment.

I accept that, for three reasons. It fails closed, and a gate that decides who
may spend a sandbox must refuse a claim it cannot verify. It matches
ai-review.yml, which defaults the same input and denies on empty. And it is
not new coupling: any caller that sets allowed-team has it today.

The refusal now reads:

this run holds no App identity, so it cannot read membership of
sei-protocol/sei-core; denying. Pass SEIDROID_APP_ID and
SEIDROID_APP_PRIVATE_KEY to this workflow. An automatic pull_request review
and @seidroid review close do not reach this check

The label check's own refusal follows the same standard. It used to end
"then ask again with @seidroid review", which sends a no-App caller to the one
path the team check refuses first. It now reads:

could not read the labels on OWNER/REPO#N, so ai: skip-review cannot be ruled
out; not reviewing. Grant pull-requests: read on the calling job, or pass
SEIDROID_APP_ID and SEIDROID_APP_PRIVATE_KEY

Cause, fix, and what still works. The allowed-team input description and the
SEIDROID_APP_ID secret description carry the same fact, because the person
configuring the caller and the person reading a refusal are different people.

One correction to the instruction. The review asked the notice to name two
ways out: configure SEIDROID_APP_ID, or set allowed-team: ''. The second
one does not work. PLT-1149 makes an empty allowed-team deny, and the same
review round accepted that change. A person who follows that advice meets
allowed-team is empty or is not org/team-slug; denying. Only one way out
exists, and the notice names it. The input description says so in as many words:
"Setting this input empty is not the way out: empty denies."

Reverting empty-denies would restore the second way out and re-open half of
PLT-1149. That is the ticket owner's call, not one for me to make silently.

Both existing callers

sei-protocol/sei-load and sei-protocol/sei-internal-skills both pass
allowed-team: 'sei-protocol/sei-core' on their review and close jobs, and both
configure the App. team-explicit-member, team-explicit-nonmember,
comment-app-* and auto-app-* are unchanged, so their behaviour holds when
they bump their pin.

Their third job, seidroid-review-reclaim, omits allowed-team. It fires a
pull_request event with mode: close. The guard's if does not match that
pair, so GitHub skips the guard and nothing reads the input. Both callers carry
a comment calling the input "optional there (default '')". That parenthesis goes
stale with this PR, though the behaviour does not change. Worth a follow-up edit
in those repositories.

actionlint

Rule set unchanged. Four SC2102 before and after, at the same offsets inside
the extracted scripts.

$ actionlint -oneline base.yml
base.yml:1474:9: shellcheck reported issue in this script: SC2102:info:44:14: Ranges can only match single chars (mentioned due to duplicates) [shellcheck]
base.yml:1474:9: shellcheck reported issue in this script: SC2102:info:45:14: Ranges can only match single chars (mentioned due to duplicates) [shellcheck]
base.yml:1978:9: shellcheck reported issue in this script: SC2102:info:207:16: Ranges can only match single chars (mentioned due to duplicates) [shellcheck]
base.yml:1978:9: shellcheck reported issue in this script: SC2102:info:208:16: Ranges can only match single chars (mentioned due to duplicates) [shellcheck]

$ actionlint -oneline .github/workflows/seidroid-review.yml
.github/workflows/seidroid-review.yml:1468:9: shellcheck reported issue in this script: SC2102:info:44:14: Ranges can only match single chars (mentioned due to duplicates) [shellcheck]
.github/workflows/seidroid-review.yml:1468:9: shellcheck reported issue in this script: SC2102:info:45:14: Ranges can only match single chars (mentioned due to duplicates) [shellcheck]
.github/workflows/seidroid-review.yml:1972:9: shellcheck reported issue in this script: SC2102:info:207:16: Ranges can only match single chars (mentioned due to duplicates) [shellcheck]
.github/workflows/seidroid-review.yml:1972:9: shellcheck reported issue in this script: SC2102:info:208:16: Ranges can only match single chars (mentioned due to duplicates) [shellcheck]

Grouping the parse step's three >> "$GITHUB_OUTPUT" writes is what keeps that
set unchanged. Removing the command= classification left three adjacent
redirects, which raised a new SC2129; the { … } >> file form matches the
shape the same step's pull_request branch already uses.

One near-miss worth recording, because it is the same class of defect the
review warned about. My first "final" verification read
origin/fix/guard-admission-parity, whose local tracking ref had not moved past
the force-push. It served the pre-fix tree and produced a table that disagreed
with the working tree. git ls-remote said f7add4a; the tracking ref said
9c56ab5. The numbers above come from a re-fetched ref, and the file behind
them is byte-identical to the working tree (cmp).

Also checked, on the changed file:

  • YAML parses (PyYAML), both jobs present.
  • shellcheck -s bash on the extracted Admit the request script: clean.
  • Every $VAR in every guard run: script resolves: a step, job or workflow
    env: key declares it, the script assigns it, or the script reads it as
    ${VAR:-}. Every env: key has a reader. That is the set -u check.
    Dropping COMMAND, APP_ID_PRESENT and APP_KEY_PRESENT must not strand
    one.

What I did not verify

  • Nothing ran on a GitHub runner. Every result above comes from the extracted
    shell against a stub.
  • Whether GitHub errors or silently downgrades when a called workflow requests a
    permission its caller did not grant.
  • The real GitHub API's exact failure shapes. The stub models an authentication
    failure and a non-zero gh api exit; it does not model a partial page or a
    rate limit.
  • Pagination. The label read fetches one pull request, so --paginate does not
    apply, but the stub serves one page for the gate's reads as well.

Accepted, not fixed

On the comment path the guard now reads repos/{owner}/{repo}/pulls/{n} twice:
once for the fork check, once for the label. Folding them into one read means
restructuring the block #89 just landed. The automatic path saves nothing
either, because it reads the fork signal from the event payload. One extra REST
call, against a review that holds a sandbox for minutes, does not pay for that
coupling.

Follow-ups, not done here

  • Both callers' seidroid-review-reclaim job carries a comment calling
    allowed-team "optional there (default '')". The parenthesis goes stale with
    this PR. The behaviour does not change, because that job never reaches the
    guard. Recorded here as a follow-up in sei-load and sei-internal-skills; I
    did not edit either repository.
  • This workflow has no README documenting its inputs the way ai-review.yml
    does. The input descriptions in the file are the only reference, and three of
    them changed here.

Corrections to the three tickets

  • PLT-1160 says the half-configured caller "already denies before this
    check". It denies inside the check, as its elif branch. That branch rests
    on one premise: half a credential mints no token, so nothing can read the
    label. The fallback to github.token ends that premise, so this PR drops the
    branch. The Report a half-configured reviewer identity step still names the
    missing half.
  • PLT-1149 says to "keep the existing behaviour that an unset team on the
    comment path refuses". The existing behaviour admits: an empty
    allowed-team skipped the check. This PR implements the refusal the sentence
    asks for. That matches ai-review.yml and the ticket's own thesis. The
    "existing behaviour" clause is wrong about the present.

Closes PLT-1169. Closes PLT-1160. Closes PLT-1149.

🤖 Generated with Claude Code

@cursor

cursor Bot commented Sep 6, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes who may trigger comment-based reviews and when reviews are refused (team default, no-App comment path, fail-closed labels), which affects CI access control and sandbox lifecycle without touching application runtime code.

Overview
Tightens the reusable seidroid-review guard so fork, skip-label, and team gates follow the caller’s inputs.mode instead of a separate command output from comment parsing (which could disagree with how the caller routed review vs close).

allowed-team now defaults to sei-protocol/sei-core; empty or malformed values deny comment-triggered reviews. The team membership check runs only on the comment path (not automatic pull_request reviews), skips mode: close so any admitted collaborator can reclaim a sandbox, and requires the GitHub App (SEIDROID_APP_ID / private key) because org team membership cannot be read with GITHUB_TOKEN.

Skip-review label is evaluated with GATE_TOKEN (github.token when no App), so ai: skip-review applies without App credentials, and label reads fail closed (like the fork check) when the PR cannot be fetched—callers need pull-requests: read on private repos.

Docs in the workflow inputs/secrets and inline comments were expanded to describe these behaviors; the parse step no longer emits a command output.

Reviewed by Cursor Bugbot for commit 740f013. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All three targeted fixes (mode-keying the skip-label check, reading labels under GATE_TOKEN with fail-closed semantics, and defaulting allowed-team) are implemented correctly, and dropping the command output is safe — nothing reads it. The one gap is that the new non-empty allowed-team default extends the team check to mode: close teardowns, which the fork and label checks deliberately exempt.

Findings: 0 blocking | 4 non-blocking | 1 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Changing allowed-team's default from '' to sei-protocol/sei-core is a silent behaviour break for any existing caller that pins this reusable workflow and relies on the old default: on the next ref bump the comment path stops working, and the denial notice names a team the commenter has never heard of. It matches ai-review.yml's convention so it is clearly intended, but seidroid-review.yml has no README entry documenting its inputs (unlike ai-review.yml, whose table at .github/seidroid/ai-review/README.md:75 already records this default), so nothing tells a caller what changed. Worth a release note on the tag that carries it.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.
  • 2 non-blocking pre-existing issue(s) listed below under pre-existing issues.

Pre-existing issues

  • [suggestion] .github/workflows/seidroid-review.yml:639 — the team membership read is gh api … 2>/dev/null || true, so a failed lookup is indistinguishable from a non-member: both fall through to the same $ACTOR is not an active member of $ALLOWED_TEAM notice at line 640, and the API's own error (missing Members: Read, org SSO, rate limit) is discarded. The neighbouring fork and label checks let stderr reach the log for exactly this reason. Now that the check runs on every comment-triggered dispatch rather than only when a caller opted in, the diagnostic loss is more costly.
  • [suggestion] The guard's own command grammar at .github/workflows/seidroid-review.yml:517 still accepts a bare seidroid review where the documented caller form requires @seidroid, so the guard and the caller can read one comment body differently. This PR works around the divergence by gating every check on $MODE instead of reconciling the two parsers, so should_run can still be true for a body the caller would never have dispatched. Aligning the guard's regex with the caller's trigger-phrase would remove the second grammar rather than route around it.

Comment thread .github/workflows/seidroid-review.yml Outdated
# silently stop reviewing every pull request opened by anyone outside the
# team, which is the opposite of what a caller sets this input for.
if [ "$EVENT_NAME" != "pull_request" ] && [ -n "$ALLOWED_TEAM" ]; then
if [ "$EVENT_NAME" != "pull_request" ]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] This check runs for every non-pull_request event, which includes mode: close. Combined with the new non-empty default it now gates teardown for callers that never set allowed-team — a case that previously skipped the block entirely.

The PR description gives "A teardown is never affected — mode: close skips the check" as the third reason for the fail-closed posture. That holds for the label check (line 714) and the fork check (line 674), both of which guard on [ "$MODE" != "close" ], but not for this one.

The concrete case is a caller with no App configured. GH_TOKEN here is App-only with no GATE_TOKEN fallback, so line 638 denies @seidroid review close outright. That same caller can still start reviews — the review job authenticates with OMNIGENT_MACHINE_CLIENT_*, not the App — so it creates sandboxes it can no longer reclaim by comment, against the block below that notes "nothing else will: no lifetime cap, no sweep." Recovery exists only through the pull_request close event, which skips the guard.

Refusing a non-member's teardown is defensible as a security control, but the identity-missing denial is not a membership decision. Either exempt close the way the other two checks do, or scope the strictness so a missing App identity does not strand a session; and the input description at line 109 should say the gate covers teardown as well as review.

@bdchatham
bdchatham force-pushed the fix/guard-admission-parity branch from 9c56ab5 to f7add4a Compare September 6, 2026 22:16

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The three fixes are coherent and well-reasoned: gating both the fork and skip-label checks on inputs.mode removes the two-grammar divergence, the label read under GATE_TOKEN makes the label bite without an App, and allowed-team now fails closed. I verified nothing still reads the dropped command output or the APP_*_PRESENT env vars, and $ENV in --jq is already relied on elsewhere in this file. Remaining notes are a now-stale comment in the review job about the close path's gating, one misleading denial message, and a rollout dependency the PR's caller analysis doesn't cover.

Findings: 0 blocking | 4 non-blocking | 3 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • The PR body's verification matrix covers close-labelled, close-label-read-fails and close-body-review, but has no case for a comment-triggered close from an actor who is not an active member of allowed-team — which is exactly the admission this PR newly widens. Worth a close-nonmember row so the harness pins the new behaviour.
  • 3 suggestion(s)/nit(s) flagged inline on specific lines.

# Reading an organisation's teams needs the App identity, so a caller that
# configures no App is refused here. The notice says so, and names the one
# thing that fixes it.
if [ "$EVENT_NAME" != "pull_request" ] && [ "$MODE" != "close" ]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Replacing [ -n "$ALLOWED_TEAM" ] with [ "$MODE" != "close" ] does two things at once: it makes an omitted team deny (PLT-1149, intended), and it exempts a comment-triggered teardown from the team gate entirely. For the two existing callers, which both pass allowed-team: 'sei-protocol/sei-core', that is a widening — a comment close was gated on active team membership before this change and is now gated only on the job condition's OWNER/MEMBER/COLLABORATOR association. The reasoning in the comment above (a sandbox nothing reclaims) is sound, but the change leaves the review job's own comment contradicting the code:

# A close asked for in a COMMENT is a different thing and does need one. It is a
# person destroying a session, so it goes through the same team gate the review
# does; the only ungated close is the one the platform itself reports.

That is no longer true — a comment close now skips the team gate. Please update that block so the two readings agree.

# Admitting on a read that did not answer costs the label its meaning, on the
# one pull request whose author asked for no review. Anything but a plain
# `false` denies.
if [ "$MODE" != "close" ] && [ -n "$SKIP_LABEL" ]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Worth calling out for rollout: on the automatic pull_request path with no App configured, this check previously made zero API calls (the old if [ -n "$GH_TOKEN" ] short-circuited, and the fork check reads repository ids straight off the payload). It now always issues a repos/{owner}/{repo}/pulls/{n} read and denies when it fails — so a caller whose calling job relies on GitHub's default GITHUB_TOKEN permissions (contents/packages/metadata read, no pull-requests) loses every automatic review on a private repository, where the old code reviewed fine.

The PR body's "Both existing callers" analysis checks their allowed-team input and App secrets but not their permissions: blocks, which this check now hard-depends on. Confirming that both callers grant pull-requests: read on the calling job before bumping the pin would close the gap. The requirement is already stated in the job's permissions: comment, so this is a verification note rather than a code change.

Comment thread .github/workflows/seidroid-review.yml Outdated
carries="$(GH_TOKEN="$GATE_TOKEN" \
gh api "repos/$REPO/pulls/$PR" \
--jq 'any(.labels[]?.name; . == $ENV.SKIP_LABEL)')" \
|| deny "could not read the labels on $REPO#$PR, so $SKIP_LABEL cannot be ruled out; not reviewing. Grant pull-requests: read on the calling job, or pass the App secrets, then ask again with @seidroid review"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] The remedy is self-contradictory on the automatic path for the configuration that most often hits it. A caller with no App that also lacks pull-requests: read is told to "ask again with @seidroid review" — but that comment path is refused first by the team check at line 671, which requires the App. Either drop the trailing clause or name the App as part of the remedy, so the notice doesn't send the reader down a path that cannot work.

bdchatham and others added 3 commits September 6, 2026 15:23
… and identity

Three defects in the guard's `Admit the request` step, all in one step.

PLT-1169: the skip-label check keyed on the guard's own parse of the comment
body, where the caller's `mode` is what decides whether a review runs. The two
grammars differ, so a labelled pull request whose body reads as a close was
admitted with the label ignored, and a teardown whose body read as a review was
refused. Both checks now gate on `$MODE`, and the guard stops deriving a command
it no longer reads.

PLT-1160: the label read used the App token alone, so a caller with no App got
no label check. It now reads under GATE_TOKEN and fails closed. A refused read
costs one review a person can retry; admitting on one costs the label its
meaning.

PLT-1149: `allowed-team` defaulted to empty, which skipped the only team gate
on a comment-triggered review. It now defaults to `sei-protocol/sei-core`, and
an empty value denies, matching ai-review.yml.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… a review is refused

The team check gated `mode: close`. Its two neighbours, the fork check and the
label check, exempt a teardown on purpose: a close is the only thing that
reclaims a sandbox, and no lifetime cap or sweep reclaims one otherwise. A team
check that refuses a close strands the pod. It now exempts a close the same way,
so any collaborator the job condition admits may reclaim a sandbox.

The refusal a caller with no App meets on the comment path now names its cause
and its fix: the run holds no App identity, reading an organisation's teams
needs one, pass both App secrets. It also names what still works without them,
because a person reading a refusal wants a next step. The `allowed-team` input
and the `SEIDROID_APP_ID` secret carry the same fact for the person configuring
the caller.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he label refusal a remedy that works

The review job said a comment-triggered close goes through the same team gate a
review does. It does not: the team, fork and label checks all exempt a teardown.
The comment now names what does admit one — the comment grammar and the job's
OWNER/MEMBER/COLLABORATOR filter.

The label refusal told a caller to ask again with @seidroid review. A caller
with no App is refused on that path by the team check, so the remedy named a
route that cannot work. It now names the two that can: grant pull-requests: read
on the calling job, or pass both App secrets.

The guard's permissions comment records that pull-requests: read is load-bearing.
The label check reads the pull request on every review, including an automatic
one under GITHUB_TOKEN, and refuses when that read fails. GitHub's default token
grants contents, packages and metadata only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bdchatham
bdchatham force-pushed the fix/guard-admission-parity branch from f7add4a to 740f013 Compare September 6, 2026 22:25
@bdchatham

Copy link
Copy Markdown
Contributor Author

All three fixed in 740f013, rebased onto 30f5c09 (#95).

Finding 1 — the review job's comment. Correct, and my exemption is what
made it false. The comment now names what admits a comment close, rather than
what used to:

A close asked for in a COMMENT is a different thing and does need one. The
guard admits it on two things only: the comment grammar it parses, and the
OWNER/MEMBER/COLLABORATOR filter on the job condition. The team, fork and
label checks all exempt a teardown, because a close a check refuses leaves a
sandbox that nothing else reclaims.

Finding 2 — the rollout dependency. The sharpest of the three, and I missed
it: I checked both callers' allowed-team and App secrets and never looked at
their permissions:. You are right that the old path made zero API calls — the
[ -n "${GH_TOKEN:-}" ] short-circuit plus a payload-only fork check — and that
this PR makes the read unconditional and fatal.

I re-checked both callers against the API rather than taking the numbers on
trust. Both clear it:

caller permissions: at all three call sites covers pull-requests: read?
sei-load contents: read, pull-requests: write, checks: write, issues: write yes, write subsumes read
sei-internal-skills contents: read, pull-requests: write, checks: write yes

The PR body now carries the finding, the table, and the reason the next caller is
the one it bites. The guard's permissions: comment states the requirement
instead of implying it: the grant is load-bearing, the label check reads the pull
request on every review including an automatic one under GITHUB_TOKEN, and
GitHub's default token grants contents, packages and metadata only.

One adjacent thing I found while checking, pre-existing and not from this PR:
sei-internal-skills grants no issues: scope anywhere, while the guard job
declares issues: read. A called workflow may only downgrade, and the docs do
not say what happens when it asks for more. Either that job errors on the next
pin bump, or issues downgrades to none and the gate's comment read fails open
with a warning. Worth resolving before that caller bumps.

Finding 3 — the self-contradicting remedy. Agreed, and it is the same
standard, so it gets the same answer. Clause dropped, both working fixes named:

could not read the labels on OWNER/REPO#N, so ai: skip-review cannot be ruled
out; not reviewing. Grant pull-requests: read on the calling job, or pass
SEIDROID_APP_ID and SEIDROID_APP_PRIVATE_KEY

The case you asked for. close-team-nonmember was already in the table from
the previous round — an actor with state: "pending", App present, team set,
false → true. It pairs exactly with team-explicit-nonmember, which stays
false → false: the same actor and the same state, refused for a review and
admitted for a close. That pair is the widening. I added
close-nonmember-team-default beside it for the same actor under the defaulted
team, which is the shape a third caller meets. It reads true → true — same
verdict as the base, different reason, since the base skipped the check on an
empty team and this PR exempts the close.

Verification. 46 cases, 13 verdicts changed, re-extracted from the pushed
commit and cmp-checked against the working tree. actionlint identical to the
base including intra-script offsets: 4x SC2102 at 44:14, 45:14, 207:16,
208:16. YAML parses. The set -u audit reports no undeclared reader and no
unused env: key. Nothing ran on a GitHub runner.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The three fixes are sound: gating the skip-label and team checks on inputs.mode instead of the guard's own parse removes the two-grammar divergence, the label read under GATE_TOKEN makes the label bite for App-less callers, and exempting mode: close from the team check is consistent with the fork and label checks (a close only runs driver review --close, never checking out PR code). No blocking defects found; the notes below are about operability of the new fail-closed paths and one stale neighbouring comment.

Findings: 0 blocking | 5 non-blocking | 3 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Verified that nothing reads the removed command output — steps.parse.outputs.command has no remaining references in the file, and no in-repo workflow calls seidroid-review.yml, so the removal is safe. Downstream callers (sei-load, sei-internal-skills) are the only consumers and should be checked for the allowed-team default change before this is tagged.
  • 3 suggestion(s)/nit(s) flagged inline on specific lines.
  • 1 non-blocking pre-existing issue(s) listed below under pre-existing issues.

Pre-existing issues

  • [suggestion] A guard refusal is only ever a ::notice:: in the run log (deny() at .github/workflows/seidroid-review.yml:636), and the reaction step that acknowledges a comment lives in the review job, which is skipped when the guard denies. A person who comments @seidroid review and is refused gets no signal at all on the pull request. This PR adds two more refusal paths on the comment path, which widens the surface, but the silent-refusal behaviour itself is on the base branch.

carries="$(GH_TOKEN="$GATE_TOKEN" \
gh api "repos/$REPO/pulls/$PR" \
--jq 'any(.labels[]?.name; . == $ENV.SKIP_LABEL)')" \
|| deny "could not read the labels on $REPO#$PR, so $SKIP_LABEL cannot be ruled out; not reviewing. Grant pull-requests: read on the calling job, or pass SEIDROID_APP_ID and SEIDROID_APP_PRIVATE_KEY"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] This denial is emitted as a ::notice:: (via deny()), which produces no visible marker on the run. Because the label check now runs on every review — including the automatic path for a caller with no App — a token that cannot read repos/{owner}/{repo}/pulls/{n} silently stops all reviews repo-wide, with the only evidence buried in the log of a run that shows green. That is exactly the failure the paragraph above anticipates ("a caller that takes the default reviews nothing on a private repository"). Consider emitting ::warning:: (or ::error::) for the unreadable case specifically, keeping ::notice:: for a genuine policy refusal — a fail-closed outage caused by misconfiguration deserves a different colour than "the author asked for no review".

required: false
type: string
default: ''
default: 'sei-protocol/sei-core'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] This is an intentional and documented breaking change for consumers, but it removes the only way to express "any collaborator the job condition admits may command a review": empty previously skipped the check and now denies, so a caller outside sei-protocol has no configuration that admits the comment path at all — they must either own a team in their own org or lose the feature. Combined with the App requirement for the membership read, upgrading to this ref silently turns every @seidroid review comment into a refusal for such a caller. Worth calling out in the release notes / a version bump for the tag downstream repos pin, since neither the guard nor the run status makes the change visible at upgrade time.

#
# Every reader prefers the App identity and falls back to this, so a caller that
# configures no App still gets all three. A refused read costs them differently,
# configures no App still gets all four. A refused read costs them differently,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] "all three" → "all four" here, but the sibling sentence ten lines above is now stale: "Three reads share it. The fork check reads the pull request. The once-per-PR gate reads a review... and a comment." The label read is a fourth read sharing pull-requests: read — and per the paragraph you added below it is the load-bearing one. Worth updating that enumeration in the same pass, since these comments are the only reference this workflow has.

@bdchatham
bdchatham merged commit d2dd154 into feat/seidroid-review Sep 6, 2026
6 checks passed
@bdchatham
bdchatham deleted the fix/guard-admission-parity branch September 6, 2026 22:31
bdchatham added a commit that referenced this pull request Sep 6, 2026
An author can now ask for a polish pass. `seidroid-review.yml` never
passed
`--include-nits`, so `IncludeNits` was false on every review and the
driver's nit
setting was unreachable from a caller. This wires it to a label on the
reviewed
pull request, and adds the flag to the install step's contract check.

## Label, not a boolean input

The person who wants nits is the author of one pull request. A boolean
input keys
off the caller's configuration, so it turns nits on for every pull
request in the
repository or for none. A label is set per pull request, by the author,
without a
workflow edit.

`nitpick-label` matches `ai-review.yml`'s input of the same name and its
`ai: nitpick` default (`ai-review.yml:66-70`), so a repository running
both tools
adds one label rather than two. Its two consumers there are the prompt
(`ai-review.yml:783`) and the poster (`ai-review.yml:799, 838, 896`),
both fed
from one label read in the preflight resolve step (`ai-review.yml:268`).

Empty disables the check, which is `skip-review-label`'s semantics in
this file
rather than `ai-review.yml`'s.

One semantic does not transfer. `ai-review.yml` re-runs on a `labeled`
event when
the changed label is the nitpick one. This workflow reviews a pull
request once
and states that a relabel earns no second review, so adding the label
starts
nothing. The author labels the pull request and comments `@seidroid
review`. The
input says so.

## Where the read goes, and what it costs

Its own step in the review job, `Read the nit setting from the pull
request`,
immediately before the driver invocation. It costs one `GET
/repos/{o}/{r}/pulls/{n}`
on the review path only.

Not in the guard's `Admit the request`. The guard's skip-label read runs
under
`GH_TOKEN`, which is the App token with no fallback; a caller that
configured no
App would never be able to opt in. Answering that caller needs
`GATE_TOKEN`, which
is a separate `gh api` call whichever job it lives in — so sharing the
guard's call
would mean restructuring a fail-open/fail-closed admission check for a
signal that
decides nothing about admission. The guard also `deny`s by `exit 0`
mid-step, so an
output added after the label check is not written on a denied path.

Not inside the drive step either, which is the tighter constraint. That
step
deliberately carries no GitHub token, and its env reaches the driver
process. A
`GH_TOKEN` there would hand the reviewing agent a GitHub credential.

The read uses `any(.labels[]?.name; . == $ENV.NITPICK_LABEL)`, the shape
#93 gives
the guard's own label check. It answers a failed read differently, and
on purpose:
the skip label withholds work, so refusing on a signal nobody could read
is the
safe answer there; this label asks for advice, so refusing would spend
the review
to protect the polish pass. A failed read warns and leaves nits off. The
comment
says so beside the code.

## The install contract check

The install step reads `review --help` and refuses a driver missing any
long flag
this file passes, before a session opens or quota is spent.
`--include-nits` is now
on that list. Without it a driver that dropped or renamed the flag would
pass the
check and fail inside `Drive session + collect verdict`, after install
had already
admitted it — which is the failure the check exists to move earlier.

The list is confirmed complete against the argv the drive step actually
builds,
not against the list as written; see report 3 below.

## What turning nits on changes on the pull request

Off does not mean dropped. Read against `sei-agent-driver` at `v0.15.0`,
which is
both the `driver-version` default and `MIN_DRIVER_VERSION` after #95:

| | label absent | label present |
|---|---|---|
| a nit-grade observation | `nitRule` sends it to `non_blockers`: prose
in the verdict comment and in the check run's Non-blocking section, no
thread on the code | reported inline with severity `nit`: a comment
thread on the line |
| a nit the review placed inline anyway | dropped — `PlaceableFindings`
(`findings.go:128`), the counts (`findings.go:373` via `countFindings`),
and the check summary, which renders only the line-less buckets | placed
and counted |
| a prior thread a nit restates | supersedes nothing, because no comment
posts | superseded, and resolved once the comment is on the code |

So the label chooses where a nit lands, not whether the review makes
one. The
prompt states the current setting on both settings and says it replaces
an earlier
one (`prompt.go:527-548`) — load-bearing here, because the session
outlives the run
and a first turn told to leave nits out still holds that instruction.

## Verification

Nothing ran on a GitHub runner. Three step scripts — `Install the review
driver`,
`Read the nit setting` and `Drive session + collect verdict` — were
extracted from
the shipped file with a YAML parser and run under `bash` with stubs. The
harness
asserts each step's `if` and the `INCLUDE_NITS` wiring against the file,
so a
rebase that changes one fails the harness rather than passing it. The
`gh` stub
runs the shipped `--jq` filter through real `jq`; the `go` stub serves a
driver
whose reported version and `review --help` flag set the case controls.

**1. driver argv**

```
case                 nit step                    --include-nits  drive rc
label present        include_nits='true'         yes             0
label absent         include_nits='false'        no              0
no labels at all     include_nits='false'        no              0
no labels key        include_nits='false'        no              0
near-miss labels     include_nits='false'        no              0
caller renamed it    include_nits='true'         yes             0
label read fails     include_nits='false'        no              0
label input empty    skipped                     no              0
close mode           skipped                     no              0
every input set      include_nits='true'         yes             0
```

`every input set` exists so the union of flags below is the whole
surface. Its argv:

```
review sei-protocol/uci 42 --out .../verdict.md --findings-out .../findings.json \
  --check-out .../check.json --conversation-context .../threads.json \
  --guidelines-file REVIEW.md --extra-instructions "be terse" --include-nits \
  --trigger-id 999
```

Close mode: `review sei-protocol/uci 42 --close`.

**2. install contract check**

```
case                         version   mode     rc   annotation
help names every flag        v0.15.0   review   0
help drops --include-nits    v0.15.0   review   1    ...does not accept `review` --include-nits
help drops --check-out       v0.15.0   review   1    ...does not accept `review` --check-out
driver below the floor       v0.14.0   review   1    ...is older than v0.15.0
below the floor, close       v0.14.0   close    0
```

The stub help gives half the flags a cobra shorthand (`-x, --out
string`), so the
check is exercised against the shape its own comment says it must
tolerate.

**3. contract list against real argv**

Parsed out of the shipped install script and compared with the union of
long flags
the drive step actually built across all ten cases:

```
contract list: --out --findings-out --check-out --close --conversation-context
               --guidelines-file --extra-instructions --include-nits --trigger-id
argv built:    --check-out --close --conversation-context --extra-instructions
               --findings-out --guidelines-file --include-nits --out --trigger-id
built but NOT in the contract list:        none
in the contract list but never built here: none
```

Both directions are assertions, so wiring a flag without listing it, or
listing one
the workflow never passes, fails the harness.

The real `sei-agent-driver@v0.15.0` was installed from the proxy and its
`review --help` names exactly `--check-out --close
--conversation-context
--extra-instructions --findings-out --guidelines-file --help
--include-nits --out
--trigger-id` — the contract list plus `--help`.

The `--jq` filter was also run through `gh`'s own engine
(`github.com/cli/go-gh/v2/pkg/jq`
v2.16.0): label present `true`, absent `false`, empty array `false`, no
`labels`
key `false`, `ai: nitpicky` `false`, `AI: Nitpick` `false`.

`actionlint` 1.7.12 on the same path with the same invocation: base
`30f5c09`
gives 4 findings, all `SC2102:info`; this branch gives 4, all
`SC2102:info`.
`shellcheck -S info` on all three extracted scripts: clean. The file
parses under
`yaml.safe_load`; 19 `workflow_call` inputs.

Not verified: any live run, and the flag's effect on a real model turn.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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