Skip to content

chore(ci): harden issue triage workflow with least-privilege split#1598

Merged
yamadashy merged 1 commit into
mainfrom
fix/issue-triage-prompt-injection
May 24, 2026
Merged

chore(ci): harden issue triage workflow with least-privilege split#1598
yamadashy merged 1 commit into
mainfrom
fix/issue-triage-prompt-injection

Conversation

@yamadashy

Copy link
Copy Markdown
Owner

Summary

Refactor the Claude issue triage workflow to follow least-privilege and reduce the blast radius of the automated agent.

  • Split the single triage job into two:
    • classify (read-only, no write token, no shell/network tools): decides labels and returns them via --json-schema structured output.
    • apply-labels (no AI): validates the chosen labels against the repository's real label list and applies them via the labels REST endpoint.
  • The classification step that processes issue content no longer holds a write-capable token or shell access, so issue content can only influence label selection, not perform any writes.
  • Behavior is unchanged: every opened issue is still auto-labeled.

Checklist

  • Run npm run test
  • Run npm run lint

decision(architecture): split triage into a read-only classify job and a separate no-AI apply job, so the step that reads issue content holds no write token and no shell access, and the step that writes labels runs fixed code rather than an agent.
decision(classify-tools): disable Bash/Edit/Write/MultiEdit/NotebookEdit/WebFetch/WebSearch/Task and return the chosen labels via --json-schema structured output, keeping the agent to read-only file access.
decision(apply-labels): validate the model-chosen labels against the real repository label list, cap at 5, and apply via the labels REST endpoint instead of an agent-built command.
learned(claude-code-action): passing --json-schema in claude_args exposes the result as steps.<id>.outputs.structured_output, parsed downstream with fromJSON().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Note

Gemini is unable to generate a review for this pull request due to the file types involved not being currently supported.

@coderabbitai

coderabbitai Bot commented May 24, 2026

Copy link
Copy Markdown
Contributor

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

Run ID: 9790871b-8506-4168-a1b4-a0908e549126

📥 Commits

Reviewing files that changed from the base of the PR and between e0924b6 and 8546d3d.

📒 Files selected for processing (1)
  • .github/workflows/claude-issue-triage.yml

📝 Walkthrough

Walkthrough

This PR refactors the GitHub Actions workflow for Claude-based issue triage to enforce least-privilege permissions and validation. The workflow splits from a single job with broad permissions into two isolated phases: a read-only classify job that invokes Claude with restricted tools to generate a structured label list, and an apply-labels job that validates the output against the repository's allowlist and applies labels via the REST API instead of AI-controlled shell commands.

Changes

Least-Privilege Triage Architecture

Layer / File(s) Summary
Workflow permissions and two-job architecture
.github/workflows/claude-issue-triage.yml
Workflow-wide default permissions set to empty; classify job has read-only permissions for issues and repository contents; apply-labels job has write permission only for contents. Job structure enforces phase separation with label output export.
Issue classification and label validation with REST API application
.github/workflows/claude-issue-triage.yml
Classify job fetches issue JSON and repository label list via gh, runs Claude action with disallowed tools in structured-output mode to generate up to 5 label names. Apply-labels job validates the output against the repository allowlist, truncates to 5, and applies labels via REST API with generated JSON payload (no AI-controlled commands).

Sequence Diagrams

sequenceDiagram
    participant GH as GitHub
    participant Classify as classify job
    participant Claude as Claude action
    participant REST as REST API
    participant ApplyLabels as apply-labels job
    
    GH->>Classify: trigger on issue.opened/edited
    Classify->>GH: fetch issue JSON (gh api)
    GH-->>Classify: issue title, body, labels
    Classify->>GH: fetch repository labels (gh api)
    GH-->>Classify: allowlist of valid labels
    Classify->>Claude: structured output request<br/>(tools=disallowed)
    Claude-->>Classify: JSON [label1, label2, ...]
    Classify->>Classify: export labels output
    ApplyLabels->>ApplyLabels: filter output by allowlist
    ApplyLabels->>ApplyLabels: truncate to 5 labels
    ApplyLabels->>REST: POST labels via API<br/>(JSON payload)
    REST->>GH: apply labels to issue
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly Related PRs

  • yamadashy/repomix#977: Introduces the initial Claude-based issue triage workflow that this PR refactors into a least-privilege two-phase architecture.
  • yamadashy/repomix#1043: Modifies Claude issue triage label logic in the same workflow; this PR restructures the label classification and application security model.
  • yamadashy/repomix#979: Updates workflow permissions configuration in the same Claude triage workflow file; this PR applies comprehensive least-privilege permission restructuring.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: hardening the issue triage workflow with a least-privilege split approach.
Description check ✅ Passed The description provides a comprehensive summary of changes, explains the two-job split strategy, and includes the required checklist section with test and lint items.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-triage-prompt-injection

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 and usage tips.

