mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-08-03 17:58:30 +08:00
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be7b93b190 | ||
|
|
e0cf66d1d2 | ||
|
|
44423bdec7 | ||
|
|
b00a3414fd | ||
|
|
fa7e2f0a29 | ||
|
|
b76a0776ae | ||
|
|
af0559ee4f | ||
|
|
3553f84341 | ||
|
|
700e7f8316 | ||
|
|
3e807ec379 | ||
|
|
2988cbe14a | ||
|
|
a1c0599a9c | ||
|
|
5bfa96a5b0 | ||
|
|
214a70611b | ||
|
|
5f509a1c1f | ||
|
|
e64308ff97 | ||
|
|
58dc33d9ad | ||
|
|
ae650f8355 | ||
|
|
1298632ce7 | ||
|
|
1253134445 | ||
|
|
4f07c81564 | ||
|
|
f1bd27ca5b | ||
|
|
a08f8913d5 | ||
|
|
972a512078 | ||
|
|
e90deca476 | ||
|
|
536f2c32a3 | ||
|
|
37b464ce72 | ||
|
|
ba0aafd430 | ||
|
|
0fe28cdb64 | ||
|
|
f87768c6d2 | ||
|
|
58a2944bbc | ||
|
|
beb753ed72 | ||
|
|
235b39bf21 | ||
|
|
d060ddc963 | ||
|
|
0f07aee435 | ||
|
|
a221ad2dd9 | ||
|
|
558b1d6cab | ||
|
|
01872ccc02 | ||
|
|
769e3bdff9 | ||
|
|
6c0083bb72 | ||
|
|
846d5d8993 | ||
|
|
fad22eb3fa | ||
|
|
4633baf526 | ||
|
|
a92e7c70a4 | ||
|
|
f8076dc008 | ||
|
|
5211368122 | ||
|
|
428971d2ec | ||
|
|
74eedf1a18 | ||
|
|
80b3182633 | ||
|
|
360be9c8fc | ||
|
|
e452eb9dce | ||
|
|
6b8063043e | ||
|
|
30544b6743 | ||
|
|
2fee155104 | ||
|
|
51705da45e | ||
|
|
806af32823 | ||
|
|
0a08a86780 | ||
|
|
9dd8b95a39 | ||
|
|
4d7e1f0cd8 | ||
|
|
3d9f0dc7dc | ||
|
|
a5e5d3b82e | ||
|
|
d5726de019 | ||
|
|
56fa348258 | ||
|
|
82d95d45af |
162
.claude/workflows/pr-stamp-sweep.js
Normal file
162
.claude/workflows/pr-stamp-sweep.js
Normal file
@ -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/<n>.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/<n>.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: [<PR numbers>]}.
|
||||||
|
|
||||||
|
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: [<PR numbers>]} as args; pre-fetch each PR to /tmp/claude/pr-sweep/<n>.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 };
|
||||||
@ -439,8 +439,10 @@ runs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
curl -L \
|
curl -L \
|
||||||
|
--connect-timeout 5 \
|
||||||
|
--max-time 10 \
|
||||||
-X DELETE \
|
-X DELETE \
|
||||||
-H "Accept: application/vnd.github+json" \
|
-H "Accept: application/vnd.github+json" \
|
||||||
-H "Authorization: Bearer ${{ steps.run.outputs.github_token }}" \
|
-H "Authorization: Bearer ${{ steps.run.outputs.github_token }}" \
|
||||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
-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
|
||||||
|
|||||||
123
agent-approval-check/README.md
Normal file
123
agent-approval-check/README.md
Normal file
@ -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 <head-sha>` 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 <old-sha>` 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 <sha>` where `<sha>` 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.
|
||||||
72
agent-approval-check/action.yml
Normal file
72
agent-approval-check/action.yml
Normal file
@ -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 }}
|
||||||
33
agent-approval-check/agent-identities.example.yaml
Normal file
33
agent-approval-check/agent-identities.example.yaml
Normal file
@ -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/]
|
||||||
1828
agent-approval-check/agent_approval_check.py
Normal file
1828
agent-approval-check/agent_approval_check.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -145,7 +145,7 @@ runs:
|
|||||||
PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
||||||
run: |
|
run: |
|
||||||
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
|
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
|
||||||
CLAUDE_CODE_VERSION="2.1.175"
|
CLAUDE_CODE_VERSION="2.1.220"
|
||||||
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
||||||
for attempt in 1 2 3; do
|
for attempt in 1 2 3; do
|
||||||
echo "Installation attempt $attempt..."
|
echo "Installation attempt $attempt..."
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
"name": "@anthropic-ai/claude-code-base-action",
|
"name": "@anthropic-ai/claude-code-base-action",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.1",
|
"@actions/core": "^1.10.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.175",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
||||||
"shell-quote": "^1.8.3",
|
"shell-quote": "^1.8.3",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -27,23 +27,23 @@
|
|||||||
|
|
||||||
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
||||||
|
|
||||||
"@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.220", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.220", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.220" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-glc7SdwPkOkLw8oxwLo9PKTdLJGqW/PIR4urWXFoRtX9YllwozsEVc5Tc1+EvLSkfrsxPJqQWqOgpjUOQXf1oA=="],
|
||||||
|
|
||||||
"@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.220", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7VxlbEosK7DODiOnsjoVd0DSJzbnaPrM2jelMHI0y8zx1UnLS3WC6EFUXbvy74F2sXqEznh2tzn7EKWInaRN6Q=="],
|
||||||
|
|
||||||
"@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.220", "", { "os": "darwin", "cpu": "x64" }, "sha512-X9RwDsSmbF6ultKZroaip+DL8WRgC64gHbrAwrRlAFSPNZV7zmJyP2ur8rW7KrxqmtuehdMMkw8+SAC/6hD2PA=="],
|
||||||
|
|
||||||
"@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.220", "", { "os": "linux", "cpu": "arm64" }, "sha512-WkROPwWskqhKR9XgnmseHQ6rLi9zM9qt57IWoToIjL/eXOqDWipp7JXZ1L5ud+LrA42dunHPZfBwD/vXZ+A7LA=="],
|
||||||
|
|
||||||
"@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.220", "", { "os": "linux", "cpu": "arm64" }, "sha512-OHoZOZ8Cf2TBr6oXIXPwyvUxj9jrq2w8E4poA8dMpacXszcPSPiCQCMuuOh4aWJzfeJE1+TtWxhKMVb2csXyZQ=="],
|
||||||
|
|
||||||
"@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.220", "", { "os": "linux", "cpu": "x64" }, "sha512-tkTJFnpR9VifvWX2fmkCAPkT6+8Wk/gVu8B5jsVekKZPiZoWRHmMXO30BnZn+f0TZhgYP+82PSX3S8crH1kn+w=="],
|
||||||
|
|
||||||
"@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.220", "", { "os": "linux", "cpu": "x64" }, "sha512-K+FWj+LcGhC1Z7wqeWoLxm1iemcba5xKpLLFVwYm4V6HyMx3ruYd/2r2TiQtjT+JWeNFWIys0ScHiItR6vWAiA=="],
|
||||||
|
|
||||||
"@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.220", "", { "os": "win32", "cpu": "arm64" }, "sha512-rIwgq0UwQExWl6KrHUyC4w5KwpL9l6nd95aUTx6RitexaAuEw//xtfTVLnuE4hDDQZFkzEwpdKc3nxDWoGcUbA=="],
|
||||||
|
|
||||||
"@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.220", "", { "os": "win32", "cpu": "x64" }, "sha512-MuOuXhbr66HlGaWXD2f3w0k2PsvmnbkwcUZ0dAe2poFLdl72GC2dapwwOBefxm9QmoNqk9+jmv/dSKGOVWyvLw=="],
|
||||||
|
|
||||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.93.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA=="],
|
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.93.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA=="],
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.1",
|
"@actions/core": "^1.10.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.175",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
||||||
"shell-quote": "^1.8.3"
|
"shell-quote": "^1.8.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -75,7 +75,8 @@ async function run() {
|
|||||||
core.setOutput("conclusion", "failure");
|
core.setOutput("conclusion", "failure");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
} finally {
|
} finally {
|
||||||
// Stop refreshing the workload identity token file so the process can exit
|
// Stop refreshing the workload identity token file (so the process can
|
||||||
|
// exit) and delete the token material so it doesn't outlive this step
|
||||||
workloadIdentity?.stop();
|
workloadIdentity?.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,11 +19,41 @@ const ACCUMULATING_FLAGS = new Set([
|
|||||||
"disallowedTools",
|
"disallowedTools",
|
||||||
"disallowed-tools",
|
"disallowed-tools",
|
||||||
"mcp-config",
|
"mcp-config",
|
||||||
|
"add-dir",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Delimiter used to join accumulated flag values
|
// Delimiter used to join accumulated flag values
|
||||||
const ACCUMULATE_DELIMITER = "\x00";
|
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 = {
|
type McpConfig = {
|
||||||
mcpServers?: Record<string, unknown>;
|
mcpServers?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
@ -106,9 +136,19 @@ function parseClaudeArgsToExtraArgs(
|
|||||||
if (!claudeArgs?.trim()) return {};
|
if (!claudeArgs?.trim()) return {};
|
||||||
|
|
||||||
const result: Record<string, string | null> = {};
|
const result: Record<string, string | null> = {};
|
||||||
const args = parseShellArgs(stripShellComments(claudeArgs)).filter(
|
const args = parseShellArgs(escapeShellMeta(stripShellComments(claudeArgs)))
|
||||||
(arg): arg is string => typeof arg === "string",
|
.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++) {
|
for (let i = 0; i < args.length; i++) {
|
||||||
const arg = args[i];
|
const arg = args[i];
|
||||||
@ -161,6 +201,17 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
|
|||||||
// Detect if --json-schema is present (for hasJsonSchema flag)
|
// Detect if --json-schema is present (for hasJsonSchema flag)
|
||||||
const hasJsonSchema = "json-schema" in extraArgs;
|
const hasJsonSchema = "json-schema" in extraArgs;
|
||||||
|
|
||||||
|
const modelFromClaudeArgs = extraArgs["model"] || undefined;
|
||||||
|
delete extraArgs["model"];
|
||||||
|
|
||||||
|
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:
|
// Extract and merge allowedTools from all sources:
|
||||||
// 1. From extraArgs (parsed from claudeArgs - contains tag mode's tools)
|
// 1. From extraArgs (parsed from claudeArgs - contains tag mode's tools)
|
||||||
// - Check both camelCase (--allowedTools) and hyphenated (--allowed-tools) variants
|
// - Check both camelCase (--allowedTools) and hyphenated (--allowed-tools) variants
|
||||||
@ -256,7 +307,7 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
|
|||||||
// Build SDK options - use merged tools from both direct options and claudeArgs
|
// Build SDK options - use merged tools from both direct options and claudeArgs
|
||||||
const sdkOptions: SdkOptions = {
|
const sdkOptions: SdkOptions = {
|
||||||
// Direct options from ClaudeOptions inputs
|
// Direct options from ClaudeOptions inputs
|
||||||
model: options.model,
|
model: options.model || modelFromClaudeArgs,
|
||||||
maxTurns: options.maxTurns ? parseInt(options.maxTurns, 10) : undefined,
|
maxTurns: options.maxTurns ? parseInt(options.maxTurns, 10) : undefined,
|
||||||
allowedTools:
|
allowedTools:
|
||||||
mergedAllowedTools.length > 0 ? mergedAllowedTools : undefined,
|
mergedAllowedTools.length > 0 ? mergedAllowedTools : undefined,
|
||||||
@ -265,6 +316,8 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
|
|||||||
systemPrompt,
|
systemPrompt,
|
||||||
fallbackModel: options.fallbackModel,
|
fallbackModel: options.fallbackModel,
|
||||||
pathToClaudeCodeExecutable: options.pathToClaudeCodeExecutable,
|
pathToClaudeCodeExecutable: options.pathToClaudeCodeExecutable,
|
||||||
|
additionalDirectories:
|
||||||
|
additionalDirectories.length > 0 ? additionalDirectories : undefined,
|
||||||
|
|
||||||
// Pass through claudeArgs as extraArgs - CLI handles --mcp-config, --json-schema, etc.
|
// Pass through claudeArgs as extraArgs - CLI handles --mcp-config, --json-schema, etc.
|
||||||
// Note: allowedTools and disallowedTools have been removed from extraArgs to prevent duplicates
|
// Note: allowedTools and disallowedTools have been removed from extraArgs to prevent duplicates
|
||||||
|
|||||||
@ -208,7 +208,10 @@ export async function runClaudeWithSdk(
|
|||||||
throw new Error("No result message received from Claude");
|
throw new Error("No result message received from Claude");
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSuccess = resultMessage.subtype === "success";
|
// subtype "success" with is_error:true means the run errored without producing
|
||||||
|
// a real result — treat it as failure so CI does not show a misleading green check.
|
||||||
|
const isSuccess =
|
||||||
|
resultMessage.subtype === "success" && !resultMessage.is_error;
|
||||||
result.conclusion = isSuccess ? "success" : "failure";
|
result.conclusion = isSuccess ? "success" : "failure";
|
||||||
|
|
||||||
// Handle structured output
|
// Handle structured output
|
||||||
@ -234,12 +237,19 @@ export async function runClaudeWithSdk(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isSuccess) {
|
if (!isSuccess) {
|
||||||
|
if (resultMessage.subtype === "success" && resultMessage.is_error) {
|
||||||
|
core.error(
|
||||||
|
"Claude result reported subtype success with is_error:true (run did not complete successfully)",
|
||||||
|
);
|
||||||
|
}
|
||||||
if ("errors" in resultMessage && resultMessage.errors) {
|
if ("errors" in resultMessage && resultMessage.errors) {
|
||||||
core.error(`Execution failed: ${resultMessage.errors.join(", ")}`);
|
core.error(`Execution failed: ${resultMessage.errors.join(", ")}`);
|
||||||
}
|
}
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Claude execution failed: ${
|
`Claude execution failed: ${
|
||||||
"errors" in resultMessage && resultMessage.errors
|
resultMessage.subtype === "success" && resultMessage.is_error
|
||||||
|
? "result is_error:true"
|
||||||
|
: "errors" in resultMessage && resultMessage.errors
|
||||||
? resultMessage.errors.join(", ")
|
? resultMessage.errors.join(", ")
|
||||||
: "unknown error"
|
: "unknown error"
|
||||||
}`,
|
}`,
|
||||||
|
|||||||
@ -15,7 +15,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import { mkdirSync, writeFileSync } from "fs";
|
import { createHash } from "crypto";
|
||||||
|
import { mkdirSync, rmSync, writeFileSync } from "fs";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { retryWithBackoff } from "./retry";
|
import { retryWithBackoff } from "./retry";
|
||||||
|
|
||||||
@ -50,6 +51,63 @@ async function fetchIdentityToken(audience: string) {
|
|||||||
return retryWithBackoff(() => core.getIDToken(audience));
|
return retryWithBackoff(() => core.getIDToken(audience));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a profile config that switches federation resolution to the
|
||||||
|
* file-backed path. Resolving federation through a profile (rather than bare
|
||||||
|
* env vars) enables the SDK's on-disk credentials cache, so the several
|
||||||
|
* `claude` processes the action spawns (plugin installs, main query) share
|
||||||
|
* one exchanged access token instead of each re-exchanging the single-use
|
||||||
|
* GitHub OIDC token, which fails with 401 (`jti_reused`).
|
||||||
|
*
|
||||||
|
* The profile is intentionally minimal: the SDK gap-fills the federation
|
||||||
|
* fields (rule, organization, identity-token file, service account, base URL)
|
||||||
|
* from the ANTHROPIC_* env vars the action already exports, so the file only
|
||||||
|
* needs to exist to turn the cache on.
|
||||||
|
*
|
||||||
|
* The config dir name embeds a fingerprint of the federation inputs. The
|
||||||
|
* SDK's cache reuses a token on `expires_at` alone, with no record of the
|
||||||
|
* config that minted it, and the token's scope is bound at mint time — so a
|
||||||
|
* later action step in the same job (RUNNER_TEMP is per-job) with different
|
||||||
|
* federation inputs must land in a different dir or it would silently reuse
|
||||||
|
* the first step's token.
|
||||||
|
*
|
||||||
|
* Sharing the cache is only safe while the action spawns its `claude`
|
||||||
|
* subprocesses sequentially: the SDK cache is not cross-process serialized,
|
||||||
|
* and concurrent cache misses would each re-exchange the same single-use
|
||||||
|
* identity token. Parallelizing the plugin installs would reintroduce the
|
||||||
|
* `jti_reused` failures.
|
||||||
|
*/
|
||||||
|
function writeFederationProfile(baseDir: string): string {
|
||||||
|
// Every input that changes which credential the exchange mints must be in
|
||||||
|
// here; service_account_id and scope are sent in the exchange request body.
|
||||||
|
const fingerprint = createHash("sha256")
|
||||||
|
.update(
|
||||||
|
JSON.stringify([
|
||||||
|
process.env.ANTHROPIC_FEDERATION_RULE_ID?.trim() ?? "",
|
||||||
|
process.env.ANTHROPIC_ORGANIZATION_ID?.trim() ?? "",
|
||||||
|
process.env.ANTHROPIC_SERVICE_ACCOUNT_ID?.trim() ?? "",
|
||||||
|
process.env.ANTHROPIC_WORKSPACE_ID?.trim() ?? "",
|
||||||
|
process.env.ANTHROPIC_BASE_URL?.trim() ?? "",
|
||||||
|
process.env.ANTHROPIC_SCOPE?.trim() ?? "",
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.digest("hex")
|
||||||
|
.slice(0, 16);
|
||||||
|
const configDir = join(baseDir, `config-${fingerprint}`);
|
||||||
|
|
||||||
|
mkdirSync(join(configDir, "configs"), { recursive: true, mode: 0o700 });
|
||||||
|
writeFileSync(
|
||||||
|
join(configDir, "configs", "default.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{ version: "1.0", authentication: { type: "oidc_federation" } },
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
return configDir;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches a GitHub Actions OIDC token, writes it to a file in RUNNER_TEMP,
|
* Fetches a GitHub Actions OIDC token, writes it to a file in RUNNER_TEMP,
|
||||||
* exports ANTHROPIC_IDENTITY_TOKEN_FILE, and starts a background refresh so
|
* exports ANTHROPIC_IDENTITY_TOKEN_FILE, and starts a background refresh so
|
||||||
@ -57,7 +115,8 @@ async function fetchIdentityToken(audience: string) {
|
|||||||
*
|
*
|
||||||
* Returns undefined when federation is not configured or is shadowed by a
|
* Returns undefined when federation is not configured or is shadowed by a
|
||||||
* higher-precedence credential. Callers must invoke stop() when execution
|
* higher-precedence credential. Callers must invoke stop() when execution
|
||||||
* finishes.
|
* finishes; it also deletes the identity token and any cached exchanged
|
||||||
|
* credential.
|
||||||
*/
|
*/
|
||||||
export async function setupWorkloadIdentity(): Promise<
|
export async function setupWorkloadIdentity(): Promise<
|
||||||
WorkloadIdentityHandle | undefined
|
WorkloadIdentityHandle | undefined
|
||||||
@ -101,6 +160,17 @@ export async function setupWorkloadIdentity(): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
process.env.ANTHROPIC_IDENTITY_TOKEN_FILE = tokenFile;
|
process.env.ANTHROPIC_IDENTITY_TOKEN_FILE = tokenFile;
|
||||||
|
if (
|
||||||
|
process.env.ANTHROPIC_CONFIG_DIR?.trim() ||
|
||||||
|
process.env.ANTHROPIC_PROFILE?.trim()
|
||||||
|
) {
|
||||||
|
core.warning(
|
||||||
|
"ANTHROPIC_CONFIG_DIR or ANTHROPIC_PROFILE is already set, so the action will not write its own federation profile. Credential caching across the spawned Claude processes follows the existing profile configuration.",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
process.env.ANTHROPIC_CONFIG_DIR = writeFederationProfile(tokenDir);
|
||||||
|
process.env.ANTHROPIC_PROFILE = "default";
|
||||||
|
}
|
||||||
console.log(
|
console.log(
|
||||||
`Workload identity federation configured (rule: ${process.env.ANTHROPIC_FEDERATION_RULE_ID}, identity token file: ${tokenFile})`,
|
`Workload identity federation configured (rule: ${process.env.ANTHROPIC_FEDERATION_RULE_ID}, identity token file: ${tokenFile})`,
|
||||||
);
|
);
|
||||||
@ -115,6 +185,12 @@ export async function setupWorkloadIdentity(): Promise<
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
tokenFile,
|
tokenFile,
|
||||||
stop: () => clearInterval(refreshInterval),
|
stop: () => {
|
||||||
|
clearInterval(refreshInterval);
|
||||||
|
// RUNNER_TEMP is per-job, not per-step: remove the identity token, the
|
||||||
|
// profile, and the cached exchanged credential so they don't outlive
|
||||||
|
// this step.
|
||||||
|
rmSync(tokenDir, { recursive: true, force: true });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -106,7 +106,8 @@ describe("parseSdkOptions", () => {
|
|||||||
const result = parseSdkOptions(options);
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
expect(result.sdkOptions.extraArgs?.["allowedTools"]).toBeUndefined();
|
expect(result.sdkOptions.extraArgs?.["allowedTools"]).toBeUndefined();
|
||||||
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-3-5-sonnet");
|
expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined();
|
||||||
|
expect(result.sdkOptions.model).toBe("claude-3-5-sonnet");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should handle hyphenated --allowed-tools flag", () => {
|
test("should handle hyphenated --allowed-tools flag", () => {
|
||||||
@ -137,6 +138,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", () => {
|
test("should handle mixed camelCase and hyphenated allowedTools flags", () => {
|
||||||
const options: ClaudeOptions = {
|
const options: ClaudeOptions = {
|
||||||
claudeArgs: '--allowedTools "Edit,Read" --allowed-tools "Write,Glob"',
|
claudeArgs: '--allowedTools "Edit,Read" --allowed-tools "Write,Glob"',
|
||||||
@ -262,7 +367,8 @@ describe("parseSdkOptions", () => {
|
|||||||
);
|
);
|
||||||
expect(mcpConfig.mcpServers).toHaveProperty("server1");
|
expect(mcpConfig.mcpServers).toHaveProperty("server1");
|
||||||
expect(mcpConfig.mcpServers).toHaveProperty("server2");
|
expect(mcpConfig.mcpServers).toHaveProperty("server2");
|
||||||
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-3-5-sonnet");
|
expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined();
|
||||||
|
expect(result.sdkOptions.model).toBe("claude-3-5-sonnet");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should handle real-world scenario: action config + user config", () => {
|
test("should handle real-world scenario: action config + user config", () => {
|
||||||
@ -298,6 +404,46 @@ 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"]).toBeUndefined();
|
||||||
|
expect(result.sdkOptions.model).toBe("claude-3-5-sonnet");
|
||||||
|
expect(result.sdkOptions.extraArgs?.["add-dir"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("other extraArgs passthrough", () => {
|
describe("other extraArgs passthrough", () => {
|
||||||
test("should pass through json-schema in extraArgs", () => {
|
test("should pass through json-schema in extraArgs", () => {
|
||||||
const options: ClaudeOptions = {
|
const options: ClaudeOptions = {
|
||||||
@ -321,7 +467,8 @@ describe("parseSdkOptions", () => {
|
|||||||
|
|
||||||
const result = parseSdkOptions(options);
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-haiku");
|
expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined();
|
||||||
|
expect(result.sdkOptions.model).toBe("claude-haiku");
|
||||||
expect(result.sdkOptions.allowedTools).toEqual(["Edit"]);
|
expect(result.sdkOptions.allowedTools).toEqual(["Edit"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -332,7 +479,8 @@ describe("parseSdkOptions", () => {
|
|||||||
|
|
||||||
const result = parseSdkOptions(options);
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-haiku");
|
expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined();
|
||||||
|
expect(result.sdkOptions.model).toBe("claude-haiku");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should not strip inline # that appears inside a quoted value", () => {
|
test("should not strip inline # that appears inside a quoted value", () => {
|
||||||
@ -342,11 +490,37 @@ describe("parseSdkOptions", () => {
|
|||||||
|
|
||||||
const result = parseSdkOptions(options);
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-haiku");
|
expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined();
|
||||||
|
expect(result.sdkOptions.model).toBe("claude-haiku");
|
||||||
expect(result.sdkOptions.extraArgs?.["prompt"]).toBe("use color #ff0000");
|
expect(result.sdkOptions.extraArgs?.["prompt"]).toBe("use color #ff0000");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("model handling", () => {
|
||||||
|
test("should map --model from claudeArgs to sdkOptions.model", () => {
|
||||||
|
const options: ClaudeOptions = {
|
||||||
|
claudeArgs: "--model claude-haiku-4-5-20251001",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
|
expect(result.sdkOptions.model).toBe("claude-haiku-4-5-20251001");
|
||||||
|
expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should prefer direct model option over --model from claudeArgs", () => {
|
||||||
|
const options: ClaudeOptions = {
|
||||||
|
model: "claude-sonnet-4-6",
|
||||||
|
claudeArgs: "--model claude-haiku-4-5-20251001",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
|
expect(result.sdkOptions.model).toBe("claude-sonnet-4-6");
|
||||||
|
expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("environment variables passthrough", () => {
|
describe("environment variables passthrough", () => {
|
||||||
test("should include OTEL environment variables in sdkOptions.env", () => {
|
test("should include OTEL environment variables in sdkOptions.env", () => {
|
||||||
// Set up test environment variables
|
// Set up test environment variables
|
||||||
|
|||||||
@ -63,4 +63,69 @@ describe("runClaudeWithSdk", () => {
|
|||||||
consoleLogSpy.mockRestore();
|
consoleLogSpy.mockRestore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("fails when result subtype is success but is_error is true", async () => {
|
||||||
|
const consoleErrorSpy = spyOn(console, "error").mockImplementation(
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
const coreErrorSpy = spyOn(
|
||||||
|
await import("@actions/core"),
|
||||||
|
"error",
|
||||||
|
).mockImplementation(() => {});
|
||||||
|
|
||||||
|
tempDir = await mkdtemp(join(tmpdir(), "claude-sdk-"));
|
||||||
|
process.env.RUNNER_TEMP = tempDir;
|
||||||
|
|
||||||
|
const promptPath = join(tempDir, "prompt.txt");
|
||||||
|
await writeFile(promptPath, "test prompt");
|
||||||
|
|
||||||
|
const initMessage = {
|
||||||
|
type: "system",
|
||||||
|
subtype: "init",
|
||||||
|
session_id: "session-123",
|
||||||
|
model: "claude-sonnet-5",
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorResultMessage = {
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
is_error: true,
|
||||||
|
duration_ms: 434,
|
||||||
|
num_turns: 1,
|
||||||
|
total_cost_usd: 0,
|
||||||
|
permission_denials: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
mock.module("@anthropic-ai/claude-agent-sdk", () => ({
|
||||||
|
query: async function* () {
|
||||||
|
yield initMessage;
|
||||||
|
yield errorResultMessage;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { runClaudeWithSdk } = await import("../src/run-claude-sdk");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runClaudeWithSdk(promptPath, {
|
||||||
|
sdkOptions: {},
|
||||||
|
showFullOutput: false,
|
||||||
|
hasJsonSchema: false,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("result is_error:true");
|
||||||
|
|
||||||
|
const executionFile = join(tempDir, "claude-execution-output.json");
|
||||||
|
await expect(readFile(executionFile, "utf-8")).resolves.toBe(
|
||||||
|
JSON.stringify([initMessage, errorResultMessage], null, 2),
|
||||||
|
);
|
||||||
|
expect(coreErrorSpy).toHaveBeenCalledWith(
|
||||||
|
"Claude result reported subtype success with is_error:true (run did not complete successfully)",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
consoleLogSpy.mockRestore();
|
||||||
|
coreErrorSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,7 +2,14 @@
|
|||||||
|
|
||||||
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "fs";
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readdirSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
statSync,
|
||||||
|
} from "fs";
|
||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import {
|
import {
|
||||||
@ -27,6 +34,12 @@ describe("workload identity federation", () => {
|
|||||||
delete process.env.ANTHROPIC_ORGANIZATION_ID;
|
delete process.env.ANTHROPIC_ORGANIZATION_ID;
|
||||||
delete process.env.ANTHROPIC_OIDC_AUDIENCE;
|
delete process.env.ANTHROPIC_OIDC_AUDIENCE;
|
||||||
delete process.env.ANTHROPIC_IDENTITY_TOKEN_FILE;
|
delete process.env.ANTHROPIC_IDENTITY_TOKEN_FILE;
|
||||||
|
delete process.env.ANTHROPIC_SERVICE_ACCOUNT_ID;
|
||||||
|
delete process.env.ANTHROPIC_WORKSPACE_ID;
|
||||||
|
delete process.env.ANTHROPIC_BASE_URL;
|
||||||
|
delete process.env.ANTHROPIC_SCOPE;
|
||||||
|
delete process.env.ANTHROPIC_CONFIG_DIR;
|
||||||
|
delete process.env.ANTHROPIC_PROFILE;
|
||||||
|
|
||||||
getIDTokenSpy = spyOn(core, "getIDToken").mockResolvedValue(
|
getIDTokenSpy = spyOn(core, "getIDToken").mockResolvedValue(
|
||||||
"test-identity-token",
|
"test-identity-token",
|
||||||
@ -123,5 +136,123 @@ describe("workload identity federation", () => {
|
|||||||
handle?.stop();
|
handle?.stop();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("writes a minimal federation profile and selects it", async () => {
|
||||||
|
process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test";
|
||||||
|
process.env.ANTHROPIC_ORGANIZATION_ID =
|
||||||
|
"00000000-0000-0000-0000-000000000000";
|
||||||
|
process.env.ANTHROPIC_SERVICE_ACCOUNT_ID = "svac_test";
|
||||||
|
process.env.ANTHROPIC_WORKSPACE_ID = "wrkspc_test";
|
||||||
|
|
||||||
|
const handle = await setupWorkloadIdentity();
|
||||||
|
try {
|
||||||
|
const configDir = process.env.ANTHROPIC_CONFIG_DIR;
|
||||||
|
expect(configDir).toBeDefined();
|
||||||
|
expect(
|
||||||
|
configDir!.startsWith(
|
||||||
|
join(tempDir, "claude-workload-identity", "config-"),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(process.env.ANTHROPIC_PROFILE).toBe("default");
|
||||||
|
|
||||||
|
const profilePath = join(configDir!, "configs", "default.json");
|
||||||
|
expect(statSync(profilePath).mode & 0o777).toBe(0o600);
|
||||||
|
// Minimal on purpose: the SDK gap-fills the federation fields from
|
||||||
|
// the ANTHROPIC_* env vars the action exports.
|
||||||
|
expect(JSON.parse(readFileSync(profilePath, "utf-8"))).toEqual({
|
||||||
|
version: "1.0",
|
||||||
|
authentication: { type: "oidc_federation" },
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
handle?.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("derives the config dir from the federation inputs", async () => {
|
||||||
|
process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test";
|
||||||
|
process.env.ANTHROPIC_ORGANIZATION_ID =
|
||||||
|
"00000000-0000-0000-0000-000000000000";
|
||||||
|
process.env.ANTHROPIC_WORKSPACE_ID = "wrkspc_a";
|
||||||
|
|
||||||
|
(await setupWorkloadIdentity())?.stop();
|
||||||
|
const firstConfigDir = process.env.ANTHROPIC_CONFIG_DIR;
|
||||||
|
expect(firstConfigDir).toBeDefined();
|
||||||
|
|
||||||
|
// A later step in the same job with a different workspace must not
|
||||||
|
// share the first step's credentials cache.
|
||||||
|
delete process.env.ANTHROPIC_CONFIG_DIR;
|
||||||
|
delete process.env.ANTHROPIC_PROFILE;
|
||||||
|
process.env.ANTHROPIC_WORKSPACE_ID = "wrkspc_b";
|
||||||
|
|
||||||
|
(await setupWorkloadIdentity())?.stop();
|
||||||
|
const secondConfigDir = process.env.ANTHROPIC_CONFIG_DIR;
|
||||||
|
expect(secondConfigDir).toBeDefined();
|
||||||
|
expect(secondConfigDir).not.toBe(firstConfigDir);
|
||||||
|
|
||||||
|
// Same inputs land in the same dir, so an unchanged config can still
|
||||||
|
// reuse a cached token.
|
||||||
|
delete process.env.ANTHROPIC_CONFIG_DIR;
|
||||||
|
delete process.env.ANTHROPIC_PROFILE;
|
||||||
|
|
||||||
|
(await setupWorkloadIdentity())?.stop();
|
||||||
|
expect(process.env.ANTHROPIC_CONFIG_DIR).toBe(secondConfigDir!);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not overwrite an operator-set ANTHROPIC_PROFILE", async () => {
|
||||||
|
process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test";
|
||||||
|
process.env.ANTHROPIC_ORGANIZATION_ID =
|
||||||
|
"00000000-0000-0000-0000-000000000000";
|
||||||
|
process.env.ANTHROPIC_PROFILE = "operator";
|
||||||
|
|
||||||
|
const handle = await setupWorkloadIdentity();
|
||||||
|
try {
|
||||||
|
expect(process.env.ANTHROPIC_PROFILE).toBe("operator");
|
||||||
|
expect(process.env.ANTHROPIC_CONFIG_DIR).toBeUndefined();
|
||||||
|
expect(warningSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
const entries = readdirSync(join(tempDir, "claude-workload-identity"));
|
||||||
|
expect(entries.filter((e) => e.startsWith("config-"))).toEqual([]);
|
||||||
|
|
||||||
|
// The identity token file is still provisioned for the operator's
|
||||||
|
// profile (or the env-var fallback) to consume.
|
||||||
|
expect(process.env.ANTHROPIC_IDENTITY_TOKEN_FILE).toBe(
|
||||||
|
handle!.tokenFile,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
handle?.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not overwrite an operator-set ANTHROPIC_CONFIG_DIR", async () => {
|
||||||
|
process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test";
|
||||||
|
process.env.ANTHROPIC_ORGANIZATION_ID =
|
||||||
|
"00000000-0000-0000-0000-000000000000";
|
||||||
|
const operatorConfigDir = join(tempDir, "operator-config");
|
||||||
|
process.env.ANTHROPIC_CONFIG_DIR = operatorConfigDir;
|
||||||
|
|
||||||
|
const handle = await setupWorkloadIdentity();
|
||||||
|
try {
|
||||||
|
expect(process.env.ANTHROPIC_CONFIG_DIR).toBe(operatorConfigDir);
|
||||||
|
expect(process.env.ANTHROPIC_PROFILE).toBeUndefined();
|
||||||
|
expect(warningSpy).toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
handle?.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stop removes the identity token and credential cache", async () => {
|
||||||
|
process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test";
|
||||||
|
process.env.ANTHROPIC_ORGANIZATION_ID =
|
||||||
|
"00000000-0000-0000-0000-000000000000";
|
||||||
|
|
||||||
|
const handle = await setupWorkloadIdentity();
|
||||||
|
const tokenDir = join(tempDir, "claude-workload-identity");
|
||||||
|
expect(existsSync(handle!.tokenFile)).toBe(true);
|
||||||
|
expect(existsSync(process.env.ANTHROPIC_CONFIG_DIR!)).toBe(true);
|
||||||
|
|
||||||
|
handle!.stop();
|
||||||
|
|
||||||
|
expect(existsSync(tokenDir)).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
20
bun.lock
20
bun.lock
@ -7,7 +7,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.1",
|
"@actions/core": "^1.10.1",
|
||||||
"@actions/github": "^6.0.1",
|
"@actions/github": "^6.0.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.175",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
||||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||||
"@octokit/graphql": "^8.2.2",
|
"@octokit/graphql": "^8.2.2",
|
||||||
"@octokit/rest": "^21.1.1",
|
"@octokit/rest": "^21.1.1",
|
||||||
@ -37,23 +37,23 @@
|
|||||||
|
|
||||||
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
||||||
|
|
||||||
"@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.220", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.220", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.220" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-glc7SdwPkOkLw8oxwLo9PKTdLJGqW/PIR4urWXFoRtX9YllwozsEVc5Tc1+EvLSkfrsxPJqQWqOgpjUOQXf1oA=="],
|
||||||
|
|
||||||
"@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.220", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7VxlbEosK7DODiOnsjoVd0DSJzbnaPrM2jelMHI0y8zx1UnLS3WC6EFUXbvy74F2sXqEznh2tzn7EKWInaRN6Q=="],
|
||||||
|
|
||||||
"@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.220", "", { "os": "darwin", "cpu": "x64" }, "sha512-X9RwDsSmbF6ultKZroaip+DL8WRgC64gHbrAwrRlAFSPNZV7zmJyP2ur8rW7KrxqmtuehdMMkw8+SAC/6hD2PA=="],
|
||||||
|
|
||||||
"@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.220", "", { "os": "linux", "cpu": "arm64" }, "sha512-WkROPwWskqhKR9XgnmseHQ6rLi9zM9qt57IWoToIjL/eXOqDWipp7JXZ1L5ud+LrA42dunHPZfBwD/vXZ+A7LA=="],
|
||||||
|
|
||||||
"@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.220", "", { "os": "linux", "cpu": "arm64" }, "sha512-OHoZOZ8Cf2TBr6oXIXPwyvUxj9jrq2w8E4poA8dMpacXszcPSPiCQCMuuOh4aWJzfeJE1+TtWxhKMVb2csXyZQ=="],
|
||||||
|
|
||||||
"@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.220", "", { "os": "linux", "cpu": "x64" }, "sha512-tkTJFnpR9VifvWX2fmkCAPkT6+8Wk/gVu8B5jsVekKZPiZoWRHmMXO30BnZn+f0TZhgYP+82PSX3S8crH1kn+w=="],
|
||||||
|
|
||||||
"@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.220", "", { "os": "linux", "cpu": "x64" }, "sha512-K+FWj+LcGhC1Z7wqeWoLxm1iemcba5xKpLLFVwYm4V6HyMx3ruYd/2r2TiQtjT+JWeNFWIys0ScHiItR6vWAiA=="],
|
||||||
|
|
||||||
"@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.220", "", { "os": "win32", "cpu": "arm64" }, "sha512-rIwgq0UwQExWl6KrHUyC4w5KwpL9l6nd95aUTx6RitexaAuEw//xtfTVLnuE4hDDQZFkzEwpdKc3nxDWoGcUbA=="],
|
||||||
|
|
||||||
"@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.220", "", { "os": "win32", "cpu": "x64" }, "sha512-MuOuXhbr66HlGaWXD2f3w0k2PsvmnbkwcUZ0dAe2poFLdl72GC2dapwwOBefxm9QmoNqk9+jmv/dSKGOVWyvLw=="],
|
||||||
|
|
||||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.93.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA=="],
|
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.93.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA=="],
|
||||||
|
|
||||||
|
|||||||
@ -338,13 +338,13 @@ For a complete list of available settings and their descriptions, see the [Claud
|
|||||||
Many individual input parameters have been consolidated into `claude_args` or `settings`. Here's how to migrate:
|
Many individual input parameters have been consolidated into `claude_args` or `settings`. Here's how to migrate:
|
||||||
|
|
||||||
| Old Input | New Approach |
|
| Old Input | New Approach |
|
||||||
| --------------------- | -------------------------------------------------------- |
|
| --------------------- | --------------------------------------------------------------- |
|
||||||
| `allowed_tools` | Use `claude_args: "--allowedTools Tool1,Tool2"` |
|
| `allowed_tools` | Use `claude_args: "--allowedTools Tool1,Tool2"` |
|
||||||
| `disallowed_tools` | Use `claude_args: "--disallowedTools Tool1,Tool2"` |
|
| `disallowed_tools` | Use `claude_args: "--disallowedTools Tool1,Tool2"` |
|
||||||
| `max_turns` | Use `claude_args: "--max-turns 10"` |
|
| `max_turns` | Use `claude_args: "--max-turns 10"` |
|
||||||
| `model` | Use `claude_args: "--model claude-4-0-sonnet-20250805"` |
|
| `model` | Use `claude_args: "--model claude-4-0-sonnet-20250805"` |
|
||||||
| `claude_env` | Use `settings` with `"env"` object |
|
| `claude_env` | Use `settings` with `"env"` object |
|
||||||
| `custom_instructions` | Use `claude_args: "--system-prompt 'Your instructions'"` |
|
| `custom_instructions` | Use `claude_args: "--append-system-prompt 'Your instructions'"` |
|
||||||
| `mcp_config` | Use `claude_args: "--mcp-config '{...}'"` |
|
| `mcp_config` | Use `claude_args: "--mcp-config '{...}'"` |
|
||||||
| `direct_prompt` | Use `prompt` input instead |
|
| `direct_prompt` | Use `prompt` input instead |
|
||||||
| `override_prompt` | Use `prompt` with GitHub context variables |
|
| `override_prompt` | Use `prompt` with GitHub context variables |
|
||||||
|
|||||||
@ -26,7 +26,7 @@ This action supports the following GitHub events ([learn more GitHub event trigg
|
|||||||
|
|
||||||
## Automated Documentation Updates
|
## Automated Documentation Updates
|
||||||
|
|
||||||
Automatically update documentation when specific files change (see [`examples/claude-pr-path-specific.yml`](../examples/claude-pr-path-specific.yml)):
|
Automatically update documentation when specific files change (see [`examples/pr-review-filtered-paths.yml`](../examples/pr-review-filtered-paths.yml)):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
on:
|
on:
|
||||||
@ -47,7 +47,7 @@ When API files are modified, the action automatically detects that a `prompt` is
|
|||||||
|
|
||||||
## Author-Specific Code Reviews
|
## Author-Specific Code Reviews
|
||||||
|
|
||||||
Automatically review PRs from specific authors or external contributors (see [`examples/claude-review-from-author.yml`](../examples/claude-review-from-author.yml)):
|
Automatically review PRs from specific authors or external contributors (see [`examples/pr-review-filtered-authors.yml`](../examples/pr-review-filtered-authors.yml)):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
on:
|
on:
|
||||||
|
|||||||
@ -153,7 +153,7 @@ prompt: "Review this PR for security vulnerabilities"
|
|||||||
**These inputs are deprecated in v1.0:**
|
**These inputs are deprecated in v1.0:**
|
||||||
|
|
||||||
- **`direct_prompt`** → Use `prompt` instead
|
- **`direct_prompt`** → Use `prompt` instead
|
||||||
- **`custom_instructions`** → Use `claude_args` with `--system-prompt`
|
- **`custom_instructions`** → Use `claude_args` with `--append-system-prompt` (appends to the default system prompt, matching v0 behavior; `--system-prompt` replaces it entirely)
|
||||||
|
|
||||||
Migration examples:
|
Migration examples:
|
||||||
|
|
||||||
@ -165,7 +165,7 @@ custom_instructions: "Focus on security"
|
|||||||
# New (v1.0)
|
# New (v1.0)
|
||||||
prompt: "Review this PR"
|
prompt: "Review this PR"
|
||||||
claude_args: |
|
claude_args: |
|
||||||
--system-prompt "Focus on security"
|
--append-system-prompt "Focus on security"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Why doesn't Claude execute my bash commands?
|
### Why doesn't Claude execute my bash commands?
|
||||||
|
|||||||
@ -15,11 +15,11 @@ This guide helps you migrate from Claude Code Action v0.x to v1.0. The new versi
|
|||||||
The following inputs have been deprecated and replaced:
|
The following inputs have been deprecated and replaced:
|
||||||
|
|
||||||
| Deprecated Input | Replacement | Notes |
|
| Deprecated Input | Replacement | Notes |
|
||||||
| --------------------- | ------------------------------------ | --------------------------------------------- |
|
| --------------------- | ------------------------------------- | ----------------------------------------------------------------------------------- |
|
||||||
| `mode` | Auto-detected | Action automatically chooses based on context |
|
| `mode` | Auto-detected | Action automatically chooses based on context |
|
||||||
| `direct_prompt` | `prompt` | Direct drop-in replacement |
|
| `direct_prompt` | `prompt` | Direct drop-in replacement |
|
||||||
| `override_prompt` | `prompt` | Use GitHub context variables instead |
|
| `override_prompt` | `prompt` | Use GitHub context variables instead |
|
||||||
| `custom_instructions` | `claude_args: --system-prompt` | Move to CLI arguments |
|
| `custom_instructions` | `claude_args: --append-system-prompt` | Appends to the default prompt (v0 behavior); `--system-prompt` replaces it entirely |
|
||||||
| `max_turns` | `claude_args: --max-turns` | Use CLI format |
|
| `max_turns` | `claude_args: --max-turns` | Use CLI format |
|
||||||
| `model` | `claude_args: --model` | Specify via CLI |
|
| `model` | `claude_args: --model` | Specify via CLI |
|
||||||
| `allowed_tools` | `claude_args: --allowedTools` | Use CLI format |
|
| `allowed_tools` | `claude_args: --allowedTools` | Use CLI format |
|
||||||
@ -52,7 +52,7 @@ The following inputs have been deprecated and replaced:
|
|||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
claude_args: |
|
claude_args: |
|
||||||
--max-turns 10
|
--max-turns 10
|
||||||
--system-prompt "Follow our coding standards"
|
--append-system-prompt "Follow our coding standards"
|
||||||
--allowedTools Edit,Read,Write
|
--allowedTools Edit,Read,Write
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -256,12 +256,13 @@ claude_args: |
|
|||||||
### Common claude_args Options
|
### Common claude_args Options
|
||||||
|
|
||||||
| Option | Description | Example |
|
| Option | Description | Example |
|
||||||
| ------------------- | ------------------------ | -------------------------------------- |
|
| ------------------------ | ------------------------------------------------------------------------- | ------------------------------------------------------ |
|
||||||
| `--max-turns` | Limit conversation turns | `--max-turns 10` |
|
| `--max-turns` | Limit conversation turns | `--max-turns 10` |
|
||||||
| `--model` | Specify Claude model | `--model claude-4-0-sonnet-20250805` |
|
| `--model` | Specify Claude model | `--model claude-4-0-sonnet-20250805` |
|
||||||
| `--allowedTools` | Enable specific tools | `--allowedTools Edit,Read,Write` |
|
| `--allowedTools` | Enable specific tools | `--allowedTools Edit,Read,Write` |
|
||||||
| `--disallowedTools` | Disable specific tools | `--disallowedTools WebSearch` |
|
| `--disallowedTools` | Disable specific tools | `--disallowedTools WebSearch` |
|
||||||
| `--system-prompt` | Add system instructions | `--system-prompt "Focus on security"` |
|
| `--system-prompt` | Replace the entire default system prompt | `--system-prompt "Focus on security"` |
|
||||||
|
| `--append-system-prompt` | Append to the default system prompt (keeps Claude Code's built-in prompt) | `--append-system-prompt "Follow our coding standards"` |
|
||||||
| `--mcp-config` | Add MCP server config | `--mcp-config '{"mcpServers": {...}}'` |
|
| `--mcp-config` | Add MCP server config | `--mcp-config '{"mcpServers": {...}}'` |
|
||||||
|
|
||||||
## Provider-Specific Updates
|
## Provider-Specific Updates
|
||||||
@ -330,7 +331,7 @@ You can also pass MCP configuration from a file:
|
|||||||
- [ ] Remove `mode` input (auto-detected now)
|
- [ ] Remove `mode` input (auto-detected now)
|
||||||
- [ ] Replace `direct_prompt` with `prompt`
|
- [ ] Replace `direct_prompt` with `prompt`
|
||||||
- [ ] Replace `override_prompt` with `prompt` using GitHub context
|
- [ ] Replace `override_prompt` with `prompt` using GitHub context
|
||||||
- [ ] Move `custom_instructions` to `claude_args` with `--system-prompt`
|
- [ ] Move `custom_instructions` to `claude_args` with `--append-system-prompt`
|
||||||
- [ ] Convert `max_turns` to `claude_args` with `--max-turns`
|
- [ ] Convert `max_turns` to `claude_args` with `--max-turns`
|
||||||
- [ ] Convert `model` to `claude_args` with `--model`
|
- [ ] Convert `model` to `claude_args` with `--model`
|
||||||
- [ ] Convert `allowed_tools` to `claude_args` with `--allowedTools`
|
- [ ] Convert `allowed_tools` to `claude_args` with `--allowedTools`
|
||||||
|
|||||||
@ -99,7 +99,7 @@ These inputs are deprecated and will be removed in a future version:
|
|||||||
| `mode` | **DEPRECATED**: Mode is now automatically detected based on workflow context | Remove this input; the action auto-detects the correct mode |
|
| `mode` | **DEPRECATED**: Mode is now automatically detected based on workflow context | Remove this input; the action auto-detects the correct mode |
|
||||||
| `direct_prompt` | **DEPRECATED**: Use `prompt` instead | Replace with `prompt` |
|
| `direct_prompt` | **DEPRECATED**: Use `prompt` instead | Replace with `prompt` |
|
||||||
| `override_prompt` | **DEPRECATED**: Use `prompt` with template variables or `claude_args` with `--system-prompt` | Use `prompt` for templates or `claude_args` for system prompts |
|
| `override_prompt` | **DEPRECATED**: Use `prompt` with template variables or `claude_args` with `--system-prompt` | Use `prompt` for templates or `claude_args` for system prompts |
|
||||||
| `custom_instructions` | **DEPRECATED**: Use `claude_args` with `--system-prompt` or include in `prompt` | Move instructions to `prompt` or use `claude_args` |
|
| `custom_instructions` | **DEPRECATED**: Use `claude_args` with `--append-system-prompt` or include in `prompt` | Move instructions to `prompt` or use `claude_args` |
|
||||||
| `max_turns` | **DEPRECATED**: Use `claude_args` with `--max-turns` instead | Use `claude_args: "--max-turns 5"` |
|
| `max_turns` | **DEPRECATED**: Use `claude_args` with `--max-turns` instead | Use `claude_args: "--max-turns 5"` |
|
||||||
| `model` | **DEPRECATED**: Use `claude_args` with `--model` instead | Use `claude_args: "--model claude-4-0-sonnet-20250805"` |
|
| `model` | **DEPRECATED**: Use `claude_args` with `--model` instead | Use `claude_args: "--model claude-4-0-sonnet-20250805"` |
|
||||||
| `fallback_model` | **DEPRECATED**: Use `claude_args` with fallback configuration | Configure fallback in `claude_args` or `settings` |
|
| `fallback_model` | **DEPRECATED**: Use `claude_args` with fallback configuration | Configure fallback in `claude_args` or `settings` |
|
||||||
@ -139,7 +139,7 @@ For a comprehensive guide on migrating from v0.x to v1.0, including step-by-step
|
|||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
claude_args: |
|
claude_args: |
|
||||||
--max-turns 10
|
--max-turns 10
|
||||||
--system-prompt "Focus on security"
|
--append-system-prompt "Focus on security"
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Automation Workflows
|
#### Automation Workflows
|
||||||
|
|||||||
39
examples/agent-approval-check.yml
Normal file
39
examples/agent-approval-check.yml
Normal file
@ -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
|
||||||
@ -12,7 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.1",
|
"@actions/core": "^1.10.1",
|
||||||
"@actions/github": "^6.0.1",
|
"@actions/github": "^6.0.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.175",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
||||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||||
"@octokit/graphql": "^8.2.2",
|
"@octokit/graphql": "^8.2.2",
|
||||||
"@octokit/rest": "^21.1.1",
|
"@octokit/rest": "^21.1.1",
|
||||||
|
|||||||
@ -122,6 +122,7 @@ export function prepareContext(
|
|||||||
|
|
||||||
// Extract trigger username and comment data based on event type
|
// Extract trigger username and comment data based on event type
|
||||||
let triggerUsername: string | undefined;
|
let triggerUsername: string | undefined;
|
||||||
|
let triggerUserId: number | undefined;
|
||||||
let commentId: string | undefined;
|
let commentId: string | undefined;
|
||||||
let commentBody: string | undefined;
|
let commentBody: string | undefined;
|
||||||
|
|
||||||
@ -129,15 +130,19 @@ export function prepareContext(
|
|||||||
commentId = context.payload.comment.id.toString();
|
commentId = context.payload.comment.id.toString();
|
||||||
commentBody = context.payload.comment.body;
|
commentBody = context.payload.comment.body;
|
||||||
triggerUsername = context.payload.comment.user.login;
|
triggerUsername = context.payload.comment.user.login;
|
||||||
|
triggerUserId = context.payload.comment.user.id;
|
||||||
} else if (isPullRequestReviewEvent(context)) {
|
} else if (isPullRequestReviewEvent(context)) {
|
||||||
commentBody = context.payload.review.body ?? "";
|
commentBody = context.payload.review.body ?? "";
|
||||||
triggerUsername = context.payload.review.user.login;
|
triggerUsername = context.payload.review.user.login;
|
||||||
|
triggerUserId = context.payload.review.user.id;
|
||||||
} else if (isPullRequestReviewCommentEvent(context)) {
|
} else if (isPullRequestReviewCommentEvent(context)) {
|
||||||
commentId = context.payload.comment.id.toString();
|
commentId = context.payload.comment.id.toString();
|
||||||
commentBody = context.payload.comment.body;
|
commentBody = context.payload.comment.body;
|
||||||
triggerUsername = context.payload.comment.user.login;
|
triggerUsername = context.payload.comment.user.login;
|
||||||
|
triggerUserId = context.payload.comment.user.id;
|
||||||
} else if (isIssuesEvent(context)) {
|
} else if (isIssuesEvent(context)) {
|
||||||
triggerUsername = context.payload.issue.user.login;
|
triggerUsername = context.payload.issue.user.login;
|
||||||
|
triggerUserId = context.payload.issue.user.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create infrastructure fields object
|
// Create infrastructure fields object
|
||||||
@ -146,6 +151,7 @@ export function prepareContext(
|
|||||||
claudeCommentId,
|
claudeCommentId,
|
||||||
triggerPhrase,
|
triggerPhrase,
|
||||||
...(triggerUsername && { triggerUsername }),
|
...(triggerUsername && { triggerUsername }),
|
||||||
|
...(triggerUserId && { triggerUserId }),
|
||||||
...(prompt && { prompt }),
|
...(prompt && { prompt }),
|
||||||
...(claudeBranch && { claudeBranch }),
|
...(claudeBranch && { claudeBranch }),
|
||||||
};
|
};
|
||||||
@ -394,9 +400,16 @@ function getCommitInstructions(
|
|||||||
context: PreparedContext,
|
context: PreparedContext,
|
||||||
useCommitSigning: boolean,
|
useCommitSigning: boolean,
|
||||||
): string {
|
): 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 =
|
const coAuthorLine =
|
||||||
(githubData.triggerDisplayName ?? context.triggerUsername) !== "Unknown"
|
triggerName && triggerName !== "Unknown" && triggerEmail
|
||||||
? `Co-authored-by: ${githubData.triggerDisplayName ?? context.triggerUsername} <${context.triggerUsername}@users.noreply.github.com>`
|
? `Co-authored-by: ${triggerName} <${triggerEmail}>`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
if (useCommitSigning) {
|
if (useCommitSigning) {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ export type CommonFields = {
|
|||||||
claudeCommentId: string;
|
claudeCommentId: string;
|
||||||
triggerPhrase: string;
|
triggerPhrase: string;
|
||||||
triggerUsername?: string;
|
triggerUsername?: string;
|
||||||
|
triggerUserId?: number;
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
claudeBranch?: string;
|
claudeBranch?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
3
src/entrypoints/format-turns.ts
Executable file → Normal file
3
src/entrypoints/format-turns.ts
Executable file → Normal file
@ -268,7 +268,8 @@ export function groupTurnsNaturally(data: Turn[]): GroupedContent[] {
|
|||||||
type: "system_init",
|
type: "system_init",
|
||||||
tools_count: tools.length,
|
tools_count: tools.length,
|
||||||
});
|
});
|
||||||
} else {
|
} else if (subtype !== "thinking_tokens") {
|
||||||
|
// Skip thinking_tokens - internal progress events not meant for summary
|
||||||
groupedContent.push({
|
groupedContent.push({
|
||||||
type: "system_other",
|
type: "system_other",
|
||||||
data: turn,
|
data: turn,
|
||||||
|
|||||||
@ -44,6 +44,13 @@ import { runClaude } from "../../base-action/src/run-claude";
|
|||||||
import type { ClaudeRunResult } from "../../base-action/src/run-claude-sdk";
|
import type { ClaudeRunResult } from "../../base-action/src/run-claude-sdk";
|
||||||
import { setExecutionFileOutputIfPresent } from "../../base-action/src/execution-file";
|
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.
|
* Install Claude Code CLI, handling retry logic and custom executable paths.
|
||||||
* Returns the absolute path to the claude executable.
|
* Returns the absolute path to the claude executable.
|
||||||
@ -68,7 +75,7 @@ async function installClaudeCode(): Promise<string> {
|
|||||||
return customExecutable;
|
return customExecutable;
|
||||||
}
|
}
|
||||||
|
|
||||||
const claudeCodeVersion = "2.1.175";
|
const claudeCodeVersion = "2.1.220";
|
||||||
console.log(`Installing Claude Code v${claudeCodeVersion}...`);
|
console.log(`Installing Claude Code v${claudeCodeVersion}...`);
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
@ -77,10 +84,7 @@ async function installClaudeCode(): Promise<string> {
|
|||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const child = spawn(
|
const child = spawn(
|
||||||
"bash",
|
"bash",
|
||||||
[
|
["-c", buildInstallCommand(claudeCodeVersion)],
|
||||||
"-c",
|
|
||||||
`curl -fsSL https://claude.ai/install.sh | bash -s -- ${claudeCodeVersion}`,
|
|
||||||
],
|
|
||||||
{ stdio: "inherit" },
|
{ stdio: "inherit" },
|
||||||
);
|
);
|
||||||
child.on("close", (code) => {
|
child.on("close", (code) => {
|
||||||
@ -314,7 +318,8 @@ async function run() {
|
|||||||
} finally {
|
} finally {
|
||||||
// Phase 4: Cleanup (always runs)
|
// Phase 4: Cleanup (always runs)
|
||||||
|
|
||||||
// Stop refreshing the workload identity token file
|
// Stop refreshing the workload identity token file and delete the token
|
||||||
|
// material so it doesn't outlive this step
|
||||||
workloadIdentity?.stop();
|
workloadIdentity?.stop();
|
||||||
|
|
||||||
// Update tracking comment
|
// Update tracking comment
|
||||||
|
|||||||
@ -204,11 +204,9 @@ export function isBodySafeToUse(
|
|||||||
* @param excludeActors - Comma-separated actors to exclude
|
* @param excludeActors - Comma-separated actors to exclude
|
||||||
* @returns Filtered array of comments
|
* @returns Filtered array of comments
|
||||||
*/
|
*/
|
||||||
export function filterCommentsByActor<T extends { author: { login: string } }>(
|
export function filterCommentsByActor<
|
||||||
comments: T[],
|
T extends { author: { login: string } | null },
|
||||||
includeActors: string = "",
|
>(comments: T[], includeActors: string = "", excludeActors: string = ""): T[] {
|
||||||
excludeActors: string = "",
|
|
||||||
): T[] {
|
|
||||||
const includeParsed = parseActorFilter(includeActors);
|
const includeParsed = parseActorFilter(includeActors);
|
||||||
const excludeParsed = parseActorFilter(excludeActors);
|
const excludeParsed = parseActorFilter(excludeActors);
|
||||||
|
|
||||||
@ -219,7 +217,9 @@ export function filterCommentsByActor<T extends { author: { login: string } }>(
|
|||||||
|
|
||||||
return comments.filter((comment) =>
|
return comments.filter((comment) =>
|
||||||
shouldIncludeCommentByActor(
|
shouldIncludeCommentByActor(
|
||||||
comment.author.login,
|
// author is null for comments from deleted ("ghost") accounts; treat them
|
||||||
|
// as the "ghost" login so filtering never dereferences null and crashes.
|
||||||
|
comment.author?.login ?? "ghost",
|
||||||
includeParsed,
|
includeParsed,
|
||||||
excludeParsed,
|
excludeParsed,
|
||||||
),
|
),
|
||||||
@ -378,34 +378,26 @@ export async function fetchGitHubData({
|
|||||||
body: c.body,
|
body: c.body,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Filter review bodies to trigger time
|
// Filter reviews and inline review comments to trigger time and by actor
|
||||||
const filteredReviewBodies = reviewData?.nodes
|
// before building anything from them. The trigger-time filter is the TOCTOU
|
||||||
? filterReviewsToTriggerTime(reviewData.nodes, triggerTime).filter(
|
// protection applied to issue/PR comments and the body above: it drops
|
||||||
(r) => r.body,
|
// 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.
|
||||||
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
|
|
||||||
if (reviewData && reviewData.nodes) {
|
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 = filterCommentsByActor(
|
||||||
reviewData.nodes,
|
filterReviewsToTriggerTime(reviewData.nodes, triggerTime),
|
||||||
includeCommentsByActor,
|
includeCommentsByActor,
|
||||||
excludeCommentsByActor,
|
excludeCommentsByActor,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Also filter inline review comments within each review
|
// Apply the same trigger-time + actor filtering to inline review comments.
|
||||||
reviewData.nodes.forEach((review) => {
|
reviewData.nodes.forEach((review) => {
|
||||||
if (review.comments?.nodes) {
|
if (review.comments?.nodes) {
|
||||||
review.comments.nodes = filterCommentsByActor(
|
review.comments.nodes = filterCommentsByActor(
|
||||||
review.comments.nodes,
|
filterCommentsToTriggerTime(review.comments.nodes, triggerTime),
|
||||||
includeCommentsByActor,
|
includeCommentsByActor,
|
||||||
excludeCommentsByActor,
|
excludeCommentsByActor,
|
||||||
);
|
);
|
||||||
@ -413,14 +405,19 @@ export async function fetchGitHubData({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const allReviewComments =
|
// Build the image-processing lists from the already-filtered review nodes,
|
||||||
reviewData?.nodes?.flatMap((r) => r.comments?.nodes ?? []) ?? [];
|
// so reviews/comments excluded from the prompt are not processed for images.
|
||||||
const filteredReviewComments = filterCommentsToTriggerTime(
|
const reviewBodies: CommentWithImages[] = (reviewData?.nodes ?? [])
|
||||||
allReviewComments,
|
.filter((r) => r.body)
|
||||||
triggerTime,
|
.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)
|
.filter((c) => c.body && !c.isMinimized)
|
||||||
.map((c) => ({
|
.map((c) => ({
|
||||||
type: "review_comment" as const,
|
type: "review_comment" as const,
|
||||||
|
|||||||
@ -21,7 +21,7 @@ export function formatContext(
|
|||||||
const prData = contextData as GitHubPullRequest;
|
const prData = contextData as GitHubPullRequest;
|
||||||
const sanitizedTitle = sanitizeContent(prData.title);
|
const sanitizedTitle = sanitizeContent(prData.title);
|
||||||
return `PR Title: ${sanitizedTitle}
|
return `PR Title: ${sanitizedTitle}
|
||||||
PR Author: ${prData.author.login}
|
PR Author: ${prData.author?.login ?? "ghost"}
|
||||||
PR Branch: ${prData.headRefName} -> ${prData.baseRefName}
|
PR Branch: ${prData.headRefName} -> ${prData.baseRefName}
|
||||||
PR State: ${prData.state}
|
PR State: ${prData.state}
|
||||||
PR Labels: ${formatLabels(prData.labels.nodes)}
|
PR Labels: ${formatLabels(prData.labels.nodes)}
|
||||||
@ -33,7 +33,7 @@ Changed Files: ${prData.files.nodes.length} files`;
|
|||||||
const issueData = contextData as GitHubIssue;
|
const issueData = contextData as GitHubIssue;
|
||||||
const sanitizedTitle = sanitizeContent(issueData.title);
|
const sanitizedTitle = sanitizeContent(issueData.title);
|
||||||
return `Issue Title: ${sanitizedTitle}
|
return `Issue Title: ${sanitizedTitle}
|
||||||
Issue Author: ${issueData.author.login}
|
Issue Author: ${issueData.author?.login ?? "ghost"}
|
||||||
Issue State: ${issueData.state}
|
Issue State: ${issueData.state}
|
||||||
Issue Labels: ${formatLabels(issueData.labels.nodes)}`;
|
Issue Labels: ${formatLabels(issueData.labels.nodes)}`;
|
||||||
}
|
}
|
||||||
@ -71,7 +71,7 @@ export function formatComments(
|
|||||||
|
|
||||||
body = sanitizeContent(body);
|
body = sanitizeContent(body);
|
||||||
|
|
||||||
return `[${comment.author.login} at ${comment.createdAt}]: ${body}`;
|
return `[${comment.author?.login ?? "ghost"} at ${comment.createdAt}]: ${body}`;
|
||||||
})
|
})
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
}
|
}
|
||||||
@ -85,7 +85,7 @@ export function formatReviewComments(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formattedReviews = reviewData.nodes.map((review) => {
|
const formattedReviews = reviewData.nodes.map((review) => {
|
||||||
let reviewOutput = `[Review by ${review.author.login} at ${review.submittedAt}]: ${review.state}`;
|
let reviewOutput = `[Review by ${review.author?.login ?? "ghost"} at ${review.submittedAt}]: ${review.state}`;
|
||||||
|
|
||||||
if (review.body && review.body.trim()) {
|
if (review.body && review.body.trim()) {
|
||||||
let body = review.body;
|
let body = review.body;
|
||||||
|
|||||||
@ -27,14 +27,15 @@ function extractFirstLabel(githubData: FetchDataResult): string | undefined {
|
|||||||
* This prevents command injection by ensuring only safe characters are used.
|
* This prevents command injection by ensuring only safe characters are used.
|
||||||
*
|
*
|
||||||
* Valid branch names:
|
* Valid branch names:
|
||||||
* - Start with alphanumeric character (not dash, to prevent option injection)
|
* - Start with alphanumeric character, underscore, or @ (not dash, to prevent option injection)
|
||||||
* - Contain only alphanumeric, forward slash, hyphen, underscore, period, or hash (#)
|
* - Contain only alphanumeric, forward slash, hyphen, underscore, period, hash (#), plus (+), comma (,), or at sign (@)
|
||||||
* - Do not start or end with a period
|
* - Do not start or end with a period
|
||||||
* - Do not end with a slash
|
* - Do not end with a slash
|
||||||
* - Do not contain '..' (path traversal)
|
* - Do not contain '..' (path traversal)
|
||||||
* - Do not contain '//' (consecutive slashes)
|
* - Do not contain '//' (consecutive slashes)
|
||||||
* - Do not end with '.lock'
|
* - Do not end with '.lock'
|
||||||
* - Do not contain '@{'
|
* - Do not contain '@{'
|
||||||
|
* - Are not the single character '@' (HEAD shorthand in git revision syntax)
|
||||||
* - Do not contain control characters or special git characters (~^:?*[\])
|
* - Do not contain control characters or special git characters (~^:?*[\])
|
||||||
*/
|
*/
|
||||||
export function validateBranchName(branchName: string): void {
|
export function validateBranchName(branchName: string): void {
|
||||||
@ -58,18 +59,24 @@ 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 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
|
// + 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").
|
// 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
|
// , 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").
|
// 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.
|
||||||
|
// _ is valid per git-check-ref-format anywhere in a ref name, including the first character;
|
||||||
|
// leading underscores are a common convention for release/internal branches (e.g.
|
||||||
|
// "_release/v1.2.3"), which previously failed validation as a PR's base branch.
|
||||||
// All git calls use execFileSync (not shell interpolation), so none of these characters carry injection risk.
|
// 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)) {
|
if (!validPattern.test(branchName)) {
|
||||||
throw new Error(
|
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, underscore, or '@' and contain only alphanumeric characters, forward slashes, hyphens, underscores, periods, hashes (#), plus signs (+), commas (,), or at signs (@).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,6 +119,15 @@ export function validateBranchName(branchName: string): void {
|
|||||||
`Invalid branch name: "${branchName}". Branch names cannot contain '@{'`,
|
`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 '@'.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -30,6 +30,21 @@ const SENSITIVE_PATHS = [
|
|||||||
|
|
||||||
const CLAUDE_PR_EXCLUDE_PATTERN = "/.claude-pr/";
|
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 {
|
function ensureClaudePrExcludedFromGit(): void {
|
||||||
const excludePath = execFileSync(
|
const excludePath = execFileSync(
|
||||||
"git",
|
"git",
|
||||||
@ -86,7 +101,7 @@ export function restoreConfigFromBase(baseBranch: string): void {
|
|||||||
rmSync(".claude-pr", { recursive: true, force: true });
|
rmSync(".claude-pr", { recursive: true, force: true });
|
||||||
for (const p of SENSITIVE_PATHS) {
|
for (const p of SENSITIVE_PATHS) {
|
||||||
if (existsSync(p)) {
|
if (existsSync(p)) {
|
||||||
cpSync(p, `.claude-pr/${p}`, { recursive: true, dereference: true });
|
snapshotSensitivePath(p, `.claude-pr/${p}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (existsSync(".claude-pr")) {
|
if (existsSync(".claude-pr")) {
|
||||||
|
|||||||
@ -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<string> {
|
async function getOidcToken(): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const oidcToken = await core.getIDToken("claude-code-github-action");
|
const oidcToken = await core.getIDToken("claude-code-github-action");
|
||||||
@ -80,25 +123,11 @@ async function exchangeForAppToken(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const responseJson = (await response.json()) as {
|
const responseJson =
|
||||||
error?: {
|
(await response.json()) as AppTokenExchangeErrorResponse;
|
||||||
message?: string;
|
|
||||||
details?: {
|
|
||||||
error_code?: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
type?: string;
|
|
||||||
message?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check for specific workflow validation error codes that should skip the action
|
if (isWorkflowValidationError(response.status, responseJson)) {
|
||||||
const errorCode = responseJson.error?.details?.error_code;
|
const message = getAppTokenExchangeErrorMessage(responseJson);
|
||||||
|
|
||||||
if (errorCode === "workflow_not_found_on_default_branch") {
|
|
||||||
const message =
|
|
||||||
responseJson.message ??
|
|
||||||
responseJson.error?.message ??
|
|
||||||
"Workflow validation failed";
|
|
||||||
core.warning(`Skipping action due to workflow validation: ${message}`);
|
core.warning(`Skipping action due to workflow validation: ${message}`);
|
||||||
console.log(
|
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.",
|
"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);
|
throw new WorkflowValidationSkipError(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const message = getAppTokenExchangeErrorMessage(responseJson);
|
||||||
console.error(
|
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 {
|
const appTokenData = (await response.json()) as {
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
// Types for GitHub GraphQL query responses
|
// Types for GitHub GraphQL query responses
|
||||||
|
|
||||||
|
// GitHub's GraphQL `author`/`actor` fields resolve to null when the underlying
|
||||||
|
// account has been deleted (the "ghost" user). Any field typed as
|
||||||
|
// `GitHubAuthor | null` can therefore be null at runtime and must be guarded.
|
||||||
export type GitHubAuthor = {
|
export type GitHubAuthor = {
|
||||||
login: string;
|
login: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@ -8,7 +12,7 @@ export type GitHubComment = {
|
|||||||
id: string;
|
id: string;
|
||||||
databaseId: string;
|
databaseId: string;
|
||||||
body: string;
|
body: string;
|
||||||
author: GitHubAuthor;
|
author: GitHubAuthor | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
lastEditedAt?: string;
|
lastEditedAt?: string;
|
||||||
@ -39,7 +43,7 @@ export type GitHubFile = {
|
|||||||
export type GitHubReview = {
|
export type GitHubReview = {
|
||||||
id: string;
|
id: string;
|
||||||
databaseId: string;
|
databaseId: string;
|
||||||
author: GitHubAuthor;
|
author: GitHubAuthor | null;
|
||||||
body: string;
|
body: string;
|
||||||
state: string;
|
state: string;
|
||||||
submittedAt: string;
|
submittedAt: string;
|
||||||
@ -53,7 +57,7 @@ export type GitHubReview = {
|
|||||||
export type GitHubPullRequest = {
|
export type GitHubPullRequest = {
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
author: GitHubAuthor;
|
author: GitHubAuthor | null;
|
||||||
baseRefName: string;
|
baseRefName: string;
|
||||||
headRefName: string;
|
headRefName: string;
|
||||||
headRefOid: string;
|
headRefOid: string;
|
||||||
@ -95,7 +99,7 @@ export type GitHubPullRequest = {
|
|||||||
export type GitHubIssue = {
|
export type GitHubIssue = {
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
author: GitHubAuthor;
|
author: GitHubAuthor | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
lastEditedAt?: string;
|
lastEditedAt?: string;
|
||||||
|
|||||||
@ -10,7 +10,13 @@ export function stripInvisibleCharacters(content: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function stripMarkdownImageAltText(content: string): string {
|
export function stripMarkdownImageAltText(content: string): string {
|
||||||
return content.replace(/!\[[^\]]*\]\(/g, ";
|
// Inline images:  -> 
|
||||||
|
content = content.replace(/!\[[^\]]*\]\(/g, ";
|
||||||
|
// Reference-style images: ![alt][ref] -> ![][ref] (keep the label, drop the
|
||||||
|
// alt text, which is otherwise a hidden-instruction channel just like the
|
||||||
|
// inline form above).
|
||||||
|
content = content.replace(/!\[[^\]]*\](\[[^\]]*\])/g, "![]$1");
|
||||||
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stripMarkdownLinkTitles(content: string): string {
|
export function stripMarkdownLinkTitles(content: string): string {
|
||||||
@ -83,6 +89,12 @@ export function redactGitHubTokens(content: string): string {
|
|||||||
"[REDACTED_GITHUB_TOKEN]",
|
"[REDACTED_GITHUB_TOKEN]",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// GitHub user-to-server tokens: ghu_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
|
||||||
|
content = content.replace(
|
||||||
|
/\bghu_[A-Za-z0-9]{36}\b/g,
|
||||||
|
"[REDACTED_GITHUB_TOKEN]",
|
||||||
|
);
|
||||||
|
|
||||||
// GitHub installation tokens: ghs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
|
// GitHub installation tokens: ghs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
|
||||||
content = content.replace(
|
content = content.replace(
|
||||||
/\bghs_[A-Za-z0-9]{36}\b/g,
|
/\bghs_[A-Za-z0-9]{36}\b/g,
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { appendFileSync } from "fs";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createOctokit } from "../github/api/client";
|
import { createOctokit } from "../github/api/client";
|
||||||
import { sanitizeContent } from "../github/utils/sanitizer";
|
import { sanitizeContent } from "../github/utils/sanitizer";
|
||||||
|
import { removeBufferedComment } from "./inline-comment-buffer";
|
||||||
|
|
||||||
// Get repository and PR information from environment variables
|
// Get repository and PR information from environment variables
|
||||||
const REPO_OWNER = process.env.REPO_OWNER;
|
const REPO_OWNER = process.env.REPO_OWNER;
|
||||||
@ -180,6 +181,16 @@ server.tool(
|
|||||||
|
|
||||||
const result = await octokit.rest.pulls.createReviewComment(params);
|
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 {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
|
|||||||
54
src/mcp/inline-comment-buffer.ts
Normal file
54
src/mcp/inline-comment-buffer.ts
Normal file
@ -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" : "",
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,24 +1,73 @@
|
|||||||
export function parseAllowedTools(claudeArgs: string): string[] {
|
import { parse as parseShellArgs } from "shell-quote";
|
||||||
// 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)
|
|
||||||
];
|
|
||||||
|
|
||||||
|
// 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[] {
|
||||||
|
if (!claudeArgs?.trim()) return [];
|
||||||
|
|
||||||
|
const args = tokenize(claudeArgs);
|
||||||
const tools: string[] = [];
|
const tools: string[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
for (const pattern of patterns) {
|
for (let i = 0; i < args.length; i++) {
|
||||||
for (const match of claudeArgs.matchAll(pattern)) {
|
const arg = args[i];
|
||||||
if (match[1]) {
|
if (!arg?.startsWith("--")) continue;
|
||||||
// Don't add if the value starts with -- (another flag)
|
|
||||||
if (match[1].startsWith("--")) {
|
const flag = arg.slice(2);
|
||||||
continue;
|
if (!ALLOWED_TOOLS_FLAGS.has(flag)) continue;
|
||||||
}
|
|
||||||
for (const tool of match[1].split(",")) {
|
// 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();
|
const trimmed = tool.trim();
|
||||||
if (trimmed && !seen.has(trimmed)) {
|
if (trimmed && !seen.has(trimmed)) {
|
||||||
seen.add(trimmed);
|
seen.add(trimmed);
|
||||||
@ -27,7 +76,6 @@ export function parseAllowedTools(claudeArgs: string): string[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return tools;
|
return tools;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,6 +28,20 @@ function extractDescription(
|
|||||||
.replace(/^-|-$/g, ""); // Remove leading/trailing hyphens
|
.replace(/^-|-$/g, ""); // Remove leading/trailing hyphens
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizes a label into a git-safe branch segment. Labels are free-form and
|
||||||
|
* often scoped (e.g. "area:permissions"), so characters that are invalid in a
|
||||||
|
* branch name (":", "/", spaces, ...) are replaced with a hyphen rather than
|
||||||
|
* dropped, keeping the label readable. Returns "" if nothing usable remains.
|
||||||
|
*/
|
||||||
|
function sanitizeLabel(label: string): string {
|
||||||
|
return label
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9-]+/g, "-") // Replace runs of invalid chars with a hyphen
|
||||||
|
.replace(/-+/g, "-") // Collapse multiple hyphens
|
||||||
|
.replace(/^-|-$/g, ""); // Remove leading/trailing hyphens
|
||||||
|
}
|
||||||
|
|
||||||
export interface BranchTemplateVariables {
|
export interface BranchTemplateVariables {
|
||||||
prefix: string;
|
prefix: string;
|
||||||
entityType: string;
|
entityType: string;
|
||||||
@ -78,7 +92,7 @@ export function generateBranchName(
|
|||||||
entityNumber,
|
entityNumber,
|
||||||
timestamp: `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}`,
|
timestamp: `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}`,
|
||||||
sha: sha?.substring(0, 8), // First 8 characters of SHA
|
sha: sha?.substring(0, 8), // First 8 characters of SHA
|
||||||
label: label || entityType, // Fall back to entityType if no label
|
label: (label && sanitizeLabel(label)) || entityType, // Sanitize; fall back to entityType if empty/no label
|
||||||
description: title ? extractDescription(title) : undefined,
|
description: title ? extractDescription(title) : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
applyBranchTemplate,
|
applyBranchTemplate,
|
||||||
generateBranchName,
|
generateBranchName,
|
||||||
} from "../src/utils/branch-template";
|
} from "../src/utils/branch-template";
|
||||||
|
import { validateBranchName } from "../src/github/operations/branch";
|
||||||
|
|
||||||
describe("branch template utilities", () => {
|
describe("branch template utilities", () => {
|
||||||
describe("applyBranchTemplate", () => {
|
describe("applyBranchTemplate", () => {
|
||||||
@ -144,6 +145,53 @@ describe("branch template utilities", () => {
|
|||||||
expect(result).toBe("dev/enhancement-issue_789");
|
expect(result).toBe("dev/enhancement-issue_789");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should sanitize scoped labels that contain invalid git characters", () => {
|
||||||
|
const template = "{{prefix}}{{label}}/{{entityNumber}}";
|
||||||
|
const result = generateBranchName(
|
||||||
|
template,
|
||||||
|
"claude/",
|
||||||
|
"issue",
|
||||||
|
123,
|
||||||
|
undefined,
|
||||||
|
"area:permissions",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe("claude/area-permissions/123");
|
||||||
|
// Regression: an unsanitized ":" here previously failed validateBranchName
|
||||||
|
// and crashed the run via process.exit(1).
|
||||||
|
expect(() => validateBranchName(result)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should replace spaces in labels with hyphens", () => {
|
||||||
|
const template = "{{prefix}}{{label}}-{{entityNumber}}";
|
||||||
|
const result = generateBranchName(
|
||||||
|
template,
|
||||||
|
"fix/",
|
||||||
|
"issue",
|
||||||
|
456,
|
||||||
|
undefined,
|
||||||
|
"needs review",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe("fix/needs-review-456");
|
||||||
|
expect(() => validateBranchName(result)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fall back to entityType when a label sanitizes to empty", () => {
|
||||||
|
const template = "{{prefix}}{{label}}-{{entityNumber}}";
|
||||||
|
const result = generateBranchName(
|
||||||
|
template,
|
||||||
|
"fix/",
|
||||||
|
"pr",
|
||||||
|
789,
|
||||||
|
undefined,
|
||||||
|
"🎉",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe("fix/pr-789");
|
||||||
|
expect(() => validateBranchName(result)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
it("should use description in template when provided", () => {
|
it("should use description in template when provided", () => {
|
||||||
const template = "{{prefix}}{{description}}/{{entityNumber}}";
|
const template = "{{prefix}}{{description}}/{{entityNumber}}";
|
||||||
const result = generateBranchName(
|
const result = generateBranchName(
|
||||||
|
|||||||
74
test/comments-common.test.ts
Normal file
74
test/comments-common.test.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import {
|
||||||
|
SPINNER_HTML,
|
||||||
|
createJobRunLink,
|
||||||
|
createBranchLink,
|
||||||
|
createCommentBody,
|
||||||
|
} from "../src/github/operations/comments/common";
|
||||||
|
import { GITHUB_SERVER_URL } from "../src/github/api/config";
|
||||||
|
|
||||||
|
describe("comments/common", () => {
|
||||||
|
describe("createJobRunLink", () => {
|
||||||
|
test("builds a markdown link to the workflow run", () => {
|
||||||
|
const result = createJobRunLink("anthropics", "claude-code-action", "42");
|
||||||
|
expect(result).toBe(
|
||||||
|
`[View job run](${GITHUB_SERVER_URL}/anthropics/claude-code-action/actions/runs/42)`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("honors GITHUB_SERVER_URL (GHES) rather than hardcoding github.com", () => {
|
||||||
|
// The link is built from the configured server URL, so it must point at
|
||||||
|
// whatever GITHUB_SERVER_URL resolves to (github.com by default, a GHES
|
||||||
|
// host in enterprise setups).
|
||||||
|
expect(createJobRunLink("o", "r", "1")).toContain(GITHUB_SERVER_URL);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createBranchLink", () => {
|
||||||
|
test("builds a leading-newline markdown link to the branch tree", () => {
|
||||||
|
const result = createBranchLink(
|
||||||
|
"anthropics",
|
||||||
|
"claude-code-action",
|
||||||
|
"feature/x",
|
||||||
|
);
|
||||||
|
expect(result).toBe(
|
||||||
|
`\n[View branch](${GITHUB_SERVER_URL}/anthropics/claude-code-action/tree/feature/x)`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prefixes the link with a newline so it renders on its own line", () => {
|
||||||
|
expect(createBranchLink("o", "r", "main").startsWith("\n")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createCommentBody", () => {
|
||||||
|
test("includes the spinner, the working message, and the job run link", () => {
|
||||||
|
const jobRunLink = createJobRunLink("o", "r", "7");
|
||||||
|
const body = createCommentBody(jobRunLink);
|
||||||
|
|
||||||
|
expect(body).toContain(SPINNER_HTML);
|
||||||
|
expect(body).toContain("Claude Code is working…");
|
||||||
|
expect(body).toContain(jobRunLink);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("omits the branch link when none is provided (defaults to empty)", () => {
|
||||||
|
const body = createCommentBody(createJobRunLink("o", "r", "7"));
|
||||||
|
expect(body).not.toContain("View branch");
|
||||||
|
// No trailing branch content: body ends with the job run link.
|
||||||
|
expect(body.endsWith(")")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("appends the branch link when provided", () => {
|
||||||
|
const jobRunLink = createJobRunLink("o", "r", "7");
|
||||||
|
const branchLink = createBranchLink("o", "r", "feature/x");
|
||||||
|
const body = createCommentBody(jobRunLink, branchLink);
|
||||||
|
|
||||||
|
expect(body).toContain(jobRunLink);
|
||||||
|
expect(body).toContain(branchLink);
|
||||||
|
// The branch link (with its leading newline) comes after the job run link.
|
||||||
|
expect(body.indexOf(branchLink)).toBeGreaterThan(
|
||||||
|
body.indexOf(jobRunLink),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -6,8 +6,10 @@ import {
|
|||||||
getEventTypeAndContext,
|
getEventTypeAndContext,
|
||||||
buildAllowedToolsString,
|
buildAllowedToolsString,
|
||||||
buildDisallowedToolsString,
|
buildDisallowedToolsString,
|
||||||
|
prepareContext,
|
||||||
} from "../src/create-prompt";
|
} from "../src/create-prompt";
|
||||||
import type { PreparedContext } from "../src/create-prompt";
|
import type { PreparedContext } from "../src/create-prompt";
|
||||||
|
import { createMockContext } from "./mockContext";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
process.env.GITHUB_ACTION_PATH = "/test/action/path";
|
process.env.GITHUB_ACTION_PATH = "/test/action/path";
|
||||||
@ -495,6 +497,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("<johndoe@users.noreply.github.com>");
|
||||||
|
});
|
||||||
|
|
||||||
test("should include PR-specific instructions only for PR events", async () => {
|
test("should include PR-specific instructions only for PR events", async () => {
|
||||||
const envVars: PreparedContext = {
|
const envVars: PreparedContext = {
|
||||||
repository: "owner/repo",
|
repository: "owner/repo",
|
||||||
@ -1244,3 +1272,83 @@ describe("buildDisallowedToolsString", () => {
|
|||||||
expect(result).toBe("BadTool1,BadTool2");
|
expect(result).toBe("BadTool1,BadTool2");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("prepareContext validation errors", () => {
|
||||||
|
const commentId = "12345";
|
||||||
|
|
||||||
|
test("throws on an unsupported event type", () => {
|
||||||
|
const context = createMockContext({
|
||||||
|
eventName: "deployment_status" as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => prepareContext(context, commentId)).toThrow(
|
||||||
|
"Unsupported event type: deployment_status",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pull_request event requires a PR number (isPR must be true)", () => {
|
||||||
|
const context = createMockContext({
|
||||||
|
eventName: "pull_request",
|
||||||
|
eventAction: "opened",
|
||||||
|
isPR: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => prepareContext(context, commentId)).toThrow(
|
||||||
|
"PR_NUMBER is required for pull_request event",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pull_request_review event requires a PR number", () => {
|
||||||
|
const context = createMockContext({
|
||||||
|
eventName: "pull_request_review",
|
||||||
|
isPR: false,
|
||||||
|
payload: {
|
||||||
|
review: { body: "please fix", user: { login: "user1" } },
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => prepareContext(context, commentId)).toThrow(
|
||||||
|
"PR_NUMBER is required for pull_request_review event",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("issues event requires an event action", () => {
|
||||||
|
const context = createMockContext({
|
||||||
|
eventName: "issues",
|
||||||
|
eventAction: "",
|
||||||
|
isPR: false,
|
||||||
|
payload: { issue: { user: { login: "user1" } } } as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => prepareContext(context, commentId)).toThrow(
|
||||||
|
"GITHUB_EVENT_ACTION is required for issues event",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("issues event rejects an unsupported action", () => {
|
||||||
|
const context = createMockContext({
|
||||||
|
eventName: "issues",
|
||||||
|
eventAction: "deleted",
|
||||||
|
isPR: false,
|
||||||
|
payload: { issue: { user: { login: "user1" } } } as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
prepareContext(context, commentId, "main", "claude/issue-1"),
|
||||||
|
).toThrow("Unsupported issue action: deleted");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("issue_comment on an issue requires a claude branch", () => {
|
||||||
|
const context = createMockContext({
|
||||||
|
eventName: "issue_comment",
|
||||||
|
isPR: false,
|
||||||
|
payload: {
|
||||||
|
comment: { id: 999, body: "@claude help", user: { login: "user1" } },
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => prepareContext(context, commentId)).toThrow(
|
||||||
|
"CLAUDE_BRANCH is required for issue_comment event",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -723,16 +723,17 @@ describe("fetchGitHubData integration with time filtering", () => {
|
|||||||
triggerTime: "2024-01-15T12:00:00Z",
|
triggerTime: "2024-01-15T12:00:00Z",
|
||||||
});
|
});
|
||||||
|
|
||||||
// The reviewData field returns all reviews (not filtered), but the filtering
|
// Only the review submitted before the trigger and not edited afterward
|
||||||
// happens when processing review bodies for download
|
// reaches the prompt. The review submitted after the trigger and the one
|
||||||
// We can check the image download map to verify filtering
|
// edited after the trigger are dropped (TOCTOU protection), matching the
|
||||||
expect(result.reviewData?.nodes?.length).toBe(3); // All reviews are returned
|
// issue/PR comment and body handling.
|
||||||
|
expect(result.reviewData?.nodes?.length).toBe(1);
|
||||||
|
expect(result.reviewData?.nodes?.[0]?.databaseId).toBe("1");
|
||||||
|
|
||||||
// Check that only the first review's body would be downloaded (filtered)
|
// Only that surviving review's body is queued for image download.
|
||||||
const reviewsInMap = Object.keys(result.imageUrlMap).filter((key) =>
|
const reviewsInMap = Object.keys(result.imageUrlMap).filter((key) =>
|
||||||
key.startsWith("review_body"),
|
key.startsWith("review_body"),
|
||||||
);
|
);
|
||||||
// Only review 1 should have its body processed (before trigger and not edited after)
|
|
||||||
expect(reviewsInMap.length).toBeLessThanOrEqual(1);
|
expect(reviewsInMap.length).toBeLessThanOrEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -805,14 +806,83 @@ describe("fetchGitHubData integration with time filtering", () => {
|
|||||||
triggerTime: "2024-01-15T12:00:00Z",
|
triggerTime: "2024-01-15T12:00:00Z",
|
||||||
});
|
});
|
||||||
|
|
||||||
// The imageUrlMap contains processed comments for image downloading
|
// The review itself is pre-trigger and kept, but its inline comments are
|
||||||
// We should have processed review comments, but only those before trigger time
|
// filtered to trigger time: the comment created after the trigger (id 11)
|
||||||
// The exact check depends on how imageUrlMap is structured, but we can verify
|
// and the one edited after the trigger (id 12) are dropped, leaving only
|
||||||
// that filtering occurred by checking the review data still has all nodes
|
// the pre-trigger comment (id 10).
|
||||||
expect(result.reviewData?.nodes?.length).toBe(1); // Original review is kept
|
expect(result.reviewData?.nodes?.length).toBe(1);
|
||||||
|
const reviewCommentIds =
|
||||||
|
result.reviewData?.nodes?.[0]?.comments?.nodes?.map((c) => c.databaseId);
|
||||||
|
expect(reviewCommentIds).toEqual(["10"]);
|
||||||
|
});
|
||||||
|
|
||||||
// The actual filtering happens during processing for image download
|
it("should filter reviews by both trigger time and actor", async () => {
|
||||||
// Since the mock doesn't actually download images, we verify the input was correct
|
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 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 () => {
|
it("should handle backward compatibility when no trigger time provided", async () => {
|
||||||
@ -1429,4 +1499,42 @@ describe("filterCommentsByActor", () => {
|
|||||||
const filtered = filterCommentsByActor(comments, "user1", "");
|
const filtered = filterCommentsByActor(comments, "user1", "");
|
||||||
expect(filtered).toHaveLength(0);
|
expect(filtered).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("does not crash on comments from deleted (null-author) accounts", () => {
|
||||||
|
// GitHub's GraphQL returns author: null for comments whose account was
|
||||||
|
// deleted. With an exclude filter set (the exact `*[bot]` config we
|
||||||
|
// recommend), the null author must not throw when dereferenced.
|
||||||
|
const comments = [
|
||||||
|
{ author: { login: "user1" }, body: "comment1" },
|
||||||
|
{ author: null, body: "from a deleted account" },
|
||||||
|
{ author: { login: "bot[bot]" }, body: "comment3" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const { filterCommentsByActor } = require("../src/github/data/fetcher");
|
||||||
|
const filtered = filterCommentsByActor(comments, "", "*[bot]");
|
||||||
|
// ghost comment is retained (it matches no exclude pattern); the bot is dropped.
|
||||||
|
expect(filtered).toHaveLength(2);
|
||||||
|
expect(filtered.map((c: any) => c.body)).toEqual([
|
||||||
|
"comment1",
|
||||||
|
"from a deleted account",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("treats null author as the 'ghost' login for include/exclude", () => {
|
||||||
|
const comments = [
|
||||||
|
{ author: null, body: "from a deleted account" },
|
||||||
|
{ author: { login: "user1" }, body: "comment2" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const { filterCommentsByActor } = require("../src/github/data/fetcher");
|
||||||
|
// Excluding "ghost" removes the deleted-account comment.
|
||||||
|
expect(filterCommentsByActor(comments, "", "ghost")).toHaveLength(1);
|
||||||
|
expect(filterCommentsByActor(comments, "", "ghost")[0].body).toBe(
|
||||||
|
"comment2",
|
||||||
|
);
|
||||||
|
// Including only "ghost" keeps just the deleted-account comment.
|
||||||
|
const onlyGhost = filterCommentsByActor(comments, "ghost", "");
|
||||||
|
expect(onlyGhost).toHaveLength(1);
|
||||||
|
expect(onlyGhost[0].body).toBe("from a deleted account");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -159,6 +159,21 @@ Issue State: OPEN
|
|||||||
Issue Labels: architecture, agent-sdk, drift:functional`,
|
Issue Labels: architecture, agent-sdk, drift:functional`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renders a deleted (null-author) issue author as 'ghost'", () => {
|
||||||
|
const issueData: GitHubIssue = {
|
||||||
|
title: "Test Issue",
|
||||||
|
body: "Issue body",
|
||||||
|
author: null,
|
||||||
|
createdAt: "2023-01-01T00:00:00Z",
|
||||||
|
state: "OPEN",
|
||||||
|
labels: { nodes: [] },
|
||||||
|
comments: { nodes: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = formatContext(issueData, false);
|
||||||
|
expect(result).toContain("Issue Author: ghost");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("formatBody", () => {
|
describe("formatBody", () => {
|
||||||
@ -252,6 +267,24 @@ describe("formatComments", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renders deleted (null-author) comments as 'ghost'", () => {
|
||||||
|
// GitHub returns author: null for comments from deleted accounts.
|
||||||
|
const comments: GitHubComment[] = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
databaseId: "100001",
|
||||||
|
body: "From a deleted account",
|
||||||
|
author: null,
|
||||||
|
createdAt: "2023-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = formatComments(comments);
|
||||||
|
expect(result).toBe(
|
||||||
|
"[ghost at 2023-01-01T00:00:00Z]: From a deleted account",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("returns empty string for empty comments array", () => {
|
test("returns empty string for empty comments array", () => {
|
||||||
const result = formatComments([]);
|
const result = formatComments([]);
|
||||||
expect(result).toBe("");
|
expect(result).toBe("");
|
||||||
@ -494,6 +527,29 @@ describe("formatReviewComments", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renders deleted (null-author) reviews as 'ghost'", () => {
|
||||||
|
const reviewData = {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: "review1",
|
||||||
|
databaseId: "300099",
|
||||||
|
author: null,
|
||||||
|
body: "Left before deleting the account",
|
||||||
|
state: "COMMENTED",
|
||||||
|
submittedAt: "2023-01-01T00:00:00Z",
|
||||||
|
comments: {
|
||||||
|
nodes: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = formatReviewComments(reviewData);
|
||||||
|
expect(result).toBe(
|
||||||
|
`[Review by ghost at 2023-01-01T00:00:00Z]: COMMENTED\nLeft before deleting the account`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("formats multiple reviews correctly", () => {
|
test("formats multiple reviews correctly", () => {
|
||||||
const reviewData = {
|
const reviewData = {
|
||||||
nodes: [
|
nodes: [
|
||||||
|
|||||||
@ -437,3 +437,81 @@ describe("integration tests", () => {
|
|||||||
expect(actualOutput).toBe(expectedOutput);
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filters out thinking_tokens system messages", () => {
|
||||||
|
const data: Turn[] = [
|
||||||
|
{ type: "system", subtype: "init", tools: [{ name: "tool1" }] },
|
||||||
|
{ type: "system", subtype: "thinking_tokens" },
|
||||||
|
{ type: "system", subtype: "thinking_tokens" },
|
||||||
|
{ type: "system", subtype: "other_subtype" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const grouped = groupTurnsNaturally(data);
|
||||||
|
|
||||||
|
// Should have init and other_subtype, but not thinking_tokens
|
||||||
|
expect(grouped).toHaveLength(2);
|
||||||
|
expect(grouped[0]?.type).toBe("system_init");
|
||||||
|
expect(grouped[1]?.type).toBe("system_other");
|
||||||
|
expect(grouped[1]?.data?.subtype).toBe("other_subtype");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("thinking_tokens does not appear in formatted output", () => {
|
||||||
|
const data: Turn[] = [
|
||||||
|
{ type: "system", subtype: "init", tools: [] },
|
||||||
|
{ type: "system", subtype: "thinking_tokens" },
|
||||||
|
{ type: "system", subtype: "thinking_tokens" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = formatTurnsFromData(data);
|
||||||
|
|
||||||
|
expect(result).not.toContain("thinking_tokens");
|
||||||
|
expect(result).toContain("## 🚀 System Initialization");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
139
test/inline-comment-buffer.test.ts
Normal file
139
test/inline-comment-buffer.test.ts
Normal file
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
50
test/install-pipefail.test.ts
Normal file
50
test/install-pipefail.test.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -37,9 +37,43 @@ describe("parseAllowedTools", () => {
|
|||||||
|
|
||||||
test("handles --allowedTools followed by another --allowedTools flag", () => {
|
test("handles --allowedTools followed by another --allowedTools flag", () => {
|
||||||
const args = "--allowedTools --allowedTools mcp__github__*";
|
const args = "--allowedTools --allowedTools mcp__github__*";
|
||||||
// The second --allowedTools is consumed as a value of the first, then skipped.
|
// The first --allowedTools has no value (the next token is another flag);
|
||||||
// This is an edge case with malformed input - returns empty.
|
// the second consumes mcp__github__*. This matches how the SDK option
|
||||||
expect(parseAllowedTools(args)).toEqual([]);
|
// 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", () => {
|
test("parses multiple separate --allowed-tools flags", () => {
|
||||||
|
|||||||
@ -2,10 +2,12 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|||||||
import { execFileSync } from "child_process";
|
import { execFileSync } from "child_process";
|
||||||
import {
|
import {
|
||||||
existsSync,
|
existsSync,
|
||||||
|
lstatSync,
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
rmSync,
|
rmSync,
|
||||||
|
symlinkSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
} from "fs";
|
} from "fs";
|
||||||
import { dirname, isAbsolute, join } from "path";
|
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", () => {
|
test("does not modify an existing .gitignore", () => {
|
||||||
writeRepoFile(".gitignore", "node_modules\n");
|
writeRepoFile(".gitignore", "node_modules\n");
|
||||||
git(["add", ".gitignore"]);
|
git(["add", ".gitignore"]);
|
||||||
@ -156,6 +200,29 @@ describe("restoreConfigFromBase", () => {
|
|||||||
return existsSync(join(repoDir, path));
|
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 {
|
function countClaudePrExcludeEntries(): number {
|
||||||
return readFileSync(getExcludePath(), "utf8")
|
return readFileSync(getExcludePath(), "utf8")
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
|
|||||||
@ -59,6 +59,22 @@ describe("stripMarkdownImageAltText", () => {
|
|||||||
it("should handle empty alt text", () => {
|
it("should handle empty alt text", () => {
|
||||||
expect(stripMarkdownImageAltText("")).toBe("");
|
expect(stripMarkdownImageAltText("")).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should remove alt text from reference-style images", () => {
|
||||||
|
expect(stripMarkdownImageAltText("![example alt text][img1]")).toBe(
|
||||||
|
"![][img1]",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
stripMarkdownImageAltText("Text ![description][ref] more text"),
|
||||||
|
).toBe("Text ![][ref] more text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve the reference label of a reference-style image", () => {
|
||||||
|
// the [ref] label must survive so the image definition still resolves;
|
||||||
|
// only the alt text (the injection channel) is removed
|
||||||
|
expect(stripMarkdownImageAltText("![alt][my-ref]")).toBe("![][my-ref]");
|
||||||
|
expect(stripMarkdownImageAltText("![][keep]")).toBe("![][keep]");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("stripMarkdownLinkTitles", () => {
|
describe("stripMarkdownLinkTitles", () => {
|
||||||
@ -276,6 +292,16 @@ describe("redactGitHubTokens", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should redact user-to-server tokens (ghu_)", () => {
|
||||||
|
const token = "ghu_16C7e42F292c6912E7710c838347Ae178B4a";
|
||||||
|
expect(redactGitHubTokens(`User token: ${token}`)).toBe(
|
||||||
|
"User token: [REDACTED_GITHUB_TOKEN]",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
redactGitHubTokens(`In a URL: x-access-token:${token}@github.com`),
|
||||||
|
).toBe("In a URL: x-access-token:[REDACTED_GITHUB_TOKEN]@github.com");
|
||||||
|
});
|
||||||
|
|
||||||
it("should redact installation tokens (ghs_)", () => {
|
it("should redact installation tokens (ghs_)", () => {
|
||||||
const token = "ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW";
|
const token = "ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW";
|
||||||
expect(redactGitHubTokens(`Install token: ${token}`)).toBe(
|
expect(redactGitHubTokens(`Install token: ${token}`)).toBe(
|
||||||
|
|||||||
164
test/token.test.ts
Normal file
164
test/token.test.ts
Normal file
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -64,6 +64,26 @@ describe("validateBranchName", () => {
|
|||||||
expect(() => validateBranchName("feature/paris,france")).not.toThrow();
|
expect(() => validateBranchName("feature/paris,france")).not.toThrow();
|
||||||
expect(() => validateBranchName("fix/issue-1,2,3")).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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept branch names starting with underscore (git-valid, common for release branches)", () => {
|
||||||
|
// Leading underscores are valid per git check-ref-format and a common
|
||||||
|
// convention for release/internal branches. Rejecting them broke the
|
||||||
|
// action on any open PR whose base branch was e.g. "_release/v1.2.3",
|
||||||
|
// since setupBranch validates the PR's baseRefName after checkout.
|
||||||
|
expect(() => validateBranchName("_release/v1.2.3")).not.toThrow();
|
||||||
|
expect(() => validateBranchName("_internal")).not.toThrow();
|
||||||
|
expect(() => validateBranchName("_wip/feature-x")).not.toThrow();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("command injection attempts", () => {
|
describe("command injection attempts", () => {
|
||||||
@ -137,6 +157,12 @@ describe("validateBranchName", () => {
|
|||||||
expect(() => validateBranchName("HEAD@{yesterday}")).toThrow(/@{/);
|
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", () => {
|
it("should reject .lock suffix", () => {
|
||||||
expect(() => validateBranchName("branch.lock")).toThrow(/\.lock/);
|
expect(() => validateBranchName("branch.lock")).toThrow(/\.lock/);
|
||||||
expect(() => validateBranchName("feature.lock")).toThrow(/\.lock/);
|
expect(() => validateBranchName("feature.lock")).toThrow(/\.lock/);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user