Skip to content

ci(review): gate the claude pairing and share the branch probe - #7076

Merged
otavio merged 1 commit into
masterfrom
ci/review-paired-branch-hardening
Sep 10, 2026
Merged

ci(review): gate the claude pairing and share the branch probe#7076
otavio merged 1 commit into
masterfrom
ci/review-paired-branch-hardening

Conversation

@otavio

@otavio otavio commented Sep 10, 2026

Copy link
Copy Markdown
Member

What

Addresses the review feedback on the branch-pairing change (shellhub-io/cloud#2545): the claude checkout only pairs for an admin author, no longer keeps the app token, and the branch probe distinguishes "absent" from "could not ask". The resolution moved into a composite action used by every call site.

Why

The claude checkout is executed, not read: workspace.sh runs from it, the .claude/ it syncs supplies hooks and settings.json that run outside the review's --allowedTools, and pr-review.md becomes the agent prompt. Making its ref selectable by PR head branch name dropped the bar from "land a reviewed commit on claude@master" to "push any branch to claude" — and on pull_request_target a fork PR can name its branch after an existing one.

Changes

  • Gate: pairing requires is_admin, so the Check PR author team membership step moved above the resolution it now feeds. A non-admin author (every fork author) gets master.
  • Token: persist-credentials: false on the claude checkout — the app token reaches two repos and had no reason to stay in a tree that gets executed.
  • Probe: curl -sf collapsed 401/403/5xx into "branch absent". The action now reads the status code and warns on anything but 200 and 404.
  • Duplication: one composite action replaces three copies of the resolution (both call sites in the review workflow, one in claude.yml). It is checked out from master into .actions/, not taken from the PR head, so a fork cannot rewrite what the workflow executes before the review starts.
  • claude.yml: uses the same action for its sibling-repo resolution. Its claude checkout stays unpaired — it has no admin gate, and pairing without one is the escalation described above.

Testing

The probe paths were exercised locally: an existing branch resolves to itself, a missing one falls back silently, and a bad token now emits ::warning::... returned HTTP 401; using master instead of passing as absent.

The claude checkout is executed, not merely read: workspace.sh runs from it, the .claude/ it
syncs supplies hooks and settings that run outside the review's tool allowlist, and the
procedure it carries becomes the agent's prompt. Selecting its ref by the PR head branch name
therefore lowered the bar from landing a reviewed commit on claude@master to pushing any branch
to claude, and a fork PR could name its branch after one. Only an admin author pairs now; for
everyone else the checkout takes master, as before.

That checkout also kept the app token in its working tree, and the token reaches two repos.

The probe read every failure as "branch absent": an expired token or an API outage silently
became master. It now warns on anything but 200 and 404.

The resolution itself existed three times in this repo. The composite action is checked out
from master rather than from the pull request, so a fork PR cannot rewrite the code the
workflow runs before the review starts.
@otavio
otavio requested a review from a team as a code owner September 10, 2026 22:33
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown

Claude finished @otavio's task in 11m 25s —— View job


Code Review Complete

Reviewed 3 files. 5 inline issues posted — Standards: 0 violations, 4 judgement calls. Spec: 0. Correctness: 1.

Standards

No documented rule is broken. The worst judgement call is .github/actions/paired-branch/action.yml:10-15 — the admin gate this PR exists to add lives in the caller's expression, and takes two inputs blanked together; head-ref: '' alone still pairs via the gh pr view path at lines 60-65.

Correctness

.github/actions/paired-branch/action.yml:77-78 — under a composite action's -e -o pipefail shell, STATUS=$(curl …) aborts the step on a transport failure (DNS, refused, timeout, TLS), so the case never runs. The if curl -sf … form it replaced fell through to master; a network blip now fails the review job, and the *) arm can never see the 000 its wording anticipates.

Spec

The Spec axis did not run: the PR references no issue, and its commits carry no Fixes: trailer. The shellhub-io/cloud#2545 reference in the body is a pull request in a repository this job's token cannot reach, so there is no spec to review against. Per the procedure, no spec was reconstructed from the PR title.

Additional notes

No findings were dropped by a cap.

One asymmetry the diff leaves in place, flagged for confirmation rather than as a finding: the cloud pairing (claude-code-review.yml:165) still feeds an unauthenticated fork's github.head_ref into the probe, while the claude pairing is now admin-gated. That behaviour is unchanged from master, and the PR body says the split is deliberate — cloud is private, so a fork author cannot push a branch there to be paired with. Recording it here so the asymmetry is a decision on the record rather than an omission.


To request another review round, comment /review.
· branch ci/review-paired-branch-hardening

Comment on lines +77 to +78
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "${AUTH[@]}" \
"https://api.github.com/repos/$REPOSITORY/branches/$BRANCH")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Correctness: a transport failure here aborts the step instead of falling back.

A composite action's shell: bash runs as bash --noprofile --norc -e -o pipefail {0}. A bare VAR=$(cmd) assignment is not one of -e's exemptions (&&/|| lists, if/while/until conditions, !), so its exit status is curl's, and the step dies before case is reached.

Concrete path: DNS failure, connection refused, connect timeout or a TLS reset reaching api.github.com (curl exits 6/7/28/35 — without -f, an HTTP 404 or 500 still exits 0). The code this replaced put curl in an if condition, which is -e-exempt, so the same failure fell through to master and the job carried on. Now Determine cloud branch fails the whole review job on a transient network blip.