@github-actions

github-actions Bot commented May 24, 2026

Copy link
Copy Markdown
Contributor

⚡ Performance Benchmark

Latest commit:8546d3d chore(ci): harden issue triage workflow with least-privilege split
Status:✅ Benchmark complete!
Ubuntu:0.62s (±0.02s) → 0.63s (±0.01s) · +0.00s (+0.2%)
macOS:0.48s (±0.14s) → 0.49s (±0.18s) · +0.01s (+2.9%)
Windows:0.87s (±0.02s) → 0.87s (±0.01s) · +0.01s (+0.6%)
Details
  • Packing the repomix repository with node bin/repomix.cjs
  • Warmup: 2 runs (discarded), interleaved execution
  • Measurement: 20 runs / 30 on macOS (median ± IQR)
  • Workflow run

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying repomix with  Cloudflare Pages  Cloudflare Pages

Latest commit: 8546d3d
Status: ✅  Deploy successful!
Preview URL: https://60211698.repomix.pages.dev
Branch Preview URL: https://fix-issue-triage-prompt-inje.repomix.pages.dev

View logs

@codecov

codecov Bot commented May 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.86%. Comparing base (e0924b6) to head (8546d3d).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #1598   +/-   ##
=======================================
  Coverage   90.86%   90.86%           
=======================================
  Files         121      121           
  Lines        4683     4683           
  Branches     1088     1088           
=======================================
  Hits         4255     4255           
  Misses        428      428           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@claude

claude Bot commented May 24, 2026

Copy link
Copy Markdown
Contributor

Claude Review

Strong hardening overall — the split cleanly removes the prompt-injection → write-token escalation path. The classify job has no write token and no execution/network tools, and apply-labels is deterministic shell that re-validates the model output against the authoritative label list. A few items worth considering before merge.

Worth addressing

1. disallowedTools is a denylist — consider an allowlist instead. .github/workflows/claude-issue-triage.yml:57
The current list (Bash,Edit,Write,MultiEdit,NotebookEdit,WebFetch,WebSearch,Task) leaves Read, Glob, Grep, LS, and any MCP tools the action may auto-enable still available. The model only needs to read issue.json / labels.json. Since the PR explicitly aims for least-privilege, an allowlist matches the intent better than a denylist that has to track every future tool the upstream action might add:

--allowedTools "Read"

(plus whatever explicit MCP-disable flag the action supports, if any).

2. Same prompt-injection pattern exists in claude-issue-similar.yml.
That workflow has issues: write + id-token: write, fires on every opened issue from anyone, feeds the untrusted body straight to Claude, and allows Bash(gh issue comment ...). A successful injection there can post arbitrary content under the bot. Either fold the same hardening into this PR or open a tracking issue and link it from the description — otherwise the threat model is only half-addressed.

Minor

Details
  • if guard is misleading. .github/workflows/claude-issue-triage.yml:67needs.classify.outputs.labels != '' will pass for {"labels":[]} (non-empty string), so apply-labels still spins up a runner and falls through the in-script empty check. Either drop the guard (the script already handles it cleanly) or use fromJson(...).labels length.

  • gh label list --limit 200 silently truncates. .github/workflows/claude-issue-triage.yml:42 and :107 — if the label set ever grows past 200, valid labels at the tail are excluded from both the model's input and the allowlist. Use --paginate (drop --limit) for future-proofing.

  • Capability regression worth acknowledging. Old prompt used gh search issues to suggest the duplicate label. New design has no similar-issue context. This appears intentional since claude-issue-similar.yml covers that separately — worth a one-liner in the PR body to confirm it isn't an accidental loss.

  • Commit type understates the change. The branch name is fix/issue-triage-prompt-injection and the change closes a real injection vector. fix(ci): reads more honestly than chore(ci):.

  • Verification gap. The trigger is issues: opened only, so this can't be exercised pre-merge. Consider a temporary workflow_dispatch with a synthetic event payload, or running the apply-labels shell block locally with sample STRUCTURED_OUTPUT strings to validate the cap / allowlist / empty-array paths. The PR checklist's npm run test / npm run lint don't exercise this change; actionlint + zizmor (already in ci-quality.yml) will catch syntax/permissions issues.

Nits (no change needed)

  • jq -R . apply.txt | jq -s '{labels: .}' is correct and reasonably readable; collapsing into a single jq call is a matter of taste.
  • --max-turns 5 is loose given there are no tools, but harmless.
  • The >- folded scalar for claude_args differs from peer workflows that use single-quoted single-line, but readability is arguably better here.
  • apply-labels correctly skips actions/checkout — small win, worth a one-line comment so a future maintainer doesn't add one defensively.

@yamadashy yamadashy merged commit 132f1b8 into main May 24, 2026
51 checks passed
@yamadashy yamadashy deleted the fix/issue-triage-prompt-injection branch May 24, 2026 13:59
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