From df38f056f6b56dc7ff1ef6e1f73a9558083648e0 Mon Sep 17 00:00:00 2001 From: Raj-StepSecurity Date: Fri, 10 Jul 2026 12:07:41 +0530 Subject: [PATCH 01/11] chore: Cherry-picked changes from upstream --- action.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/action.yml b/action.yml index b798cf3..de9388f 100644 --- a/action.yml +++ b/action.yml @@ -439,8 +439,10 @@ runs: shell: bash run: | curl -L \ + --connect-timeout 5 \ + --max-time 10 \ -X DELETE \ -H "Accept: application/vnd.github+json" \ -H "Authorization: Bearer ${{ steps.run.outputs.github_token }}" \ -H "X-GitHub-Api-Version: 2022-11-28" \ - ${GITHUB_API_URL:-https://api.github.com}/installation/token + ${GITHUB_API_URL:-https://api.github.com}/installation/token || true From 0404374c410acbb4315abf1437b4167b04725655 Mon Sep 17 00:00:00 2001 From: Raj-StepSecurity Date: Fri, 10 Jul 2026 12:40:48 +0530 Subject: [PATCH 02/11] conflicted commits cherry-picked --- .claude/workflows/pr-stamp-sweep.js | 162 ++ agent-approval-check/README.md | 123 ++ agent-approval-check/action.yml | 72 + .../agent-identities.example.yaml | 33 + agent-approval-check/agent_approval_check.py | 1828 +++++++++++++++++ base-action/action.yml | 2 +- base-action/bun.lock | 20 +- base-action/package.json | 2 +- base-action/src/parse-sdk-options.ts | 56 +- base-action/test/parse-sdk-options.test.ts | 143 ++ bun.lock | 20 +- examples/agent-approval-check.yml | 39 + package.json | 2 +- src/create-prompt/index.ts | 17 +- src/create-prompt/types.ts | 1 + src/entrypoints/run.ts | 63 +- src/github/data/fetcher.ts | 49 +- src/github/operations/branch.ts | 23 +- src/github/operations/restore-config.ts | 17 +- src/github/token.ts | 70 +- src/mcp/github-inline-comment-server.ts | 11 + src/mcp/inline-comment-buffer.ts | 54 + src/modes/agent/parse-tools.ts | 90 +- test/create-prompt.test.ts | 26 + test/data-fetcher.test.ts | 98 +- test/format-turns.test.ts | 48 + test/inline-comment-buffer.test.ts | 139 ++ test/install-pipefail.test.ts | 50 + test/modes/parse-tools.test.ts | 40 +- test/restore-config.test.ts | 67 + test/token.test.ts | 164 ++ test/validate-branch-name.test.ts | 16 + 32 files changed, 3373 insertions(+), 172 deletions(-) create mode 100644 .claude/workflows/pr-stamp-sweep.js create mode 100644 agent-approval-check/README.md create mode 100644 agent-approval-check/action.yml create mode 100644 agent-approval-check/agent-identities.example.yaml create mode 100644 agent-approval-check/agent_approval_check.py create mode 100644 examples/agent-approval-check.yml create mode 100644 src/mcp/inline-comment-buffer.ts create mode 100644 test/inline-comment-buffer.test.ts create mode 100644 test/install-pipefail.test.ts create mode 100644 test/token.test.ts diff --git a/.claude/workflows/pr-stamp-sweep.js b/.claude/workflows/pr-stamp-sweep.js new file mode 100644 index 0000000..64b5400 --- /dev/null +++ b/.claude/workflows/pr-stamp-sweep.js @@ -0,0 +1,162 @@ +export const meta = { + name: "pr-stamp-sweep", + description: + "Review candidate PRs for stampability, then adversarially verify security of stamp candidates", + whenToUse: + "Sweep candidate PRs for stampability: per-PR review + adversarial security verify. Requires pre-fetched PR dossiers in /tmp/claude/pr-sweep/.md and args {prs: [...]}.", + phases: [ + { title: "Review", detail: "one reviewer agent per PR" }, + { + title: "Verify", + detail: "adversarial security skeptic per stamp candidate", + }, + ], +}; + +// PRECONDITION: before invoking this workflow, pre-fetch each candidate PR to +// /tmp/claude/pr-sweep/.md, containing the PR's metadata, body, existing +// reviews/comments, and the full diff (e.g. via `gh pr view` + `gh pr diff`). +// Sandboxed agents can't reliably call gh themselves, so they read these +// dossier files instead. Pass the PR numbers as args: {prs: []}. + +const REVIEW_SCHEMA = { + type: "object", + properties: { + number: { type: "number" }, + verdict: { type: "string", enum: ["stamp", "skip", "needs-discussion"] }, + category: { + type: "string", + description: "docs | tests | bugfix | nicety | security-fix | other", + }, + summary: { + type: "string", + description: "1-2 sentence plain-language summary of what the PR does", + }, + reasoning: { + type: "string", + description: "why this verdict — correctness, scope, quality", + }, + behaviorChange: { + type: "string", + description: 'what user-visible behavior changes, or "none"', + }, + concerns: { type: "array", items: { type: "string" } }, + securitySensitive: { + type: "boolean", + description: + "true if it touches auth, sanitization, parsers of untrusted input, actor checks, file restore, or shell construction", + }, + duplicateOf: { + type: "string", + description: "PR number(s) this duplicates, or empty string", + }, + }, + required: [ + "number", + "verdict", + "category", + "summary", + "reasoning", + "behaviorChange", + "concerns", + "securitySensitive", + "duplicateOf", + ], +}; + +const VERDICT_SCHEMA = { + type: "object", + properties: { + number: { type: "number" }, + safeToStamp: { type: "boolean" }, + findings: { + type: "array", + items: { type: "string" }, + description: + "concrete security/correctness problems found, empty if clean", + }, + confidence: { type: "string", enum: ["high", "medium", "low"] }, + }, + required: ["number", "safeToStamp", "findings", "confidence"], +}; + +if (!args || !Array.isArray(args.prs) || args.prs.length === 0) + throw new Error( + "pass {prs: []} as args; pre-fetch each PR to /tmp/claude/pr-sweep/.md first", + ); +const prs = args.prs; + +log(`Reviewing ${prs.length} candidate PRs`); + +const results = await pipeline( + prs, + (n) => + agent( + `You are reviewing open PR #${n} on anthropics/claude-code-action to decide if it is safe for a maintainer to approve ("stamp") with minimal further discussion. + +The full PR (metadata, body, existing reviews/comments, and complete diff) is in /tmp/claude/pr-sweep/${n}.md — read it first. The repo is checked out at the current working directory. Read the actual current source files the diff touches to verify the diff applies cleanly conceptually and the claims in the PR body are true. Do NOT modify anything or run git commands that change state. + +Context about this repo: +- It's a GitHub Action that runs Claude on issues/PRs. It processes UNTRUSTED content (PR bodies, comments, branch names, file contents from forks). Treat any change touching content sanitization, actor/bot allowlists, config restoration, prompt construction, or shell command construction as high-risk. +- Most candidate PRs are from EXTERNAL contributors. Treat the diff with suspicion: look for subtle malicious changes, weakened validation, injection vectors, overly broad permissions, or changes whose description doesn't match the code. +- Runtime is Bun; strict TypeScript (noUnusedLocals/noUnusedParameters). Tests are unit tests run with bun test. + +Stamp criteria (ALL must hold): +1. Small, focused, and the code does exactly what the title/body says. +2. No major behavior change — bug fixes restoring intended behavior, docs fixes, test-only additions, and small niceties qualify. New inputs/features, behavior redesigns, or large refactors do NOT. +3. Correct: you verified the logic against the actual current source, not just the diff. Check edge cases. +4. No security concern. Check explicitly for: prompt injection (untrusted text reaching Claude's prompt without sanitization), code execution (untrusted data reaching shell commands, eval/spawn, or GitHub workflow expressions), path traversal (untrusted input influencing filesystem paths), credential exposure (tokens reaching logs, comments, or attacker-readable output), weakened validation or permission checks, and suspicious hunks unrelated to the stated purpose. +5. Wouldn't break the public API of base-action/ or action.yml output wiring. + +If the PR is a docs change, verify the docs claims against the actual code behavior. If test-only, check tests actually pass conceptually (assert the right things, match real implementations) and don't weaken or skip anything. + +Verdicts: "stamp" = approve as-is; "needs-discussion" = plausible but has questions/issues worth a comment; "skip" = too big, wrong, redundant, or risky. + +If this PR appears to duplicate another open PR (same fix, same files), still judge it on its own merits but note the duplication in duplicateOf. + +Return structured output only.`, + { label: `review:#${n}`, phase: "Review", schema: REVIEW_SCHEMA }, + ), + (review, n) => { + if (!review) return null; + if (review.verdict !== "stamp") return { review, verify: null }; + return agent( + `You are an adversarial security skeptic. Another reviewer recommended APPROVING open PR #${n} on anthropics/claude-code-action. Your job is to REFUTE that recommendation — find any reason it should NOT be stamped. + +Their assessment: ${JSON.stringify(review)} + +Read the full PR at /tmp/claude/pr-sweep/${n}.md and the touched source files in the current working directory. This repo processes untrusted PR/issue content from forks; anything that lets untrusted content reach Claude's prompt, a shell command, a workflow expression, or a filesystem path unsanitized is a critical vulnerability. + +Hunt specifically for: +- Subtle malice or scope creep: hunks that don't match the stated purpose, weakened validation, regex changes that widen acceptance, removed escaping. +- Prompt injection: untrusted data (comment bodies, branch names, file contents, command output, downloaded files) reaching Claude's prompt or context without sanitization, including indirect routes like tool output Claude later reads. +- Code execution: untrusted data reaching shell commands, eval/spawn argv, GitHub workflow \${{ }} expressions, or API call templates; new process spawning; path traversal letting untrusted input write or read outside intended directories. +- Credential exposure: tokens or secrets flowing into logs, posted comments, error messages, env passed to untrusted code, or files Claude can read. +- Logic errors the first reviewer missed: off-by-one, wrong polarity, unhandled edge cases (empty strings, unicode, very long inputs). +- Supply-chain angles: pinned versions that don't match the claimed SHA/tag, new dependencies, fetched URLs. +- For docs PRs: claims that would mislead users into insecure configurations. +- For test-only PRs: tests that codify wrong behavior, or that would mask future regressions. + +If the diff pins a version/SHA, verify the claim is plausible from local information; flag if unverifiable. Be strict: if uncertain whether something is a real problem, lean toward reporting it as a finding with your uncertainty noted. Only return safeToStamp=true if you genuinely failed to find any disqualifying issue. + +Return structured output only.`, + { label: `verify:#${n}`, phase: "Verify", schema: VERDICT_SCHEMA }, + ).then((v) => ({ review, verify: v })); + }, +); + +const clean = results.filter(Boolean); +const stamped = clean.filter( + (r) => r.review.verdict === "stamp" && r.verify && r.verify.safeToStamp, +); +const demoted = clean.filter( + (r) => r.review.verdict === "stamp" && (!r.verify || !r.verify.safeToStamp), +); +const discuss = clean.filter((r) => r.review.verdict === "needs-discussion"); +const skipped = clean.filter((r) => r.review.verdict === "skip"); + +log( + `stamp: ${stamped.length}, demoted by verifier: ${demoted.length}, needs-discussion: ${discuss.length}, skip: ${skipped.length}`, +); + +return { stamped, demoted, discuss, skipped }; diff --git a/agent-approval-check/README.md b/agent-approval-check/README.md new file mode 100644 index 0000000..c95c8ed --- /dev/null +++ b/agent-approval-check/README.md @@ -0,0 +1,123 @@ +# Agent Approval Check + +Require **N human approvals** on any pull request that contains commits +authored by an AI agent (Claude, Claude Code, or any bot identity you +configure). PRs without agent activity are unaffected. + +This is the same gate Anthropic runs internally on every agent-authored PR. + +## What it does + +When a PR is opened, pushed to, or commented on, this action: + +1. Scans the PR's commits, author, and reviews for the configured agent + identities (committer email, bot login, or an `APPROVED` review from a + bot). If none are found it posts `success: No agent activity` and stops. +2. Counts distinct human approvals: the latest `APPROVED` review per login, + plus any `/approve ` comment whose SHA matches the current + head. Only users with write access to the repo count (verified per-user + via the collaborators permission API); agent and excluded-bot logins + never count. +3. Posts an `agent-approval-check` commit status (`success` once the count + reaches `required_approvals`, otherwise `pending`) and a sticky PR + comment explaining what's still needed. +4. Re-evaluates on every new push or comment. A push moves the head SHA, + so earlier `/approve ` comments are flagged stale. Approving + reviews still count toward the threshold — they're picked up the next + time the workflow runs (on push or `/approve`); they just don't trigger + a run on their own. + +Mark `agent-approval-check` as a **required status check** on your protected +branches and GitHub will refuse to merge until it's green. + +## Setup + +Copy [`examples/agent-approval-check.yml`](../examples/agent-approval-check.yml) +into `.github/workflows/` in your repo, then add `agent-approval-check` to the +required status checks on your protected branch. + +This action is designed to run **alongside** GitHub's native branch +protection, not replace it. On the same protected branch you should also: + +1. Require at least 1 approving review from someone with write access. +2. Enable **Dismiss stale pull request approvals when new commits are pushed**. + +```yaml +name: agent-approval-check +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review] + issue_comment: + types: [created] +permissions: + contents: read + pull-requests: write + statuses: write +jobs: + check: + if: github.event_name != 'issue_comment' || github.event.issue.pull_request + runs-on: ubuntu-latest + steps: + - uses: anthropics/claude-code-action/agent-approval-check@main + with: + required_approvals: 2 + agent_emails: noreply@anthropic.com + agent_logins: claude[bot],claude-code[bot] +``` + +## Inputs + +| Input | Default | Meaning | +| ---------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | +| `required_approvals` | `2` | Distinct human approvals needed. | +| `agent_emails` | `noreply@anthropic.com` | Committer emails that mark a commit agent-authored. | +| `agent_logins` | `claude[bot],claude-code[bot]` | Logins treated as agents (PR author or approving reviewer). | +| `excluded_approvers` | _(empty)_ | Logins whose approvals never count. | +| `exempt_head_branches` | _(empty)_ | Head-branch globs that auto-pass. ⚠️ Leave empty — branch names are attacker-controlled, so this is not a safe place to encode trust. | +| `exempt_path_prefixes` | _(empty)_ | PRs touching only these prefixes auto-pass. | +| `protected_bases` | _(default branch)_ | Base branches this check gates (see threat model). | +| `config_file` | _(empty)_ | Path to an [agent-identities YAML](./agent-identities.example.yaml) replacing the inline inputs. See the warning below. | +| `docs_url` | this README | Link in the PR comment footer. | +| `github_token` | `${{ github.token }}` | Needs `statuses:write` + `pull-requests:write`. | + +> ⚠️ **`config_file` and checkout:** if you set `config_file`, your workflow +> must check out the **base** branch to read it (the default behaviour of +> `actions/checkout` under `pull_request_target`). Never check out the PR +> head ref — doing so would let the PR author control the config and bypass +> this check. + +## Approving + +A human counts as an approver by either: + +- submitting a normal GitHub **Approve** review, or +- commenting `/approve ` where `` is the current head commit + (12–40 hex chars). This path lets the PR author — who can't approve their + own PR in GitHub's UI — vouch for commits an agent pushed on their behalf. + The author's `/approve` is subject to the same write-access verification + as any other approver, so a fork-PR author without write access on the + base repository cannot self-count. The author counts as **one** approval; + the remaining approvals must come from other reviewers with write access. + +## Threat model + +- **Tamper-proof triggers.** `pull_request_target` and `issue_comment` run + the workflow file from the base/default branch, so the PR under review + cannot edit this check. `pull_request_review` does **not** share this + property — it runs from the merge ref — so the example workflow omits it; + native Approve reviews are picked up on the next synchronize or + `/approve` comment. This tamper-resistance assumes the workflow file + itself is protected: an actor who can push workflow changes to the + default branch can spoof any required status check, including this one, + so protect `.github/workflows/` via branch protection or CODEOWNERS. +- **Fail-closed.** Any unhandled error exits non-zero; the required status + stays non-success and the PR stays blocked. PRs with >100 commits are + treated as agent-authored because the full commit list can't be verified. +- **Sibling-PR guard.** Commit statuses attach to a SHA, not a PR. The + action refuses to post a status on a PR whose base isn't in + `protected_bases`, and withholds `success` while another open PR to a + protected base shares the same head commit — otherwise a green status on + one PR would also unblock the other. +- **No checkout of PR code.** The action never checks out the PR's branch; + it reads PR metadata via the GitHub API, so the usual + `pull_request_target` code-execution risk does not apply. diff --git a/agent-approval-check/action.yml b/agent-approval-check/action.yml new file mode 100644 index 0000000..4863f3b --- /dev/null +++ b/agent-approval-check/action.yml @@ -0,0 +1,72 @@ +name: Agent Approval Check +description: | + Require N human approvals on PRs that contain agent-authored commits + (Claude, Claude Code, or any configured bot identity). Posts an + `agent-approval-check` commit status — mark it as a required check on + protected branches to gate merges. + +inputs: + github_token: + description: Token with statuses:write and pull-requests:write on this repo. + default: ${{ github.token }} + required_approvals: + description: Number of distinct human approvals required. Must be >= 1. + default: "2" + agent_emails: + description: Comma-separated committer emails treated as agent-authored. + default: noreply@anthropic.com + agent_logins: + description: | + Comma-separated GitHub logins treated as agents — a PR opened by, or an + APPROVED review from, one of these triggers the check. + default: claude[bot],claude-code[bot] + excluded_approvers: + description: Comma-separated logins whose approvals never count (e.g. rubber-stamp bots). + default: "" + exempt_head_branches: + description: | + Comma-separated glob patterns; PRs from matching head branches auto-pass. + WARNING: leave empty — branch names are attacker-controlled, so this is + not a safe place to encode trust. + default: "" + exempt_path_prefixes: + description: Comma-separated path prefixes; PRs touching only these auto-pass. + default: "" + protected_bases: + description: | + Comma-separated base branches this check gates. Empty = the repo's + default branch only. PRs targeting any other base are refused (no + status posted) so a sibling PR sharing the head SHA can't get the + shared commit stamped green. + default: "" + config_file: + description: Optional path to an agent-identities YAML file (overrides the inline inputs). + default: "" + docs_url: + description: Link shown in the PR comment footer. + default: "https://github.com/anthropics/claude-code-action/tree/main/agent-approval-check" + +runs: + using: composite + steps: + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + - run: pip install 'httpx==0.28.1' 'pyyaml==6.0.3' 'tenacity==9.1.4' + shell: bash + - run: python "${{ github.action_path }}/agent_approval_check.py" + shell: bash + env: + GH_TOKEN: ${{ inputs.github_token }} + GH_REPOSITORY: ${{ github.repository }} + GH_EVENT_NAME: ${{ github.event_name }} + GH_EVENT_PATH: ${{ github.event_path }} + REQUIRED_APPROVALS: ${{ inputs.required_approvals }} + AGENT_EMAILS: ${{ inputs.agent_emails }} + AGENT_LOGINS: ${{ inputs.agent_logins }} + EXCLUDED_APPROVERS: ${{ inputs.excluded_approvers }} + EXEMPT_HEAD_BRANCHES: ${{ inputs.exempt_head_branches }} + EXEMPT_PATH_PREFIXES: ${{ inputs.exempt_path_prefixes }} + PROTECTED_BASES: ${{ inputs.protected_bases }} + CONFIG_FILE: ${{ inputs.config_file }} + DOCS_URL: ${{ inputs.docs_url }} diff --git a/agent-approval-check/agent-identities.example.yaml b/agent-approval-check/agent-identities.example.yaml new file mode 100644 index 0000000..bc466ea --- /dev/null +++ b/agent-approval-check/agent-identities.example.yaml @@ -0,0 +1,33 @@ +--- +# Optional config-file form of the agent-approval-check inputs. +# Pass via `with: { config_file: .github/agent-identities.yaml }` instead of +# the inline `agent_emails` / `agent_logins` / … inputs. + +# Committer emails that mark a commit as agent-authored. +agent_emails: + - noreply@anthropic.com + +# GitHub logins treated as agents — a PR opened by, or an APPROVED review +# from, one of these triggers the check. +agent_app_logins: + - claude[bot] + - claude-code[bot] + +# Logins whose approvals never count toward the required total. +excluded_approver_logins: [] + +# Head-branch glob patterns that auto-pass. Leave empty: branch names are +# attacker-controlled, so this is not a safe place to encode trust. +exempt_head_branches: [] + +# Per-repo path prefixes whose PRs auto-pass when ONLY those paths change. +exempt_path_prefixes: + owner/repo: + - docs/ + +# Per-repo base branches this check gates. A repo with no entry defaults to +# its default branch only. Listing a repo here REPLACES that default. +protected_bases: + owner/repo: + exact: [main] + prefixes: [release/] diff --git a/agent-approval-check/agent_approval_check.py b/agent-approval-check/agent_approval_check.py new file mode 100644 index 0000000..a60fc7f --- /dev/null +++ b/agent-approval-check/agent_approval_check.py @@ -0,0 +1,1828 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "httpx", +# "pyyaml", +# "tenacity", +# ] +# /// +""" +Agent Approval Check + +Enforces that PRs containing agent-authored commits (e.g. from Claude Code) +receive N human approvals before the `agent-approval-check` commit status +turns green. Mark that status as a required check on protected branches to +gate merges. + +SECURITY MODEL: + This script must run from the BASE/DEFAULT branch — via pull_request_target, + issue_comment, or workflow_run triggers — so a PR cannot modify the check + that gates it. It is fail-closed: any unhandled exception exits non-zero + and the required status stays non-success. + + Tamper-resistance assumes the workflow file itself is protected (branch + protection or CODEOWNERS on .github/workflows/). An actor who can push + workflow changes to the default branch can spoof any required status + check, including this one. + +AGENT DETECTION: + A commit is agent-authored if its committer email is in `agent_emails`. + A PR is agent-authored if its creator login is in `agent_app_logins`. + A PR also counts as having agent activity if an `agent_app_logins` identity + has submitted an APPROVED review (so an agent's approval can never satisfy + this check on its own). + +APPROVAL COUNTING: + Valid approvals come from non-agent users with write access to the + repository (verified via the collaborators permission API), via either: + - A non-dismissed APPROVED review (staleness is controlled by GitHub's + branch protection dismiss_stale_reviews setting) + - A /approve comment + + Approvals that DON'T count: + - Reviews/comments from agent identities + - /approve for a SHA that doesn't match the PR head commit + - CHANGES_REQUESTED overrides earlier APPROVED from same user + (COMMENTED reviews are ignored - they don't change approval status) + +OUTPUTS: + - Commit status: pending (yellow) or success (green) + - PR comment with full approval status (updated on each run) + - Stale approval notifications when /approve becomes outdated + +RATE LIMIT OPTIMIZATION: + - 1 GraphQL query to fetch PR, commits, reviews, and comments + (paginated only if >100 comments) + - 1 REST permission check per unique candidate approver (cached) + - 1 GraphQL mutation batch for all writes (comments, reactions, minimize) + - 1 REST call for commit status (no GraphQL equivalent) + Total: a small, bounded number of API calls per workflow run +""" + +import fnmatch +import json +import logging +import os +import re +import sys +from collections.abc import Callable, Iterator +from dataclasses import dataclass, field +from datetime import UTC, datetime +from pathlib import Path + +import httpx +import yaml +from tenacity import ( + retry, + retry_if_exception, + retry_if_exception_type, + stop_after_attempt, + wait_exponential, +) + + +def _retryable_http_error(exc: BaseException) -> bool: + # Retry network errors and 5xx; 4xx won't succeed on retry and the + # collaborator-permission endpoint legitimately returns 404. + if isinstance(exc, httpx.RequestError): + return True + if isinstance(exc, httpx.HTTPStatusError): + return exc.response is not None and exc.response.status_code >= 500 + return False + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", + handlers=[logging.StreamHandler(sys.stderr)], +) +logger = logging.getLogger(__name__) + +# --- Constants --- + +CHECK_NAME = "agent-approval-check" +REQUIRED_APPROVALS = int(os.environ.get("REQUIRED_APPROVALS") or 2) + +# Only approvals from users with write access to the repo count. authorAssociation +# alone can't prove that (a MEMBER or COLLABORATOR may have read/triage only), so +# it's used as a cheap pre-filter and the actual gate is a per-user REST +# `GET /repos/{o}/{r}/collaborators/{login}/permission` check. A login outside +# this set is not a collaborator at all, so the REST call is skipped. +WRITE_ACCESS_ASSOCIATIONS = frozenset({"OWNER", "MEMBER", "COLLABORATOR"}) +WRITE_PERMISSION_LEVELS = frozenset({"write", "push", "maintain", "admin"}) +DOCS_URL = ( + os.environ.get("DOCS_URL") + or "https://github.com/anthropics/claude-code-action/tree/main/agent-approval-check" +) + + +# GraphQL query to fetch all PR data in a single call. +# +# PAGINATION STRATEGY: +# - Use `last:` to get the most recent items (HEAD commit, recent reviews/approvals) +# - For commits: fail-closed if hasPreviousPage (can't verify all commits are human) +# - For reviews: warn only (old items are likely stale anyway) +# - For comments: paginated fully (older /approve and the sticky comment must be found) +GRAPHQL_PR_QUERY = """ +query GetPRData($owner: String!, $repo: String!, $prNumber: Int!) { + rateLimit { + limit + remaining + used + resetAt + } + repository(owner: $owner, name: $repo) { + defaultBranchRef { + name + } + pullRequest(number: $prNumber) { + id + number + headRefOid + headRefName + baseRefName + createdAt + author { + __typename + login + } + commits(last: 100) { + nodes { + commit { + oid + committedDate + committer { + email + } + signature { + state + verifiedAt + } + } + } + pageInfo { + hasPreviousPage + } + } + headCommit: commits(last: 1) { + nodes { + commit { + oid + associatedPullRequests(first: 50) { + nodes { + number + state + baseRefName + headRefOid + } + pageInfo { + hasNextPage + } + } + } + } + } + reviews(last: 100) { + nodes { + author { + __typename + login + } + authorAssociation + state + commit { + oid + } + submittedAt + } + pageInfo { + hasPreviousPage + } + } + comments(last: 100) { + nodes { + id + databaseId + author { + __typename + login + } + authorAssociation + body + isMinimized + } + pageInfo { + hasPreviousPage + startCursor + } + } + files(first: 100) { + nodes { + path + } + pageInfo { + hasNextPage + } + } + } + } +} +""" + +GRAPHQL_COMMENTS_PAGE_QUERY = """ +query GetPRComments($owner: String!, $repo: String!, $prNumber: Int!, $before: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $prNumber) { + comments(last: 100, before: $before) { + nodes { + id + databaseId + author { + __typename + login + } + authorAssociation + body + isMinimized + } + pageInfo { + hasPreviousPage + startCursor + } + } + } + } +} +""" + + +# --- Data Classes --- + + +@dataclass +class AgentConfig: + """Configuration for agent identities.""" + + agent_emails: list[str] + agent_app_logins: list[str] + excluded_approver_logins: list[str] + exempt_head_branches: list[str] + exempt_path_prefixes: dict[str, list[str]] = field(default_factory=dict) + protected_bases: dict[str, dict[str, list[str]]] = field(default_factory=dict) + + +@dataclass +class PRData: + """All data needed for a PR approval check, fetched via GraphQL.""" + + node_id: str # GraphQL node ID for the PR (used for addComment) + number: int + head_sha: str # headRefOid — authoritative head commit (status target) + head_ref: str # Branch name (for exempt branch check) + base_ref: str # Target branch (for protected-base check) + default_branch: str # Repo default branch (protected-base fallback) + created_at: str + author_login: str + commits: list[dict] # Normalized to REST-like format + reviews: list[dict] # Normalized to REST-like format + comments: list[dict] # Normalized to REST-like format + files: list[str] # Changed file paths + commits_incomplete: bool # True if PR has >100 commits (fail-closed) + files_incomplete: bool # True if PR has >100 files (fail-closed) + # Other OPEN PRs whose head is this exact commit, as (number, base_ref). + # Statuses are SHA-scoped, so a success here would satisfy them too. + same_sha_open_prs: list[tuple[int, str]] = field(default_factory=list) + same_sha_prs_incomplete: bool = False # associatedPullRequests overflowed + + +@dataclass +class AgentActivityResult: + """Result of checking for agent activity in a PR.""" + + has_agent_activity: bool + latest_agent_commit: dict | None + detection_reason: str + + +@dataclass +class ApproveCommand: + """A parsed /approve command from a comment.""" + + commenter: str + sha: str + comment_id: int + node_id: str + + +@dataclass +class MutationBatch: + """Collects GraphQL mutations to execute in a single call.""" + + # Reactions to add: list of (comment_node_id, reaction_content) + reactions: list[tuple[str, str]] = field(default_factory=list[tuple[str, str]]) + # Comment to create: (subject_node_id, body) - only one notification comment + create_comment: tuple[str, str] | None = None + # Comment to update: (comment_node_id, body) + update_comment: tuple[str, str] | None = None + # Stale notification to create: (subject_node_id, body) + create_stale_comment: tuple[str, str] | None = None + # Comments to minimize: list of (comment_node_id, reason) + minimize_comments: list[tuple[str, str]] = field( + default_factory=list[tuple[str, str]] + ) + # Comments to un-minimize: list of comment_node_id + unminimize_comments: list[str] = field(default_factory=list[str]) + + def is_empty(self) -> bool: + return ( + not self.reactions + and not self.create_comment + and not self.update_comment + and not self.create_stale_comment + and not self.minimize_comments + and not self.unminimize_comments + ) + + +class MutationBuilder: + """Builds GraphQL mutations with proper variable handling.""" + + def __init__(self) -> None: + self._parts: list[str] = [] + self._var_defs: list[str] = [] + self._variables: dict[str, dict] = {} + self._counter: int = 0 + + def _next_id(self) -> int: + self._counter += 1 + return self._counter + + def add_reaction(self, node_id: str, content: str) -> None: + """Add a reaction to a comment.""" + i = self._next_id() + self._parts.append( + f"r{i}: addReaction(input: $r{i}) {{ reaction {{ content }} }}" + ) + self._var_defs.append(f"$r{i}: AddReactionInput!") + self._variables[f"r{i}"] = {"subjectId": node_id, "content": content} + + def add_comment(self, alias: str, subject_id: str, body: str) -> None: + """Add a comment to the PR.""" + self._parts.append( + f"{alias}: addComment(input: ${alias}) {{ commentEdge {{ node {{ id }} }} }}" + ) + self._var_defs.append(f"${alias}: AddCommentInput!") + self._variables[alias] = {"subjectId": subject_id, "body": body} + + def update_comment(self, alias: str, node_id: str, body: str) -> None: + """Update an existing comment.""" + self._parts.append( + f"{alias}: updateIssueComment(input: ${alias}) {{ issueComment {{ id }} }}" + ) + self._var_defs.append(f"${alias}: UpdateIssueCommentInput!") + self._variables[alias] = {"id": node_id, "body": body} + + def minimize_comment(self, node_id: str, reason: str) -> None: + """Minimize a comment.""" + i = self._next_id() + self._parts.append( + f"m{i}: minimizeComment(input: $m{i}) {{ minimizedComment {{ isMinimized }} }}" + ) + self._var_defs.append(f"$m{i}: MinimizeCommentInput!") + self._variables[f"m{i}"] = {"subjectId": node_id, "classifier": reason} + + def unminimize_comment(self, node_id: str) -> None: + """Un-minimize a comment (make it visible again).""" + i = self._next_id() + self._parts.append( + f"u{i}: unminimizeComment(input: $u{i}) {{ unminimizedComment {{ isMinimized }} }}" + ) + self._var_defs.append(f"$u{i}: UnminimizeCommentInput!") + self._variables[f"u{i}"] = {"subjectId": node_id} + + def build(self) -> tuple[str, dict[str, dict]] | None: + """Build the mutation query and variables. Returns None if empty.""" + if not self._parts: + return None + mutation = ( + f"mutation M({', '.join(self._var_defs)}) {{ {' '.join(self._parts)} }}" + ) + return mutation, self._variables + + +# --- Rate Limit Logging --- + + +def log_rate_limit(rate_limit: dict | None, context: str = "") -> None: + """Log GitHub API rate limit status.""" + if not rate_limit: + return + + remaining = rate_limit.get("remaining", "?") + limit = rate_limit.get("limit", "?") + used = rate_limit.get("used", "?") + reset_at = rate_limit.get("resetAt", "") + + reset_str = "" + if reset_at: + try: + reset_time = datetime.fromisoformat(reset_at.replace("Z", "+00:00")) + now = datetime.now(UTC) + minutes_until_reset = (reset_time - now).total_seconds() / 60 + reset_str = f", resets in {minutes_until_reset:.0f}m" + except (ValueError, TypeError): + reset_str = f", resets at {reset_at}" + + prefix = f"[{context}] " if context else "" + logger.info( + "%sGitHub API rate limit: %s/%s remaining (%s used%s)", + prefix, + remaining, + limit, + used, + reset_str, + ) + + if isinstance(remaining, int) and isinstance(limit, int): + percent_remaining = (remaining / limit) * 100 if limit > 0 else 0 + if percent_remaining < 10: + logger.warning( + "Rate limit critically low: %.1f%% remaining", percent_remaining + ) + elif percent_remaining < 25: + logger.warning( + "Rate limit getting low: %.1f%% remaining", percent_remaining + ) + + +def log_rest_rate_limit(response: httpx.Response, context: str = "") -> None: + """Log rate limit from REST API response headers.""" + remaining = response.headers.get("X-RateLimit-Remaining", "?") + limit = response.headers.get("X-RateLimit-Limit", "?") + used = response.headers.get("X-RateLimit-Used", "?") + resource = response.headers.get("X-RateLimit-Resource", "core") + + prefix = f"[{context}] " if context else "" + logger.info( + "%sGitHub REST API (%s): %s/%s remaining (%s used)", + prefix, + resource, + remaining, + limit, + used, + ) + + +# --- Commit Helpers --- + + +def normalize_graphql_login(author: dict | None) -> str: + """Normalize a GraphQL author node to a REST-compatible login. + + GitHub's GraphQL API returns bot logins without the [bot] suffix + (e.g. "nrg-test"), while the REST API includes it ("nrg-test[bot]"). + This normalizes to REST format for consistent identity matching. + """ + if not author: + return "" + login = author.get("login", "") + if author.get("__typename") == "Bot" and not login.endswith("[bot]"): + login = f"{login}[bot]" + return login + + +def get_committer_email(commit: dict) -> str: + return commit.get("commit", {}).get("committer", {}).get("email", "") + + +def is_agent_commit(commit: dict, config: AgentConfig) -> bool: + # Case-insensitive comparison per RFC 5321 (email addresses are case-insensitive) + email = get_committer_email(commit).lower() + return email in (e.lower() for e in config.agent_emails) + + +# --- User/PR Helpers --- + + +def is_review_exempt_pr(pr_data: PRData, config: AgentConfig, repo: str) -> bool: + """Check if all changed files are under a configured exempt path prefix. + + Fail-closed: returns False if no prefixes are configured for this repo, + if the file list is incomplete (>100 files), or if no files are present. + """ + exempt_prefixes = tuple(config.exempt_path_prefixes.get(repo, [])) + if not exempt_prefixes or pr_data.files_incomplete or not pr_data.files: + return False + return all(f.startswith(exempt_prefixes) for f in pr_data.files) + + +def is_protected_base( + base_ref: str, config: AgentConfig, repo: str, default_branch: str +) -> bool: + """Check if the PR's base branch is one this check is meant to protect. + + Commit statuses are SHA-scoped, so evaluating a PR that targets an + unprotected base (e.g. a sibling PR opened from the same head branch) + could stamp success on a SHA shared with a PR that does target a + protected base. + + A repo with a protected_bases entry uses it exclusively (the default + branch is not implicitly included — list it). A repo without an entry + protects exactly its default branch, so onboarding a repo needs no + config unless it gates additional branches. + """ + repo_config = config.protected_bases.get(repo) + if repo_config is None: + if not default_branch: + logger.warning( + "No protected_bases entry for %s and default branch unknown " + "— sibling-PR defense skipped", + repo, + ) + return True + return base_ref == default_branch + if base_ref in repo_config.get("exact", []): + return True + return any(base_ref.startswith(p) for p in repo_config.get("prefixes", [])) + + +def select_pr_candidate( + pr_number: int, candidates_json: str, config: AgentConfig, repo: str +) -> int: + """Pick which PR to evaluate when the triggering run lists several. + + For pull_request / pull_request_review signals, workflow_run.pull_requests + lists every open PR sharing the run's head SHA — including sibling PRs from + the same head branch that target a different base. Prefer a candidate whose + base is protected so the status update lands on the PR this check gates. + + Routing only, not enforcement: is_protected_base on the fetched PR data is + what actually refuses to evaluate an unprotected base, so any fallback to + the original pr_number here is safe. + """ + if not candidates_json.strip(): + return pr_number + if repo not in config.protected_bases: + # Without an explicit entry we can't rank candidates before fetching + # (the default-branch fallback needs the GraphQL response). + return pr_number + try: + candidates = json.loads(candidates_json) + except json.JSONDecodeError: + logger.warning("GH_PR_CANDIDATES is not valid JSON — keeping PR #%d", pr_number) + return pr_number + if not isinstance(candidates, list): + logger.warning("GH_PR_CANDIDATES is not a list — keeping PR #%d", pr_number) + return pr_number + + protected_numbers: list[int] = [] + for candidate in candidates: + if not isinstance(candidate, dict): + continue + number = candidate.get("number") + base_ref = ((candidate.get("base") or {}).get("ref")) or "" + if isinstance(number, int) and is_protected_base( + base_ref, config, repo, default_branch="" + ): + protected_numbers.append(number) + + if pr_number in protected_numbers: + return pr_number + if not protected_numbers: + logger.warning( + "No workflow_run PR candidate targets a protected base for %s — " + "keeping PR #%d (the protected-base check will refuse it)", + repo, + pr_number, + ) + return pr_number + selected = min(protected_numbers) + logger.warning( + "PR #%d does not target a protected base for %s; evaluating sibling " + "PR #%d from the same head SHA instead (protected candidates: %s)", + pr_number, + repo, + selected, + sorted(protected_numbers), + ) + return selected + + +def is_exempt_branch(head_ref: str, config: AgentConfig) -> bool: + """Check if the PR's head branch matches an exempt pattern. + + Uses fnmatch glob matching against configured exempt_head_branches patterns. + """ + return any( + fnmatch.fnmatch(head_ref, pattern) for pattern in config.exempt_head_branches + ) + + +def is_agent_user(login: str, config: AgentConfig) -> bool: + # Case-insensitive comparison (GitHub usernames are case-insensitive) + login_lower = login.lower() + return login_lower in (l.lower() for l in config.agent_app_logins) + + +def is_excluded_approver(login: str, config: AgentConfig) -> bool: + """Check if a login is excluded from counting as an approver. + + These are bots (e.g. an auto-approve bot) whose automated approvals should not + satisfy the human review requirement. Unlike agents, they don't trigger agent + detection — they are simply ignored when counting approvals. + """ + login_lower = login.lower() + return login_lower in (l.lower() for l in config.excluded_approver_logins) + + +def is_pr_created_by_agent(pr_author: str, config: AgentConfig) -> bool: + # Case-insensitive comparison (GitHub usernames are case-insensitive) + author_lower = pr_author.lower() + return author_lower in (l.lower() for l in config.agent_app_logins) + + +# --- Approval Helpers --- + + +def parse_approve_command(body: str | None) -> str | None: + """Parse /approve command and return the SHA, or None if not valid. + + Only the first line of the comment is considered. GitHub email replies + append the quoted notification below the user's text, so a reply of just + "/approve " arrives as "/approve \r\n\r\nOn ... wrote:". + The first line must still be exactly the command — leading text or extra + tokens on that line are rejected. + """ + first_line = (body or "").lstrip().splitlines()[0:1] + first_line = first_line[0].strip() if first_line else "" + match = re.match(r"^/approve\s+([a-f0-9]{12,40})\s*$", first_line, re.IGNORECASE) + return match.group(1).lower() if match else None + + +def sha_matches(approved_sha: str, target_sha: str) -> bool: + return target_sha.lower().startswith(approved_sha.lower()) + + +def iter_approve_commands( + comments: list[dict], + config: AgentConfig, + permission_check: Callable[[str], bool], +) -> Iterator[ApproveCommand]: + for comment in comments: + commenter = comment.get("user", {}).get("login", "") + if not commenter: # Skip deleted users or null authors + continue + if comment.get("author_association") not in WRITE_ACCESS_ASSOCIATIONS: + continue + if is_agent_user(commenter, config): + continue + if is_excluded_approver(commenter, config): + continue + approved_sha = parse_approve_command(comment.get("body", "")) + if not approved_sha: + continue + if not permission_check(commenter): + continue + yield ApproveCommand( + commenter=commenter, + sha=approved_sha, + comment_id=comment.get("id", 0), + node_id=comment.get("node_id", ""), + ) + + +def get_latest_review_per_user(reviews: list[dict]) -> dict[str, dict]: + """Get the latest decision review per user. + + Only considers "decision" reviews (APPROVED, CHANGES_REQUESTED) that change + approval status. COMMENTED reviews are ignored because they don't represent + a decision change - a reviewer who approves then adds a comment is still + approving, matching GitHub's native behavior. + """ + decision_states = {"APPROVED", "CHANGES_REQUESTED"} + latest: dict[str, dict] = {} + for review in reviews: + login = review.get("user", {}).get("login") + if not login: + continue + # Only consider decision reviews (APPROVED, CHANGES_REQUESTED) + if review.get("state") not in decision_states: + continue + existing = latest.get(login) + if not existing or review.get("submitted_at", "") > existing.get( + "submitted_at", "" + ): + latest[login] = review + return latest + + +# --- Config Loading --- + + +def load_agent_config(config_path: Path) -> AgentConfig: + with config_path.open() as f: + config = yaml.safe_load(f) + + if not isinstance(config, dict): + raise ValueError(f"Invalid config format: expected dict, got {type(config)}") + + agent_emails = config.get("agent_emails", []) + agent_app_logins = config.get("agent_app_logins", []) + excluded_approver_logins = config.get("excluded_approver_logins", []) + exempt_head_branches = config.get("exempt_head_branches", []) + exempt_path_prefixes = config.get("exempt_path_prefixes", {}) + protected_bases = config.get("protected_bases", {}) + + # Validate list types to prevent silent failures (e.g., iterating over string chars) + if not isinstance(agent_emails, list): + raise ValueError(f"agent_emails must be a list, got {type(agent_emails)}") + if not isinstance(agent_app_logins, list): + raise ValueError( + f"agent_app_logins must be a list, got {type(agent_app_logins)}" + ) + if not isinstance(excluded_approver_logins, list): + raise ValueError( + f"excluded_approver_logins must be a list, got {type(excluded_approver_logins)}" + ) + if not isinstance(exempt_head_branches, list): + raise ValueError( + f"exempt_head_branches must be a list, got {type(exempt_head_branches)}" + ) + if not isinstance(exempt_path_prefixes, dict): + raise ValueError( + f"exempt_path_prefixes must be a dict keyed by owner/repo, " + f"got {type(exempt_path_prefixes)}" + ) + for repo, prefixes in exempt_path_prefixes.items(): + if not isinstance(prefixes, list): + raise ValueError( + f"exempt_path_prefixes[{repo!r}] must be a list, got {type(prefixes)}" + ) + if not all(isinstance(p, str) and p for p in prefixes): + raise ValueError( + f"exempt_path_prefixes[{repo!r}] entries must be non-empty strings" + ) + if not isinstance(protected_bases, dict): + raise ValueError( + f"protected_bases must be a dict keyed by owner/repo, " + f"got {type(protected_bases)}" + ) + for repo, entry in protected_bases.items(): + if not isinstance(entry, dict): + raise ValueError( + f"protected_bases[{repo!r}] must be a dict with exact/prefixes, " + f"got {type(entry)}" + ) + for key in ("exact", "prefixes"): + vals = entry.get(key, []) + if not isinstance(vals, list) or not all( + isinstance(v, str) and v for v in vals + ): + raise ValueError( + f"protected_bases[{repo!r}][{key!r}] must be a list of " + f"non-empty strings" + ) + + return AgentConfig( + agent_emails=agent_emails, + agent_app_logins=agent_app_logins, + excluded_approver_logins=excluded_approver_logins, + exempt_head_branches=exempt_head_branches, + exempt_path_prefixes=exempt_path_prefixes, + protected_bases=protected_bases, + ) + + +def _csv(name: str) -> list[str]: + return [v.strip() for v in (os.environ.get(name) or "").split(",") if v.strip()] + + +def load_agent_config_from_env(repo: str) -> AgentConfig: + """Build an AgentConfig from action inputs (env vars). + + A CONFIG_FILE, if set, takes precedence over the inline inputs. + """ + config_file = os.environ.get("CONFIG_FILE", "").strip() + if config_file: + return load_agent_config(Path(config_file)) + + exempt_prefixes = _csv("EXEMPT_PATH_PREFIXES") + protected_exact = _csv("PROTECTED_BASES") + return AgentConfig( + agent_emails=_csv("AGENT_EMAILS"), + agent_app_logins=_csv("AGENT_LOGINS"), + excluded_approver_logins=_csv("EXCLUDED_APPROVERS"), + exempt_head_branches=_csv("EXEMPT_HEAD_BRANCHES"), + exempt_path_prefixes={repo: exempt_prefixes} if exempt_prefixes else {}, + protected_bases=( + {repo: {"exact": protected_exact, "prefixes": []}} + if protected_exact + else {} + ), + ) + + +def resolve_pr_number(event_name: str, event_path: str) -> int | None: + """Derive the PR number from the triggering event payload. + + Supports pull_request / pull_request_target, pull_request_review, and + issue_comment (only when the issue is a PR). Returns None when the event + has no PR (e.g. an issue_comment on a plain issue) — callers exit 0 so + the workflow run succeeds without posting a status. + """ + with open(event_path) as f: + event = json.load(f) + if event_name in {"pull_request", "pull_request_target", "pull_request_review"}: + return int(event["pull_request"]["number"]) + if event_name == "issue_comment": + issue = event.get("issue") or {} + if issue.get("pull_request"): + return int(issue["number"]) + return None + if event_name == "workflow_run": + prs = event.get("workflow_run", {}).get("pull_requests") or [] + return int(prs[0]["number"]) if prs else None + raise ValueError(f"Unsupported event: {event_name}") + + +# --- Core Logic --- + + +def get_detection_reason(commit: dict, config: AgentConfig) -> str: + email = get_committer_email(commit) + short_sha = commit.get("sha", "")[:12] + # Case-insensitive check to match is_agent_commit behavior + email_lower = email.lower() + assert email_lower in (e.lower() for e in config.agent_emails), ( + f"Expected agent email but got {email}" + ) + return f"Commit {short_sha} has agent email ({email})" + + +def has_agent_approval( + reviews: list[dict], + config: AgentConfig, +) -> str | None: + """Check if any agent identity has submitted an APPROVED review. + + Returns the agent login if found, None otherwise. + """ + for review in reviews: + if review.get("state") != "APPROVED": + continue + login = review.get("user", {}).get("login", "") + if login and is_agent_user(login, config): + return login + return None + + +def check_for_agent_activity( + commits: list[dict], + pr_author: str, + config: AgentConfig, + reviews: list[dict] | None = None, +) -> AgentActivityResult: + is_agent_pr = is_pr_created_by_agent(pr_author, config) + latest_agent_commit: dict | None = None + + # Any agent-email commit counts as agent activity. An earlier carve-out + # that endorsed agent commits pushed before the PR was opened was removed: + # closing a pending PR and reopening a new one against the same head + # rewinds pr.createdAt past every commit, so the carve-out auto-passed + # the very PR it was meant to gate. + for commit in commits: + if is_agent_commit(commit, config): + logger.info("Agent commit detected: %s", commit.get("sha")) + latest_agent_commit = commit + + if latest_agent_commit: + return AgentActivityResult( + has_agent_activity=True, + latest_agent_commit=latest_agent_commit, + detection_reason=get_detection_reason(latest_agent_commit, config), + ) + + if is_agent_pr: + logger.info( + "PR created by agent app %s, no agent commits - using HEAD", pr_author + ) + return AgentActivityResult( + has_agent_activity=True, + latest_agent_commit=commits[-1] if commits else None, + detection_reason=f"PR was created by {pr_author}", + ) + + # Edge case: an agent has submitted an APPROVED review. Without this + # check, the agent's approval would count toward branch protection's + # required-reviews threshold as if it were human. + if reviews: + agent_login = has_agent_approval(reviews, config) + if agent_login: + logger.info( + "Agent APPROVED review from %s detected", + agent_login, + ) + return AgentActivityResult( + has_agent_activity=True, + latest_agent_commit=commits[-1] if commits else None, + detection_reason=f"PR has an APPROVED review from agent: {agent_login}", + ) + + return AgentActivityResult( + has_agent_activity=False, latest_agent_commit=None, detection_reason="" + ) + + +def count_approvers( + head_sha: str, + reviews: list[dict], + comments: list[dict], + config: AgentConfig, + permission_check: Callable[[str], bool], +) -> set[str]: + # Use lowercase for deduplication (GitHub usernames are case-insensitive) + approvers: set[str] = set() + + # Count all non-dismissed APPROVED reviews from non-agent users. + # GitHub's branch protection settings (dismiss_stale_reviews_on_push) + # control which reviews remain active — we defer to that. + for login, review in get_latest_review_per_user(reviews).items(): + if review.get("author_association") not in WRITE_ACCESS_ASSOCIATIONS: + continue + if is_agent_user(login, config): + continue + if is_excluded_approver(login, config): + continue + if review.get("state") != "APPROVED": + continue + if not permission_check(login): + continue + logger.info("Counting APPROVE from %s", login) + approvers.add(login.lower()) + + # /approve comments must match the PR head SHA — approving an older + # commit does not vouch for what's currently being merged. + for cmd in iter_approve_commands(comments, config, permission_check): + if not sha_matches(cmd.sha, head_sha): + continue + logger.info("Counting /approve from %s for SHA %s", cmd.commenter, cmd.sha) + approvers.add(cmd.commenter.lower()) + + logger.info("Total approvers: %d (%s)", len(approvers), ", ".join(approvers)) + return approvers + + +# --- Reaction/Comment Helpers --- + +REACTION_VALID = "THUMBS_UP" # GraphQL enum value + + +def collect_approval_reactions( + batch: MutationBatch, + comments: list[dict], + head_sha: str, + config: AgentConfig, + permission_check: Callable[[str], bool], +) -> None: + """Add reactions for valid /approve comments to the batch.""" + if not head_sha: + return + + for cmd in iter_approve_commands(comments, config, permission_check): + if sha_matches(cmd.sha, head_sha) and cmd.node_id: + batch.reactions.append((cmd.node_id, REACTION_VALID)) + logger.info("Will add thumbs up to /approve from %s", cmd.commenter) + + +def find_stale_approvals( + comments: list[dict], + head_sha: str, + config: AgentConfig, + commits: list[dict], + permission_check: Callable[[str], bool], + current_approvers: set[str] | None = None, +) -> list[dict]: + if not head_sha: + return [] + + # current_approvers uses lowercase (from count_approvers) + current_approvers = current_approvers or set() + stale_by_user: dict[str, str] = {} + commit_shas = [c.get("sha", "") for c in commits] + + for cmd in iter_approve_commands(comments, config, permission_check): + # Use lowercase to match current_approvers + commenter_lower = cmd.commenter.lower() + if commenter_lower in current_approvers: + continue + if not any(sha_matches(cmd.sha, sha) for sha in commit_shas): + continue + if not sha_matches(cmd.sha, head_sha): + if commenter_lower not in stale_by_user: + # Store original case for display in notifications + stale_by_user[commenter_lower] = cmd.sha + + # Return original usernames for @ mentions (GitHub handles case) + return [{"user": user, "sha": sha} for user, sha in stale_by_user.items()] + + +# --- Notification Comment --- + +COMMENT_MARKER = "" +STALE_MARKER = "" + + +def find_notification_comment(comments: list[dict]) -> dict | None: + for c in comments: + if COMMENT_MARKER in (c.get("body") or ""): + return c + return None + + +def find_stale_notification_for_commit( + comments: list[dict], latest_sha: str +) -> dict | None: + short_sha = latest_sha[:12] + for c in comments: + body = c.get("body") or "" + if STALE_MARKER in body and short_sha in body: + return c + return None + + +def find_old_stale_notifications(comments: list[dict], latest_sha: str) -> list[dict]: + short_sha = latest_sha[:12] + return [ + c + for c in comments + if STALE_MARKER in (c.get("body") or "") + and short_sha not in (c.get("body") or "") + ] + + +def generate_notification_comment( + approvers: set[str], + stale_approvals: list[dict], + detection_reason: str, + head_sha: str, + sibling_blocker_prs: list[int] | None = None, + sibling_list_incomplete: bool = False, +) -> str: + short_sha = head_sha[:12] + approver_count = len(approvers) + has_enough = approver_count >= REQUIRED_APPROVALS + + lines = [COMMENT_MARKER] + + if has_enough: + lines.append( + f"### Agent Activity - Approved ({approver_count}/{REQUIRED_APPROVALS})\n" + ) + lines.append( + f"This PR has received {approver_count}/{REQUIRED_APPROVALS} required approvals.\n" + ) + else: + lines.append( + f"### Agent Activity - Needs Approval ({approver_count}/{REQUIRED_APPROVALS})\n" + ) + lines.append( + f"This PR requires **{REQUIRED_APPROVALS} trusted actor approvals** before it can be merged.\n" + ) + + lines.append(f"\n> {detection_reason}\n") + + # The commit status is held at pending while sibling PRs share this head + # commit (statuses are SHA-scoped — see post_status). Say so here too, + # otherwise an "Approved" comment contradicts a pending required check. + if sibling_blocker_prs or sibling_list_incomplete: + lines.append("\n### Blocked — Sibling PRs Share This Commit\n") + if sibling_blocker_prs: + listed = ", ".join(f"#{n}" for n in sibling_blocker_prs) + lines.append( + f"Open PR(s) with the same head commit also target a protected " + f"branch: {listed}." + ) + if sibling_list_incomplete: + lines.append( + "The full list of PRs sharing this commit could not be verified — " + "more may exist." + ) + lines.append( + "\nA success status here would also satisfy their required check " + "without review, so this check stays `pending` until those PRs are " + "closed or re-targeted." + ) + + if approvers: + lines.append("\n### Approvers\n") + for approver in sorted(approvers): + lines.append(f"- @{approver}") + lines.append("") + + if stale_approvals: + lines.append("\n### Stale Approvals\n") + lines.append("The following approvals need to be re-submitted:\n") + for stale in stale_approvals: + lines.append(f"- @{stale['user']} (approved `{stale['sha'][:12]}`)") + lines.append("") + + if not has_enough: + lines.append("\n### How to Approve\n") + lines.append("1. **Submit a GitHub review** with 'Approve', or") + lines.append("2. **Comment:**") + lines.append(f"```\n/approve {short_sha}\n```") + lines.append( + f"\n> **Note:** If you requested this change, your approval counts as " + f"one of the {REQUIRED_APPROVALS} required approvals." + ) + + lines.append("\n---\n") + lines.append(f"[Learn more about this check]({DOCS_URL})") + + return "\n".join(lines) + + +def generate_stale_notification(stale_approvals: list[dict], latest_sha: str) -> str: + short_sha = latest_sha[:12] + users = ", ".join(f"@{a['user']}" for a in stale_approvals) + return f"""{STALE_MARKER} +{users}: Your previous `/approve` has become stale because new commits were pushed (head is now `{short_sha}`). + +Please re-approve: +``` +/approve {short_sha} +```""" + + +# --- GitHub Client --- + + +class GitHubClient: + """GitHub API client optimized for minimal API calls. + + Uses GraphQL for reads and batched writes, REST only for commit status. + """ + + def __init__(self, token: str, repo: str): + self.token = token + self.repo = repo + parts = repo.split("/") + if len(parts) != 2: + raise ValueError(f"Invalid repo format, expected 'owner/repo': {repo}") + self.owner, self.repo_name = parts + self._write_permission_cache: dict[str, bool] = {} + self.base_url = "https://api.github.com" + self.graphql_url = "https://api.github.com/graphql" + self.headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + + @retry( + retry=retry_if_exception_type((httpx.RequestError, httpx.HTTPStatusError)), + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=2, max=10), + reraise=True, + ) + def _graphql(self, query: str, variables: dict | None = None) -> dict: + response = httpx.post( + self.graphql_url, + headers=self.headers, + json={"query": query, "variables": variables or {}}, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + if "errors" in data: + # Log rate limit even on errors - helps debug rate limit issues + log_rate_limit(data.get("rateLimit"), "GraphQL error") + errors = data["errors"] + error_messages = [e.get("message", str(e)) for e in errors] + raise RuntimeError(f"GraphQL errors: {error_messages}") + + return data.get("data", {}) + + @retry( + retry=retry_if_exception(_retryable_http_error), + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=2, max=10), + reraise=True, + ) + def _rest_request( + self, method: str, path: str, context: str = "rest", **kwargs + ) -> httpx.Response: + url = f"{self.base_url}{path}" + response = httpx.request( + method, url, headers=self.headers, timeout=30, **kwargs + ) + response.raise_for_status() + log_rest_rate_limit(response, context) + return response + + def has_write_permission(self, login: str) -> bool: + """True if `login` has write/maintain/admin on this repo. + + Cached per login so repeated reviews/comments from the same user cost + one REST call. 404 (not a collaborator) and read/triage return False. + """ + key = login.lower() + if key in self._write_permission_cache: + return self._write_permission_cache[key] + path = f"/repos/{self.owner}/{self.repo_name}/collaborators/{login}/permission" + try: + response = self._rest_request( + "GET", path, context="collaborator-permission" + ) + except httpx.HTTPStatusError as e: + if e.response is not None and e.response.status_code == 404: + logger.info("Permission check: %s is not a collaborator", login) + self._write_permission_cache[key] = False + return False + raise + permission = (response.json() or {}).get("permission", "") + result = permission in WRITE_PERMISSION_LEVELS + logger.info( + "Permission check: %s -> %s (%s)", + login, + permission, + "write" if result else "no-write", + ) + self._write_permission_cache[key] = result + return result + + def fetch_pr_data(self, pr_number: int) -> PRData: + """Fetch all PR data in a single GraphQL call.""" + logger.info("Fetching PR #%d data via GraphQL", pr_number) + + data = self._graphql( + GRAPHQL_PR_QUERY, + {"owner": self.owner, "repo": self.repo_name, "prNumber": pr_number}, + ) + + log_rate_limit(data.get("rateLimit"), "GraphQL read") + + repository = data.get("repository") or {} + pr_data = repository.get("pullRequest") + if not pr_data: + raise RuntimeError(f"PR #{pr_number} not found") + + # Authoritative head commit. commits(last:1) is usually the head, but + # headRefOid is the branch tip GitHub actually merges and attaches + # statuses to, so it's used everywhere a head SHA is needed. + head_ref_oid = pr_data.get("headRefOid") or "" + + # GraphQL can return an explicit `null` for a connection on a + # partial/retried response. For commits/reviews/comments/headCommit, + # acting on empty data is fail-open (missed agent activity, dropped + # /approve comments, missed same-SHA sibling PRs), so raise and let + # the workflow fail closed. For files, empty data is safe: + # is_review_exempt_pr fails closed on an empty file list. + for name in ("commits", "reviews", "comments", "headCommit"): + conn = pr_data.get(name) + if conn is None or conn.get("nodes") is None: + raise RuntimeError( + f"GraphQL returned null for {name!r} connection (partial response)" + ) + commits_conn = pr_data["commits"] + reviews_conn = pr_data["reviews"] + comments_conn = pr_data["comments"] + files_conn = pr_data.get("files") or {} + + # Check pagination - using `last:` so we check hasPreviousPage + # For commits: fail-closed if incomplete (security concern) + # For reviews/comments: just warn (old items likely stale) + + commits_incomplete = (commits_conn.get("pageInfo") or {}).get( + "hasPreviousPage", False + ) + if commits_incomplete: + logger.warning( + "PR has >100 commits - cannot verify all commits, will require approval" + ) + if (reviews_conn.get("pageInfo") or {}).get("hasPreviousPage"): + logger.warning("PR has more than 100 reviews - some may be missed") + + commit_nodes = commits_conn["nodes"] + + # Normalize to REST-like format + commits = [ + { + "sha": (node.get("commit") or {}).get("oid", ""), + "commit": { + "committer": { + "email": ( + (node.get("commit") or {}).get("committer") or {} + ).get("email", ""), + }, + "signature": (node.get("commit") or {}).get("signature"), + }, + } + for node in commit_nodes + ] + + reviews = [ + { + "user": {"login": normalize_graphql_login(node.get("author"))}, + "author_association": node.get("authorAssociation", ""), + "state": node.get("state", ""), + "commit_id": (node.get("commit") or {}).get("oid", ""), + "submitted_at": node.get("submittedAt", ""), + } + for node in reviews_conn["nodes"] + ] + + def normalize_comment(node: dict) -> dict: + return { + "id": node.get("databaseId", 0), + "node_id": node.get("id", ""), + "user": {"login": normalize_graphql_login(node.get("author"))}, + "author_association": node.get("authorAssociation", ""), + "body": node.get("body", ""), + "is_minimized": node.get("isMinimized", False), + } + + comments = [normalize_comment(node) for node in comments_conn["nodes"]] + + # Paginate the full comment history. The notification comment (and any + # earlier /approve commands) can fall outside the most-recent-100 window + # on long-lived PRs; without the full set we'd post duplicate sticky + # comments and silently drop valid approvals. + page_info = comments_conn.get("pageInfo") or {} + cursor = page_info.get("startCursor") + while page_info.get("hasPreviousPage") and cursor: + page = self._graphql( + GRAPHQL_COMMENTS_PAGE_QUERY, + { + "owner": self.owner, + "repo": self.repo_name, + "prNumber": pr_number, + "before": cursor, + }, + ) + page_conn = ( + ((page.get("repository") or {}).get("pullRequest") or {}).get( + "comments" + ) + or {} + ) + page_nodes = page_conn.get("nodes") + if page_nodes is None: + raise RuntimeError( + "GraphQL returned null for paginated comments (partial response)" + ) + comments = [normalize_comment(n) for n in page_nodes] + comments + page_info = page_conn.get("pageInfo") or {} + cursor = page_info.get("startCursor") + + files = [node.get("path", "") for node in (files_conn.get("nodes") or [])] + files_incomplete = (files_conn.get("pageInfo") or {}).get("hasNextPage", False) + if files_incomplete: + logger.warning("PR has >100 changed files - cannot verify all file paths") + + # Other open PRs whose head is this exact commit. Filtering on + # headRefOid keeps stacked PRs (whose branches merely contain this + # commit) out of the list — only true same-SHA siblings count. + # + # Any null along the nested path (headCommit node, commit, + # associatedPullRequests, its nodes) means the sibling list is + # unverifiable. Treating it as empty would be fail-open — the guard + # in process_pr would stamp success on the shared SHA — so mark it + # incomplete instead, which holds success at pending. Same partial- + # response class as the top-level connection check above, but + # non-lossy: the run still completes and posts a status. + same_sha_open_prs: list[tuple[int, str]] = [] + head_commit_nodes = pr_data["headCommit"]["nodes"] + head_commit = ( + (head_commit_nodes[0] or {}).get("commit") if head_commit_nodes else None + ) or {} + # `.get(k) or default` (not `.get(k, default)`) throughout: an explicit + # JSON null returns None from .get(k, default), and these values feed + # security decisions. + # commits(last:1) is date-ordered, so it can return a non-head commit; + # if its oid != headRefOid the associatedPullRequests below are for the + # wrong commit and the sibling list is unverifiable. + head_commit_oid = head_commit.get("oid") + assoc = head_commit.get("associatedPullRequests") + assoc_nodes = (assoc or {}).get("nodes") + if ( + not head_commit + or not head_ref_oid + or head_commit_oid != head_ref_oid + or assoc_nodes is None + ): + # Without the head OID the stacked-PR filter below can't tell + # siblings from stacked PRs, so the whole list is unverifiable. + same_sha_prs_incomplete = True + logger.warning( + "Head commit's associatedPullRequests unverifiable (partial " + "response or commits(last:1) != headRefOid) — treating " + "same-SHA sibling list as incomplete" + ) + else: + same_sha_prs_incomplete = ((assoc or {}).get("pageInfo") or {}).get( + "hasNextPage", False + ) + for node in assoc_nodes: + state = (node or {}).get("state") + number = (node or {}).get("number") + base_ref = (node or {}).get("baseRefName") + node_head_oid = (node or {}).get("headRefOid") + if ( + not node + or state is None + or not isinstance(number, int) + or not base_ref + or not node_head_oid + ): + # Null element or null scalar leaf — we can't tell whether + # this entry is an open protected-base sibling. Coercing + # (e.g. base_ref -> "") would silently drop it from the + # guard, which is fail-open; mark unverifiable instead. + same_sha_prs_incomplete = True + continue + if state != "OPEN" or number == pr_number: + continue + if node_head_oid != head_ref_oid: + continue + same_sha_open_prs.append((number, base_ref)) + + return PRData( + node_id=pr_data.get("id") or "", + number=pr_data.get("number") or pr_number, + head_sha=head_ref_oid, + head_ref=pr_data.get("headRefName") or "", + base_ref=pr_data.get("baseRefName") or "", + default_branch=(repository.get("defaultBranchRef") or {}).get("name") or "", + created_at=pr_data.get("createdAt") or "", + author_login=normalize_graphql_login(pr_data.get("author")), + commits=commits, + reviews=reviews, + comments=comments, + files=files, + commits_incomplete=commits_incomplete, + files_incomplete=files_incomplete, + same_sha_open_prs=same_sha_open_prs, + same_sha_prs_incomplete=same_sha_prs_incomplete, + ) + + def execute_mutation_batch(self, batch: MutationBatch) -> None: + """Execute all mutations in a single GraphQL call.""" + if batch.is_empty(): + logger.info("No mutations to execute") + return + + builder = MutationBuilder() + + for node_id, content in batch.reactions: + builder.add_reaction(node_id, content) + + if batch.create_comment: + builder.add_comment("createNotif", *batch.create_comment) + + if batch.update_comment: + builder.update_comment("updateNotif", *batch.update_comment) + + if batch.create_stale_comment: + builder.add_comment("createStale", *batch.create_stale_comment) + + for node_id, reason in batch.minimize_comments: + builder.minimize_comment(node_id, reason) + + for node_id in batch.unminimize_comments: + builder.unminimize_comment(node_id) + + result = builder.build() + if not result: + return + + logger.info( + "Executing batched mutations: %d reactions, %s notification, %s stale, %d minimize, %d unminimize", + len(batch.reactions), + "create" + if batch.create_comment + else ("update" if batch.update_comment else "none"), + "create" if batch.create_stale_comment else "none", + len(batch.minimize_comments), + len(batch.unminimize_comments), + ) + + mutation, variables = result + data = self._graphql(mutation, variables) + log_rate_limit(data.get("rateLimit"), "GraphQL write") + + def create_commit_status( + self, + sha: str, + state: str, + context: str, + description: str, + target_url: str | None = None, + ) -> dict: + """Create commit status via REST (no GraphQL equivalent).""" + payload: dict = {"state": state, "context": context, "description": description} + if target_url: + payload["target_url"] = target_url + return self._rest_request( + "POST", + f"/repos/{self.repo}/statuses/{sha}", + context="commit-status", + json=payload, + ).json() + + +# --- Main Processing --- + + +def get_workflow_run_url(repo: str) -> str | None: + run_id = os.environ.get("GITHUB_RUN_ID") + if run_id: + return f"https://github.com/{repo}/actions/runs/{run_id}" + return None + + +# Status description format includes SHA for cache detection +def format_status_description(message: str, head_sha: str) -> str: + """Format status description with SHA suffix for traceability. + + GitHub rejects commit-status descriptions over 140 characters; clamp the + message rather than letting the status POST fail. + """ + suffix = f" [{head_sha[:12]}]" + max_message = 140 - len(suffix) + if len(message) > max_message: + message = message[: max_message - 1] + "…" + return f"{message}{suffix}" + + +def process_pr( + client: GitHubClient, + pr_number: int, + config: AgentConfig, +) -> None: + """Process a single PR for agent approval check.""" + logger.info("Processing PR #%d", pr_number) + run_url = get_workflow_run_url(client.repo) + + # === GraphQL read === + pr_data = client.fetch_pr_data(pr_number) + + if not is_protected_base( + pr_data.base_ref, config, client.repo, pr_data.default_branch + ): + # Refuse to post any status for a PR that doesn't target a protected + # base — commit statuses are SHA-scoped, so a success here would also + # satisfy the required check on a sibling PR from the same head SHA + # that does target a protected base. + logger.info( + "PR #%d targets %r, which is not a protected base for %s — " + "not posting a status", + pr_number, + pr_data.base_ref, + client.repo, + ) + return + + if not pr_data.commits: + logger.info("No commits found in PR") + return + + head_sha = pr_data.head_sha + if not head_sha: + logger.error("Could not determine HEAD SHA") + return + + # Statuses are SHA-scoped: a success posted here also satisfies the + # required check on any other open protected-base PR whose head is this + # same commit, even though that PR's base-relative diff was never + # evaluated. Hold success at pending until those siblings are closed or + # re-targeted (rare in practice — a handful of same-branch hotfix pairs + # per month). + open_protected_siblings = sorted( + number + for number, base_ref in pr_data.same_sha_open_prs + if number != pr_number + and is_protected_base(base_ref, config, client.repo, pr_data.default_branch) + ) + + sibling_blocker = bool(open_protected_siblings) or pr_data.same_sha_prs_incomplete + + def sibling_blocker_message() -> str: + if not open_protected_siblings: + return "Cannot list PRs sharing this commit — holding at pending" + listed = ", ".join(f"#{n}" for n in open_protected_siblings[:3]) + if len(open_protected_siblings) > 3: + listed += f" +{len(open_protected_siblings) - 3} more" + note = " (list may be incomplete)" if pr_data.same_sha_prs_incomplete else "" + return ( + f"Sibling PR(s) {listed}{note} share this commit — close or re-target them" + ) + + def post_status(state: str, message: str) -> None: + if state == "success" and sibling_blocker: + message = sibling_blocker_message() + logger.warning( + "Withholding success for PR #%d: open protected-base PRs %s share head %s", + pr_number, + open_protected_siblings or "(unverified)", + head_sha, + ) + state = "pending" + client.create_commit_status( + sha=head_sha, + state=state, + context=CHECK_NAME, + description=format_status_description(message, head_sha), + target_url=run_url, + ) + logger.info("Set status: %s — %s", state, message) + + if is_review_exempt_pr(pr_data, config, client.repo): + post_status("success", "Review-exempt PR") + logger.info("Review-exempt PR") + return + + if is_exempt_branch(pr_data.head_ref, config): + post_status("success", "Exempt branch") + logger.info("Exempt branch '%s'", pr_data.head_ref) + return + + # If we couldn't fetch all commits, fail-closed (require approval) + # This prevents an attacker from hiding agent commits beyond the 100 limit + if pr_data.commits_incomplete: + result = AgentActivityResult( + has_agent_activity=True, + latest_agent_commit=pr_data.commits[-1] if pr_data.commits else None, + detection_reason=( + "⚠️ PR has >100 commits — cannot verify all commits are human-authored, " + "so approval is required as a security precaution" + ), + ) + else: + # Check for agent activity + result = check_for_agent_activity( + pr_data.commits, + pr_data.author_login, + config, + reviews=pr_data.reviews, + ) + + if not result.has_agent_activity: + # No agent activity - set success status directly + # === REST commit status === + post_status("success", "No agent activity") + logger.info("No agent activity detected") + return + + logger.info("Agent activity detected. Head SHA: %s", head_sha) + + # Count approvals. The PR author counts as one approver (via /approve + # comment) like anyone else with write access — they cannot single-handedly + # satisfy the threshold. + approvers = count_approvers( + head_sha, + pr_data.reviews, + pr_data.comments, + config, + client.has_write_permission, + ) + stale_approvals = find_stale_approvals( + pr_data.comments, + head_sha, + config, + pr_data.commits, + client.has_write_permission, + current_approvers=approvers, + ) + + approver_count = len(approvers) + has_enough = approver_count >= REQUIRED_APPROVALS + + # Build mutation batch + batch = MutationBatch() + + # Collect reactions for valid /approve comments + collect_approval_reactions( + batch, pr_data.comments, head_sha, config, client.has_write_permission + ) + + # Prepare notification comment. The sibling blocker is threaded in so the + # comment never claims "approved" while post_status holds the required + # check at pending for sibling PRs sharing this head commit. + comment_body = generate_notification_comment( + approvers=approvers, + stale_approvals=stale_approvals, + detection_reason=result.detection_reason, + head_sha=head_sha, + sibling_blocker_prs=open_protected_siblings or None, + sibling_list_incomplete=pr_data.same_sha_prs_incomplete, + ) + + existing_comment = find_notification_comment(pr_data.comments) + if existing_comment: + batch.update_comment = (existing_comment["node_id"], comment_body) + # Minimize when approved (including author-approved) — once the check + # succeeds, the remaining gate is a normal GitHub review which shows + # natively in the UI. The bot comment is just noise at that point. + # Never minimize while a sibling blocker holds the status at pending: + # the comment carries the only explanation of why. + if ( + has_enough + and not sibling_blocker + and not existing_comment.get("is_minimized") + ): + batch.minimize_comments.append((existing_comment["node_id"], "RESOLVED")) + elif (not has_enough or sibling_blocker) and existing_comment.get( + "is_minimized" + ): + batch.unminimize_comments.append(existing_comment["node_id"]) + else: + batch.create_comment = (pr_data.node_id, comment_body) + + # Handle stale notifications + if stale_approvals and not has_enough: + # Minimize old stale notifications + for old_comment in find_old_stale_notifications(pr_data.comments, head_sha): + if not old_comment.get("is_minimized"): + batch.minimize_comments.append((old_comment["node_id"], "OUTDATED")) + + # Create new stale notification if not exists + if not find_stale_notification_for_commit(pr_data.comments, head_sha): + stale_body = generate_stale_notification(stale_approvals, head_sha) + batch.create_stale_comment = (pr_data.node_id, stale_body) + + # === GraphQL batch write === + client.execute_mutation_batch(batch) + + # === REST commit status === + if has_enough: + post_status("success", f"{approver_count}/{REQUIRED_APPROVALS} approvals") + else: + post_status( + "pending", + f"Need {REQUIRED_APPROVALS} approvals (have {approver_count})", + ) + + logger.info("Approvals: %d/%d", approver_count, REQUIRED_APPROVALS) + + +def main() -> None: + """Main entry point.""" + token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") + if not token: + logger.error("GH_TOKEN not set") + sys.exit(1) + + repo = os.environ.get("GH_REPOSITORY") or os.environ.get("GITHUB_REPOSITORY") + if not repo: + logger.error("GH_REPOSITORY not set") + sys.exit(1) + + config = load_agent_config_from_env(repo) + logger.info( + "Agent config: emails=%s app_logins=%s excluded_approvers=%s " + "exempt_path_prefixes[%s]=%s required_approvals=%d", + config.agent_emails, + config.agent_app_logins, + config.excluded_approver_logins, + repo, + config.exempt_path_prefixes.get(repo, []), + REQUIRED_APPROVALS, + ) + if not config.agent_emails and not config.agent_app_logins: + logger.error( + "No agent identities configured (agent_emails / agent_logins are empty)" + ) + sys.exit(1) + if REQUIRED_APPROVALS < 1: + logger.error("REQUIRED_APPROVALS must be >= 1 (got %d)", REQUIRED_APPROVALS) + sys.exit(1) + + pr_number_str = os.environ.get("GH_PR_NUMBER", "").strip() + if pr_number_str: + pr_number: int | None = int(pr_number_str) + else: + event_name = os.environ.get("GH_EVENT_NAME") or os.environ.get( + "GITHUB_EVENT_NAME", "" + ) + event_path = os.environ.get("GH_EVENT_PATH") or os.environ.get( + "GITHUB_EVENT_PATH", "" + ) + if not event_name or not event_path: + logger.error("Neither GH_PR_NUMBER nor GH_EVENT_NAME/PATH are set") + sys.exit(1) + pr_number = resolve_pr_number(event_name, event_path) + if pr_number is None: + logger.info("Event %s is not associated with a PR — nothing to do", event_name) + return + + pr_number = select_pr_candidate( + pr_number, os.environ.get("GH_PR_CANDIDATES", ""), config, repo + ) + + client = GitHubClient(token, repo) + process_pr(client, pr_number, config) + + +if __name__ == "__main__": + main() diff --git a/base-action/action.yml b/base-action/action.yml index 2946fa4..70de492 100644 --- a/base-action/action.yml +++ b/base-action/action.yml @@ -145,7 +145,7 @@ runs: PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }} run: | if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then - CLAUDE_CODE_VERSION="2.1.175" + CLAUDE_CODE_VERSION="2.1.206" echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..." for attempt in 1 2 3; do echo "Installation attempt $attempt..." diff --git a/base-action/bun.lock b/base-action/bun.lock index db54871..5ac222e 100644 --- a/base-action/bun.lock +++ b/base-action/bun.lock @@ -5,7 +5,7 @@ "name": "@step-security/claude-code-base-action", "dependencies": { "@actions/core": "^2.0.3", - "@anthropic-ai/claude-agent-sdk": "^0.3.175", + "@anthropic-ai/claude-agent-sdk": "^0.3.206", "axios": "^1.16.1", "shell-quote": "^1.8.4", }, @@ -27,23 +27,23 @@ "@actions/io": ["@actions/io@2.0.0", "", {}, "sha512-Jv33IN09XLO+0HS79aaODsvIRyduiF7NY/F6LYeK5oeUmrsz7aFdRphQjFoESF4jS7lMauDOttKALcpapVDIAg=="], - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.175", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.175", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.175", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.175", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.175", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.175", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.175", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.175", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.175" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-RAuqHadT+JJqkUC0DsOHIivxTbe1+5Zu02SfIeJoxF1fNS/wazDCVGCmIMPQIRZ+d6HedV047tH7oYhRc2D1bQ=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.206", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.206", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.206", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.206", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.206", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.206", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.206", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.206", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.206" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-KljDh9Pg4YCYpoXS8dnWoVSsOHtU4yLCW268K2iOruSxFXE8/Tay6DPvmJzYuqjP5YLNYfj05ZGykwZSUn6GXA=="], - "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.175", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ud/25HB7esWldzXwGaa+gK8/+A1dZf6yJ5HCKCJN7BMFFJdbCe28pwCwoh9zE+5imNSuXtlqSRDMuxa2fPsYGw=="], + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.206", "", { "os": "darwin", "cpu": "arm64" }, "sha512-FL2+NKcMMN47vcnCW2Fkt3AOgeRRlQxrisbPNaxrxqPJFzhUKs17x5j0XzLefd0xRbDAr74hd0PK/tnp6PHM6w=="], - "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.175", "", { "os": "darwin", "cpu": "x64" }, "sha512-QLd1FCTtLb0peWqIIf/FTNQI/pSn/kFdy+SuxFbodPaHB0gehDhoFZ6ADm2HLS83tWxqGQAa0G5cHstkiuDzNQ=="], + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.206", "", { "os": "darwin", "cpu": "x64" }, "sha512-mRW8PPMfQN15EunLwpdmcVzk3XuM4DXQUM8DOzaeA1Hr1yxYPaVVBLr9hkdBKOqr8XTl3ueoTR6RZAy6a/n7OA=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.175", "", { "os": "linux", "cpu": "arm64" }, "sha512-mvCJ37aecg2dfzS8XZbwOfcmA45RFXUZwN84nXiKMnZFazZ6hn7daMmHlCXSp9zV0NpxbizLIb6SmmHgjefHzg=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.206", "", { "os": "linux", "cpu": "arm64" }, "sha512-Id6H8l6EsGb7849EAZDOB4Ic+FQpbzt4D5HGOwp59CTW3o/1c4etjQA/Kl1K+DSEWn3FGo6D5UMVgc/rwhBj8g=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.175", "", { "os": "linux", "cpu": "arm64" }, "sha512-2FKNFy6JxIgYXitZ7ARO5wxQWHdDhmw3O+RkuohshPQ+10n5Zf0CpX7Lx2Vq2vz0DFRCiY9cYiPHf8hAI7ZWWg=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.206", "", { "os": "linux", "cpu": "arm64" }, "sha512-aMZe1Kl+kYv5QlA15W9Ae25MAzBsA9FA40f5TOtJebA+M/xliF0r2LLb2NdyBviiZCDllcE31l0zgYE0rQqFQw=="], - "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.175", "", { "os": "linux", "cpu": "x64" }, "sha512-vymQcmn39+BQ8JYwUrafPqgxbpMFBGLfV7PPIxQSsi3z4iBwciW4csb7KwpRaehODa3sD69HruAFpOUkJfMkzA=="], + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.206", "", { "os": "linux", "cpu": "x64" }, "sha512-egZhOC1RlEVhZyq6Oa1b04AF7hh4hO+8oCsJCZ3gOifJQuHih1oW3lZ8fOUxU85jlT2ytAnt5kRp4uQr6PJgbg=="], - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.175", "", { "os": "linux", "cpu": "x64" }, "sha512-4YUpjcLbDqTYIuvb7gLWVRM3J9CiTisuiEMnckv8lrBWhj5AN0ULLG5pWTOxw4Pzsbja5pAdf9UMqzsKFiukUw=="], + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.206", "", { "os": "linux", "cpu": "x64" }, "sha512-xQBOBhlcmTNc7YeYT5qLZOikpSq3WHlsJ/t6i7kJqUWrcXlOPaAoSMdKp5xO/V0CjAdzdJoWB88I55UAh8mclQ=="], - "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.175", "", { "os": "win32", "cpu": "arm64" }, "sha512-PwiduMKtisfEQRH8KP6bQ7T+XTC1yNFutrN3v1wQS7BuNTm2bPdXFkW97++OcjW5H4RgdVibIzQZ1V12Hcy4EA=="], + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.206", "", { "os": "win32", "cpu": "arm64" }, "sha512-oRQk23bFXSz4QRhOxqnnvlLWq/KiF2PtSBYXrMg1AQrCOHJd2k66aOAS7AJ4ZSzejiyV4N6sS6UThfmF6ipHBQ=="], - "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.175", "", { "os": "win32", "cpu": "x64" }, "sha512-II4yfIrKCrscig918R6hEOvqEejX56nH8+NI9KvGY47g0rZnPgGgXZCQrRC5ZgRihqNiwF1i6faJxvmmOEx5Rw=="], + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.206", "", { "os": "win32", "cpu": "x64" }, "sha512-BdjKmDojZjc5RjN+8Q6K7Yqf1WYhel6I3DkMXc9zdxS/xIuN42sx7zgQpdMGY73pm7ROPLqo3LieOeMhVH161w=="], "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.104.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-gGACa/+IaiXzRRmF96aOhamoBgapKRBiFWbmmTFP8aMkpaEcuStF+Q61bjo4vPxBM7gqWJNZqsngslRdnLHv0Q=="], diff --git a/base-action/package.json b/base-action/package.json index a3185a7..a598647 100644 --- a/base-action/package.json +++ b/base-action/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@actions/core": "^2.0.3", - "@anthropic-ai/claude-agent-sdk": "^0.3.175", + "@anthropic-ai/claude-agent-sdk": "^0.3.206", "axios": "^1.16.1", "shell-quote": "^1.8.4" }, diff --git a/base-action/src/parse-sdk-options.ts b/base-action/src/parse-sdk-options.ts index ec65b8f..e109509 100644 --- a/base-action/src/parse-sdk-options.ts +++ b/base-action/src/parse-sdk-options.ts @@ -19,11 +19,41 @@ const ACCUMULATING_FLAGS = new Set([ "disallowedTools", "disallowed-tools", "mcp-config", + "add-dir", ]); // Delimiter used to join accumulated flag values const ACCUMULATE_DELIMITER = "\x00"; +// shell-quote treats ()|&;<> as control operators and splits adjacent text +// around them into separate tokens (returned as `{op}` objects, which we then +// dropped). For CLI args these must be literal characters — e.g. unquoted +// `--allowedTools Bash(gh:*)` was being mangled into bare `Bash`, silently +// widening a scoped permission rule to Bash(*). We escape each metachar to a +// Unicode private-use codepoint before parsing and restore it afterward, +// keeping shell-quote's quote/whitespace handling intact. +const SHELL_META_PAIRS: [string, string][] = [ + ["(", ""], + [")", ""], + ["|", ""], + ["&", ""], + [";", ""], + ["<", ""], + [">", ""], +]; +const SHELL_META_ESCAPE = new Map(SHELL_META_PAIRS); +const SHELL_META_UNESCAPE = new Map(SHELL_META_PAIRS.map(([k, v]) => [v, k])); +const SHELL_META_ESCAPE_RE = /[()|&;<>]/g; +const SHELL_META_UNESCAPE_RE = /[-]/g; + +function escapeShellMeta(s: string): string { + return s.replace(SHELL_META_ESCAPE_RE, (c) => SHELL_META_ESCAPE.get(c)!); +} + +function unescapeShellMeta(s: string): string { + return s.replace(SHELL_META_UNESCAPE_RE, (c) => SHELL_META_UNESCAPE.get(c)!); +} + type McpConfig = { mcpServers?: Record; }; @@ -106,9 +136,19 @@ function parseClaudeArgsToExtraArgs( if (!claudeArgs?.trim()) return {}; const result: Record = {}; - const args = parseShellArgs(stripShellComments(claudeArgs)).filter( - (arg): arg is string => typeof arg === "string", - ); + const args = parseShellArgs(escapeShellMeta(stripShellComments(claudeArgs))) + .map((arg) => { + if (typeof arg === "string") return unescapeShellMeta(arg); + // With control metachars escaped above, the only non-string shell-quote + // can still emit is a glob op (bareword containing *, ?, or [). Its + // `pattern` field is the verbatim token text — use it as-is so values + // like `Bash(cmd:*)` and `Read(path/**)` round-trip intact. + if (typeof arg === "object" && arg !== null && "pattern" in arg) { + return unescapeShellMeta((arg as { pattern: string }).pattern); + } + return undefined; + }) + .filter((arg): arg is string => typeof arg === "string"); for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -161,6 +201,14 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions { // Detect if --json-schema is present (for hasJsonSchema flag) const hasJsonSchema = "json-schema" in extraArgs; + const additionalDirectories = extraArgs["add-dir"] + ? extraArgs["add-dir"] + .split(ACCUMULATE_DELIMITER) + .map((dir) => dir.trim()) + .filter(Boolean) + : []; + delete extraArgs["add-dir"]; + // Extract and merge allowedTools from all sources: // 1. From extraArgs (parsed from claudeArgs - contains tag mode's tools) // - Check both camelCase (--allowedTools) and hyphenated (--allowed-tools) variants @@ -265,6 +313,8 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions { systemPrompt, fallbackModel: options.fallbackModel, pathToClaudeCodeExecutable: options.pathToClaudeCodeExecutable, + additionalDirectories: + additionalDirectories.length > 0 ? additionalDirectories : undefined, // Pass through claudeArgs as extraArgs - CLI handles --mcp-config, --json-schema, etc. // Note: allowedTools and disallowedTools have been removed from extraArgs to prevent duplicates diff --git a/base-action/test/parse-sdk-options.test.ts b/base-action/test/parse-sdk-options.test.ts index c74d98e..924d7fb 100644 --- a/base-action/test/parse-sdk-options.test.ts +++ b/base-action/test/parse-sdk-options.test.ts @@ -137,6 +137,110 @@ describe("parseSdkOptions", () => { ]); }); + test("should preserve unquoted Bash(cmd:*) rules instead of collapsing to bare Bash", () => { + // Regression: shell-quote tokenizes unquoted `(`/`)` as control ops and + // `*` as a glob, which were filtered out — collapsing scoped rules like + // `Bash(gh:*)` into bare `Bash` (= Bash(*), unrestricted shell). + const options: ClaudeOptions = { + claudeArgs: "--allowedTools View,Bash(gh:*),Bash(cat:*)", + }; + + const result = parseSdkOptions(options); + + expect(result.sdkOptions.allowedTools).toEqual([ + "View", + "Bash(gh:*)", + "Bash(cat:*)", + ]); + expect(result.sdkOptions.allowedTools).not.toContain("Bash"); + }); + + test("should preserve unquoted space-separated Bash(cmd:*) rules", () => { + const options: ClaudeOptions = { + claudeArgs: "--allowed-tools Bash(gh:*) Bash(cat:*) Read(//tmp/**)", + }; + + const result = parseSdkOptions(options); + + expect(result.sdkOptions.allowedTools).toEqual([ + "Bash(gh:*)", + "Bash(cat:*)", + "Read(//tmp/**)", + ]); + expect(result.sdkOptions.allowedTools).not.toContain("Bash"); + }); + + test("should preserve unquoted Tool(content) rules without glob chars", () => { + const options: ClaudeOptions = { + claudeArgs: + "--allowedTools Read(~/file),WebFetch(domain:example.com),Edit", + }; + + const result = parseSdkOptions(options); + + expect(result.sdkOptions.allowedTools).toEqual([ + "Read(~/file)", + "WebFetch(domain:example.com)", + "Edit", + ]); + }); + + test("should still preserve quoted Bash(cmd:*) rules (no regression)", () => { + const options: ClaudeOptions = { + claudeArgs: '--allowedTools "Bash(gh:*),Bash(cat:*)"', + }; + + const result = parseSdkOptions(options); + + expect(result.sdkOptions.allowedTools).toEqual([ + "Bash(gh:*)", + "Bash(cat:*)", + ]); + }); + + test("should merge quoted tag-mode tools with unquoted user tools without widening", () => { + // Real-world shape: the action's tag mode wraps its own --allowedTools in + // double quotes, then appends the user's claude_args (typically unquoted + // in workflow YAML). Both halves must round-trip. + const options: ClaudeOptions = { + claudeArgs: + '--permission-mode acceptEdits --allowedTools "Glob,Grep,Read,Bash(git add:*),Bash(git commit:*)" ' + + "--model claude-opus-4-7\n" + + "--allowedTools View,Bash(gh:*),Bash(printf:*),Bash(cat:*)", + }; + + const result = parseSdkOptions(options); + + expect(result.sdkOptions.allowedTools).toEqual([ + "Glob", + "Grep", + "Read", + "Bash(git add:*)", + "Bash(git commit:*)", + "View", + "Bash(gh:*)", + "Bash(printf:*)", + "Bash(cat:*)", + ]); + expect(result.sdkOptions.allowedTools).not.toContain("Bash"); + }); + + test("should preserve unquoted disallowedTools rules without widening", () => { + // Same bug class on the deny side: a scoped deny collapsing to bare + // `Bash` would block all shell instead of the intended prefix. + const options: ClaudeOptions = { + claudeArgs: "--disallowedTools Bash(rm:*),Bash(sudo:*)", + }; + + const result = parseSdkOptions(options); + + expect(result.sdkOptions.disallowedTools).toEqual([ + "Bash(rm:*)", + "Bash(sudo:*)", + ]); + expect(result.sdkOptions.disallowedTools).not.toContain("Bash"); + }); + test("should handle mixed camelCase and hyphenated allowedTools flags", () => { const options: ClaudeOptions = { claudeArgs: '--allowedTools "Edit,Read" --allowed-tools "Write,Glob"', @@ -298,6 +402,45 @@ describe("parseSdkOptions", () => { }); }); + describe("add-dir handling", () => { + test("should accumulate multiple add-dir flags into additionalDirectories", () => { + const options: ClaudeOptions = { + claudeArgs: '--add-dir "/path/to/dir-a"\n--add-dir "/path/to/dir-b"', + }; + + const result = parseSdkOptions(options); + + expect(result.sdkOptions.additionalDirectories).toEqual([ + "/path/to/dir-a", + "/path/to/dir-b", + ]); + expect(result.sdkOptions.extraArgs?.["add-dir"]).toBeUndefined(); + }); + + test("should map a single add-dir flag to additionalDirectories", () => { + const options: ClaudeOptions = { + claudeArgs: '--add-dir "/path/to/dir"', + }; + + const result = parseSdkOptions(options); + + expect(result.sdkOptions.additionalDirectories).toEqual(["/path/to/dir"]); + expect(result.sdkOptions.extraArgs?.["add-dir"]).toBeUndefined(); + }); + + test("should preserve other extraArgs when extracting add-dir", () => { + const options: ClaudeOptions = { + claudeArgs: '--model "claude-3-5-sonnet" --add-dir "/path/to/dir"', + }; + + const result = parseSdkOptions(options); + + expect(result.sdkOptions.additionalDirectories).toEqual(["/path/to/dir"]); + expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-3-5-sonnet"); + expect(result.sdkOptions.extraArgs?.["add-dir"]).toBeUndefined(); + }); + }); + describe("other extraArgs passthrough", () => { test("should pass through json-schema in extraArgs", () => { const options: ClaudeOptions = { diff --git a/bun.lock b/bun.lock index d71936e..409a824 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "dependencies": { "@actions/core": "^2.0.3", "@actions/github": "^6.0.1", - "@anthropic-ai/claude-agent-sdk": "^0.3.175", + "@anthropic-ai/claude-agent-sdk": "^0.3.206", "@modelcontextprotocol/sdk": "^1.29.0", "@octokit/graphql": "^8.2.2", "@octokit/rest": "^21.1.1", @@ -37,23 +37,23 @@ "@actions/io": ["@actions/io@2.0.0", "", {}, "sha512-Jv33IN09XLO+0HS79aaODsvIRyduiF7NY/F6LYeK5oeUmrsz7aFdRphQjFoESF4jS7lMauDOttKALcpapVDIAg=="], - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.175", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.175", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.175", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.175", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.175", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.175", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.175", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.175", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.175" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-RAuqHadT+JJqkUC0DsOHIivxTbe1+5Zu02SfIeJoxF1fNS/wazDCVGCmIMPQIRZ+d6HedV047tH7oYhRc2D1bQ=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.206", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.206", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.206", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.206", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.206", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.206", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.206", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.206", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.206" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-KljDh9Pg4YCYpoXS8dnWoVSsOHtU4yLCW268K2iOruSxFXE8/Tay6DPvmJzYuqjP5YLNYfj05ZGykwZSUn6GXA=="], - "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.175", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ud/25HB7esWldzXwGaa+gK8/+A1dZf6yJ5HCKCJN7BMFFJdbCe28pwCwoh9zE+5imNSuXtlqSRDMuxa2fPsYGw=="], + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.206", "", { "os": "darwin", "cpu": "arm64" }, "sha512-FL2+NKcMMN47vcnCW2Fkt3AOgeRRlQxrisbPNaxrxqPJFzhUKs17x5j0XzLefd0xRbDAr74hd0PK/tnp6PHM6w=="], - "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.175", "", { "os": "darwin", "cpu": "x64" }, "sha512-QLd1FCTtLb0peWqIIf/FTNQI/pSn/kFdy+SuxFbodPaHB0gehDhoFZ6ADm2HLS83tWxqGQAa0G5cHstkiuDzNQ=="], + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.206", "", { "os": "darwin", "cpu": "x64" }, "sha512-mRW8PPMfQN15EunLwpdmcVzk3XuM4DXQUM8DOzaeA1Hr1yxYPaVVBLr9hkdBKOqr8XTl3ueoTR6RZAy6a/n7OA=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.175", "", { "os": "linux", "cpu": "arm64" }, "sha512-mvCJ37aecg2dfzS8XZbwOfcmA45RFXUZwN84nXiKMnZFazZ6hn7daMmHlCXSp9zV0NpxbizLIb6SmmHgjefHzg=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.206", "", { "os": "linux", "cpu": "arm64" }, "sha512-Id6H8l6EsGb7849EAZDOB4Ic+FQpbzt4D5HGOwp59CTW3o/1c4etjQA/Kl1K+DSEWn3FGo6D5UMVgc/rwhBj8g=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.175", "", { "os": "linux", "cpu": "arm64" }, "sha512-2FKNFy6JxIgYXitZ7ARO5wxQWHdDhmw3O+RkuohshPQ+10n5Zf0CpX7Lx2Vq2vz0DFRCiY9cYiPHf8hAI7ZWWg=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.206", "", { "os": "linux", "cpu": "arm64" }, "sha512-aMZe1Kl+kYv5QlA15W9Ae25MAzBsA9FA40f5TOtJebA+M/xliF0r2LLb2NdyBviiZCDllcE31l0zgYE0rQqFQw=="], - "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.175", "", { "os": "linux", "cpu": "x64" }, "sha512-vymQcmn39+BQ8JYwUrafPqgxbpMFBGLfV7PPIxQSsi3z4iBwciW4csb7KwpRaehODa3sD69HruAFpOUkJfMkzA=="], + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.206", "", { "os": "linux", "cpu": "x64" }, "sha512-egZhOC1RlEVhZyq6Oa1b04AF7hh4hO+8oCsJCZ3gOifJQuHih1oW3lZ8fOUxU85jlT2ytAnt5kRp4uQr6PJgbg=="], - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.175", "", { "os": "linux", "cpu": "x64" }, "sha512-4YUpjcLbDqTYIuvb7gLWVRM3J9CiTisuiEMnckv8lrBWhj5AN0ULLG5pWTOxw4Pzsbja5pAdf9UMqzsKFiukUw=="], + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.206", "", { "os": "linux", "cpu": "x64" }, "sha512-xQBOBhlcmTNc7YeYT5qLZOikpSq3WHlsJ/t6i7kJqUWrcXlOPaAoSMdKp5xO/V0CjAdzdJoWB88I55UAh8mclQ=="], - "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.175", "", { "os": "win32", "cpu": "arm64" }, "sha512-PwiduMKtisfEQRH8KP6bQ7T+XTC1yNFutrN3v1wQS7BuNTm2bPdXFkW97++OcjW5H4RgdVibIzQZ1V12Hcy4EA=="], + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.206", "", { "os": "win32", "cpu": "arm64" }, "sha512-oRQk23bFXSz4QRhOxqnnvlLWq/KiF2PtSBYXrMg1AQrCOHJd2k66aOAS7AJ4ZSzejiyV4N6sS6UThfmF6ipHBQ=="], - "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.175", "", { "os": "win32", "cpu": "x64" }, "sha512-II4yfIrKCrscig918R6hEOvqEejX56nH8+NI9KvGY47g0rZnPgGgXZCQrRC5ZgRihqNiwF1i6faJxvmmOEx5Rw=="], + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.206", "", { "os": "win32", "cpu": "x64" }, "sha512-BdjKmDojZjc5RjN+8Q6K7Yqf1WYhel6I3DkMXc9zdxS/xIuN42sx7zgQpdMGY73pm7ROPLqo3LieOeMhVH161w=="], "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.104.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-gGACa/+IaiXzRRmF96aOhamoBgapKRBiFWbmmTFP8aMkpaEcuStF+Q61bjo4vPxBM7gqWJNZqsngslRdnLHv0Q=="], diff --git a/examples/agent-approval-check.yml b/examples/agent-approval-check.yml new file mode 100644 index 0000000..fb6f51f --- /dev/null +++ b/examples/agent-approval-check.yml @@ -0,0 +1,39 @@ +# Require human approvals on PRs that contain agent-authored commits. +# +# Both triggers run the workflow file from the BASE/DEFAULT branch, so a PR +# cannot edit this check to approve itself. (`pull_request_review` is not +# used because it runs from the merge ref, not the default branch; native +# Approve reviews are picked up on the next synchronize or `/approve` +# comment.) +# +# After adding this workflow, mark `agent-approval-check` as a required +# status check on your protected branches. + +name: agent-approval-check + +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review] + issue_comment: + types: [created] + +permissions: + contents: read + pull-requests: write + statuses: write + +jobs: + check: + # issue_comment also fires on plain issues; skip those early. + if: github.event_name != 'issue_comment' || github.event.issue.pull_request + runs-on: ubuntu-latest + steps: + - uses: anthropics/claude-code-action/agent-approval-check@main + with: + required_approvals: 2 + agent_emails: noreply@anthropic.com + agent_logins: claude[bot],claude-code[bot] + # Uncomment to tune: + # excluded_approvers: dependabot[bot] + # exempt_path_prefixes: docs/ + # protected_bases: main,release diff --git a/package.json b/package.json index b633d60..172a7b5 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "dependencies": { "@actions/core": "^2.0.3", "@actions/github": "^6.0.1", - "@anthropic-ai/claude-agent-sdk": "^0.3.175", + "@anthropic-ai/claude-agent-sdk": "^0.3.206", "@modelcontextprotocol/sdk": "^1.29.0", "@octokit/graphql": "^8.2.2", "@octokit/rest": "^21.1.1", diff --git a/src/create-prompt/index.ts b/src/create-prompt/index.ts index 0b7b201..c7c7ba0 100644 --- a/src/create-prompt/index.ts +++ b/src/create-prompt/index.ts @@ -122,6 +122,7 @@ export function prepareContext( // Extract trigger username and comment data based on event type let triggerUsername: string | undefined; + let triggerUserId: number | undefined; let commentId: string | undefined; let commentBody: string | undefined; @@ -129,15 +130,19 @@ export function prepareContext( commentId = context.payload.comment.id.toString(); commentBody = context.payload.comment.body; triggerUsername = context.payload.comment.user.login; + triggerUserId = context.payload.comment.user.id; } else if (isPullRequestReviewEvent(context)) { commentBody = context.payload.review.body ?? ""; triggerUsername = context.payload.review.user.login; + triggerUserId = context.payload.review.user.id; } else if (isPullRequestReviewCommentEvent(context)) { commentId = context.payload.comment.id.toString(); commentBody = context.payload.comment.body; triggerUsername = context.payload.comment.user.login; + triggerUserId = context.payload.comment.user.id; } else if (isIssuesEvent(context)) { triggerUsername = context.payload.issue.user.login; + triggerUserId = context.payload.issue.user.id; } // Create infrastructure fields object @@ -146,6 +151,7 @@ export function prepareContext( claudeCommentId, triggerPhrase, ...(triggerUsername && { triggerUsername }), + ...(triggerUserId && { triggerUserId }), ...(prompt && { prompt }), ...(claudeBranch && { claudeBranch }), }; @@ -394,9 +400,16 @@ function getCommitInstructions( context: PreparedContext, useCommitSigning: boolean, ): string { + const triggerName = githubData.triggerDisplayName ?? context.triggerUsername; + const triggerEmail = + context.triggerUserId && context.triggerUsername + ? `${context.triggerUserId}+${context.triggerUsername}@users.noreply.github.com` + : context.triggerUsername + ? `${context.triggerUsername}@users.noreply.github.com` + : undefined; const coAuthorLine = - (githubData.triggerDisplayName ?? context.triggerUsername) !== "Unknown" - ? `Co-authored-by: ${githubData.triggerDisplayName ?? context.triggerUsername} <${context.triggerUsername}@users.noreply.github.com>` + triggerName && triggerName !== "Unknown" && triggerEmail + ? `Co-authored-by: ${triggerName} <${triggerEmail}>` : ""; if (useCommitSigning) { diff --git a/src/create-prompt/types.ts b/src/create-prompt/types.ts index 27a15df..5b3dd0e 100644 --- a/src/create-prompt/types.ts +++ b/src/create-prompt/types.ts @@ -5,6 +5,7 @@ export type CommonFields = { claudeCommentId: string; triggerPhrase: string; triggerUsername?: string; + triggerUserId?: number; prompt?: string; claudeBranch?: string; }; diff --git a/src/entrypoints/run.ts b/src/entrypoints/run.ts index e8c9aa6..f1c08a7 100644 --- a/src/entrypoints/run.ts +++ b/src/entrypoints/run.ts @@ -11,7 +11,6 @@ import { dirname } from "path"; import { spawn } from "child_process"; import { appendFile } from "fs/promises"; import { existsSync, readFileSync } from "fs"; -import axios, { isAxiosError } from "axios"; import { setupGitHubToken, WorkflowValidationSkipError } from "../github/token"; import { checkWritePermissions } from "../github/validation/permissions"; import { createOctokit } from "../github/api/client"; @@ -45,6 +44,13 @@ import { runClaude } from "../../base-action/src/run-claude"; import type { ClaudeRunResult } from "../../base-action/src/run-claude-sdk"; import { setExecutionFileOutputIfPresent } from "../../base-action/src/execution-file"; +// Exported for unit testing. `set -o pipefail` makes curl's non-zero exit +// propagate through the pipe so the install retry logic actually triggers +// on 429/403 instead of silently succeeding (see #1136). +export function buildInstallCommand(version: string): string { + return `set -o pipefail; curl -fsSL https://claude.ai/install.sh | bash -s -- ${version}`; +} + /** * Install Claude Code CLI, handling retry logic and custom executable paths. * Returns the absolute path to the claude executable. @@ -69,7 +75,7 @@ async function installClaudeCode(): Promise { return customExecutable; } - const claudeCodeVersion = "2.1.175"; + const claudeCodeVersion = "2.1.206"; console.log(`Installing Claude Code v${claudeCodeVersion}...`); for (let attempt = 1; attempt <= 3; attempt++) { @@ -78,10 +84,7 @@ async function installClaudeCode(): Promise { await new Promise((resolve, reject) => { const child = spawn( "bash", - [ - "-c", - `curl -fsSL https://claude.ai/install.sh | bash -s -- ${claudeCodeVersion}`, - ], + ["-c", buildInstallCommand(claudeCodeVersion)], { stdio: "inherit" }, ); child.on("close", (code) => { @@ -142,55 +145,7 @@ async function writeStepSummary(executionFile: string): Promise { } } -async function validateSubscription(): Promise { - const eventPath = process.env.GITHUB_EVENT_PATH; - let repoPrivate: boolean | undefined; - - if (eventPath && existsSync(eventPath)) { - const eventData = JSON.parse(readFileSync(eventPath, "utf8")); - repoPrivate = eventData?.repository?.private; - } - - const upstream = "anthropics/claude-code-action"; - const action = process.env.GITHUB_ACTION_REPOSITORY; - const docsUrl = - "https://docs.stepsecurity.io/actions/stepsecurity-maintained-actions"; - - core.info(""); - core.info("\u001b[1;36mStepSecurity Maintained Action\u001b[0m"); - core.info(`Secure drop-in replacement for ${upstream}`); - if (repoPrivate === false) - core.info("\u001b[32m\u2713 Free for public repositories\u001b[0m"); - core.info(`\u001b[36mLearn more:\u001b[0m ${docsUrl}`); - core.info(""); - - if (repoPrivate === false) return; - - const serverUrl = process.env.GITHUB_SERVER_URL || "https://github.com"; - const body: Record = { action: action || "" }; - if (serverUrl !== "https://github.com") body.ghes_server = serverUrl; - try { - await axios.post( - `https://agent.api.stepsecurity.io/v1/github/${process.env.GITHUB_REPOSITORY}/actions/maintained-actions-subscription`, - body, - { timeout: 3000 }, - ); - } catch (error) { - if (isAxiosError(error) && error.response?.status === 403) { - core.error( - `\u001b[1;31mThis action requires a StepSecurity subscription for private repositories.\u001b[0m`, - ); - core.error( - `\u001b[31mLearn how to enable a subscription: ${docsUrl}\u001b[0m`, - ); - process.exit(1); - } - core.info("Timeout or API not reachable. Continuing to next step."); - } -} - async function run() { - await validateSubscription(); let githubToken: string | undefined; let commentId: number | undefined; let claudeBranch: string | undefined; diff --git a/src/github/data/fetcher.ts b/src/github/data/fetcher.ts index 219f1cb..4d12388 100644 --- a/src/github/data/fetcher.ts +++ b/src/github/data/fetcher.ts @@ -378,34 +378,26 @@ export async function fetchGitHubData({ body: c.body, })); - // Filter review bodies to trigger time - const filteredReviewBodies = reviewData?.nodes - ? filterReviewsToTriggerTime(reviewData.nodes, triggerTime).filter( - (r) => r.body, - ) - : []; - - const reviewBodies: CommentWithImages[] = filteredReviewBodies.map((r) => ({ - type: "review_body" as const, - id: r.databaseId, - pullNumber: prNumber, - body: r.body, - })); - - // Filter review comments to trigger time and by actor + // Filter reviews and inline review comments to trigger time and by actor + // before building anything from them. The trigger-time filter is the TOCTOU + // protection applied to issue/PR comments and the body above: it drops + // anything submitted, created, or edited at/after the trigger so an attacker + // cannot inject content into the prompt after an authorized trigger. Without + // it, review bodies and inline review comments would reach the prompt + // verbatim regardless of when they landed. if (reviewData && reviewData.nodes) { - // Filter reviews by actor + // Drop reviews submitted or edited after the trigger, then filter by actor. reviewData.nodes = filterCommentsByActor( - reviewData.nodes, + filterReviewsToTriggerTime(reviewData.nodes, triggerTime), includeCommentsByActor, excludeCommentsByActor, ); - // Also filter inline review comments within each review + // Apply the same trigger-time + actor filtering to inline review comments. reviewData.nodes.forEach((review) => { if (review.comments?.nodes) { review.comments.nodes = filterCommentsByActor( - review.comments.nodes, + filterCommentsToTriggerTime(review.comments.nodes, triggerTime), includeCommentsByActor, excludeCommentsByActor, ); @@ -413,14 +405,19 @@ export async function fetchGitHubData({ }); } - const allReviewComments = - reviewData?.nodes?.flatMap((r) => r.comments?.nodes ?? []) ?? []; - const filteredReviewComments = filterCommentsToTriggerTime( - allReviewComments, - triggerTime, - ); + // Build the image-processing lists from the already-filtered review nodes, + // so reviews/comments excluded from the prompt are not processed for images. + const reviewBodies: CommentWithImages[] = (reviewData?.nodes ?? []) + .filter((r) => r.body) + .map((r) => ({ + type: "review_body" as const, + id: r.databaseId, + pullNumber: prNumber, + body: r.body, + })); - const reviewComments: CommentWithImages[] = filteredReviewComments + const reviewComments: CommentWithImages[] = (reviewData?.nodes ?? []) + .flatMap((r) => r.comments?.nodes ?? []) .filter((c) => c.body && !c.isMinimized) .map((c) => ({ type: "review_comment" as const, diff --git a/src/github/operations/branch.ts b/src/github/operations/branch.ts index 920eec5..253f763 100644 --- a/src/github/operations/branch.ts +++ b/src/github/operations/branch.ts @@ -27,14 +27,15 @@ function extractFirstLabel(githubData: FetchDataResult): string | undefined { * This prevents command injection by ensuring only safe characters are used. * * Valid branch names: - * - Start with alphanumeric character (not dash, to prevent option injection) - * - Contain only alphanumeric, forward slash, hyphen, underscore, period, or hash (#) + * - Start with alphanumeric character or @ (not dash, to prevent option injection) + * - Contain only alphanumeric, forward slash, hyphen, underscore, period, hash (#), plus (+), comma (,), or at sign (@) * - Do not start or end with a period * - Do not end with a slash * - Do not contain '..' (path traversal) * - Do not contain '//' (consecutive slashes) * - Do not end with '.lock' * - Do not contain '@{' + * - Are not the single character '@' (HEAD shorthand in git revision syntax) * - Do not contain control characters or special git characters (~^:?*[\]) */ export function validateBranchName(branchName: string): void { @@ -58,18 +59,21 @@ export function validateBranchName(branchName: string): void { ); } - // Strict whitelist pattern: alphanumeric start, then alphanumeric/slash/hyphen/underscore/period/hash/plus/comma. + // Strict whitelist pattern: alphanumeric or @ start, then alphanumeric/slash/hyphen/underscore/period/hash/plus/comma/at-sign. // # is valid per git-check-ref-format and commonly used in branch names like "fix/#123-description". // + is valid per git-check-ref-format and generated by Claude Code's EnterWorktree tool when // converting worktree names containing "/" (e.g. "feat/foo" becomes "worktree-feat+foo"). // , is valid per git-check-ref-format and commonly appears in branch names derived from titles // or external identifiers (e.g. place names like "feature/paris,france"). + // @ is valid per git-check-ref-format anywhere in a ref name, including the first character + // (e.g. ticket conventions like "TICKET-123@add-feature" or prefixes like "@hotfix/..."); + // the bare name "@" (HEAD shorthand) and the "@{" sequence (reflog syntax) are rejected below. // All git calls use execFileSync (not shell interpolation), so none of these characters carry injection risk. - const validPattern = /^[a-zA-Z0-9][a-zA-Z0-9/_.#+,-]*$/; + const validPattern = /^[a-zA-Z0-9@][a-zA-Z0-9/_.#+,@-]*$/; if (!validPattern.test(branchName)) { throw new Error( - `Invalid branch name: "${branchName}". Branch names must start with an alphanumeric character and contain only alphanumeric characters, forward slashes, hyphens, underscores, periods, hashes (#), plus signs (+), or commas (,).`, + `Invalid branch name: "${branchName}". Branch names must start with an alphanumeric character or '@' and contain only alphanumeric characters, forward slashes, hyphens, underscores, periods, hashes (#), plus signs (+), commas (,), or at signs (@).`, ); } @@ -112,6 +116,15 @@ export function validateBranchName(branchName: string): void { `Invalid branch name: "${branchName}". Branch names cannot contain '@{'`, ); } + + // Per git-check-ref-format, a refname cannot be the single character "@"; "@" also + // resolves to HEAD in git revision syntax, so a bare "@" must never reach git as a + // branch argument where it could be interpreted as a revision instead. + if (branchName === "@") { + throw new Error( + `Invalid branch name: "@". Branch names cannot be the single character '@'.`, + ); + } } /** diff --git a/src/github/operations/restore-config.ts b/src/github/operations/restore-config.ts index b847cf3..92ab2be 100644 --- a/src/github/operations/restore-config.ts +++ b/src/github/operations/restore-config.ts @@ -30,6 +30,21 @@ const SENSITIVE_PATHS = [ const CLAUDE_PR_EXCLUDE_PATTERN = "/.claude-pr/"; +function snapshotSensitivePath(src: string, dest: string): void { + try { + cpSync(src, dest, { recursive: true, dereference: true }); + } catch (error) { + // Symlinks whose targets are absent on the PR head (e.g. `.claude/CLAUDE.md` + // -> `../AGENTS.md` when the PR deleted the target) make dereferenced + // copies throw ENOENT. Preserve the symlink for the review snapshot instead. + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + cpSync(src, dest, { recursive: true }); + return; + } + throw error; + } +} + function ensureClaudePrExcludedFromGit(): void { const excludePath = execFileSync( "git", @@ -86,7 +101,7 @@ export function restoreConfigFromBase(baseBranch: string): void { rmSync(".claude-pr", { recursive: true, force: true }); for (const p of SENSITIVE_PATHS) { if (existsSync(p)) { - cpSync(p, `.claude-pr/${p}`, { recursive: true, dereference: true }); + snapshotSensitivePath(p, `.claude-pr/${p}`); } } if (existsSync(".claude-pr")) { diff --git a/src/github/token.ts b/src/github/token.ts index ddf8eee..c5f03a2 100644 --- a/src/github/token.ts +++ b/src/github/token.ts @@ -10,6 +10,49 @@ export class WorkflowValidationSkipError extends Error { } } +type AppTokenExchangeErrorResponse = { + error?: { + message?: string; + details?: { + error_code?: string; + }; + }; + type?: string; + message?: string; +}; + +const WORKFLOW_VALIDATION_ERROR_CODES = new Set([ + "workflow_not_found_on_default_branch", +]); + +function getAppTokenExchangeErrorMessage( + responseJson: AppTokenExchangeErrorResponse, +): string { + return responseJson.error?.message ?? responseJson.message ?? "Unknown error"; +} + +function isWorkflowValidationError( + status: number, + responseJson: AppTokenExchangeErrorResponse, +): boolean { + const errorCode = responseJson.error?.details?.error_code; + if ( + errorCode !== undefined && + WORKFLOW_VALIDATION_ERROR_CODES.has(errorCode) + ) { + return true; + } + + if (status !== 401) { + return false; + } + + const workflowValidationMessage = "workflow validation failed"; + return [responseJson.message, responseJson.error?.message].some((message) => + message?.toLowerCase().includes(workflowValidationMessage), + ); +} + async function getOidcToken(): Promise { try { const oidcToken = await core.getIDToken("claude-code-github-action"); @@ -80,25 +123,11 @@ async function exchangeForAppToken( ); if (!response.ok) { - const responseJson = (await response.json()) as { - error?: { - message?: string; - details?: { - error_code?: string; - }; - }; - type?: string; - message?: string; - }; - - // Check for specific workflow validation error codes that should skip the action - const errorCode = responseJson.error?.details?.error_code; + const responseJson = + (await response.json()) as AppTokenExchangeErrorResponse; - if (errorCode === "workflow_not_found_on_default_branch") { - const message = - responseJson.message ?? - responseJson.error?.message ?? - "Workflow validation failed"; + if (isWorkflowValidationError(response.status, responseJson)) { + const message = getAppTokenExchangeErrorMessage(responseJson); core.warning(`Skipping action due to workflow validation: ${message}`); console.log( "Action skipped due to workflow validation error. This is expected when adding Claude Code workflows to new repositories or on PRs with workflow changes. If you're seeing this, your workflow will begin working once you merge your PR.", @@ -106,10 +135,11 @@ async function exchangeForAppToken( throw new WorkflowValidationSkipError(message); } + const message = getAppTokenExchangeErrorMessage(responseJson); console.error( - `App token exchange failed: ${response.status} ${response.statusText} - ${responseJson?.error?.message ?? "Unknown error"}`, + `App token exchange failed: ${response.status} ${response.statusText} - ${message}`, ); - throw new Error(`${responseJson?.error?.message ?? "Unknown error"}`); + throw new Error(message); } const appTokenData = (await response.json()) as { diff --git a/src/mcp/github-inline-comment-server.ts b/src/mcp/github-inline-comment-server.ts index 535124f..a023d91 100644 --- a/src/mcp/github-inline-comment-server.ts +++ b/src/mcp/github-inline-comment-server.ts @@ -5,6 +5,7 @@ import { appendFileSync } from "fs"; import { z } from "zod"; import { createOctokit } from "../github/api/client"; import { sanitizeContent } from "../github/utils/sanitizer"; +import { removeBufferedComment } from "./inline-comment-buffer"; // Get repository and PR information from environment variables const REPO_OWNER = process.env.REPO_OWNER; @@ -180,6 +181,16 @@ server.tool( const result = await octokit.rest.pulls.createReviewComment(params); + // The comment is now live. Drop any buffered copy of it so the + // post-session replay step cannot post it a second time (the model often + // re-issues a buffered call with confirmed=true after the buffer reply). + if (CLASSIFY_ENABLED) { + removeBufferedComment( + { path, line, startLine, body: sanitizedBody }, + BUFFER_PATH, + ); + } + return { content: [ { diff --git a/src/mcp/inline-comment-buffer.ts b/src/mcp/inline-comment-buffer.ts new file mode 100644 index 0000000..4fb70ca --- /dev/null +++ b/src/mcp/inline-comment-buffer.ts @@ -0,0 +1,54 @@ +import { existsSync, readFileSync, writeFileSync } from "fs"; + +export type BufferedCommentMatch = { + path: string; + line?: number; + startLine?: number; + body: string; +}; + +/** + * Remove any buffered inline comment that matches an already-posted comment. + * + * When a comment is posted live (confirmed=true), an earlier buffered copy of + * the same comment must be dropped so the post-session replay step does not + * post it a second time. The model frequently re-issues a buffered call with + * confirmed=true after reading the "Set confirmed=true to post immediately" + * reply; previously the original buffered entry was left behind and replayed, + * producing duplicate inline comments. + * + * Entries are matched on path, line, startLine and body. Lines that cannot be + * parsed are kept untouched. + */ +export function removeBufferedComment( + match: BufferedCommentMatch, + bufferPath: string, +): void { + if (!existsSync(bufferPath)) { + return; + } + + const remaining = readFileSync(bufferPath, "utf8") + .split("\n") + .filter((line) => line.trim() !== "") + .filter((line) => { + let entry: BufferedCommentMatch; + try { + entry = JSON.parse(line); + } catch { + // Keep anything we cannot parse rather than silently dropping it. + return true; + } + const isSameComment = + entry.path === match.path && + entry.line === match.line && + entry.startLine === match.startLine && + entry.body === match.body; + return !isSameComment; + }); + + writeFileSync( + bufferPath, + remaining.length > 0 ? remaining.join("\n") + "\n" : "", + ); +} diff --git a/src/modes/agent/parse-tools.ts b/src/modes/agent/parse-tools.ts index 639c913..013fda5 100644 --- a/src/modes/agent/parse-tools.ts +++ b/src/modes/agent/parse-tools.ts @@ -1,29 +1,77 @@ +import { parse as parseShellArgs } from "shell-quote"; + +// Flags whose values make up the allowed-tools list. +// Include both camelCase and hyphenated variants for CLI compatibility. +const ALLOWED_TOOLS_FLAGS = new Set(["allowedTools", "allowed-tools"]); + +/** + * Strip comment lines from a shell argument string. + * Lines whose first non-whitespace character is `#` are removed entirely. + * Mirrors stripShellComments in base-action/src/parse-sdk-options.ts. + */ +function stripShellComments(input: string): string { + return input + .split("\n") + .filter((line) => !line.trim().startsWith("#")) + .join("\n"); +} + +/** + * Tokenize a claude_args string the same way base-action/src/parse-sdk-options.ts + * does: strip full comment lines, then run shell-quote. shell-quote returns + * unquoted glob patterns (e.g. `mcp__github__*`) as `{ op: "glob", pattern }` + * objects rather than strings, so recover their literal text; drop operator + * tokens (`|`, `>`, `;`, ...) which carry no value. + */ +function tokenize(claudeArgs: string): string[] { + return parseShellArgs(stripShellComments(claudeArgs)) + .map((token) => { + if (typeof token === "string") return token; + if (token && typeof token === "object" && "pattern" in token) { + return (token as { pattern: string }).pattern; + } + return null; + }) + .filter((token): token is string => token !== null); +} + +/** + * Parse the list of allowed tool names from a user-provided claude_args string. + * + * This is used to decide which GitHub MCP servers to install. It MUST stay in + * agreement with how the actual tool list is built for the SDK in + * base-action/src/parse-sdk-options.ts (parseClaudeArgsToExtraArgs): otherwise a + * tool can be granted to Claude without its MCP server being installed, or a + * server can be installed for a tool that was never granted (#1357). + * + * To stay in agreement it uses the same shell-quote tokenizer and the same + * "an accumulating flag consumes all consecutive non-flag values" semantics, + * so `--allowedTools "Read" "Grep" "mcp__github__get_commit"` captures all + * three values, and commented-out lines are ignored. + */ export function parseAllowedTools(claudeArgs: string): string[] { - // Match --allowedTools or --allowed-tools followed by the value - // Handle both quoted and unquoted values - // Use /g flag to find ALL occurrences, not just the first one - const patterns = [ - /--(?:allowedTools|allowed-tools)\s+"([^"]+)"/g, // Double quoted - /--(?:allowedTools|allowed-tools)\s+'([^']+)'/g, // Single quoted - /--(?:allowedTools|allowed-tools)\s+([^'"\s][^\s]*)/g, // Unquoted (must not start with quote) - ]; + if (!claudeArgs?.trim()) return []; + const args = tokenize(claudeArgs); const tools: string[] = []; const seen = new Set(); - for (const pattern of patterns) { - for (const match of claudeArgs.matchAll(pattern)) { - if (match[1]) { - // Don't add if the value starts with -- (another flag) - if (match[1].startsWith("--")) { - continue; - } - for (const tool of match[1].split(",")) { - const trimmed = tool.trim(); - if (trimmed && !seen.has(trimmed)) { - seen.add(trimmed); - tools.push(trimmed); - } + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (!arg?.startsWith("--")) continue; + + const flag = arg.slice(2); + if (!ALLOWED_TOOLS_FLAGS.has(flag)) continue; + + // Consume all consecutive non-flag values, e.g. + // --allowedTools "Read" "Grep" "mcp__github__get_commit" + while (i + 1 < args.length && !args[i + 1]!.startsWith("--")) { + i++; + for (const tool of args[i]!.split(",")) { + const trimmed = tool.trim(); + if (trimmed && !seen.has(trimmed)) { + seen.add(trimmed); + tools.push(trimmed); } } } diff --git a/test/create-prompt.test.ts b/test/create-prompt.test.ts index b6dab09..44e510c 100644 --- a/test/create-prompt.test.ts +++ b/test/create-prompt.test.ts @@ -495,6 +495,32 @@ describe("generatePrompt", () => { ); }); + test("should use numeric GitHub noreply address when trigger user id is provided", async () => { + const envVars: PreparedContext = { + repository: "owner/repo", + claudeCommentId: "12345", + triggerPhrase: "@claude", + triggerUsername: "johndoe", + triggerUserId: 123456, + eventData: { + eventName: "issue_comment", + commentId: "67890", + isPR: false, + issueNumber: "123", + baseBranch: "main", + claudeBranch: "claude/issue-67890-20240101-1200", + commentBody: "@claude please fix this", + }, + }; + + const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag"); + + expect(prompt).toContain( + "Co-authored-by: johndoe <123456+johndoe@users.noreply.github.com>", + ); + expect(prompt).not.toContain(""); + }); + test("should include PR-specific instructions only for PR events", async () => { const envVars: PreparedContext = { repository: "owner/repo", diff --git a/test/data-fetcher.test.ts b/test/data-fetcher.test.ts index c474264..f92fc14 100644 --- a/test/data-fetcher.test.ts +++ b/test/data-fetcher.test.ts @@ -723,16 +723,17 @@ describe("fetchGitHubData integration with time filtering", () => { triggerTime: "2024-01-15T12:00:00Z", }); - // The reviewData field returns all reviews (not filtered), but the filtering - // happens when processing review bodies for download - // We can check the image download map to verify filtering - expect(result.reviewData?.nodes?.length).toBe(3); // All reviews are returned - - // Check that only the first review's body would be downloaded (filtered) + // Only the review submitted before the trigger and not edited afterward + // reaches the prompt. The review submitted after the trigger and the one + // edited after the trigger are dropped (TOCTOU protection), matching the + // issue/PR comment and body handling. + expect(result.reviewData?.nodes?.length).toBe(1); + expect(result.reviewData?.nodes?.[0]?.databaseId).toBe("1"); + + // Only that surviving review's body is queued for image download. const reviewsInMap = Object.keys(result.imageUrlMap).filter((key) => key.startsWith("review_body"), ); - // Only review 1 should have its body processed (before trigger and not edited after) expect(reviewsInMap.length).toBeLessThanOrEqual(1); }); @@ -805,14 +806,83 @@ describe("fetchGitHubData integration with time filtering", () => { triggerTime: "2024-01-15T12:00:00Z", }); - // The imageUrlMap contains processed comments for image downloading - // We should have processed review comments, but only those before trigger time - // The exact check depends on how imageUrlMap is structured, but we can verify - // that filtering occurred by checking the review data still has all nodes - expect(result.reviewData?.nodes?.length).toBe(1); // Original review is kept + // The review itself is pre-trigger and kept, but its inline comments are + // filtered to trigger time: the comment created after the trigger (id 11) + // and the one edited after the trigger (id 12) are dropped, leaving only + // the pre-trigger comment (id 10). + expect(result.reviewData?.nodes?.length).toBe(1); + const reviewCommentIds = + result.reviewData?.nodes?.[0]?.comments?.nodes?.map((c) => c.databaseId); + expect(reviewCommentIds).toEqual(["10"]); + }); + + it("should filter reviews by both trigger time and actor", async () => { + const mockOctokits = { + graphql: jest.fn().mockResolvedValue({ + repository: { + pullRequest: { + number: 321, + title: "Test PR", + body: "PR body", + author: { login: "author" }, + comments: { nodes: [] }, + files: { nodes: [] }, + reviews: { + nodes: [ + { + id: "1", + databaseId: "1", + author: { login: "reviewer1" }, + body: "Pre-trigger human review", + state: "APPROVED", + submittedAt: "2024-01-15T11:00:00Z", + comments: { nodes: [] }, + }, + { + id: "2", + databaseId: "2", + author: { login: "scanner[bot]" }, + body: "Pre-trigger bot review", + state: "COMMENTED", + submittedAt: "2024-01-15T11:00:00Z", + comments: { nodes: [] }, + }, + { + id: "3", + databaseId: "3", + author: { login: "reviewer3" }, + body: "Post-trigger human review", + state: "CHANGES_REQUESTED", + submittedAt: "2024-01-15T13:00:00Z", + comments: { nodes: [] }, + }, + ], + }, + }, + }, + user: { login: "trigger-user" }, + }), + rest: { + pulls: { + listFiles: jest.fn().mockResolvedValue({ data: [] }), + }, + }, + }; + + const result = await fetchGitHubData({ + octokits: mockOctokits as any, + repository: "test-owner/test-repo", + prNumber: "321", + isPR: true, + triggerUsername: "trigger-user", + triggerTime: "2024-01-15T12:00:00Z", + excludeCommentsByActor: "*[bot]", + }); - // The actual filtering happens during processing for image download - // Since the mock doesn't actually download images, we verify the input was correct + // The trigger-time and actor filters compose: the pre-trigger human review + // is kept, the pre-trigger bot review is dropped by actor, and the + // post-trigger human review is dropped by trigger time. + expect(result.reviewData?.nodes?.map((r) => r.databaseId)).toEqual(["1"]); }); it("should handle backward compatibility when no trigger time provided", async () => { diff --git a/test/format-turns.test.ts b/test/format-turns.test.ts index bb26f2e..e6ac058 100644 --- a/test/format-turns.test.ts +++ b/test/format-turns.test.ts @@ -437,3 +437,51 @@ describe("integration tests", () => { expect(actualOutput).toBe(expectedOutput); }); }); + +describe("detectContentType fallbacks", () => { + test("falls back to text for malformed JSON objects", () => { + // Looks like an object (starts with { ends with }) but does not parse. + expect(detectContentType("{not valid json}")).toBe("text"); + }); + + test("falls back to text for malformed JSON arrays", () => { + // Looks like an array (starts with [ ends with ]) but does not parse. + expect(detectContentType("[not, valid, json]")).toBe("text"); + }); + + test("classifies non-python, non-js code keywords as python by default", () => { + // Contains a code keyword ("class ") but matches neither the python-specific + // nor the javascript-specific checks, so it hits the default branch. + expect(detectContentType("class Foo {}")).toBe("python"); + }); +}); + +describe("formatResultContent non-string input", () => { + test("handles a numeric (non-string) result value", () => { + const result = formatResultContent(42); + expect(result).toContain("42"); + }); + + test("handles a plain object (non-string, non-text-array) result value", () => { + const result = formatResultContent({ status: "ok" }); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + }); +}); + +describe("system_other handling", () => { + test("groups a non-init system turn as system_other", () => { + const systemTurn: Turn = { type: "system", subtype: "some_other_subtype" }; + const grouped = groupTurnsNaturally([systemTurn]); + expect(grouped).toHaveLength(1); + expect(grouped[0]?.type).toBe("system_other"); + expect(grouped[0]?.data).toEqual(systemTurn); + }); + + test("renders a system_other group as a System Message section", () => { + const markdown = formatGroupedContent([ + { type: "system_other", data: { type: "system" } as Turn }, + ]); + expect(markdown).toContain("## ⚙️ System Message"); + }); +}); diff --git a/test/inline-comment-buffer.test.ts b/test/inline-comment-buffer.test.ts new file mode 100644 index 0000000..0731486 --- /dev/null +++ b/test/inline-comment-buffer.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { removeBufferedComment } from "../src/mcp/inline-comment-buffer"; + +describe("removeBufferedComment", () => { + let dir: string; + let bufferPath: string; + + const entryA = { + ts: "2026-06-13T00:00:00.000Z", + path: "src/index.ts", + line: 10, + startLine: undefined, + side: "RIGHT", + body: "Comment A", + }; + const entryB = { + ts: "2026-06-13T00:00:01.000Z", + path: "src/other.ts", + line: 20, + startLine: undefined, + side: "RIGHT", + body: "Comment B", + }; + + const writeBuffer = (entries: object[]): void => { + writeFileSync( + bufferPath, + entries.map((e) => JSON.stringify(e)).join("\n") + "\n", + ); + }; + + const readBuffer = (): Array<{ body: string }> => { + if (!existsSync(bufferPath)) { + return []; + } + return readFileSync(bufferPath, "utf8") + .split("\n") + .filter((line) => line.trim() !== "") + .map((line) => JSON.parse(line)); + }; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "inline-buffer-")); + bufferPath = join(dir, "buffer.jsonl"); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("removes the matching buffered entry and keeps the others", () => { + writeBuffer([entryA, entryB]); + + removeBufferedComment( + { + path: "src/index.ts", + line: 10, + startLine: undefined, + body: "Comment A", + }, + bufferPath, + ); + + const remaining = readBuffer(); + expect(remaining.map((e) => e.body)).toEqual(["Comment B"]); + }); + + it("removes every copy when the same comment was buffered more than once", () => { + writeBuffer([entryA, entryA, entryB]); + + removeBufferedComment( + { + path: "src/index.ts", + line: 10, + startLine: undefined, + body: "Comment A", + }, + bufferPath, + ); + + expect(readBuffer().map((e) => e.body)).toEqual(["Comment B"]); + }); + + it("leaves the buffer untouched when nothing matches", () => { + writeBuffer([entryA, entryB]); + + removeBufferedComment( + { + path: "src/index.ts", + line: 999, + startLine: undefined, + body: "Comment A", + }, + bufferPath, + ); + + expect(readBuffer().map((e) => e.body)).toEqual(["Comment A", "Comment B"]); + }); + + it("does nothing when the buffer file does not exist", () => { + expect(() => + removeBufferedComment( + { path: "src/index.ts", line: 10, body: "Comment A" }, + bufferPath, + ), + ).not.toThrow(); + expect(existsSync(bufferPath)).toBe(false); + }); + + it("keeps lines that cannot be parsed as JSON", () => { + writeFileSync( + bufferPath, + ["not json", JSON.stringify(entryA)].join("\n") + "\n", + ); + + removeBufferedComment( + { + path: "src/index.ts", + line: 10, + startLine: undefined, + body: "Comment A", + }, + bufferPath, + ); + + const raw = readFileSync(bufferPath, "utf8"); + expect(raw).toContain("not json"); + expect(raw).not.toContain("Comment A"); + }); +}); diff --git a/test/install-pipefail.test.ts b/test/install-pipefail.test.ts new file mode 100644 index 0000000..2ab8869 --- /dev/null +++ b/test/install-pipefail.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "bun:test"; +import { spawnSync } from "child_process"; +import { buildInstallCommand } from "../src/entrypoints/run"; + +describe("buildInstallCommand (regression for #1136)", () => { + it("includes the pinned claude version in the bash -s args", () => { + const cmd = buildInstallCommand("2.1.114"); + expect(cmd).toContain("bash -s -- 2.1.114"); + }); + + it("prefixes the pipeline with `set -o pipefail`", () => { + const cmd = buildInstallCommand("2.1.114"); + expect(cmd.startsWith("set -o pipefail;")).toBe(true); + }); + + it("keeps the curl -fsSL flags so the script is fetched, not inlined", () => { + const cmd = buildInstallCommand("2.1.114"); + expect(cmd).toContain( + "curl -fsSL https://claude.ai/install.sh | bash -s --", + ); + }); +}); + +describe("pipefail semantics (proves the bug shape and the fix)", () => { + // Mirrors the real install invocation: a curl that returns non-zero + // feeding into `bash -s --`. Without pipefail, the pipeline exits 0 + // because bash -s receives an empty stdin and does nothing. With + // pipefail, curl's exit code wins and the retry loop in run.ts triggers. + // + // Uses port 1 (reserved/unused) so curl fails deterministically with no + // network access. No shell-escaping traps here: the version argument is + // a numeric literal. + const unreachable = "http://127.0.0.1:1/nope"; + const version = "2.1.114"; + + it("BEFORE FIX: pipeline without pipefail swallows curl failure (exit 0)", () => { + const buggy = `curl -fsSL ${unreachable} | bash -s -- ${version}`; + const result = spawnSync("bash", ["-c", buggy], { stdio: "pipe" }); + expect(result.status).toBe(0); + }); + + it("AFTER FIX: buildInstallCommand (against unreachable host) exits non-zero", () => { + const fixed = buildInstallCommand(version).replace( + "https://claude.ai/install.sh", + unreachable, + ); + const result = spawnSync("bash", ["-c", fixed], { stdio: "pipe" }); + expect(result.status).not.toBe(0); + }); +}); diff --git a/test/modes/parse-tools.test.ts b/test/modes/parse-tools.test.ts index 84916fb..cc85472 100644 --- a/test/modes/parse-tools.test.ts +++ b/test/modes/parse-tools.test.ts @@ -37,9 +37,43 @@ describe("parseAllowedTools", () => { test("handles --allowedTools followed by another --allowedTools flag", () => { const args = "--allowedTools --allowedTools mcp__github__*"; - // The second --allowedTools is consumed as a value of the first, then skipped. - // This is an edge case with malformed input - returns empty. - expect(parseAllowedTools(args)).toEqual([]); + // The first --allowedTools has no value (the next token is another flag); + // the second consumes mcp__github__*. This matches how the SDK option + // parser (parse-sdk-options.ts) reads the same input. + expect(parseAllowedTools(args)).toEqual(["mcp__github__*"]); + }); + + test("captures multiple values after a single --allowedTools flag", () => { + // Regression for #1357: the install-decision parser must capture every + // value, not just the first, so it agrees with the tools actually granted + // to Claude. Previously only "Read" was seen, so the github MCP server was + // not installed even though mcp__github__get_commit was granted. + const args = '--allowedTools "Read" "Grep" "mcp__github__get_commit"'; + expect(parseAllowedTools(args)).toEqual([ + "Read", + "Grep", + "mcp__github__get_commit", + ]); + }); + + test("captures multiple values spread across lines under one flag", () => { + const args = `--allowedTools + "Read" + "Grep" + "mcp__github__get_commit"`; + expect(parseAllowedTools(args)).toEqual([ + "Read", + "Grep", + "mcp__github__get_commit", + ]); + }); + + test("ignores commented-out lines", () => { + // Regression for #1357: a commented-out flag must not be counted, matching + // the SDK parser which strips comment lines before parsing. + const args = `# --allowedTools "mcp__github__get_commit" +--allowedTools "Read"`; + expect(parseAllowedTools(args)).toEqual(["Read"]); }); test("parses multiple separate --allowed-tools flags", () => { diff --git a/test/restore-config.test.ts b/test/restore-config.test.ts index 80439ea..43dbf75 100644 --- a/test/restore-config.test.ts +++ b/test/restore-config.test.ts @@ -2,10 +2,12 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { execFileSync } from "child_process"; import { existsSync, + lstatSync, mkdtempSync, mkdirSync, readFileSync, rmSync, + symlinkSync, writeFileSync, } from "fs"; import { dirname, isAbsolute, join } from "path"; @@ -121,6 +123,48 @@ describe("restoreConfigFromBase", () => { } }); + test("restores symlinked CLAUDE.md paths from the PR base branch", () => { + setupSymlinkedMainBranch(); + + git(["checkout", "pr"]); + writeRepoFile( + ".claude/settings.json", + `${JSON.stringify({ source: "pr-with-symlinks" })}\n`, + ); + git(["add", ".claude/settings.json"]); + git(["commit", "-m", "pr updates settings"]); + + restoreConfigFromBase("main"); + + expect(lstatRepoFile("CLAUDE.md").isSymbolicLink()).toBe(true); + expect(lstatRepoFile(".claude/CLAUDE.md").isSymbolicLink()).toBe(true); + expect(readRepoFile("CLAUDE.md").trim()).toBe("shared agent instructions"); + expect(readRepoFile(".claude/CLAUDE.md").trim()).toBe( + "shared agent instructions", + ); + expect(readRepoFile(".claude/settings.json")).toBe( + `${JSON.stringify({ source: "base" })}\n`, + ); + }); + + test("snapshots symlinked sensitive paths even when the PR head target is missing", () => { + setupSymlinkedMainBranch(); + + git(["checkout", "pr"]); + rmSync(join(repoDir, "AGENTS.md"), { force: true }); + git(["add", "-A"]); + git(["commit", "-m", "pr deletes agents file"]); + + restoreConfigFromBase("main"); + + expect(lstatRepoFile(".claude-pr/.claude/CLAUDE.md").isSymbolicLink()).toBe( + true, + ); + expect(readRepoFile(".claude/settings.json")).toBe( + `${JSON.stringify({ source: "base" })}\n`, + ); + }); + test("does not modify an existing .gitignore", () => { writeRepoFile(".gitignore", "node_modules\n"); git(["add", ".gitignore"]); @@ -156,6 +200,29 @@ describe("restoreConfigFromBase", () => { return existsSync(join(repoDir, path)); } + function symlinkRepoFile(path: string, target: string): void { + const fullPath = join(repoDir, path); + mkdirSync(dirname(fullPath), { recursive: true }); + symlinkSync(target, fullPath); + } + + function lstatRepoFile(path: string) { + return lstatSync(join(repoDir, path)); + } + + function setupSymlinkedMainBranch(): void { + git(["checkout", "main"]); + rmSync(join(repoDir, "CLAUDE.md"), { force: true }); + writeRepoFile("AGENTS.md", "shared agent instructions\n"); + symlinkRepoFile("CLAUDE.md", "AGENTS.md"); + symlinkRepoFile(".claude/CLAUDE.md", "../AGENTS.md"); + git(["add", "AGENTS.md", "CLAUDE.md", ".claude/CLAUDE.md"]); + git(["commit", "-m", "add symlinked claude files"]); + git(["push", "origin", "main"]); + git(["branch", "-D", "pr"]); + git(["checkout", "-b", "pr"]); + } + function countClaudePrExcludeEntries(): number { return readFileSync(getExcludePath(), "utf8") .split(/\r?\n/) diff --git a/test/token.test.ts b/test/token.test.ts new file mode 100644 index 0000000..a85692c --- /dev/null +++ b/test/token.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"; +import * as core from "@actions/core"; +import { + setupGitHubToken, + WorkflowValidationSkipError, +} from "../src/github/token"; + +describe("setupGitHubToken", () => { + let originalOverrideToken: string | undefined; + let originalAdditionalPermissions: string | undefined; + let getIDTokenSpy: any; + let setSecretSpy: any; + let warningSpy: any; + let fetchSpy: any; + let setTimeoutSpy: any; + let consoleLogSpy: any; + let consoleErrorSpy: any; + + beforeEach(() => { + originalOverrideToken = process.env.OVERRIDE_GITHUB_TOKEN; + originalAdditionalPermissions = process.env.ADDITIONAL_PERMISSIONS; + delete process.env.OVERRIDE_GITHUB_TOKEN; + delete process.env.ADDITIONAL_PERMISSIONS; + + getIDTokenSpy = spyOn(core, "getIDToken").mockResolvedValue("oidc-token"); + setSecretSpy = spyOn(core, "setSecret").mockImplementation(() => {}); + warningSpy = spyOn(core, "warning").mockImplementation(() => {}); + fetchSpy = spyOn(global, "fetch").mockResolvedValue( + new Response(JSON.stringify({ token: "app-token" }), { + status: 200, + statusText: "OK", + }), + ); + setTimeoutSpy = spyOn(global, "setTimeout").mockImplementation((( + handler: any, + ) => { + handler(); + return 0 as any; + }) as any); + consoleLogSpy = spyOn(console, "log").mockImplementation(() => {}); + consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + if (originalOverrideToken === undefined) { + delete process.env.OVERRIDE_GITHUB_TOKEN; + } else { + process.env.OVERRIDE_GITHUB_TOKEN = originalOverrideToken; + } + + if (originalAdditionalPermissions === undefined) { + delete process.env.ADDITIONAL_PERMISSIONS; + } else { + process.env.ADDITIONAL_PERMISSIONS = originalAdditionalPermissions; + } + + getIDTokenSpy.mockRestore(); + setSecretSpy.mockRestore(); + warningSpy.mockRestore(); + fetchSpy.mockRestore(); + setTimeoutSpy.mockRestore(); + consoleLogSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + test("returns app token from OIDC exchange", async () => { + await expect(setupGitHubToken()).resolves.toBe("app-token"); + + expect(getIDTokenSpy).toHaveBeenCalledWith("claude-code-github-action"); + expect(setSecretSpy).toHaveBeenCalledWith("app-token"); + }); + + test("skips without retrying when workflow is missing from default branch", async () => { + const message = + "Workflow validation failed. The workflow file must exist and have identical content to the version on the repository's default branch."; + fetchSpy.mockResolvedValue( + new Response( + JSON.stringify({ + error: { + message, + details: { + error_code: "workflow_not_found_on_default_branch", + }, + }, + }), + { status: 401, statusText: "Unauthorized" }, + ), + ); + + await expect(setupGitHubToken()).rejects.toBeInstanceOf( + WorkflowValidationSkipError, + ); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(warningSpy).toHaveBeenCalledWith( + `Skipping action due to workflow validation: ${message}`, + ); + }); + + test("skips without retrying when workflow validation message has no error code", async () => { + const message = + "Workflow validation failed. The workflow file must exist and have identical content to the version on the repository's default branch."; + fetchSpy.mockResolvedValue( + new Response( + JSON.stringify({ + error: { + message, + }, + }), + { status: 401, statusText: "Unauthorized" }, + ), + ); + + await expect(setupGitHubToken()).rejects.toBeInstanceOf( + WorkflowValidationSkipError, + ); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(warningSpy).toHaveBeenCalledWith( + `Skipping action due to workflow validation: ${message}`, + ); + }); + + test("retries ordinary token exchange errors instead of skipping", async () => { + const message = "Bad credentials"; + fetchSpy.mockImplementation( + async () => + new Response( + JSON.stringify({ + error: { + message, + }, + }), + { status: 401, statusText: "Unauthorized" }, + ), + ); + + await expect(setupGitHubToken()).rejects.toThrow(message); + + expect(fetchSpy).toHaveBeenCalledTimes(3); + expect(warningSpy).not.toHaveBeenCalled(); + }); + + test("does not skip message-only workflow validation errors with unexpected status", async () => { + const message = + "Workflow validation failed. The workflow file must exist and have identical content to the version on the repository's default branch."; + fetchSpy.mockImplementation( + async () => + new Response( + JSON.stringify({ + error: { + message, + }, + }), + { status: 500, statusText: "Internal Server Error" }, + ), + ); + + await expect(setupGitHubToken()).rejects.toThrow(message); + + expect(fetchSpy).toHaveBeenCalledTimes(3); + expect(warningSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/test/validate-branch-name.test.ts b/test/validate-branch-name.test.ts index 5a9bf68..fe03ce4 100644 --- a/test/validate-branch-name.test.ts +++ b/test/validate-branch-name.test.ts @@ -64,6 +64,16 @@ describe("validateBranchName", () => { expect(() => validateBranchName("feature/paris,france")).not.toThrow(); expect(() => validateBranchName("fix/issue-1,2,3")).not.toThrow(); }); + + it("should accept branch names containing @ (git-valid, used in team and tooling conventions)", () => { + // Reported in #998: branches like "TICKET-123@add-feature" were rejected, even + // though git check-ref-format and GitHub both accept @ anywhere in a ref name. + // Also common as a leading prefix (e.g. "@hotfix/...") and in agent-generated + // names ("task@sessionid"). Bare "@" and "@{" are still rejected. + expect(() => validateBranchName("TICKET-123@add-feature")).not.toThrow(); + expect(() => validateBranchName("@hotfix/login-timeout")).not.toThrow(); + expect(() => validateBranchName("agent/task@abc123")).not.toThrow(); + }); }); describe("command injection attempts", () => { @@ -137,6 +147,12 @@ describe("validateBranchName", () => { expect(() => validateBranchName("HEAD@{yesterday}")).toThrow(/@{/); }); + it("should reject the single character @", () => { + // Per git-check-ref-format, a refname cannot be the single character "@"; + // "@" also resolves to HEAD in git revision syntax. + expect(() => validateBranchName("@")).toThrow(/single character '@'/); + }); + it("should reject .lock suffix", () => { expect(() => validateBranchName("branch.lock")).toThrow(/\.lock/); expect(() => validateBranchName("feature.lock")).toThrow(/\.lock/); From 1931054777fabca18339a7cb9a3dcfd1bd276a59 Mon Sep 17 00:00:00 2001 From: Raj-StepSecurity Date: Fri, 10 Jul 2026 13:10:38 +0530 Subject: [PATCH 03/11] upstream author removed --- .claude/workflows/pr-stamp-sweep.js | 4 ++-- agent-approval-check/README.md | 2 +- agent-approval-check/action.yml | 2 +- agent-approval-check/agent_approval_check.py | 2 +- examples/agent-approval-check.yml | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.claude/workflows/pr-stamp-sweep.js b/.claude/workflows/pr-stamp-sweep.js index 64b5400..386dfbc 100644 --- a/.claude/workflows/pr-stamp-sweep.js +++ b/.claude/workflows/pr-stamp-sweep.js @@ -92,7 +92,7 @@ const results = await pipeline( prs, (n) => agent( - `You are reviewing open PR #${n} on anthropics/claude-code-action to decide if it is safe for a maintainer to approve ("stamp") with minimal further discussion. + `You are reviewing open PR #${n} on step-security/claude-code-action to decide if it is safe for a maintainer to approve ("stamp") with minimal further discussion. The full PR (metadata, body, existing reviews/comments, and complete diff) is in /tmp/claude/pr-sweep/${n}.md — read it first. The repo is checked out at the current working directory. Read the actual current source files the diff touches to verify the diff applies cleanly conceptually and the claims in the PR body are true. Do NOT modify anything or run git commands that change state. @@ -121,7 +121,7 @@ Return structured output only.`, if (!review) return null; if (review.verdict !== "stamp") return { review, verify: null }; return agent( - `You are an adversarial security skeptic. Another reviewer recommended APPROVING open PR #${n} on anthropics/claude-code-action. Your job is to REFUTE that recommendation — find any reason it should NOT be stamped. + `You are an adversarial security skeptic. Another reviewer recommended APPROVING open PR #${n} on step-security/claude-code-action. Your job is to REFUTE that recommendation — find any reason it should NOT be stamped. Their assessment: ${JSON.stringify(review)} diff --git a/agent-approval-check/README.md b/agent-approval-check/README.md index c95c8ed..2baa50f 100644 --- a/agent-approval-check/README.md +++ b/agent-approval-check/README.md @@ -58,7 +58,7 @@ jobs: if: github.event_name != 'issue_comment' || github.event.issue.pull_request runs-on: ubuntu-latest steps: - - uses: anthropics/claude-code-action/agent-approval-check@main + - uses: step-security/claude-code-action/agent-approval-check@main with: required_approvals: 2 agent_emails: noreply@anthropic.com diff --git a/agent-approval-check/action.yml b/agent-approval-check/action.yml index 4863f3b..6eb4397 100644 --- a/agent-approval-check/action.yml +++ b/agent-approval-check/action.yml @@ -44,7 +44,7 @@ inputs: default: "" docs_url: description: Link shown in the PR comment footer. - default: "https://github.com/anthropics/claude-code-action/tree/main/agent-approval-check" + default: "https://github.com/step-security/claude-code-action/tree/main/agent-approval-check" runs: using: composite diff --git a/agent-approval-check/agent_approval_check.py b/agent-approval-check/agent_approval_check.py index a60fc7f..fa828a9 100644 --- a/agent-approval-check/agent_approval_check.py +++ b/agent-approval-check/agent_approval_check.py @@ -111,7 +111,7 @@ def _retryable_http_error(exc: BaseException) -> bool: WRITE_PERMISSION_LEVELS = frozenset({"write", "push", "maintain", "admin"}) DOCS_URL = ( os.environ.get("DOCS_URL") - or "https://github.com/anthropics/claude-code-action/tree/main/agent-approval-check" + or "https://github.com/step-security/claude-code-action/tree/main/agent-approval-check" ) diff --git a/examples/agent-approval-check.yml b/examples/agent-approval-check.yml index fb6f51f..d7a463a 100644 --- a/examples/agent-approval-check.yml +++ b/examples/agent-approval-check.yml @@ -28,7 +28,7 @@ jobs: if: github.event_name != 'issue_comment' || github.event.issue.pull_request runs-on: ubuntu-latest steps: - - uses: anthropics/claude-code-action/agent-approval-check@main + - uses: step-security/claude-code-action/agent-approval-check@main with: required_approvals: 2 agent_emails: noreply@anthropic.com From e4358654e8e41589368cde6462d6c651a86ef2f7 Mon Sep 17 00:00:00 2001 From: Raj-StepSecurity Date: Fri, 10 Jul 2026 14:44:47 +0530 Subject: [PATCH 04/11] comments addressed --- action.yml | 45 +++++++++++++++++++++++++++++++ agent-approval-check/README.md | 2 +- base-action/action.yml | 45 +++++++++++++++++++++++++++++++ examples/agent-approval-check.yml | 2 +- src/entrypoints/run.ts | 44 ++++++++++++++++++++++++++++++ 5 files changed, 136 insertions(+), 2 deletions(-) diff --git a/action.yml b/action.yml index de9388f..7d84487 100644 --- a/action.yml +++ b/action.yml @@ -186,6 +186,51 @@ outputs: runs: using: "composite" steps: + - name: Subscription check + env: + REPO_PRIVATE: ${{ github.event.repository.private }} + run: | + # validate subscription status + UPSTREAM="anthropics/claude-code-action" + ACTION_REPO="${GITHUB_ACTION_REPOSITORY:-}" + DOCS_URL="https://docs.stepsecurity.io/actions/stepsecurity-maintained-actions" + + echo "" + echo -e "\033[1;36mStepSecurity Maintained Action\033[0m" + echo "Secure drop-in replacement for $UPSTREAM" + if [ "$REPO_PRIVATE" = "false" ]; then + echo -e "\033[32m✓ Free for public repositories\033[0m" + fi + echo -e "\033[36mLearn more:\033[0m $DOCS_URL" + echo "" + + if [ "$REPO_PRIVATE" != "false" ]; then + SERVER_URL="${GITHUB_SERVER_URL:-https://github.com}" + + if [ "$SERVER_URL" != "https://github.com" ]; then + BODY=$(printf '{"action":"%s","ghes_server":"%s"}' "$ACTION_REPO" "$SERVER_URL") + else + BODY=$(printf '{"action":"%s"}' "$ACTION_REPO") + fi + + API_URL="https://agent.api.stepsecurity.io/v1/github/$GITHUB_REPOSITORY/actions/maintained-actions-subscription" + + RESPONSE=$(curl --max-time 3 -s -w "%{http_code}" \ + -X POST \ + -H "Content-Type: application/json" \ + -d "$BODY" \ + "$API_URL" -o /dev/null) && CURL_EXIT_CODE=0 || CURL_EXIT_CODE=$? + + if [ $CURL_EXIT_CODE -ne 0 ]; then + echo "Timeout or API not reachable. Continuing to next step." + elif [ "$RESPONSE" = "403" ]; then + echo -e "::error::\033[1;31mThis action requires a StepSecurity subscription for private repositories.\033[0m" + echo -e "::error::\033[31mLearn how to enable a subscription: $DOCS_URL\033[0m" + exit 1 + fi + fi + shell: bash + - name: Install Bun id: setup-bun if: inputs.path_to_bun_executable == '' diff --git a/agent-approval-check/README.md b/agent-approval-check/README.md index 2baa50f..9c143b6 100644 --- a/agent-approval-check/README.md +++ b/agent-approval-check/README.md @@ -58,7 +58,7 @@ jobs: if: github.event_name != 'issue_comment' || github.event.issue.pull_request runs-on: ubuntu-latest steps: - - uses: step-security/claude-code-action/agent-approval-check@main + - uses: step-security/claude-code-action/agent-approval-check@v1 with: required_approvals: 2 agent_emails: noreply@anthropic.com diff --git a/base-action/action.yml b/base-action/action.yml index 70de492..193c418 100644 --- a/base-action/action.yml +++ b/base-action/action.yml @@ -109,6 +109,51 @@ outputs: runs: using: "composite" steps: + - name: Subscription check + env: + REPO_PRIVATE: ${{ github.event.repository.private }} + run: | + # validate subscription status + UPSTREAM="anthropics/claude-code-action" + ACTION_REPO="${GITHUB_ACTION_REPOSITORY:-}" + DOCS_URL="https://docs.stepsecurity.io/actions/stepsecurity-maintained-actions" + + echo "" + echo -e "\033[1;36mStepSecurity Maintained Action\033[0m" + echo "Secure drop-in replacement for $UPSTREAM" + if [ "$REPO_PRIVATE" = "false" ]; then + echo -e "\033[32m✓ Free for public repositories\033[0m" + fi + echo -e "\033[36mLearn more:\033[0m $DOCS_URL" + echo "" + + if [ "$REPO_PRIVATE" != "false" ]; then + SERVER_URL="${GITHUB_SERVER_URL:-https://github.com}" + + if [ "$SERVER_URL" != "https://github.com" ]; then + BODY=$(printf '{"action":"%s","ghes_server":"%s"}' "$ACTION_REPO" "$SERVER_URL") + else + BODY=$(printf '{"action":"%s"}' "$ACTION_REPO") + fi + + API_URL="https://agent.api.stepsecurity.io/v1/github/$GITHUB_REPOSITORY/actions/maintained-actions-subscription" + + RESPONSE=$(curl --max-time 3 -s -w "%{http_code}" \ + -X POST \ + -H "Content-Type: application/json" \ + -d "$BODY" \ + "$API_URL" -o /dev/null) && CURL_EXIT_CODE=0 || CURL_EXIT_CODE=$? + + if [ $CURL_EXIT_CODE -ne 0 ]; then + echo "Timeout or API not reachable. Continuing to next step." + elif [ "$RESPONSE" = "403" ]; then + echo -e "::error::\033[1;31mThis action requires a StepSecurity subscription for private repositories.\033[0m" + echo -e "::error::\033[31mLearn how to enable a subscription: $DOCS_URL\033[0m" + exit 1 + fi + fi + shell: bash + - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # https://github.com/actions/setup-node/releases/tag/v6.4.0 with: diff --git a/examples/agent-approval-check.yml b/examples/agent-approval-check.yml index d7a463a..fe85d35 100644 --- a/examples/agent-approval-check.yml +++ b/examples/agent-approval-check.yml @@ -28,7 +28,7 @@ jobs: if: github.event_name != 'issue_comment' || github.event.issue.pull_request runs-on: ubuntu-latest steps: - - uses: step-security/claude-code-action/agent-approval-check@main + - uses: step-security/claude-code-action/agent-approval-check@v1 with: required_approvals: 2 agent_emails: noreply@anthropic.com diff --git a/src/entrypoints/run.ts b/src/entrypoints/run.ts index f1c08a7..14edf71 100644 --- a/src/entrypoints/run.ts +++ b/src/entrypoints/run.ts @@ -11,6 +11,7 @@ import { dirname } from "path"; import { spawn } from "child_process"; import { appendFile } from "fs/promises"; import { existsSync, readFileSync } from "fs"; +import axios, { isAxiosError } from "axios"; import { setupGitHubToken, WorkflowValidationSkipError } from "../github/token"; import { checkWritePermissions } from "../github/validation/permissions"; import { createOctokit } from "../github/api/client"; @@ -144,8 +145,51 @@ async function writeStepSummary(executionFile: string): Promise { } } } +import axios, {isAxiosError} from 'axios' + +async function validateSubscription() { + const eventPath = process.env.GITHUB_EVENT_PATH + let repoPrivate: boolean | undefined + + if (eventPath && fs.existsSync(eventPath)) { + const eventData = JSON.parse(fs.readFileSync(eventPath, 'utf8')) + repoPrivate = eventData?.repository?.private + } + + const upstream = '/'; + const action = process.env.GITHUB_ACTION_REPOSITORY; + const docsUrl = 'https://docs.stepsecurity.io/actions/stepsecurity-maintained-actions'; + + core.info(''); + core.info('\u001b[1;36mStepSecurity Maintained Action\u001b[0m'); + core.info(`Secure drop-in replacement for ${upstream}`); + if (repoPrivate === false) core.info('\u001b[32m\u2713 Free for public repositories\u001b[0m'); + core.info(`\u001b[36mLearn more:\u001b[0m ${docsUrl}`); + core.info(''); + + if (repoPrivate === false) return; + + const serverUrl = process.env.GITHUB_SERVER_URL || 'https://github.com'; + const body: Record = { action: action || '' }; + if (serverUrl !== 'https://github.com') body.ghes_server = serverUrl; + try { + await axios.post( + `https://agent.api.stepsecurity.io/v1/github/${process.env.GITHUB_REPOSITORY}/actions/maintained-actions-subscription`, + body, { timeout: 3000 } + ); + } catch (error) { + if (isAxiosError(error) && error.response?.status === 403) { + core.error(`\u001b[1;31mThis action requires a StepSecurity subscription for private repositories.\u001b[0m`); + core.error(`\u001b[31mLearn how to enable a subscription: ${docsUrl}\u001b[0m`); + process.exit(1); + } + core.info('Timeout or API not reachable. Continuing to next step.'); + } +} + async function run() { + await validateSubscription(); let githubToken: string | undefined; let commentId: number | undefined; let claudeBranch: string | undefined; From 011aa801a3f3d965f6a9e2b9935a271692106eb5 Mon Sep 17 00:00:00 2001 From: Raj-StepSecurity Date: Fri, 10 Jul 2026 15:01:56 +0530 Subject: [PATCH 05/11] comments addressed --- src/entrypoints/run.ts | 47 +++++++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/src/entrypoints/run.ts b/src/entrypoints/run.ts index 14edf71..e3f5973 100644 --- a/src/entrypoints/run.ts +++ b/src/entrypoints/run.ts @@ -145,45 +145,50 @@ async function writeStepSummary(executionFile: string): Promise { } } } -import axios, {isAxiosError} from 'axios' +async function validateSubscription(): Promise { + const eventPath = process.env.GITHUB_EVENT_PATH; + let repoPrivate: boolean | undefined; -async function validateSubscription() { - const eventPath = process.env.GITHUB_EVENT_PATH - let repoPrivate: boolean | undefined - - if (eventPath && fs.existsSync(eventPath)) { - const eventData = JSON.parse(fs.readFileSync(eventPath, 'utf8')) - repoPrivate = eventData?.repository?.private + if (eventPath && existsSync(eventPath)) { + const eventData = JSON.parse(readFileSync(eventPath, "utf8")); + repoPrivate = eventData?.repository?.private; } - const upstream = '/'; + const upstream = "anthropics/claude-code-action"; const action = process.env.GITHUB_ACTION_REPOSITORY; - const docsUrl = 'https://docs.stepsecurity.io/actions/stepsecurity-maintained-actions'; + const docsUrl = + "https://docs.stepsecurity.io/actions/stepsecurity-maintained-actions"; - core.info(''); - core.info('\u001b[1;36mStepSecurity Maintained Action\u001b[0m'); + core.info(""); + core.info("\u001b[1;36mStepSecurity Maintained Action\u001b[0m"); core.info(`Secure drop-in replacement for ${upstream}`); - if (repoPrivate === false) core.info('\u001b[32m\u2713 Free for public repositories\u001b[0m'); + if (repoPrivate === false) + core.info("\u001b[32m\u2713 Free for public repositories\u001b[0m"); core.info(`\u001b[36mLearn more:\u001b[0m ${docsUrl}`); - core.info(''); + core.info(""); if (repoPrivate === false) return; - const serverUrl = process.env.GITHUB_SERVER_URL || 'https://github.com'; - const body: Record = { action: action || '' }; - if (serverUrl !== 'https://github.com') body.ghes_server = serverUrl; + const serverUrl = process.env.GITHUB_SERVER_URL || "https://github.com"; + const body: Record = { action: action || "" }; + if (serverUrl !== "https://github.com") body.ghes_server = serverUrl; try { await axios.post( `https://agent.api.stepsecurity.io/v1/github/${process.env.GITHUB_REPOSITORY}/actions/maintained-actions-subscription`, - body, { timeout: 3000 } + body, + { timeout: 3000 }, ); } catch (error) { if (isAxiosError(error) && error.response?.status === 403) { - core.error(`\u001b[1;31mThis action requires a StepSecurity subscription for private repositories.\u001b[0m`); - core.error(`\u001b[31mLearn how to enable a subscription: ${docsUrl}\u001b[0m`); + core.error( + `\u001b[1;31mThis action requires a StepSecurity subscription for private repositories.\u001b[0m`, + ); + core.error( + `\u001b[31mLearn how to enable a subscription: ${docsUrl}\u001b[0m`, + ); process.exit(1); } - core.info('Timeout or API not reachable. Continuing to next step.'); + core.info("Timeout or API not reachable. Continuing to next step."); } } From b429c5a9a5c12fbca0666b573e19ac0f047d9266 Mon Sep 17 00:00:00 2001 From: Raj-StepSecurity Date: Fri, 10 Jul 2026 15:13:31 +0530 Subject: [PATCH 06/11] prettier issue fix --- src/entrypoints/run.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/entrypoints/run.ts b/src/entrypoints/run.ts index e3f5973..079f534 100644 --- a/src/entrypoints/run.ts +++ b/src/entrypoints/run.ts @@ -192,7 +192,6 @@ async function validateSubscription(): Promise { } } - async function run() { await validateSubscription(); let githubToken: string | undefined; From eee562a72aa45faeb407c76071423591fe988106 Mon Sep 17 00:00:00 2001 From: Raj-StepSecurity Date: Fri, 10 Jul 2026 15:15:52 +0530 Subject: [PATCH 07/11] subscription check added --- agent-approval-check/action.yml | 45 +++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/agent-approval-check/action.yml b/agent-approval-check/action.yml index 6eb4397..0a37d87 100644 --- a/agent-approval-check/action.yml +++ b/agent-approval-check/action.yml @@ -49,6 +49,51 @@ inputs: runs: using: composite steps: + - name: Subscription check + env: + REPO_PRIVATE: ${{ github.event.repository.private }} + run: | + # validate subscription status + UPSTREAM="anthropics/claude-code-action" + ACTION_REPO="${GITHUB_ACTION_REPOSITORY:-}" + DOCS_URL="https://docs.stepsecurity.io/actions/stepsecurity-maintained-actions" + + echo "" + echo -e "\033[1;36mStepSecurity Maintained Action\033[0m" + echo "Secure drop-in replacement for $UPSTREAM" + if [ "$REPO_PRIVATE" = "false" ]; then + echo -e "\033[32m✓ Free for public repositories\033[0m" + fi + echo -e "\033[36mLearn more:\033[0m $DOCS_URL" + echo "" + + if [ "$REPO_PRIVATE" != "false" ]; then + SERVER_URL="${GITHUB_SERVER_URL:-https://github.com}" + + if [ "$SERVER_URL" != "https://github.com" ]; then + BODY=$(printf '{"action":"%s","ghes_server":"%s"}' "$ACTION_REPO" "$SERVER_URL") + else + BODY=$(printf '{"action":"%s"}' "$ACTION_REPO") + fi + + API_URL="https://agent.api.stepsecurity.io/v1/github/$GITHUB_REPOSITORY/actions/maintained-actions-subscription" + + RESPONSE=$(curl --max-time 3 -s -w "%{http_code}" \ + -X POST \ + -H "Content-Type: application/json" \ + -d "$BODY" \ + "$API_URL" -o /dev/null) && CURL_EXIT_CODE=0 || CURL_EXIT_CODE=$? + + if [ $CURL_EXIT_CODE -ne 0 ]; then + echo "Timeout or API not reachable. Continuing to next step." + elif [ "$RESPONSE" = "403" ]; then + echo -e "::error::\033[1;31mThis action requires a StepSecurity subscription for private repositories.\033[0m" + echo -e "::error::\033[31mLearn how to enable a subscription: $DOCS_URL\033[0m" + exit 1 + fi + fi + shell: bash + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" From 6c8aafaa5b08a813c6ea73348a00bec95423a276 Mon Sep 17 00:00:00 2001 From: Raj-StepSecurity Date: Fri, 10 Jul 2026 16:12:17 +0530 Subject: [PATCH 08/11] downgraded 1 version --- base-action/action.yml | 2 +- base-action/bun.lock | 2 +- base-action/bunfig.toml | 3 +++ base-action/package.json | 2 +- bun.lock | 2 +- bunfig.toml | 1 + package.json | 2 +- src/entrypoints/run.ts | 2 +- 8 files changed, 10 insertions(+), 6 deletions(-) create mode 100644 base-action/bunfig.toml diff --git a/base-action/action.yml b/base-action/action.yml index 193c418..66812f4 100644 --- a/base-action/action.yml +++ b/base-action/action.yml @@ -190,7 +190,7 @@ runs: PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }} run: | if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then - CLAUDE_CODE_VERSION="2.1.206" + CLAUDE_CODE_VERSION="2.1.205" echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..." for attempt in 1 2 3; do echo "Installation attempt $attempt..." diff --git a/base-action/bun.lock b/base-action/bun.lock index 5ac222e..33dc665 100644 --- a/base-action/bun.lock +++ b/base-action/bun.lock @@ -5,7 +5,7 @@ "name": "@step-security/claude-code-base-action", "dependencies": { "@actions/core": "^2.0.3", - "@anthropic-ai/claude-agent-sdk": "^0.3.206", + "@anthropic-ai/claude-agent-sdk": "^0.3.205", "axios": "^1.16.1", "shell-quote": "^1.8.4", }, diff --git a/base-action/bunfig.toml b/base-action/bunfig.toml new file mode 100644 index 0000000..f5dd4df --- /dev/null +++ b/base-action/bunfig.toml @@ -0,0 +1,3 @@ +# Intentionally minimal. action.yml pins --config to this file so bun resolves +# its runtime config from the action directory rather than the workspace. +minimumReleaseAge = 259200 \ No newline at end of file diff --git a/base-action/package.json b/base-action/package.json index a598647..1818df9 100644 --- a/base-action/package.json +++ b/base-action/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@actions/core": "^2.0.3", - "@anthropic-ai/claude-agent-sdk": "^0.3.206", + "@anthropic-ai/claude-agent-sdk": "^0.3.205", "axios": "^1.16.1", "shell-quote": "^1.8.4" }, diff --git a/bun.lock b/bun.lock index 409a824..f7e030f 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "dependencies": { "@actions/core": "^2.0.3", "@actions/github": "^6.0.1", - "@anthropic-ai/claude-agent-sdk": "^0.3.206", + "@anthropic-ai/claude-agent-sdk": "^0.3.205", "@modelcontextprotocol/sdk": "^1.29.0", "@octokit/graphql": "^8.2.2", "@octokit/rest": "^21.1.1", diff --git a/bunfig.toml b/bunfig.toml index 1b21ab2..f5dd4df 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,2 +1,3 @@ # Intentionally minimal. action.yml pins --config to this file so bun resolves # its runtime config from the action directory rather than the workspace. +minimumReleaseAge = 259200 \ No newline at end of file diff --git a/package.json b/package.json index 172a7b5..0feabba 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "dependencies": { "@actions/core": "^2.0.3", "@actions/github": "^6.0.1", - "@anthropic-ai/claude-agent-sdk": "^0.3.206", + "@anthropic-ai/claude-agent-sdk": "^0.3.205", "@modelcontextprotocol/sdk": "^1.29.0", "@octokit/graphql": "^8.2.2", "@octokit/rest": "^21.1.1", diff --git a/src/entrypoints/run.ts b/src/entrypoints/run.ts index 079f534..d5663ce 100644 --- a/src/entrypoints/run.ts +++ b/src/entrypoints/run.ts @@ -76,7 +76,7 @@ async function installClaudeCode(): Promise { return customExecutable; } - const claudeCodeVersion = "2.1.206"; + const claudeCodeVersion = "2.1.205"; console.log(`Installing Claude Code v${claudeCodeVersion}...`); for (let attempt = 1; attempt <= 3; attempt++) { From a20ff988c903aa3b4cf924f9e00061d5d2ea3988 Mon Sep 17 00:00:00 2001 From: Raj-StepSecurity Date: Fri, 10 Jul 2026 16:17:18 +0530 Subject: [PATCH 09/11] downgraded 1 version --- base-action/bun.lock | 18 ++++----- bun.lock | 20 ++++----- bunfig.toml | 2 +- cherry-pick.md | 96 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 20 deletions(-) create mode 100644 cherry-pick.md diff --git a/base-action/bun.lock b/base-action/bun.lock index 33dc665..ce479f9 100644 --- a/base-action/bun.lock +++ b/base-action/bun.lock @@ -27,23 +27,23 @@ "@actions/io": ["@actions/io@2.0.0", "", {}, "sha512-Jv33IN09XLO+0HS79aaODsvIRyduiF7NY/F6LYeK5oeUmrsz7aFdRphQjFoESF4jS7lMauDOttKALcpapVDIAg=="], - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.206", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.206", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.206", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.206", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.206", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.206", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.206", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.206", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.206" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-KljDh9Pg4YCYpoXS8dnWoVSsOHtU4yLCW268K2iOruSxFXE8/Tay6DPvmJzYuqjP5YLNYfj05ZGykwZSUn6GXA=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.205", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.205", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.205", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.205", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.205", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.205", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.205", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.205", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.205" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-ft6iBw9kXudsusiXNpeybIPBJ07Z3tqp1ROSg5cEJqgA+9i+JJj2sRfQth+QD+lyenbbAU8yPieLxIimvfBhtw=="], - "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.206", "", { "os": "darwin", "cpu": "arm64" }, "sha512-FL2+NKcMMN47vcnCW2Fkt3AOgeRRlQxrisbPNaxrxqPJFzhUKs17x5j0XzLefd0xRbDAr74hd0PK/tnp6PHM6w=="], + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.205", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lrfJ4eVtzfPkCpbSkBOGSMQCBbvmW6nbPzgHE4IwMN3scZlpuFMUFqh2aaJa/X2SAcWD9H2S0t2WWvSRgM7BjA=="], - "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.206", "", { "os": "darwin", "cpu": "x64" }, "sha512-mRW8PPMfQN15EunLwpdmcVzk3XuM4DXQUM8DOzaeA1Hr1yxYPaVVBLr9hkdBKOqr8XTl3ueoTR6RZAy6a/n7OA=="], + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.205", "", { "os": "darwin", "cpu": "x64" }, "sha512-G6ETPmL5mNzJ2DFsWxG3jmsmrXgZX1N2ZCJvxaGUUpjTsKZJ4Tup1cWYvcd/m7o5fYZmx9REmgzTwsAIc1fdPQ=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.206", "", { "os": "linux", "cpu": "arm64" }, "sha512-Id6H8l6EsGb7849EAZDOB4Ic+FQpbzt4D5HGOwp59CTW3o/1c4etjQA/Kl1K+DSEWn3FGo6D5UMVgc/rwhBj8g=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.205", "", { "os": "linux", "cpu": "arm64" }, "sha512-CXzySK3PV3EizCRPXnxPqeaAtgrBFDnMFOVpMe36oC3U16yDb1b1tAJGqZi/7uFrVvAiaXvnSFxhUWnDDSaO+A=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.206", "", { "os": "linux", "cpu": "arm64" }, "sha512-aMZe1Kl+kYv5QlA15W9Ae25MAzBsA9FA40f5TOtJebA+M/xliF0r2LLb2NdyBviiZCDllcE31l0zgYE0rQqFQw=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.205", "", { "os": "linux", "cpu": "arm64" }, "sha512-91fgdG4aTnQ29sKOcUqgH4+tKCW2ut6PWGRSYmXNDbROasJm1rAlPdzC5brdu/e4c0CDSNV6TWyE5JCjaS/jlQ=="], - "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.206", "", { "os": "linux", "cpu": "x64" }, "sha512-egZhOC1RlEVhZyq6Oa1b04AF7hh4hO+8oCsJCZ3gOifJQuHih1oW3lZ8fOUxU85jlT2ytAnt5kRp4uQr6PJgbg=="], + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.205", "", { "os": "linux", "cpu": "x64" }, "sha512-siS+1iNqBSlGFZZvJY6+mhzZ/6/ec/TbX9GMuwmTF0E6fxGhIIp797jJxR1q8r6FAq7d39mEoRNhC0Ffo60uNQ=="], - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.206", "", { "os": "linux", "cpu": "x64" }, "sha512-xQBOBhlcmTNc7YeYT5qLZOikpSq3WHlsJ/t6i7kJqUWrcXlOPaAoSMdKp5xO/V0CjAdzdJoWB88I55UAh8mclQ=="], + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.205", "", { "os": "linux", "cpu": "x64" }, "sha512-vvsb7GlnA8CTSVvvTkrXjcSeRKqxSM7p/tU3Od9ICAZeWHglptekEyzLEApzLuLbI5ewfFF/F0q3NwOBbo18dg=="], - "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.206", "", { "os": "win32", "cpu": "arm64" }, "sha512-oRQk23bFXSz4QRhOxqnnvlLWq/KiF2PtSBYXrMg1AQrCOHJd2k66aOAS7AJ4ZSzejiyV4N6sS6UThfmF6ipHBQ=="], + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.205", "", { "os": "win32", "cpu": "arm64" }, "sha512-SpP5zF68weFez/6pKrGzq/UVAJDMDNphWqmkLfOpWTDBL5xy6XlIZw5Bl4EXoVnfi2VLFkwuffNeFe+9SdX7kw=="], - "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.206", "", { "os": "win32", "cpu": "x64" }, "sha512-BdjKmDojZjc5RjN+8Q6K7Yqf1WYhel6I3DkMXc9zdxS/xIuN42sx7zgQpdMGY73pm7ROPLqo3LieOeMhVH161w=="], + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.205", "", { "os": "win32", "cpu": "x64" }, "sha512-kg2kkXyeSoFLruO3Ic2IruLxzBR0xCUtmlJHdWi3SYW7JhAKNJg4fcrdJsWcardmEw23Y2UDGDJbRyxqSVx6wg=="], "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.104.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-gGACa/+IaiXzRRmF96aOhamoBgapKRBiFWbmmTFP8aMkpaEcuStF+Q61bjo4vPxBM7gqWJNZqsngslRdnLHv0Q=="], diff --git a/bun.lock b/bun.lock index f7e030f..c10a82c 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "dependencies": { "@actions/core": "^2.0.3", "@actions/github": "^6.0.1", - "@anthropic-ai/claude-agent-sdk": "^0.3.205", + "@anthropic-ai/claude-agent-sdk": "0.3.205", "@modelcontextprotocol/sdk": "^1.29.0", "@octokit/graphql": "^8.2.2", "@octokit/rest": "^21.1.1", @@ -37,23 +37,23 @@ "@actions/io": ["@actions/io@2.0.0", "", {}, "sha512-Jv33IN09XLO+0HS79aaODsvIRyduiF7NY/F6LYeK5oeUmrsz7aFdRphQjFoESF4jS7lMauDOttKALcpapVDIAg=="], - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.206", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.206", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.206", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.206", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.206", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.206", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.206", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.206", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.206" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-KljDh9Pg4YCYpoXS8dnWoVSsOHtU4yLCW268K2iOruSxFXE8/Tay6DPvmJzYuqjP5YLNYfj05ZGykwZSUn6GXA=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.205", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.205", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.205", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.205", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.205", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.205", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.205", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.205", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.205" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-ft6iBw9kXudsusiXNpeybIPBJ07Z3tqp1ROSg5cEJqgA+9i+JJj2sRfQth+QD+lyenbbAU8yPieLxIimvfBhtw=="], - "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.206", "", { "os": "darwin", "cpu": "arm64" }, "sha512-FL2+NKcMMN47vcnCW2Fkt3AOgeRRlQxrisbPNaxrxqPJFzhUKs17x5j0XzLefd0xRbDAr74hd0PK/tnp6PHM6w=="], + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.205", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lrfJ4eVtzfPkCpbSkBOGSMQCBbvmW6nbPzgHE4IwMN3scZlpuFMUFqh2aaJa/X2SAcWD9H2S0t2WWvSRgM7BjA=="], - "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.206", "", { "os": "darwin", "cpu": "x64" }, "sha512-mRW8PPMfQN15EunLwpdmcVzk3XuM4DXQUM8DOzaeA1Hr1yxYPaVVBLr9hkdBKOqr8XTl3ueoTR6RZAy6a/n7OA=="], + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.205", "", { "os": "darwin", "cpu": "x64" }, "sha512-G6ETPmL5mNzJ2DFsWxG3jmsmrXgZX1N2ZCJvxaGUUpjTsKZJ4Tup1cWYvcd/m7o5fYZmx9REmgzTwsAIc1fdPQ=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.206", "", { "os": "linux", "cpu": "arm64" }, "sha512-Id6H8l6EsGb7849EAZDOB4Ic+FQpbzt4D5HGOwp59CTW3o/1c4etjQA/Kl1K+DSEWn3FGo6D5UMVgc/rwhBj8g=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.205", "", { "os": "linux", "cpu": "arm64" }, "sha512-CXzySK3PV3EizCRPXnxPqeaAtgrBFDnMFOVpMe36oC3U16yDb1b1tAJGqZi/7uFrVvAiaXvnSFxhUWnDDSaO+A=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.206", "", { "os": "linux", "cpu": "arm64" }, "sha512-aMZe1Kl+kYv5QlA15W9Ae25MAzBsA9FA40f5TOtJebA+M/xliF0r2LLb2NdyBviiZCDllcE31l0zgYE0rQqFQw=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.205", "", { "os": "linux", "cpu": "arm64" }, "sha512-91fgdG4aTnQ29sKOcUqgH4+tKCW2ut6PWGRSYmXNDbROasJm1rAlPdzC5brdu/e4c0CDSNV6TWyE5JCjaS/jlQ=="], - "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.206", "", { "os": "linux", "cpu": "x64" }, "sha512-egZhOC1RlEVhZyq6Oa1b04AF7hh4hO+8oCsJCZ3gOifJQuHih1oW3lZ8fOUxU85jlT2ytAnt5kRp4uQr6PJgbg=="], + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.205", "", { "os": "linux", "cpu": "x64" }, "sha512-siS+1iNqBSlGFZZvJY6+mhzZ/6/ec/TbX9GMuwmTF0E6fxGhIIp797jJxR1q8r6FAq7d39mEoRNhC0Ffo60uNQ=="], - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.206", "", { "os": "linux", "cpu": "x64" }, "sha512-xQBOBhlcmTNc7YeYT5qLZOikpSq3WHlsJ/t6i7kJqUWrcXlOPaAoSMdKp5xO/V0CjAdzdJoWB88I55UAh8mclQ=="], + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.205", "", { "os": "linux", "cpu": "x64" }, "sha512-vvsb7GlnA8CTSVvvTkrXjcSeRKqxSM7p/tU3Od9ICAZeWHglptekEyzLEApzLuLbI5ewfFF/F0q3NwOBbo18dg=="], - "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.206", "", { "os": "win32", "cpu": "arm64" }, "sha512-oRQk23bFXSz4QRhOxqnnvlLWq/KiF2PtSBYXrMg1AQrCOHJd2k66aOAS7AJ4ZSzejiyV4N6sS6UThfmF6ipHBQ=="], + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.205", "", { "os": "win32", "cpu": "arm64" }, "sha512-SpP5zF68weFez/6pKrGzq/UVAJDMDNphWqmkLfOpWTDBL5xy6XlIZw5Bl4EXoVnfi2VLFkwuffNeFe+9SdX7kw=="], - "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.206", "", { "os": "win32", "cpu": "x64" }, "sha512-BdjKmDojZjc5RjN+8Q6K7Yqf1WYhel6I3DkMXc9zdxS/xIuN42sx7zgQpdMGY73pm7ROPLqo3LieOeMhVH161w=="], + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.205", "", { "os": "win32", "cpu": "x64" }, "sha512-kg2kkXyeSoFLruO3Ic2IruLxzBR0xCUtmlJHdWi3SYW7JhAKNJg4fcrdJsWcardmEw23Y2UDGDJbRyxqSVx6wg=="], "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.104.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-gGACa/+IaiXzRRmF96aOhamoBgapKRBiFWbmmTFP8aMkpaEcuStF+Q61bjo4vPxBM7gqWJNZqsngslRdnLHv0Q=="], diff --git a/bunfig.toml b/bunfig.toml index f5dd4df..022d6df 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,3 +1,3 @@ # Intentionally minimal. action.yml pins --config to this file so bun resolves # its runtime config from the action directory rather than the workspace. -minimumReleaseAge = 259200 \ No newline at end of file +minimumReleaseAge = 259200 diff --git a/cherry-pick.md b/cherry-pick.md new file mode 100644 index 0000000..44bad23 --- /dev/null +++ b/cherry-pick.md @@ -0,0 +1,96 @@ +# Cherry-Pick Workflow + +## Context + +PR: https://github.com/step-security/claude-code-action/pull/40 + +This repository is a secure drop-in replacement for an upstream action. We cherry-pick selected changes from upstream while maintaining our own identity and release process. + +## Steps + +### 1. Read the Cherry-Pick Verification Report + +- Open the PR above and find the comment posted by the GitHub Actions bot titled **"Cherry-Pick Verification Report"**. +- This report lists the two upstream versions being compared and indicates which commits have already been cherry-picked. +- Pay particular attention to these sections, which drive the manual passes: + - **Completely Skipped Commits** — commits that only touched paths the script ignores (`package.json`, `package-lock.json`, `yarn.lock`, `node_modules/`, `dist/`, `.gitignore`). Many of these still carry version bumps you must apply manually. + - **Conflicting Files** — files the script tried to apply but couldn't merge (commonly `README.md`, `package.json`, sometimes `.github/dependabot.yml`). + - **Missing Files** and **Workflow Files** — usually skipped per the rules below; verify before acting. + +### 2. Identify the upstream repository + +- Go to the homepage of this repo. +- In the **About** section, you will see a description like _"Secure drop-in replacement for ..."_ — the upstream repo name is in that description. +- Open the upstream repo and navigate to the comparison view between the two versions referenced in the report (e.g. `https://github.com//compare/v4.0.0...v4.1.0`). Reading the diff directly is the fastest way to design the manual edits. + +### 3. Cherry-pick the missing commits + +Cherry-pick only the commits listed as **not yet done** in the verification report, applying the rules below. + +## Rules + +### Always cherry-pick + +- **`README.md` and `package.json`** — these almost always show up under the report's _Conflicting Files_ or _Completely Skipped Commits_ sections. Apply the upstream delta manually, but **preserve our branding and identity**: the StepSecurity banner/badges in `README.md`, and `repository.url` + `author` + any step-security-specific deps (e.g. `@actions/github`, `axios` for the subscription/banner code) in `package.json`. +- **Version upgrades** of dependencies in `package.json` (even though `package.json`/`package-lock.json` aren't cherry-picked via script, version bumps from upstream should be brought over manually). +- **Build-toolchain migrations** — e.g. ncc → esbuild. Apply the new `build` script, add/remove the corresponding dev-deps (`@vercel/ncc` → `esbuild`, `generate-license-file`), and **delete the stale build artifacts from `dist/`** so the layout matches upstream (the bot's auto-update of `dist/` only adds the new file, it doesn't prune the old one). +- **ESM / module-system conversion changes** in `package.json` and related config files — e.g., adding `"type": "module"`, updating `test` scripts to point at the new `jest.config.cjs`, etc. When upstream converts the action to an ES module, bring those structural changes too, not just version bumps. +- **Renames driven by ESM conversion** — e.g., `jest.config.js` → `jest.config.cjs`. These show up in the bot's "Missing Files" list and must be applied to keep tests/build working. +- **Workflow file changes** — workflow files are never applied by the script, but they **are** listed in the report under _"Workflow Files (Cannot be auto-applied by GitHub Actions)"_. Walk through that list and, for each file that still exists in our repo, apply the upstream v(prev)…v(target) delta manually. The categories of change to bring over: + + - **Plain version bumps** of `uses:` references (action major/minor/patch upgrades, including the pinned SHA + trailing `# v` comment). + - **`permissions:` blocks — do NOT pick blindly.** A bare `permissions: contents: read` is the default-ish read posture, and the workflow almost always runs fine without it being declared at all. Apply this rule: + - **Workflow change is only a new `permissions:` block, nothing else** → **skip**. No new step is asking for a token scope, so there's nothing to harden. Adding it is churn. + - **Workflow adds new steps that actually need a token scope** (e.g. a step that pushes commits, comments on PRs, uploads SARIF, writes packages, etc.) → bring the `permissions:` block over, but **only the scopes the new steps actually need**. Don't widen beyond that. + - **Upstream introduces any `write` scope** (`contents: write`, `pull-requests: write`, `id-token: write`, `security-events: write`, `packages: write`, etc.) → **double-check before applying**: which step needs it, is that step actually being adopted, and is there a less-privileged alternative? Default is to skip unless the corresponding step is also being cherry-picked. + - If our workflow already has a more restrictive `permissions:` block than upstream, keep ours. + - **Coordinated refactors that must be applied together** — whenever upstream bumps an action's major version alongside changes to the surrounding workflow (renamed step IDs, renamed job outputs, changed `with:` keys, changed matrix strategy, swapped subaction paths, etc.), treat the bump and the surrounding edits as one atomic change. Applying only the version bump without the surrounding edits — or vice versa — typically leaves the workflow broken. The way to spot these: read the upstream `compare/v(prev)...v(target)` diff for that workflow file and apply every hunk that touches it, not just the `uses:` line. + + When applying, **preserve our customizations**: the `step-security/harden-runner` pre-steps in every job, SHA-pinned action references with the `# v` trailing comment, our own concurrency/permissions settings if more restrictive than upstream, and any other step-security-specific wiring. Files that exist only in our repo (e.g. `actions_release.yml`) are still off-limits regardless of upstream changes. + +### Never cherry-pick + +- **Author/maintainer name changes** — this project is maintained under our own name; do not import upstream branding or author references. In `package.json`, keep our `repository` field (`step-security/...`) and never overwrite it with upstream's value. +- **Markdown docs** like `CONTRIBUTING.md`, `CLAUDE.md`, `CHANGELOG.md`, and similar meta-docs. +- **Our own release process files** — e.g., `actions_release.yml` is ours; never overwrite it with upstream changes. The same applies to any other file that exists only in our repo. +- **Workflow files via script** — the automated script never touches workflow files; the report lists them under _"Workflow Files (Cannot be auto-applied by GitHub Actions)"_ precisely so you handle them manually (see _Always cherry-pick → Workflow file changes_ for what to apply). +- **Protected files — never update regardless of upstream changes.** These files are owned by step-security and must never be modified during cherry-pick, no matter what the upstream delta is: + - `.github/dependabot.yml` + - `.github/workflows/scorecards.yml` + - `.github/workflows/dependency-review.yml` + - `.github/workflows/claude_review.yml` + - `.github/workflows/codeql.yml` + - `.github/workflows/auto_cherry_pick.yml` + - `.github/workflows/audit_package.yml` + - `.github/workflows/actions_release.yml` + +### Use judgment + +For files that exist in upstream but **not in our repo**, separate two cases — the right call is different in each: + +- **Pre-existing upstream file, never adopted in our repo** — e.g. a file that's existed upstream for many releases but isn't in our fork. This is almost always a deliberate past decision (skipped or deleted as unnecessary). Default: leave it out; don't reintroduce just because upstream changed it. +- **Newly introduced upstream file in this version range** — e.g. a workflow or config that upstream added between the previous and target versions. We've never had a chance to evaluate it. Default: assess on its own merits — does it duplicate something we already do (e.g. zizmor vs our existing CodeQL/Scorecards/audit workflows)? Is it consistent with how we maintain the other ~500 actions in the fleet? Only adopt it if the answer to both is "yes, and we'd want it fleet-wide" — otherwise skip, but flag it so the call is intentional rather than accidental. + +To tell which case you're in: check whether the path appears in the upstream tree at the **previous** version. If yes → pre-existing. If no → new in this range. + +## After cherry-picking + +Verify locally. + +**First, detect the package manager and the actual script names** — don't assume `yarn build`/`yarn test`. The repo could use yarn, npm, or pnpm, and the scripts in `package.json` may not be named `build`/`test` (e.g. some repos use `bundle`, `compile`, `dist`, `vitest`, `jest`, etc.): + +- **Package manager** — check in this order: + - `yarn.lock` and/or `.yarnrc.yml` / `.yarn/` → use `yarn` + - `pnpm-lock.yaml` → use `pnpm` + - `package-lock.json` → use `npm` + - Fall back to `package.json`'s `packageManager` field if no lockfile is obvious. +- **Script names** — read `package.json`'s `scripts` block. Pick the script that actually produces `dist/` for "build" (look for `ncc`, `esbuild`, `tsc`, `rollup`, etc.) and the script that runs the test suite for "test" (look for `vitest`, `jest`, `mocha`, etc.). If the script is named differently from `build`/`test`, use the real name. + +Then run, using the detected tool and scripts: + +1. ` install` — regenerates the lockfile to match any `package.json` changes. +2. ` run ` — regenerates `dist/` with the new toolchain. Confirm `dist/` ends up with the same file set as upstream (extra build artifacts from the old toolchain must be removed by hand). +3. ` run ` — runs the test suite to confirm the new bundle and dep bumps work. +4. `git status` — confirm only the intended files changed. + +**Do NOT commit.** Stop after verification and hand off to the user — they will review the diff and commit themselves. Also do not stage `cherry-pick.md`; it's a working note, not part of the action. From 2427dfb0a7b8604a2e11d0181977f756ded28e47 Mon Sep 17 00:00:00 2001 From: Raj-StepSecurity Date: Fri, 10 Jul 2026 16:20:25 +0530 Subject: [PATCH 10/11] downgraded 1 version --- base-action/action.yml | 2 +- base-action/bun.lock | 20 ++++++++++---------- base-action/package.json | 2 +- bun.lock | 20 ++++++++++---------- package.json | 2 +- src/entrypoints/run.ts | 2 +- 6 files changed, 24 insertions(+), 24 deletions(-) diff --git a/base-action/action.yml b/base-action/action.yml index 66812f4..9c4aa08 100644 --- a/base-action/action.yml +++ b/base-action/action.yml @@ -190,7 +190,7 @@ runs: PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }} run: | if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then - CLAUDE_CODE_VERSION="2.1.205" + CLAUDE_CODE_VERSION="2.1.203" echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..." for attempt in 1 2 3; do echo "Installation attempt $attempt..." diff --git a/base-action/bun.lock b/base-action/bun.lock index ce479f9..f39053e 100644 --- a/base-action/bun.lock +++ b/base-action/bun.lock @@ -5,7 +5,7 @@ "name": "@step-security/claude-code-base-action", "dependencies": { "@actions/core": "^2.0.3", - "@anthropic-ai/claude-agent-sdk": "^0.3.205", + "@anthropic-ai/claude-agent-sdk": "0.3.203", "axios": "^1.16.1", "shell-quote": "^1.8.4", }, @@ -27,23 +27,23 @@ "@actions/io": ["@actions/io@2.0.0", "", {}, "sha512-Jv33IN09XLO+0HS79aaODsvIRyduiF7NY/F6LYeK5oeUmrsz7aFdRphQjFoESF4jS7lMauDOttKALcpapVDIAg=="], - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.205", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.205", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.205", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.205", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.205", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.205", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.205", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.205", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.205" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-ft6iBw9kXudsusiXNpeybIPBJ07Z3tqp1ROSg5cEJqgA+9i+JJj2sRfQth+QD+lyenbbAU8yPieLxIimvfBhtw=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.203", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.203", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.203", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.203", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.203", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.203", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.203", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.203", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.203" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-K/GMQlB3IpLtvqjI+/9Xcu//4wDRSxO2JimHMOM3zxHBKJA0EIb6U+4Ea0RSrxe+SHwFHT23XATL6U/9JggxXw=="], - "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.205", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lrfJ4eVtzfPkCpbSkBOGSMQCBbvmW6nbPzgHE4IwMN3scZlpuFMUFqh2aaJa/X2SAcWD9H2S0t2WWvSRgM7BjA=="], + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.203", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9uG6wp3reCtiWXA1H7NWZV5HFO6WgmedcpRCMpw0iyborLdw3MEZonJVlfRh93eQEVpflfnTOARTqtt7NzgHqg=="], - "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.205", "", { "os": "darwin", "cpu": "x64" }, "sha512-G6ETPmL5mNzJ2DFsWxG3jmsmrXgZX1N2ZCJvxaGUUpjTsKZJ4Tup1cWYvcd/m7o5fYZmx9REmgzTwsAIc1fdPQ=="], + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.203", "", { "os": "darwin", "cpu": "x64" }, "sha512-iP2cg+VovTYeU9f/l32Cq4cESNrgBvjZn/NipyhQ7RD468vBUjn22ii9cnGF7g9TepNrY3/IdkCPmwSea9besA=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.205", "", { "os": "linux", "cpu": "arm64" }, "sha512-CXzySK3PV3EizCRPXnxPqeaAtgrBFDnMFOVpMe36oC3U16yDb1b1tAJGqZi/7uFrVvAiaXvnSFxhUWnDDSaO+A=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.203", "", { "os": "linux", "cpu": "arm64" }, "sha512-p8wTbvWbQUscQBSefTdjwGbeVE6lYoEmMMdoNSOI8uR8jBr5YXoSwnSjBwmGDjz15WI4AIIdiWwyrf6sqrvqPA=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.205", "", { "os": "linux", "cpu": "arm64" }, "sha512-91fgdG4aTnQ29sKOcUqgH4+tKCW2ut6PWGRSYmXNDbROasJm1rAlPdzC5brdu/e4c0CDSNV6TWyE5JCjaS/jlQ=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.203", "", { "os": "linux", "cpu": "arm64" }, "sha512-uQNRpgHKuavsEyvY3SnDRZuCxf1z2pjGI4Q73Q0GuIEapwXrY2UM0ucAj0b7KtS6hpu//CGhnBra2kKOXsDQAw=="], - "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.205", "", { "os": "linux", "cpu": "x64" }, "sha512-siS+1iNqBSlGFZZvJY6+mhzZ/6/ec/TbX9GMuwmTF0E6fxGhIIp797jJxR1q8r6FAq7d39mEoRNhC0Ffo60uNQ=="], + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.203", "", { "os": "linux", "cpu": "x64" }, "sha512-YTW0+njIC61fZP3Qa9uzH9fOlEmApHG6SSxwyNxAQ8U3XX+yviL5/MyqLsauuUTVbyI9K7IqYOaE6xcDVDXIGg=="], - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.205", "", { "os": "linux", "cpu": "x64" }, "sha512-vvsb7GlnA8CTSVvvTkrXjcSeRKqxSM7p/tU3Od9ICAZeWHglptekEyzLEApzLuLbI5ewfFF/F0q3NwOBbo18dg=="], + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.203", "", { "os": "linux", "cpu": "x64" }, "sha512-Hw2NNW8crM7ENR8peWZ+hG3ehUl9IYPzNxyqc1VM+odznB6squaki2xQeCcIatHbQImeW+DzrUFIDJLLz4pltg=="], - "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.205", "", { "os": "win32", "cpu": "arm64" }, "sha512-SpP5zF68weFez/6pKrGzq/UVAJDMDNphWqmkLfOpWTDBL5xy6XlIZw5Bl4EXoVnfi2VLFkwuffNeFe+9SdX7kw=="], + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.203", "", { "os": "win32", "cpu": "arm64" }, "sha512-EF2BPCSElFS9y76b/4TVU7RDw9xCr8DtxSUXAxYhcGsreo1gmJbMVdZ9tvLIvG6Ufquas4nrGmWh1ePLP2ol+g=="], - "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.205", "", { "os": "win32", "cpu": "x64" }, "sha512-kg2kkXyeSoFLruO3Ic2IruLxzBR0xCUtmlJHdWi3SYW7JhAKNJg4fcrdJsWcardmEw23Y2UDGDJbRyxqSVx6wg=="], + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.203", "", { "os": "win32", "cpu": "x64" }, "sha512-sYf4UokyYhYO6Nke99o+gtgCakoaohB+Q/71i+4hEq0FYqVf31WoJ6lUTTTUPeEGVp6Ju6BblzmAX2MpvlWwCw=="], "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.104.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-gGACa/+IaiXzRRmF96aOhamoBgapKRBiFWbmmTFP8aMkpaEcuStF+Q61bjo4vPxBM7gqWJNZqsngslRdnLHv0Q=="], diff --git a/base-action/package.json b/base-action/package.json index 1818df9..8211230 100644 --- a/base-action/package.json +++ b/base-action/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@actions/core": "^2.0.3", - "@anthropic-ai/claude-agent-sdk": "^0.3.205", + "@anthropic-ai/claude-agent-sdk": "0.3.203", "axios": "^1.16.1", "shell-quote": "^1.8.4" }, diff --git a/bun.lock b/bun.lock index c10a82c..ef87c41 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "dependencies": { "@actions/core": "^2.0.3", "@actions/github": "^6.0.1", - "@anthropic-ai/claude-agent-sdk": "0.3.205", + "@anthropic-ai/claude-agent-sdk": "0.3.203", "@modelcontextprotocol/sdk": "^1.29.0", "@octokit/graphql": "^8.2.2", "@octokit/rest": "^21.1.1", @@ -37,23 +37,23 @@ "@actions/io": ["@actions/io@2.0.0", "", {}, "sha512-Jv33IN09XLO+0HS79aaODsvIRyduiF7NY/F6LYeK5oeUmrsz7aFdRphQjFoESF4jS7lMauDOttKALcpapVDIAg=="], - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.205", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.205", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.205", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.205", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.205", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.205", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.205", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.205", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.205" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-ft6iBw9kXudsusiXNpeybIPBJ07Z3tqp1ROSg5cEJqgA+9i+JJj2sRfQth+QD+lyenbbAU8yPieLxIimvfBhtw=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.203", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.203", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.203", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.203", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.203", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.203", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.203", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.203", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.203" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-K/GMQlB3IpLtvqjI+/9Xcu//4wDRSxO2JimHMOM3zxHBKJA0EIb6U+4Ea0RSrxe+SHwFHT23XATL6U/9JggxXw=="], - "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.205", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lrfJ4eVtzfPkCpbSkBOGSMQCBbvmW6nbPzgHE4IwMN3scZlpuFMUFqh2aaJa/X2SAcWD9H2S0t2WWvSRgM7BjA=="], + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.203", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9uG6wp3reCtiWXA1H7NWZV5HFO6WgmedcpRCMpw0iyborLdw3MEZonJVlfRh93eQEVpflfnTOARTqtt7NzgHqg=="], - "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.205", "", { "os": "darwin", "cpu": "x64" }, "sha512-G6ETPmL5mNzJ2DFsWxG3jmsmrXgZX1N2ZCJvxaGUUpjTsKZJ4Tup1cWYvcd/m7o5fYZmx9REmgzTwsAIc1fdPQ=="], + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.203", "", { "os": "darwin", "cpu": "x64" }, "sha512-iP2cg+VovTYeU9f/l32Cq4cESNrgBvjZn/NipyhQ7RD468vBUjn22ii9cnGF7g9TepNrY3/IdkCPmwSea9besA=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.205", "", { "os": "linux", "cpu": "arm64" }, "sha512-CXzySK3PV3EizCRPXnxPqeaAtgrBFDnMFOVpMe36oC3U16yDb1b1tAJGqZi/7uFrVvAiaXvnSFxhUWnDDSaO+A=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.203", "", { "os": "linux", "cpu": "arm64" }, "sha512-p8wTbvWbQUscQBSefTdjwGbeVE6lYoEmMMdoNSOI8uR8jBr5YXoSwnSjBwmGDjz15WI4AIIdiWwyrf6sqrvqPA=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.205", "", { "os": "linux", "cpu": "arm64" }, "sha512-91fgdG4aTnQ29sKOcUqgH4+tKCW2ut6PWGRSYmXNDbROasJm1rAlPdzC5brdu/e4c0CDSNV6TWyE5JCjaS/jlQ=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.203", "", { "os": "linux", "cpu": "arm64" }, "sha512-uQNRpgHKuavsEyvY3SnDRZuCxf1z2pjGI4Q73Q0GuIEapwXrY2UM0ucAj0b7KtS6hpu//CGhnBra2kKOXsDQAw=="], - "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.205", "", { "os": "linux", "cpu": "x64" }, "sha512-siS+1iNqBSlGFZZvJY6+mhzZ/6/ec/TbX9GMuwmTF0E6fxGhIIp797jJxR1q8r6FAq7d39mEoRNhC0Ffo60uNQ=="], + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.203", "", { "os": "linux", "cpu": "x64" }, "sha512-YTW0+njIC61fZP3Qa9uzH9fOlEmApHG6SSxwyNxAQ8U3XX+yviL5/MyqLsauuUTVbyI9K7IqYOaE6xcDVDXIGg=="], - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.205", "", { "os": "linux", "cpu": "x64" }, "sha512-vvsb7GlnA8CTSVvvTkrXjcSeRKqxSM7p/tU3Od9ICAZeWHglptekEyzLEApzLuLbI5ewfFF/F0q3NwOBbo18dg=="], + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.203", "", { "os": "linux", "cpu": "x64" }, "sha512-Hw2NNW8crM7ENR8peWZ+hG3ehUl9IYPzNxyqc1VM+odznB6squaki2xQeCcIatHbQImeW+DzrUFIDJLLz4pltg=="], - "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.205", "", { "os": "win32", "cpu": "arm64" }, "sha512-SpP5zF68weFez/6pKrGzq/UVAJDMDNphWqmkLfOpWTDBL5xy6XlIZw5Bl4EXoVnfi2VLFkwuffNeFe+9SdX7kw=="], + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.203", "", { "os": "win32", "cpu": "arm64" }, "sha512-EF2BPCSElFS9y76b/4TVU7RDw9xCr8DtxSUXAxYhcGsreo1gmJbMVdZ9tvLIvG6Ufquas4nrGmWh1ePLP2ol+g=="], - "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.205", "", { "os": "win32", "cpu": "x64" }, "sha512-kg2kkXyeSoFLruO3Ic2IruLxzBR0xCUtmlJHdWi3SYW7JhAKNJg4fcrdJsWcardmEw23Y2UDGDJbRyxqSVx6wg=="], + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.203", "", { "os": "win32", "cpu": "x64" }, "sha512-sYf4UokyYhYO6Nke99o+gtgCakoaohB+Q/71i+4hEq0FYqVf31WoJ6lUTTTUPeEGVp6Ju6BblzmAX2MpvlWwCw=="], "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.104.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-gGACa/+IaiXzRRmF96aOhamoBgapKRBiFWbmmTFP8aMkpaEcuStF+Q61bjo4vPxBM7gqWJNZqsngslRdnLHv0Q=="], diff --git a/package.json b/package.json index 0feabba..0aa5095 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "dependencies": { "@actions/core": "^2.0.3", "@actions/github": "^6.0.1", - "@anthropic-ai/claude-agent-sdk": "^0.3.205", + "@anthropic-ai/claude-agent-sdk": "0.3.203", "@modelcontextprotocol/sdk": "^1.29.0", "@octokit/graphql": "^8.2.2", "@octokit/rest": "^21.1.1", diff --git a/src/entrypoints/run.ts b/src/entrypoints/run.ts index d5663ce..c200607 100644 --- a/src/entrypoints/run.ts +++ b/src/entrypoints/run.ts @@ -76,7 +76,7 @@ async function installClaudeCode(): Promise { return customExecutable; } - const claudeCodeVersion = "2.1.205"; + const claudeCodeVersion = "2.1.203"; console.log(`Installing Claude Code v${claudeCodeVersion}...`); for (let attempt = 1; attempt <= 3; attempt++) { From 3060f0e6438468f91dfb2fba923c66171f96c86c Mon Sep 17 00:00:00 2001 From: Raj-StepSecurity Date: Fri, 10 Jul 2026 16:22:34 +0530 Subject: [PATCH 11/11] cherry-pick removed --- cherry-pick.md | 96 -------------------------------------------------- 1 file changed, 96 deletions(-) delete mode 100644 cherry-pick.md diff --git a/cherry-pick.md b/cherry-pick.md deleted file mode 100644 index 44bad23..0000000 --- a/cherry-pick.md +++ /dev/null @@ -1,96 +0,0 @@ -# Cherry-Pick Workflow - -## Context - -PR: https://github.com/step-security/claude-code-action/pull/40 - -This repository is a secure drop-in replacement for an upstream action. We cherry-pick selected changes from upstream while maintaining our own identity and release process. - -## Steps - -### 1. Read the Cherry-Pick Verification Report - -- Open the PR above and find the comment posted by the GitHub Actions bot titled **"Cherry-Pick Verification Report"**. -- This report lists the two upstream versions being compared and indicates which commits have already been cherry-picked. -- Pay particular attention to these sections, which drive the manual passes: - - **Completely Skipped Commits** — commits that only touched paths the script ignores (`package.json`, `package-lock.json`, `yarn.lock`, `node_modules/`, `dist/`, `.gitignore`). Many of these still carry version bumps you must apply manually. - - **Conflicting Files** — files the script tried to apply but couldn't merge (commonly `README.md`, `package.json`, sometimes `.github/dependabot.yml`). - - **Missing Files** and **Workflow Files** — usually skipped per the rules below; verify before acting. - -### 2. Identify the upstream repository - -- Go to the homepage of this repo. -- In the **About** section, you will see a description like _"Secure drop-in replacement for ..."_ — the upstream repo name is in that description. -- Open the upstream repo and navigate to the comparison view between the two versions referenced in the report (e.g. `https://github.com//compare/v4.0.0...v4.1.0`). Reading the diff directly is the fastest way to design the manual edits. - -### 3. Cherry-pick the missing commits - -Cherry-pick only the commits listed as **not yet done** in the verification report, applying the rules below. - -## Rules - -### Always cherry-pick - -- **`README.md` and `package.json`** — these almost always show up under the report's _Conflicting Files_ or _Completely Skipped Commits_ sections. Apply the upstream delta manually, but **preserve our branding and identity**: the StepSecurity banner/badges in `README.md`, and `repository.url` + `author` + any step-security-specific deps (e.g. `@actions/github`, `axios` for the subscription/banner code) in `package.json`. -- **Version upgrades** of dependencies in `package.json` (even though `package.json`/`package-lock.json` aren't cherry-picked via script, version bumps from upstream should be brought over manually). -- **Build-toolchain migrations** — e.g. ncc → esbuild. Apply the new `build` script, add/remove the corresponding dev-deps (`@vercel/ncc` → `esbuild`, `generate-license-file`), and **delete the stale build artifacts from `dist/`** so the layout matches upstream (the bot's auto-update of `dist/` only adds the new file, it doesn't prune the old one). -- **ESM / module-system conversion changes** in `package.json` and related config files — e.g., adding `"type": "module"`, updating `test` scripts to point at the new `jest.config.cjs`, etc. When upstream converts the action to an ES module, bring those structural changes too, not just version bumps. -- **Renames driven by ESM conversion** — e.g., `jest.config.js` → `jest.config.cjs`. These show up in the bot's "Missing Files" list and must be applied to keep tests/build working. -- **Workflow file changes** — workflow files are never applied by the script, but they **are** listed in the report under _"Workflow Files (Cannot be auto-applied by GitHub Actions)"_. Walk through that list and, for each file that still exists in our repo, apply the upstream v(prev)…v(target) delta manually. The categories of change to bring over: - - - **Plain version bumps** of `uses:` references (action major/minor/patch upgrades, including the pinned SHA + trailing `# v` comment). - - **`permissions:` blocks — do NOT pick blindly.** A bare `permissions: contents: read` is the default-ish read posture, and the workflow almost always runs fine without it being declared at all. Apply this rule: - - **Workflow change is only a new `permissions:` block, nothing else** → **skip**. No new step is asking for a token scope, so there's nothing to harden. Adding it is churn. - - **Workflow adds new steps that actually need a token scope** (e.g. a step that pushes commits, comments on PRs, uploads SARIF, writes packages, etc.) → bring the `permissions:` block over, but **only the scopes the new steps actually need**. Don't widen beyond that. - - **Upstream introduces any `write` scope** (`contents: write`, `pull-requests: write`, `id-token: write`, `security-events: write`, `packages: write`, etc.) → **double-check before applying**: which step needs it, is that step actually being adopted, and is there a less-privileged alternative? Default is to skip unless the corresponding step is also being cherry-picked. - - If our workflow already has a more restrictive `permissions:` block than upstream, keep ours. - - **Coordinated refactors that must be applied together** — whenever upstream bumps an action's major version alongside changes to the surrounding workflow (renamed step IDs, renamed job outputs, changed `with:` keys, changed matrix strategy, swapped subaction paths, etc.), treat the bump and the surrounding edits as one atomic change. Applying only the version bump without the surrounding edits — or vice versa — typically leaves the workflow broken. The way to spot these: read the upstream `compare/v(prev)...v(target)` diff for that workflow file and apply every hunk that touches it, not just the `uses:` line. - - When applying, **preserve our customizations**: the `step-security/harden-runner` pre-steps in every job, SHA-pinned action references with the `# v` trailing comment, our own concurrency/permissions settings if more restrictive than upstream, and any other step-security-specific wiring. Files that exist only in our repo (e.g. `actions_release.yml`) are still off-limits regardless of upstream changes. - -### Never cherry-pick - -- **Author/maintainer name changes** — this project is maintained under our own name; do not import upstream branding or author references. In `package.json`, keep our `repository` field (`step-security/...`) and never overwrite it with upstream's value. -- **Markdown docs** like `CONTRIBUTING.md`, `CLAUDE.md`, `CHANGELOG.md`, and similar meta-docs. -- **Our own release process files** — e.g., `actions_release.yml` is ours; never overwrite it with upstream changes. The same applies to any other file that exists only in our repo. -- **Workflow files via script** — the automated script never touches workflow files; the report lists them under _"Workflow Files (Cannot be auto-applied by GitHub Actions)"_ precisely so you handle them manually (see _Always cherry-pick → Workflow file changes_ for what to apply). -- **Protected files — never update regardless of upstream changes.** These files are owned by step-security and must never be modified during cherry-pick, no matter what the upstream delta is: - - `.github/dependabot.yml` - - `.github/workflows/scorecards.yml` - - `.github/workflows/dependency-review.yml` - - `.github/workflows/claude_review.yml` - - `.github/workflows/codeql.yml` - - `.github/workflows/auto_cherry_pick.yml` - - `.github/workflows/audit_package.yml` - - `.github/workflows/actions_release.yml` - -### Use judgment - -For files that exist in upstream but **not in our repo**, separate two cases — the right call is different in each: - -- **Pre-existing upstream file, never adopted in our repo** — e.g. a file that's existed upstream for many releases but isn't in our fork. This is almost always a deliberate past decision (skipped or deleted as unnecessary). Default: leave it out; don't reintroduce just because upstream changed it. -- **Newly introduced upstream file in this version range** — e.g. a workflow or config that upstream added between the previous and target versions. We've never had a chance to evaluate it. Default: assess on its own merits — does it duplicate something we already do (e.g. zizmor vs our existing CodeQL/Scorecards/audit workflows)? Is it consistent with how we maintain the other ~500 actions in the fleet? Only adopt it if the answer to both is "yes, and we'd want it fleet-wide" — otherwise skip, but flag it so the call is intentional rather than accidental. - -To tell which case you're in: check whether the path appears in the upstream tree at the **previous** version. If yes → pre-existing. If no → new in this range. - -## After cherry-picking - -Verify locally. - -**First, detect the package manager and the actual script names** — don't assume `yarn build`/`yarn test`. The repo could use yarn, npm, or pnpm, and the scripts in `package.json` may not be named `build`/`test` (e.g. some repos use `bundle`, `compile`, `dist`, `vitest`, `jest`, etc.): - -- **Package manager** — check in this order: - - `yarn.lock` and/or `.yarnrc.yml` / `.yarn/` → use `yarn` - - `pnpm-lock.yaml` → use `pnpm` - - `package-lock.json` → use `npm` - - Fall back to `package.json`'s `packageManager` field if no lockfile is obvious. -- **Script names** — read `package.json`'s `scripts` block. Pick the script that actually produces `dist/` for "build" (look for `ncc`, `esbuild`, `tsc`, `rollup`, etc.) and the script that runs the test suite for "test" (look for `vitest`, `jest`, `mocha`, etc.). If the script is named differently from `build`/`test`, use the real name. - -Then run, using the detected tool and scripts: - -1. ` install` — regenerates the lockfile to match any `package.json` changes. -2. ` run ` — regenerates `dist/` with the new toolchain. Confirm `dist/` ends up with the same file set as upstream (extra build artifacts from the old toolchain must be removed by hand). -3. ` run ` — runs the test suite to confirm the new bundle and dep bumps work. -4. `git status` — confirm only the intended files changed. - -**Do NOT commit.** Stop after verification and hand off to the user — they will review the diff and commit themselves. Also do not stage `cherry-pick.md`; it's a working note, not part of the action.