It also makes the *) arm unreachable for exactly the case its wording anticipates: curl prints 000 on a connect error, but that value never reaches $STATUS. And default-branch's own description (line 27) promises it is used when the branch "does not exist or cannot be probed" — the second half is currently only true for HTTP-level failures.

Suggested change
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "${AUTH[@]}" \
"https://api.github.com/repos/$REPOSITORY/branches/$BRANCH")
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "${AUTH[@]}" \
"https://api.github.com/repos/$REPOSITORY/branches/$BRANCH") || STATUS="000"

000 falls into the existing *) arm, which warns and returns the default. There is no pipeline here, so pipefail is not involved. Separately, consider --max-time: with no timeout, a hung API stalls the step until the 30-minute job timeout.

Comment on lines +10 to +15
head-ref:
description: >-
The PR head branch name. Pass an empty string to skip pairing and take the default; a caller
that gates pairing on authorization does that.
required: false
default: ''

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Standards (judgement call) — codebase-design: "Interface — everything a caller must know to use the module correctly: the type signature, but also invariants, ordering constraints, error modes […]".

The invariant this PR exists to establish — pairing happens only for an admin author — is not in the action's interface. It lives in the caller's expression, and enforcing it takes two inputs blanked in concert. head-ref: '' alone does not skip pairing: lines 60-65 re-derive the branch via gh pr view whenever pr-repository and pr-number are set. The description here says "Pass an empty string to skip pairing and take the default" without saying that pr-repository must go too.

The call site at claude-code-review.yml:209-211 is correct today — pr-repository is gated, so the fallback never fires, and the ungated pr-number on line 211 is inert. But the gate can be half-applied and nothing notices: a future caller that reads this description and blanks only head-ref silently resumes pairing through the gh pr view path, reopening the hole the commit message describes. head-ref is also carrying two jobs — a branch name and an on/off flag — which is what forces the is_admin == 'true' && … || '' ternary at both gated inputs.

Worth moving the decision behind the seam: an explicit paired:/enabled: input that owns "not paired ⇒ default branch", with the PR coordinates passed unconditionally. Then the gate is one expression, and it cannot be applied to only part of the input set.

ref: master
sparse-checkout: .github/actions
sparse-checkout-cone-mode: false
path: .actions

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Standards (judgement call): this new checkout keeps the token that the same commit removed from its sibling.

actions/checkout defaults persist-credentials to true, so secrets.GITHUB_TOKEN lands as an http.extraheader in .actions/.git/config and stays there for the rest of the job — inside the workspace the review agent has Read/Grep over. This step only reads action.yml off disk; nothing later runs a git operation in .actions/, so the credential has no reason to persist.

The commit message states the rule it should be following ("That checkout also kept the app token in its working tree, and the token reaches two repos"), and applies it to the claude checkout at line 221 — but not to the checkout this commit introduces.

Suggested change
path: .actions
path: .actions
persist-credentials: false

Same at .github/workflows/claude.yml:67-73, where it matters more: that job holds permissions: contents: write, so the persisted GITHUB_TOKEN is write-scoped.

Comment on lines +67 to +73
- name: Checkout trusted actions
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: master
sparse-checkout: .github/actions
sparse-checkout-cone-mode: false
path: .actions

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Standards (judgement call): this checkout is unguarded while its only consumer is guarded, and the two workflows now diverge on it for no stated reason.

Determine cloud branch (line 77) carries if: github.event_name == 'pull_request_review_comment' || (github.event_name == 'issue_comment' && github.event.issue.pull_request), and it is the sole reference to .actions in this file. The job-level if admits an @claude mention on a plain (non-PR) issue, so on that path this step clones for a step that never runs. The counterpart in claude-code-review.yml:151 is guarded (steps.gate.outputs.proceed == 'true').

Copying the consumer's condition onto the checkout would restore the symmetry. (I'm not offering a suggestion block — the condition is long enough that it wants to be read against line 77 rather than pasted.)

Minor, and not something this branch introduces: .actions/ joins cloud/ and claude/ as an untracked directory at the repo root that .gitignore does not cover, in the workflow where the agent holds contents: write. git-workflow.md's "Never git add -A or git add ." is the only thing keeping it out of a commit.

Comment on lines +26 to +33
default-branch:
description: Branch to use when the paired branch does not exist or cannot be probed.
required: false
default: master
token:
description: Token authorising the branch probe. Required when the repository is private.
required: false
default: ''

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Standards (judgement call): two inputs on this new interface are worth a second look — Speculative Generality on one, a silent failure mode on the other.

default-branch is a knob no caller turns. All three call sites (claude.yml:80, claude-code-review.yml:164 and :208) take the master default.

token matters more. It is required: false with an empty default, and both probed repos are private — GitHub answers a private-repo endpoint with 404, not 401/403, precisely so it does not leak existence. So a caller that forgets token gets a 404, which line 84 maps to "branch absent", and the job proceeds on master with nothing in the log. That is the exact failure mode this commit set out to eliminate ("The probe read every failure as 'branch absent': an expired token or an API outage silently became master"); a missing token still slips through the one arm that stayed silent. The rate-limit case is fine — an unauthenticated request over the limit returns 403, which the new *) arm catches and warns on.

No caller omits token today, so this is about the interface rather than a live bug. required: true would make the misconfiguration loud.

@otavio
otavio merged commit d45f7e4 into master Sep 10, 2026
42 checks passed
@otavio
otavio deleted the ci/review-paired-branch-hardening branch September 10, 2026 22:52
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