Skip to content

ci: run the ai-scan model session with a read-only token - #580

Merged
allxsmith merged 3 commits into
mainfrom
ci/455-ai-scan-readonly-session
Aug 28, 2026
Merged

allxsmith merged 3 commits into
mainfrom
ci/455-ai-scan-readonly-session

Conversation

@allxsmith

@allxsmith allxsmith commented Aug 28, 2026 •

Copy link
Copy Markdown
Owner

Fixes #455.

This is a security change (.github/CLAUDE.md rule 2)

The --allowedTools allowlist is unchanged. What changes is that it is no longer the only thing standing behind it: the model session's job now holds no write scope at all.

Credential per job, after the split:

Job Token Scope Runs repo code?
gate GITHUB_TOKEN issues: write (budget marker) no
scan GITHUB_TOKEN contents/issues/pull-requests read yes (the verdict parser)
label GITHUB_TOKEN issues: write (the labels API covers PRs too) no

Two properties worth stating plainly, because neither held before:

  • The Claude session's job has no write permission of any kind.
  • No job holding a write token runs any checked-out repository code. gate and label are dependency-free shell; the parser runs in the read-only scan job.

Why the parser stays in the scan job

execution_file is a runner-local path, so it cannot cross a job boundary, and uploading it as an artifact would publish the scanner's reasoning to anyone with read access — precisely the evasion oracle show_full_output: false exists to prevent. So scripts/parse-scan-verdict.mjs (unchanged) runs in scan, and only its coarse enum (clean / flagged <category>) becomes a job output. The labeler's case enum, its flagged/other defaults, the step-summary lines, and the addLabels call are moved verbatim — the only edit is reading $SCAN_VERDICT instead of invoking node.

The gate shell is byte-identical to the original (verified by diff).

Fail directions

Situation Result Direction
Scan job crashes, or output plumbing drops the value SCAN_VERDICT empty → enum default → flagged closed, preserved
Gate errors on the budget read run never true → scan and label skip → unscanned, unlabeled open, preserved
Mode off / fork head gate's if: false → nothing runs unchanged
Scan cancelled label runs under always() → flagged closed
Gate fails after writing run=true scan skips, label skips open
Kill switch flipped off before a re-run scan skips, label skips no spurious flag

An earlier revision of this PR claimed the cancelled row was a behavior change introduced by the split. That was wrong — the pre-split labeler was a step carrying always(), which also runs through cancellation — and the claim has been removed. Cancelling a run does not prevent the flag, before or after; that is now stated as a property rather than a delta.

One deviation from the issue text

The issue sketches scan as contents: read. Declaring only that sets every other scope to none, so the session's gh issue view / gh pr diff would work solely because this repository is public — an undocumented dependency, invisible in review, that breaks under a private fork. The job therefore declares issues: read and pull-requests: read as well. That reads as more permissions than the issue asked for while being strictly read-only, and it satisfies the acceptance box ("no write permission of any kind").

Review round (commit 2)

A review of the first commit found three real defects, all fixed in a551303:

  1. Kill-switch bypass. AI_LOOP_ENABLED / AI_SCAN_MODE were only on gate. "Re-run failed jobs" replays a green gate's cached run=true rather than re-running it, so a billed session could start after a maintainer set AI_SCAN_MODE=off. Both variables are now repeated on scan, the job that spends the usage (rule 3).
  2. label could flag an unscanned item. Its condition read only needs.gate.outputs.run, so a gate that wrote run=true and then failed would skip scan while label ran against an empty verdict and applied needs-security-review — fail-closed on a path rule 4 mandates fail-open. It now also requires needs.gate.result == 'success' and needs.scan.result != 'skipped'.
  3. harden-runner could lose a flag. Sitting ahead of the labeling call, a failed action download would discard a computed flagged verdict. It is continue-on-error: true now; the policy is not enforced anyway ([Security] harden-runner silently downgrades egress-policy: block to audit — no AI job has ever enforced egress #487).

Plus several claims scoped back to what the mechanism delivers: the .github/CLAUDE.md row no longer says the ai-scan allowlist holds back "nothing" (the job still runs Bash beside CLAUDE_CODE_OAUTH_TOKEN, so by I1 the allowlist is still the only control on that credential — repository write is what the split removed), SECURITY.md no longer says the label job is one the session "cannot reach" (the session's verdict is that job's only input), and parse-scan-verdict.mjs and its test no longer describe the pre-split topology. Also dropped the unused pull-requests: write from label, and restored the repo-uniform egress endpoint list on the two new jobs.

Empirically retired risk

A reviewer flagged that read-only permissions plus claude-code-action on a pull_request trigger had no precedent in this repo, with a repo-wide false-positive as the failure mode. This PR's own ai-scan run settles it: gate → scan → label all passed on PR #580 itself, and the label job logged scan clean for #580 — no label. The action works at read scope on this trigger.

Known follow-ups (not blocking)

harden-runner is now declared three times in this one file; the gate's budget shell is a near-verbatim copy of ai-triage.yml's against the same tracking issue with opposite fail directions and no test on either; the verdict category enum lives in three places; and scan does a full checkout for one 160-line script that sparse-checkout would trim.

Acceptance

  • The Claude session step runs in a job with no write permission of any kind
  • The fail-closed labeler behaviour is unchanged (enum and defaults byte-identical; crashed / unparsable / is_error still flag, now via an empty verdict output)
  • The budget accounting on the tracking issue is unchanged (shell moved verbatim)
  • The header comment's description of the token scope matches the new structure

Review checklist

  • Allowlist growth: none. --allowedTools and --disallowedTools are untouched.
  • Model session gaining execute/fetch/network: no.
  • Model text reaching a comment body: no comments are posted by this workflow.
  • Posting identity: GITHUB_TOKEN throughout; no PAT anywhere in this file.
  • New repository variable: none.
  • Action SHAs: unchanged, still one SHA per action repo-wide (grep -rho "actions/checkout@[a-f0-9]\{40\}" .github/workflows/ | sort | uniq -c → one line).
  • Verdict path fails closed; counter path fails open. Both audited above.
  • Egress: the two new jobs declare block per rule 10, and the header still states plainly that nothing in this repo enforces egress until [Security] harden-runner silently downgrades egress-policy: block to audit — no AI job has ever enforced egress #487 closes. No comment cites egress as a control.

Verification

pnpm all green locally on both commits. parse-scan-verdict.mjs and its test sibling have header-only edits (their topology description was stale after the split); no behavior changed, and all 453 script tests pass.

Worth watching on the first live run (needs AI_SCAN_MODE=on): the three jobs chaining, the gate charging the marker on #290, and the label job logging scan clean … — no label.

Summary by CodeRabbit

  • Security Improvements

    • Isolated scanning, access checks, and security labeling into separate workflow steps with limited permissions.
    • Scanning sessions can no longer write to the repository or apply labels.
    • Improved protection of credentials and repository access during automated scans.
  • Documentation

    • Updated security guidance to explain the revised scanning and labeling process.
    • Clarified verdict handling and permission boundaries.

Split ai-scan.yml into gate/scan/label, the same author/publish shape
claude-repro.yml already uses: the gate charges the daily budget marker
with issues:write, the Claude session runs in a job whose grants are
contents/issues/pull-requests read, and a separate fail-closed labeler
holds the write scopes.

Only the coarse verdict enum crosses a job boundary. The execution file
stays on the scan runner — publishing it as an artifact would hand out
the scanner's reasoning, which is the evasion oracle show_full_output:
false exists to prevent — so parse-scan-verdict.mjs runs in the scan job
and its output feeds the labeler's unchanged enum.

The session's Bash allowlist is now defense in depth rather than the one
control between an injected session and repository write. Fail directions
are preserved: an empty verdict output (crash, cancellation, parser never
ran) hits the labeler's flagged/other default, and a gate error still
leaves the item unscanned and unlabeled. One deliberate delta: a
cancelled scan now flags, where cancellation used to kill the labeler
too.

Fixes #455.
Copilot AI balanced review requested due to automatic review settings August 28, 2026 02:51
@coderabbitai

coderabbitai Bot commented Aug 28, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a31afa6b-6a2a-49d9-a233-0564db2bace5

📥 Commits

Reviewing files that changed from the base of the PR and between b844dca and ec056da.

📒 Files selected for processing (6)
  • .github/CLAUDE.md
  • .github/workflows/ai-scan.yml
  • SECURITY.md
  • docs/docs/guides/getting-started/ai-development.md
  • scripts/parse-scan-verdict.mjs
  • scripts/parse-scan-verdict.test.mjs

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


Walkthrough

The AI scan workflow now uses separate gate, scan, and label jobs. The Claude session has read-only permissions. Verdict extraction crosses the job boundary as a coarse output. Security and developer documentation describe the new credential boundaries.

Changes

AI scan security split

Layer / File(s) Summary
Separate workflow jobs
.github/workflows/ai-scan.yml
The workflow separates budget gating, the read-only Claude session, and security labeling into distinct jobs. Job outputs and conditions preserve gate and rerun behavior.
Verdict extraction and handoff
.github/workflows/ai-scan.yml, scripts/parse-scan-verdict.mjs, scripts/parse-scan-verdict.test.mjs
The scan job extracts a coarse verdict from the runner-local execution file. The label job consumes the output and applies the flagged/other default for empty or unrecognized values. Comments describe the job boundary and its untested YAML plumbing.
Security and workflow documentation
.github/CLAUDE.md, SECURITY.md, docs/docs/guides/getting-started/ai-development.md
The documentation states that the scan session has no write-scoped credentials and that labeling runs in a separate credentialed job. The workflow split is recorded as complete.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to ec056

The workflow now runs the scan session with read-only permissions while keeping write operations in separate jobs. No actionable merge-blocking risk remains, so the PR is merge-ready after normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant GitHubEvent
  participant gate
  participant scan
  participant label
  participant GitHubIssues
  GitHubEvent->>gate: trigger workflow
  gate->>GitHubIssues: charge daily budget marker
  gate->>scan: provide run, is_pr, and number
  scan->>scan: run Claude with read-only token scope
  scan->>label: provide coarse verdict
  label->>GitHubIssues: apply needs-security-review
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the primary change: running the ai-scan model session with a read-only token.
Description check ✅ Passed The description provides a detailed summary, links issue #455, explains the security design, documents safeguards, states acceptance criteria, and records verification. It does not use every template …
Linked Issues check ✅ Passed The changes satisfy issue #455. The Claude session runs in the read-only scan job, write operations remain in separate jobs, fail-closed labeling and budget accounting are preserved, and the workflow …
Out of Scope Changes check ✅ Passed The workflow, documentation, security policy, and topology comments all support the security objective in issue #455. No unrelated code changes are identified.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 2…
Full details: Description check

Explanation

The description provides a detailed summary, links issue #455, explains the security design, documents safeguards, states acceptance criteria, and records verification. It does not use every template heading or checkbox, but the required change context is complete.

Full details: Linked Issues check

Explanation

The changes satisfy issue #455. The Claude session runs in the read-only scan job, write operations remain in separate jobs, fail-closed labeling and budget accounting are preserved, and the workflow documentation matches the new token scopes.

Full details: Docstring Coverage

Explanation

No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 2 files. (4 skipped: 4 unsupported.)

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ci/455-ai-scan-readonly-session

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Preview Deployment

Preview URL: https://4413b950.bestax.pages.dev

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

The security-critical permission and failure-path changes warrant final human review despite no identified blocking defect.

Pull request overview

Splits AI security scanning into least-privilege jobs so the model session has read-only repository access.

Changes:

  • Separates budget gating, scanning, and labeling.
  • Preserves fail-closed verdict handling.
  • Updates security documentation to reflect token boundaries.
File summaries
File Description
.github/workflows/ai-scan.yml Implements the three-job permission split.
.github/CLAUDE.md Documents the revised security boundary.
SECURITY.md Updates the repository security posture.
docs/docs/guides/getting-started/ai-development.md Updates user-facing AI workflow documentation.
Review details
  • Files reviewed: 4/4 changed files
  • Comments generated: 0
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Review of the split found three real defects and several overstated
claims. The defects:

Re-running a failed `scan` bypassed the kill switches. AI_LOOP_ENABLED
and AI_SCAN_MODE lived only on `gate`, and "Re-run failed jobs" does not
re-run an already-green gate — it replays its cached run=true — so a
maintainer who set AI_SCAN_MODE=off could still have a billed session
start on re-run. Rule 3 wants the opt-in on the job that spends usage, so
both variables are repeated on `scan`.

`label` could flag an item nothing ever scanned. Its condition checked
only `needs.gate.outputs.run`, so a gate that wrote run=true and then
failed would skip `scan` while `label` ran against an empty verdict and
applied needs-security-review — fail-closed on a path rule 4 mandates
fail-open. It now also requires the gate to have succeeded and `scan` not
to have been skipped; a scan that failed or was cancelled still flags.

harden-runner ahead of the labeling call could lose a flag. If the action
fails to download, the `gh api` labels call never runs and a genuinely
flagged item goes unlabeled — fail-open on the verdict side, which the
pre-split design could not do. It is continue-on-error now; the policy is
not enforced today regardless (#487).

The claims: the header asserted that a cancelled scan now flags "where
cancellation used to kill the labeler too". That is backwards — the old
labeler step also carried always() — and calling it a deliberate delta
implied a run-state matrix that had not been enumerated, which is how the
`label` gap above went unexamined. Corrected to describe the behavior
without claiming a change. `.github/CLAUDE.md`'s rule 2 row said the
ai-scan allowlist now holds back "nothing", which would license widening
it: the job still runs Bash beside CLAUDE_CODE_OAUTH_TOKEN, so by I1 the
allowlist is still the only control on that credential. The same
overstatement is scoped down in the workflow and in SECURITY.md, whose
"a separate job it cannot reach" ignored that the session's own verdict
is the label job's only input.

Also: drop the unused pull-requests:write from `label` (the labels call
is the issues endpoint), restore the repo-uniform egress endpoint list on
the two new jobs, refresh parse-scan-verdict.mjs's header and its test's
for the new topology, and record the budget-charge and check-run
visibility consequences the split introduced.
Copilot AI review requested due to automatic review settings August 28, 2026 03:32
@github-actions

Copy link
Copy Markdown
Contributor

Preview Deployment

Preview URL: https://47e8f0c4.bestax.pages.dev

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Two security documentation claims conflict with the implemented credential boundaries.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 6/6 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment on lines 102 to 105
output, never a public comment). The scan holds no write tools, no PAT, and no write-scoped
token — the labeling happens in a separate job, holding its own credentials, that the session
never runs in — so it has no channel to post or leak anything; it fails **closed** (a crashed or inconclusive scan flags rather than
passes). It is **opt-in**: it runs only when `AI_SCAN_MODE` is `on` (or `y`). Every other
Comment thread .github/workflows/ai-scan.yml Outdated
# defense in depth rather than the ONE control standing between an
# injected session and repository write. It is still
# security-critical (rule 2) — keep it narrow.
# label — issues + pull-requests: write. Applies needs-security-review from
The header's job topology still described `label` as holding
issues + pull-requests write after the scope was cut to issues only,
and the user-facing docs claimed the session 'has no channel to post or
leak anything'. The leak half does not hold: the scan job still carries
CLAUDE_CODE_OAUTH_TOKEN beside Bash and egress is not enforced (#487),
which the same PR says plainly two files over. Both now claim only what
the credential split delivers — no write to this repository.
Copilot AI review requested due to automatic review settings August 28, 2026 13:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

The security-critical workflow permission and failure-path changes warrant final human review despite no identified blocking defect.

Review details
  • Files reviewed: 6/6 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@github-actions

Copy link
Copy Markdown
Contributor

Preview Deployment

Preview URL: https://4cc9666a.bestax.pages.dev

@allxsmith

Copy link
Copy Markdown
Owner Author

Both of Copilot's comments were correct, and both were stale claims my own earlier fixes left behind. Addressed in ec056da.

ai-scan.yml:50 — the header's job topology still described label as holding issues + pull-requests: write after I cut the scope to issues only (PR labels go through the issues API). Header and job now agree.

ai-development.md:105 — "no channel to post or leak anything" does not hold, and the same PR says so two files over: the scan job still carries CLAUDE_CODE_OAUTH_TOKEN beside Bash, and egress is not enforced (#487), so the allowlist remains the only guard on that credential. Narrowed to what the credential split actually delivers — no write to this repository. Made the same correction in SECURITY.md, which said "cannot post or act"; "act" was doing the same overreach.

This is the second round of the same class of error in this PR — a security comment claiming more than the mechanism delivers — so it is worth naming the pattern: the split genuinely removed repository write from the session, and each time I have overstated it, I have written it as though it removed the boundary. It did not. Rule 2's table now spells out which credential the allowlist still guards.

@claude claude 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.

Deep review — 0 blocking · 3 advisory

# Severity Area Finding Location
1 🔵 Advisory Robustness gate charges the daily budget then finishes; a scan that is never scheduled (runner outage / inter-job cancellation) burns quota for a session that never ran. On record; the author documents it. .github/workflows/ai-scan.yml:74
2 🔵 Advisory Security Three named jobs are three public check runs, so scan's conclusion (skipped vs success) now leaks whether an item was scanned — and a budget-exhausted, fail-open day is exactly the unscanned/unflagged window. No reasoning leaks (show_full_output:false still holds). Author-documented and judged worth the credential split. .github/workflows/ai-scan.yml:79
3 🔵 Advisory Robustness always() on label means cancelling a run after the gate charges applies needs-security-review (empty verdict → fail-closed default). Safe direction, matches the pre-split always() labeler step, but worth knowing operationally. .github/workflows/ai-scan.yml:401

Overall: The change is sound and unusually well-reasoned. The core security property — the model session's job drops to contents/issues/pull-requests: read while the two write-scoped jobs (gate, label) run zero repository code and no model — is achieved exactly as issue #455 asks, and the --allowedTools allowlist is genuinely untouched. I traced every fail direction (gate skip, run=false, gate-fail-after-run=true, scan crash/cancel/skip, kill-switch-on-rerun) against the actual if: expressions and job-result semantics: the verdict path fails closed (every crash mode routes to the label job's flagged/other enum default), and the gate/counter path fails open, with no new fail-open hole where a real flag is lost. The riskiest part is the label job's if: (always() && gate.result=='success' && gate.outputs.run=='true' && scan.result != 'skipped') — that one expression carries both directions at once — so a human should focus there, but each clause is individually correct and the two guards added in commit 2 close the "flag an unscanned item" and "skipped-on-rerun reads as failure" gaps precisely.

Residual risk: the failure class this PR addresses is the session gaining write via a silently-widened allowlist and a flag being lost or spuriously applied across the new job boundary.

  • Session write via allowlist widening — refuted: the write scopes now live only on gate/label, which hold no CLAUDE_CODE_OAUTH_TOKEN and run no checked-out code; scan's token is read-only, so widening allowedTools can no longer reach issue/PR write. The header and .github/CLAUDE.md correctly keep the narrower claim that the allowlist still guards the OAuth token in-environment (I1).
  • Lost flag (fail-open on verdict) — refuted: I enumerated scan success (flagged/empty verdict), failure, and cancelled; all satisfy scan.result != 'skipped' and reach the flagged/other default. The only skip paths (gate.outputs.run != true, kill-switch off) are cases where no scan was warranted. label's harden-runner is continue-on-error: true, so it cannot swallow a computed flag.
  • Spurious flag / double-label — refuted: gate.result=='success' blocks flagging an item the gate never cleared to scan; scan.result != 'skipped' blocks the kill-switch-on-rerun path; addLabels is idempotent. The issues: write-only scope on label is correct — the /issues/{n}/labels REST endpoint covers PRs, so dropping pull-requests: write loses nothing.

🏄 Righteous refactor, dude — took one gnarly do-everything job and carved it into three clean breaks, and the write token never once shares a wave with the model. Fail-closed still holds all the way to the beach. Ship it.

@allxsmith
allxsmith merged commit 0df313b into main Aug 28, 2026
28 checks passed
@allxsmith
allxsmith deleted the ci/455-ai-scan-readonly-session branch August 28, 2026 15:58
@bestax-release-bot

Copy link
Copy Markdown

🎉 This PR is included in version 5.11.5 🎉

The release is available on:

Your semantic-release bot 📦🚀

@bestax-release-bot

Copy link
Copy Markdown

🎉 This PR is included in version 4.2.2 🎉

The release is available on:

Your semantic-release bot 📦🚀

@bestax-release-bot

Copy link
Copy Markdown

🎉 This PR is included in version 2.1.5 🎉

The release is available on:

Your semantic-release bot 📦🚀

@bestax-release-bot

Copy link
Copy Markdown

🎉 This PR is included in version 1.2.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Security] ai-scan's Claude session runs with a write-scoped GITHUB_TOKEN

2 participants