mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-09-19 15:40:33 +08:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51ea8ea73a | ||
|
|
acfa366ca8 | ||
|
|
9eb125afe3 | ||
|
|
1450f658d3 | ||
|
|
0756f6ef2b | ||
|
|
f4d6a11de1 | ||
|
|
bf6d40e068 | ||
|
|
86eb26bf01 | ||
|
|
f4fb5c6cdc | ||
|
|
dde2242db6 | ||
|
|
476e359e62 | ||
|
|
ad67978e5e | ||
|
|
034cbdb008 | ||
|
|
939ae9c056 | ||
|
|
e9c374db23 | ||
|
|
9db782c3a1 |
+3
-3
@@ -8,8 +8,8 @@ This repository is maintained by [Anthropic](https://www.anthropic.com/).
|
||||
|
||||
The security of our systems and user data is Anthropic’s top priority. We appreciate the work of security researchers acting in good faith in identifying and reporting potential vulnerabilities.
|
||||
|
||||
Our security program is managed on HackerOne and we ask that any validated vulnerability in this functionality be reported through their [submission form](https://hackerone.com/anthropic-vdp/reports/new?type=team&report_type=vulnerability).
|
||||
Our security program is managed on HackerOne and we ask that any validated vulnerability in this functionality be reported through their [submission form](https://hackerone.com/4f1f16ba-10d3-4d09-9ecc-c721aad90f24/embedded_submissions/new).
|
||||
|
||||
## Vulnerability Disclosure Program
|
||||
## Anthropic Bug Bounty
|
||||
|
||||
Our Vulnerability Program Guidelines are defined on our [HackerOne program page](https://hackerone.com/anthropic-vdp).
|
||||
Our Bug Bounty Program Guidelines are defined on our [HackerOne program page](https://hackerone.com/anthropic).
|
||||
|
||||
+1
-1
@@ -175,7 +175,7 @@ runs:
|
||||
if: inputs.path_to_bun_executable == ''
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # https://github.com/oven-sh/setup-bun/releases/tag/v2.2.0
|
||||
with:
|
||||
bun-version: 1.3.6
|
||||
bun-version: 1.3.14
|
||||
token: ${{ inputs.github_token || github.token }}
|
||||
|
||||
- name: Setup Custom Bun Path
|
||||
|
||||
@@ -99,7 +99,7 @@ runs:
|
||||
if: inputs.path_to_bun_executable == ''
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # https://github.com/oven-sh/setup-bun/releases/tag/v2.2.0
|
||||
with:
|
||||
bun-version: 1.3.6
|
||||
bun-version: 1.3.14
|
||||
|
||||
- name: Setup Custom Bun Path
|
||||
if: inputs.path_to_bun_executable != ''
|
||||
@@ -124,7 +124,7 @@ runs:
|
||||
PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
||||
run: |
|
||||
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
|
||||
CLAUDE_CODE_VERSION="2.1.131"
|
||||
CLAUDE_CODE_VERSION="2.1.142"
|
||||
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
||||
for attempt in 1 2 3; do
|
||||
echo "Installation attempt $attempt..."
|
||||
|
||||
+11
-11
@@ -6,7 +6,7 @@
|
||||
"name": "@anthropic-ai/claude-code-base-action",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.131",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.142",
|
||||
"shell-quote": "^1.8.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -27,25 +27,25 @@
|
||||
|
||||
"@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.2.131", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.131", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.131", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.131", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.131", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.131", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.131", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.131", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.131" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-4Xak+BlcxXuni5BvNeb0tnSapIoCBxE7cFnXvkUs0EwbY88FkmdJEtBXZbF7NRuN8bUwDeNxvy0Fs0dWnzpU+g=="],
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.142", "", { "dependencies": { "@anthropic-ai/sdk": "^0.93.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.142", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.142", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.142", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.142", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.142", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.142", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.142", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.142" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-k1xBon6ov0PT/vZNf+Z+SuAqmylGJU/+a+h/k04MW5cBbzOIwiVcGFRTGJ/qbY5pcboJbLtts/yBwSu9AvSipg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.131", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jOGq8lAi6bakqX0MBVkJDOddC2xSYnP1XHzps2cBF696dQlHoXs4hqU+69Wt4oKScyw4tM4Pe+Mmeut9LJqbEg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.142", "", { "os": "darwin", "cpu": "arm64" }, "sha512-yBHOiRqJ8JcD9OAMGJALbypaD3u3K8hyUmcnZ+91AHJtymzWxuMkVi4IY1qp8L5jzkKeTnvYfCspzkbiHLuYWg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.131", "", { "os": "darwin", "cpu": "x64" }, "sha512-IxewhApb20ucAxnpUCAwETLjO5PsQRAJIBBlDlNqPsd20LIZVVQuQ5orFf6CGEs6MfYRnWz2FYwfHhguGNPIyQ=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.142", "", { "os": "darwin", "cpu": "x64" }, "sha512-/a/bVMjvAl3gNzWiPIgynYktTYckTcp4YAacV/2F4Jd8XeCV0+DMQW7OFeR+3fnPcBg/8kcOAVYfLZXDExqO1w=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.131", "", { "os": "linux", "cpu": "arm64" }, "sha512-GDwaga8aadtVeYq1wJM2BSWp5l/Srel7L5WRbEvkEWXeGP463S7VLJyiNVcbjbi/HLmyQigEkzFoHfZdeqKOvw=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.142", "", { "os": "linux", "cpu": "arm64" }, "sha512-QKG553PSbIcQ5KLvnl2ekfy5lTyU3dW/X5fDQlRLv4YHNHnqf2o7scJ6eUdfaVTQdIZ+Pa7SNN3bsvVs4bNjQw=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.131", "", { "os": "linux", "cpu": "arm64" }, "sha512-7efL5otHqTKMeNxIztEjEGs8ktlR3hfMmVbo1HaEbs+tkJ6fvMwS3k4xnUP7Bqy+GsM+U9r9kRdNz4MVdc80hg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.142", "", { "os": "linux", "cpu": "arm64" }, "sha512-KZuwSupNJovnMJ7MZxjp1Qq0yu7rAmbzO4Zlmr3jtKDU95t2kgs3c6j4evzQDCgTQMlwH8QTSV4mItDGxlYEbg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.131", "", { "os": "linux", "cpu": "x64" }, "sha512-tJJggvCGtkK876CowajF/42AdUy0TTJk0gHeCKuDCMJF3hMs70EtYnwyM81nb10tKUFb6zYdvn6iPn6iGx7iFQ=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.142", "", { "os": "linux", "cpu": "x64" }, "sha512-o1QZmCNRL5BFTc14KEvT23Fxm1jNv0aa0e9T0OZUjua0oW8DRpri3HKvDEM36qEGWUOANBG7h6Ca/KNqxaTnYg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.131", "", { "os": "linux", "cpu": "x64" }, "sha512-WNqUJscB1F86Igbnw5zXpndT89I7l3aIvPJQEOrSA5JaIDmfJft8QA1rrJPwf2tcxP8nNS0H3MbEFBAxq92bNw=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.142", "", { "os": "linux", "cpu": "x64" }, "sha512-QkDwLMsdYO7n/i1zPCt5YZIet5u+Eo07UpF9UX5yD7bnwRZKDe22L6LVVwiLLjeTO0fTz+uNY7w9/XOYQMlxUQ=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.131", "", { "os": "win32", "cpu": "arm64" }, "sha512-LDXYMqR3T1JtaIusmVDr6e539IhE+IULKYBiLC7+v7VvLG6niP1cC+4W/zYZRnUcbzUgcfoIi1FvrWhtF6/M+A=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.142", "", { "os": "win32", "cpu": "arm64" }, "sha512-x8lbY1m7E/BiFF0Gu/Mx9lkD/zW3vBr3viw0GYNuqY9GYHfLOX9+l9H8C+INeGzB4+ibG8+xD2pnRhWdQxuvUg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.131", "", { "os": "win32", "cpu": "x64" }, "sha512-gwLUkQWtK9Un2i9mWWQgoaEk+2rzamiH3r4j7aoTyVzB4ZQgxdBBOP9ac5o9pIwQE+vflr0HvKk1O54Z320Vng=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.142", "", { "os": "win32", "cpu": "x64" }, "sha512-NpNxdiCEUNjjwvBltpDnkgdjVQ+nRsALpfM1Pe4GhnYiOkTk/TvjMZUuA2qGh0F8KyF0FbqzUsi0uXIgojJT5w=="],
|
||||
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.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-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="],
|
||||
"@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=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.131",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.142",
|
||||
"shell-quote": "^1.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as core from "@actions/core";
|
||||
import { existsSync } from "fs";
|
||||
import { writeFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
|
||||
const EXECUTION_FILENAME = "claude-execution-output.json";
|
||||
|
||||
export function getExecutionFilePath(): string | undefined {
|
||||
if (!process.env.RUNNER_TEMP) {
|
||||
return undefined;
|
||||
}
|
||||
return join(process.env.RUNNER_TEMP, EXECUTION_FILENAME);
|
||||
}
|
||||
|
||||
export async function writeExecutionFile(
|
||||
messages: unknown[],
|
||||
): Promise<string | undefined> {
|
||||
const executionFile = getExecutionFilePath();
|
||||
if (!executionFile) {
|
||||
core.warning("Failed to write execution file: RUNNER_TEMP is not set");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
await writeFile(executionFile, JSON.stringify(messages, null, 2));
|
||||
console.log(`Log saved to ${executionFile}`);
|
||||
return executionFile;
|
||||
} catch (error) {
|
||||
core.warning(`Failed to write execution file: ${error}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function setExecutionFileOutputIfPresent(): string | undefined {
|
||||
const executionFile = getExecutionFilePath();
|
||||
if (!executionFile || !existsSync(executionFile)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
core.setOutput("execution_file", executionFile);
|
||||
return executionFile;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { runClaude } from "./run-claude";
|
||||
import { setupClaudeCodeSettings } from "./setup-claude-code-settings";
|
||||
import { validateEnvironmentVariables } from "./validate-env";
|
||||
import { installPlugins } from "./install-plugins";
|
||||
import { setExecutionFileOutputIfPresent } from "./execution-file";
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
@@ -62,6 +63,7 @@ async function run() {
|
||||
core.setOutput("structured_output", result.structuredOutput);
|
||||
}
|
||||
} catch (error) {
|
||||
setExecutionFileOutputIfPresent();
|
||||
core.setFailed(`Action failed with error: ${error}`);
|
||||
core.setOutput("conclusion", "failure");
|
||||
process.exit(1);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as core from "@actions/core";
|
||||
import { readFile, writeFile, access } from "fs/promises";
|
||||
import { readFile, access } from "fs/promises";
|
||||
import { dirname, join } from "path";
|
||||
import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type {
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
SDKUserMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ParsedSdkOptions } from "./parse-sdk-options";
|
||||
import { writeExecutionFile } from "./execution-file";
|
||||
|
||||
export type ClaudeRunResult = {
|
||||
executionFile?: string;
|
||||
@@ -16,8 +17,6 @@ export type ClaudeRunResult = {
|
||||
structuredOutput?: string;
|
||||
};
|
||||
|
||||
const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`;
|
||||
|
||||
/** Filename for the user request file, written by prompt generation */
|
||||
const USER_REQUEST_FILENAME = "claude-user-request.txt";
|
||||
|
||||
@@ -172,6 +171,7 @@ export async function runClaudeWithSdk(
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("SDK execution error:", error);
|
||||
await writeExecutionFile(messages);
|
||||
throw new Error(`SDK execution error: ${error}`);
|
||||
}
|
||||
|
||||
@@ -179,13 +179,9 @@ export async function runClaudeWithSdk(
|
||||
conclusion: "failure",
|
||||
};
|
||||
|
||||
// Write execution file
|
||||
try {
|
||||
await writeFile(EXECUTION_FILE, JSON.stringify(messages, null, 2));
|
||||
console.log(`Log saved to ${EXECUTION_FILE}`);
|
||||
result.executionFile = EXECUTION_FILE;
|
||||
} catch (error) {
|
||||
core.warning(`Failed to write execution file: ${error}`);
|
||||
const executionFile = await writeExecutionFile(messages);
|
||||
if (executionFile) {
|
||||
result.executionFile = executionFile;
|
||||
}
|
||||
|
||||
// Extract session_id from system.init message
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import * as core from "@actions/core";
|
||||
import { afterEach, describe, expect, spyOn, test } from "bun:test";
|
||||
import { mkdtemp, rm, writeFile } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { setExecutionFileOutputIfPresent } from "../src/execution-file";
|
||||
|
||||
describe("execution file output", () => {
|
||||
const originalRunnerTemp = process.env.RUNNER_TEMP;
|
||||
let tempDir: string | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempDir) {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
tempDir = undefined;
|
||||
}
|
||||
process.env.RUNNER_TEMP = originalRunnerTemp;
|
||||
});
|
||||
|
||||
test("sets execution_file output when the default execution file exists", async () => {
|
||||
const setOutputSpy = spyOn(core, "setOutput").mockImplementation(() => {});
|
||||
tempDir = await mkdtemp(join(tmpdir(), "claude-execution-file-"));
|
||||
process.env.RUNNER_TEMP = tempDir;
|
||||
const executionFile = join(tempDir, "claude-execution-output.json");
|
||||
await writeFile(executionFile, "[]");
|
||||
|
||||
try {
|
||||
expect(setExecutionFileOutputIfPresent()).toBe(executionFile);
|
||||
expect(setOutputSpy).toHaveBeenCalledWith(
|
||||
"execution_file",
|
||||
executionFile,
|
||||
);
|
||||
} finally {
|
||||
setOutputSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
describe("runClaudeWithSdk", () => {
|
||||
const originalRunnerTemp = process.env.RUNNER_TEMP;
|
||||
let tempDir: string | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempDir) {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
tempDir = undefined;
|
||||
}
|
||||
process.env.RUNNER_TEMP = originalRunnerTemp;
|
||||
});
|
||||
|
||||
test("writes the execution file when the SDK throws after yielding messages", async () => {
|
||||
const consoleErrorSpy = spyOn(console, "error").mockImplementation(
|
||||
() => {},
|
||||
);
|
||||
const consoleLogSpy = spyOn(console, "log").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-4-6",
|
||||
};
|
||||
|
||||
mock.module("@anthropic-ai/claude-agent-sdk", () => ({
|
||||
query: async function* () {
|
||||
yield initMessage;
|
||||
throw new Error("Claude Code returned error_max_turns");
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
const { runClaudeWithSdk } = await import("../src/run-claude-sdk");
|
||||
|
||||
await expect(
|
||||
runClaudeWithSdk(promptPath, {
|
||||
sdkOptions: {},
|
||||
showFullOutput: false,
|
||||
hasJsonSchema: false,
|
||||
}),
|
||||
).rejects.toThrow("SDK execution error");
|
||||
|
||||
const executionFile = join(tempDir, "claude-execution-output.json");
|
||||
await expect(readFile(executionFile, "utf-8")).resolves.toBe(
|
||||
JSON.stringify([initMessage], null, 2),
|
||||
);
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
consoleLogSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1",
|
||||
"@actions/github": "^6.0.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.131",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.142",
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
"@octokit/graphql": "^8.2.2",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
@@ -37,25 +37,25 @@
|
||||
|
||||
"@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.2.131", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.131", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.131", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.131", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.131", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.131", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.131", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.131", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.131" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-4Xak+BlcxXuni5BvNeb0tnSapIoCBxE7cFnXvkUs0EwbY88FkmdJEtBXZbF7NRuN8bUwDeNxvy0Fs0dWnzpU+g=="],
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.142", "", { "dependencies": { "@anthropic-ai/sdk": "^0.93.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.142", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.142", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.142", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.142", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.142", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.142", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.142", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.142" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-k1xBon6ov0PT/vZNf+Z+SuAqmylGJU/+a+h/k04MW5cBbzOIwiVcGFRTGJ/qbY5pcboJbLtts/yBwSu9AvSipg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.131", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jOGq8lAi6bakqX0MBVkJDOddC2xSYnP1XHzps2cBF696dQlHoXs4hqU+69Wt4oKScyw4tM4Pe+Mmeut9LJqbEg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.142", "", { "os": "darwin", "cpu": "arm64" }, "sha512-yBHOiRqJ8JcD9OAMGJALbypaD3u3K8hyUmcnZ+91AHJtymzWxuMkVi4IY1qp8L5jzkKeTnvYfCspzkbiHLuYWg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.131", "", { "os": "darwin", "cpu": "x64" }, "sha512-IxewhApb20ucAxnpUCAwETLjO5PsQRAJIBBlDlNqPsd20LIZVVQuQ5orFf6CGEs6MfYRnWz2FYwfHhguGNPIyQ=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.142", "", { "os": "darwin", "cpu": "x64" }, "sha512-/a/bVMjvAl3gNzWiPIgynYktTYckTcp4YAacV/2F4Jd8XeCV0+DMQW7OFeR+3fnPcBg/8kcOAVYfLZXDExqO1w=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.131", "", { "os": "linux", "cpu": "arm64" }, "sha512-GDwaga8aadtVeYq1wJM2BSWp5l/Srel7L5WRbEvkEWXeGP463S7VLJyiNVcbjbi/HLmyQigEkzFoHfZdeqKOvw=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.142", "", { "os": "linux", "cpu": "arm64" }, "sha512-QKG553PSbIcQ5KLvnl2ekfy5lTyU3dW/X5fDQlRLv4YHNHnqf2o7scJ6eUdfaVTQdIZ+Pa7SNN3bsvVs4bNjQw=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.131", "", { "os": "linux", "cpu": "arm64" }, "sha512-7efL5otHqTKMeNxIztEjEGs8ktlR3hfMmVbo1HaEbs+tkJ6fvMwS3k4xnUP7Bqy+GsM+U9r9kRdNz4MVdc80hg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.142", "", { "os": "linux", "cpu": "arm64" }, "sha512-KZuwSupNJovnMJ7MZxjp1Qq0yu7rAmbzO4Zlmr3jtKDU95t2kgs3c6j4evzQDCgTQMlwH8QTSV4mItDGxlYEbg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.131", "", { "os": "linux", "cpu": "x64" }, "sha512-tJJggvCGtkK876CowajF/42AdUy0TTJk0gHeCKuDCMJF3hMs70EtYnwyM81nb10tKUFb6zYdvn6iPn6iGx7iFQ=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.142", "", { "os": "linux", "cpu": "x64" }, "sha512-o1QZmCNRL5BFTc14KEvT23Fxm1jNv0aa0e9T0OZUjua0oW8DRpri3HKvDEM36qEGWUOANBG7h6Ca/KNqxaTnYg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.131", "", { "os": "linux", "cpu": "x64" }, "sha512-WNqUJscB1F86Igbnw5zXpndT89I7l3aIvPJQEOrSA5JaIDmfJft8QA1rrJPwf2tcxP8nNS0H3MbEFBAxq92bNw=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.142", "", { "os": "linux", "cpu": "x64" }, "sha512-QkDwLMsdYO7n/i1zPCt5YZIet5u+Eo07UpF9UX5yD7bnwRZKDe22L6LVVwiLLjeTO0fTz+uNY7w9/XOYQMlxUQ=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.131", "", { "os": "win32", "cpu": "arm64" }, "sha512-LDXYMqR3T1JtaIusmVDr6e539IhE+IULKYBiLC7+v7VvLG6niP1cC+4W/zYZRnUcbzUgcfoIi1FvrWhtF6/M+A=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.142", "", { "os": "win32", "cpu": "arm64" }, "sha512-x8lbY1m7E/BiFF0Gu/Mx9lkD/zW3vBr3viw0GYNuqY9GYHfLOX9+l9H8C+INeGzB4+ibG8+xD2pnRhWdQxuvUg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.131", "", { "os": "win32", "cpu": "x64" }, "sha512-gwLUkQWtK9Un2i9mWWQgoaEk+2rzamiH3r4j7aoTyVzB4ZQgxdBBOP9ac5o9pIwQE+vflr0HvKk1O54Z320Vng=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.142", "", { "os": "win32", "cpu": "x64" }, "sha512-NpNxdiCEUNjjwvBltpDnkgdjVQ+nRsALpfM1Pe4GhnYiOkTk/TvjMZUuA2qGh0F8KyF0FbqzUsi0uXIgojJT5w=="],
|
||||
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.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-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="],
|
||||
"@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=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1",
|
||||
"@actions/github": "^6.0.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.131",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.142",
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
"@octokit/graphql": "^8.2.2",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
|
||||
@@ -40,6 +40,7 @@ import { installPlugins } from "../../base-action/src/install-plugins";
|
||||
import { preparePrompt } from "../../base-action/src/prepare-prompt";
|
||||
import { runClaude } from "../../base-action/src/run-claude";
|
||||
import type { ClaudeRunResult } from "../../base-action/src/run-claude-sdk";
|
||||
import { setExecutionFileOutputIfPresent } from "../../base-action/src/execution-file";
|
||||
|
||||
/**
|
||||
* Install Claude Code CLI, handling retry logic and custom executable paths.
|
||||
@@ -65,7 +66,7 @@ async function installClaudeCode(): Promise<string> {
|
||||
return customExecutable;
|
||||
}
|
||||
|
||||
const claudeCodeVersion = "2.1.131";
|
||||
const claudeCodeVersion = "2.1.142";
|
||||
console.log(`Installing Claude Code v${claudeCodeVersion}...`);
|
||||
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
@@ -296,6 +297,7 @@ async function run() {
|
||||
core.setOutput("conclusion", claudeResult.conclusion);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
executionFile ??= setExecutionFileOutputIfPresent();
|
||||
// Only mark as prepare failure if we haven't completed the prepare phase
|
||||
if (!prepareCompleted) {
|
||||
prepareSuccess = false;
|
||||
|
||||
@@ -58,16 +58,18 @@ export function validateBranchName(branchName: string): void {
|
||||
);
|
||||
}
|
||||
|
||||
// Strict whitelist pattern: alphanumeric start, then alphanumeric/slash/hyphen/underscore/period/hash/plus.
|
||||
// Strict whitelist pattern: alphanumeric start, then alphanumeric/slash/hyphen/underscore/period/hash/plus/comma.
|
||||
// # is valid per git-check-ref-format and commonly used in branch names like "fix/#123-description".
|
||||
// + is valid per git-check-ref-format and generated by Claude Code's EnterWorktree tool when
|
||||
// converting worktree names containing "/" (e.g. "feat/foo" becomes "worktree-feat+foo").
|
||||
// All git calls use execFileSync (not shell interpolation), so neither # nor + carries injection risk.
|
||||
const validPattern = /^[a-zA-Z0-9][a-zA-Z0-9/_.#+-]*$/;
|
||||
// , 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").
|
||||
// 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/_.#+,-]*$/;
|
||||
|
||||
if (!validPattern.test(branchName)) {
|
||||
throw new Error(
|
||||
`Invalid branch name: "${branchName}". Branch names must start with an alphanumeric character and contain only alphanumeric characters, forward slashes, hyphens, underscores, periods, hashes (#), or plus signs (+).`,
|
||||
`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 (,).`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { execFileSync } from "child_process";
|
||||
import { cpSync, existsSync, rmSync } from "fs";
|
||||
import {
|
||||
appendFileSync,
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
} from "fs";
|
||||
import { dirname } from "path";
|
||||
|
||||
// Paths that are both PR-controllable and read from cwd at CLI startup.
|
||||
//
|
||||
@@ -20,6 +28,30 @@ const SENSITIVE_PATHS = [
|
||||
".husky",
|
||||
];
|
||||
|
||||
const CLAUDE_PR_EXCLUDE_PATTERN = "/.claude-pr/";
|
||||
|
||||
function ensureClaudePrExcludedFromGit(): void {
|
||||
const excludePath = execFileSync(
|
||||
"git",
|
||||
["rev-parse", "--git-path", "info/exclude"],
|
||||
{ encoding: "utf8" },
|
||||
).trim();
|
||||
|
||||
const excludeContents = existsSync(excludePath)
|
||||
? readFileSync(excludePath, "utf8")
|
||||
: "";
|
||||
|
||||
if (excludeContents.split(/\r?\n/).includes(CLAUDE_PR_EXCLUDE_PATTERN)) {
|
||||
return;
|
||||
}
|
||||
|
||||
mkdirSync(dirname(excludePath), { recursive: true });
|
||||
|
||||
const prefix =
|
||||
excludeContents.length === 0 || excludeContents.endsWith("\n") ? "" : "\n";
|
||||
appendFileSync(excludePath, `${prefix}${CLAUDE_PR_EXCLUDE_PATTERN}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores security-sensitive config paths from the PR base branch.
|
||||
*
|
||||
@@ -54,13 +86,14 @@ export function restoreConfigFromBase(baseBranch: string): void {
|
||||
rmSync(".claude-pr", { recursive: true, force: true });
|
||||
for (const p of SENSITIVE_PATHS) {
|
||||
if (existsSync(p)) {
|
||||
cpSync(p, `.claude-pr/${p}`, { recursive: true });
|
||||
cpSync(p, `.claude-pr/${p}`, { recursive: true, dereference: true });
|
||||
}
|
||||
}
|
||||
if (existsSync(".claude-pr")) {
|
||||
console.log(
|
||||
"Preserved PR's sensitive paths → .claude-pr/ for review agents (not executed)",
|
||||
"Preserved PR's sensitive paths -> .claude-pr/ for review agents (not executed)",
|
||||
);
|
||||
ensureClaudePrExcludedFromGit();
|
||||
}
|
||||
|
||||
// Delete PR-controlled versions BEFORE fetching so the attacker-controlled
|
||||
|
||||
@@ -8,53 +8,73 @@
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import type { GitHubContext } from "../context";
|
||||
|
||||
function isAllowedBot(actor: string, allowedBots: string): boolean {
|
||||
const trimmed = allowedBots.trim();
|
||||
if (trimmed === "*") return true;
|
||||
if (!trimmed) return false;
|
||||
|
||||
const allowedList = trimmed
|
||||
.split(",")
|
||||
.map((bot) =>
|
||||
bot
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\[bot\]$/, ""),
|
||||
)
|
||||
.filter((bot) => bot.length > 0);
|
||||
|
||||
const normalizedActor = actor.toLowerCase().replace(/\[bot\]$/, "");
|
||||
return allowedList.includes(normalizedActor);
|
||||
}
|
||||
|
||||
export async function checkHumanActor(
|
||||
octokit: Octokit,
|
||||
githubContext: GitHubContext,
|
||||
) {
|
||||
// Fetch user information from GitHub API
|
||||
const { data: userData } = await octokit.users.getByUsername({
|
||||
username: githubContext.actor,
|
||||
});
|
||||
const allowedBots = githubContext.inputs.allowedBots;
|
||||
|
||||
const actorType = userData.type;
|
||||
// Check allowed_bots BEFORE calling the GitHub Users API.
|
||||
// Some bot actors (e.g. GitHub Copilot with GITHUB_ACTOR="Copilot") are
|
||||
// not resolvable via the Users API and would cause a 404 if we called it
|
||||
// first. By checking the allow-list early we avoid the unnecessary API
|
||||
// call and the resulting crash.
|
||||
if (isAllowedBot(githubContext.actor, allowedBots)) {
|
||||
console.log(
|
||||
`Actor ${githubContext.actor} is in allowed_bots list, skipping human actor check`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch user information from GitHub API
|
||||
let actorType: string;
|
||||
try {
|
||||
const { data: userData } = await octokit.users.getByUsername({
|
||||
username: githubContext.actor,
|
||||
});
|
||||
actorType = userData.type;
|
||||
} catch (error) {
|
||||
// Handle 404 for non-user actors (GitHub Apps whose GITHUB_ACTOR
|
||||
// doesn't match any user account, e.g. "Copilot").
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.message.includes("Not Found") ||
|
||||
error.message.includes("is not a user"))
|
||||
) {
|
||||
const botName = githubContext.actor.toLowerCase().replace(/\[bot\]$/, "");
|
||||
throw new Error(
|
||||
`Workflow initiated by non-human actor: ${botName} (actor not found on GitHub). Add bot to allowed_bots list or use '*' to allow all bots.`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log(`Actor type: ${actorType}`);
|
||||
|
||||
// Check bot permissions if actor is not a User
|
||||
if (actorType !== "User") {
|
||||
const allowedBots = githubContext.inputs.allowedBots;
|
||||
|
||||
// Check if all bots are allowed
|
||||
if (allowedBots.trim() === "*") {
|
||||
console.log(
|
||||
`All bots are allowed, skipping human actor check for: ${githubContext.actor}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse allowed bots list
|
||||
const allowedBotsList = allowedBots
|
||||
.split(",")
|
||||
.map((bot) =>
|
||||
bot
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\[bot\]$/, ""),
|
||||
)
|
||||
.filter((bot) => bot.length > 0);
|
||||
|
||||
const botName = githubContext.actor.toLowerCase().replace(/\[bot\]$/, "");
|
||||
|
||||
// Check if specific bot is allowed
|
||||
if (allowedBotsList.includes(botName)) {
|
||||
console.log(
|
||||
`Bot ${botName} is in allowed list, skipping human actor check`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bot not allowed
|
||||
// Bot not allowed (we already checked allowed_bots above)
|
||||
throw new Error(
|
||||
`Workflow initiated by non-human actor: ${botName} (type: ${actorType}). Add bot to allowed_bots list or use '*' to allow all bots.`,
|
||||
);
|
||||
|
||||
@@ -2,6 +2,28 @@ import * as core from "@actions/core";
|
||||
import type { ParsedGitHubContext } from "../context";
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
|
||||
/**
|
||||
* Check if a bot actor is in the allowed bots list.
|
||||
*/
|
||||
function isAllowedBot(actor: string, allowedBots: string): boolean {
|
||||
const trimmed = allowedBots.trim();
|
||||
if (trimmed === "*") return true;
|
||||
if (!trimmed) return false;
|
||||
|
||||
const allowedList = trimmed
|
||||
.split(",")
|
||||
.map((bot) =>
|
||||
bot
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\[bot\]$/, ""),
|
||||
)
|
||||
.filter((bot) => bot.length > 0);
|
||||
|
||||
const normalizedActor = actor.toLowerCase().replace(/\[bot\]$/, "");
|
||||
return allowedList.includes(normalizedActor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the actor has write permissions to the repository
|
||||
* @param octokit - The Octokit REST client
|
||||
@@ -17,6 +39,7 @@ export async function checkWritePermissions(
|
||||
githubTokenProvided?: boolean,
|
||||
): Promise<boolean> {
|
||||
const { repository, actor } = context;
|
||||
const allowedBots = context.inputs.allowedBots ?? "";
|
||||
|
||||
try {
|
||||
core.info(`Checking permissions for actor: ${actor}`);
|
||||
@@ -43,12 +66,21 @@ export async function checkWritePermissions(
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the actor is a GitHub App (bot user)
|
||||
// Check if the actor is a GitHub App (bot user with [bot] suffix)
|
||||
if (actor.endsWith("[bot]")) {
|
||||
core.info(`Actor is a GitHub App: ${actor}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the actor is in the allowed bots list (handles non-[bot] actors
|
||||
// like GitHub Copilot whose GITHUB_ACTOR is "Copilot", not "Copilot[bot]")
|
||||
if (isAllowedBot(actor, allowedBots)) {
|
||||
core.info(
|
||||
`Actor ${actor} is in allowed_bots list, skipping permission check`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check permissions directly using the permission endpoint
|
||||
const response = await octokit.repos.getCollaboratorPermissionLevel({
|
||||
owner: repository.owner,
|
||||
@@ -67,6 +99,25 @@ export async function checkWritePermissions(
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle 404 errors for non-user actors (e.g. GitHub Apps like Copilot
|
||||
// whose GITHUB_ACTOR doesn't end with [bot]).
|
||||
// The collaborator permission API only works for user accounts.
|
||||
if (error instanceof Error && error.message.includes("is not a user")) {
|
||||
core.info(
|
||||
`Actor ${actor} is not a GitHub user (likely a GitHub App). Checking allowed_bots...`,
|
||||
);
|
||||
if (isAllowedBot(actor, allowedBots)) {
|
||||
core.info(
|
||||
`Non-user actor ${actor} is in allowed_bots list, granting access`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
core.warning(
|
||||
`Non-user actor ${actor} is not in allowed_bots list. Add it to allowed_bots or use '*' to allow all bots.`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
core.error(`Failed to check permissions: ${error}`);
|
||||
throw new Error(`Failed to check permissions for ${actor}: ${error}`);
|
||||
}
|
||||
|
||||
@@ -93,4 +93,78 @@ describe("checkHumanActor", () => {
|
||||
"Workflow initiated by non-human actor: other-bot (type: Bot). Add bot to allowed_bots list or use '*' to allow all bots.",
|
||||
);
|
||||
});
|
||||
|
||||
describe("non-[bot] actors (e.g. GitHub Copilot)", () => {
|
||||
// GitHub Copilot SWE Agent sets GITHUB_ACTOR="Copilot" which is not a
|
||||
// valid GitHub user and doesn't end with [bot], causing 404 on the
|
||||
// Users API. These tests verify the fix handles this gracefully.
|
||||
|
||||
function createMockOctokitThat404s(): Octokit {
|
||||
return {
|
||||
users: {
|
||||
getByUsername: async () => {
|
||||
const err = new Error("Not Found");
|
||||
(err as any).status = 404;
|
||||
throw err;
|
||||
},
|
||||
},
|
||||
} as unknown as Octokit;
|
||||
}
|
||||
|
||||
test("should pass for non-[bot] actor when in allowed_bots list", async () => {
|
||||
const mockOctokit = createMockOctokitThat404s();
|
||||
const context = createMockContext();
|
||||
context.actor = "Copilot";
|
||||
context.inputs.allowedBots = "copilot,cursor";
|
||||
|
||||
// Should not even call the API — allowed_bots check happens first
|
||||
await expect(
|
||||
checkHumanActor(mockOctokit, context),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("should pass for non-[bot] actor when all bots are allowed", async () => {
|
||||
const mockOctokit = createMockOctokitThat404s();
|
||||
const context = createMockContext();
|
||||
context.actor = "Copilot";
|
||||
context.inputs.allowedBots = "*";
|
||||
|
||||
await expect(
|
||||
checkHumanActor(mockOctokit, context),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("should throw with clear message for non-[bot] actor that 404s and is not in allowed list", async () => {
|
||||
const mockOctokit = createMockOctokitThat404s();
|
||||
const context = createMockContext();
|
||||
context.actor = "Copilot";
|
||||
context.inputs.allowedBots = "cursor";
|
||||
|
||||
await expect(checkHumanActor(mockOctokit, context)).rejects.toThrow(
|
||||
"Workflow initiated by non-human actor: copilot (actor not found on GitHub). Add bot to allowed_bots list or use '*' to allow all bots.",
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw with clear message for non-[bot] actor that 404s and allowed_bots is empty", async () => {
|
||||
const mockOctokit = createMockOctokitThat404s();
|
||||
const context = createMockContext();
|
||||
context.actor = "Copilot";
|
||||
context.inputs.allowedBots = "";
|
||||
|
||||
await expect(checkHumanActor(mockOctokit, context)).rejects.toThrow(
|
||||
"Workflow initiated by non-human actor: copilot (actor not found on GitHub). Add bot to allowed_bots list or use '*' to allow all bots.",
|
||||
);
|
||||
});
|
||||
|
||||
test("should match allowed_bots case-insensitively for non-[bot] actors", async () => {
|
||||
const mockOctokit = createMockOctokitThat404s();
|
||||
const context = createMockContext();
|
||||
context.actor = "Copilot";
|
||||
context.inputs.allowedBots = "COPILOT";
|
||||
|
||||
await expect(
|
||||
checkHumanActor(mockOctokit, context),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -303,4 +303,105 @@ describe("checkWritePermissions", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-[bot] actors (e.g. GitHub Copilot)", () => {
|
||||
// GitHub Copilot SWE Agent sets GITHUB_ACTOR="Copilot" which doesn't
|
||||
// end with [bot] and is not a valid GitHub user, so the collaborator
|
||||
// permission API returns 404 with "is not a user".
|
||||
|
||||
const createMockOctokitThat404s = () =>
|
||||
({
|
||||
repos: {
|
||||
getCollaboratorPermissionLevel: async () => {
|
||||
const err = new Error(
|
||||
"HttpError: Copilot is not a user - https://docs.github.com/rest/collaborators/collaborators#get-repository-permissions-for-a-user",
|
||||
);
|
||||
(err as any).status = 404;
|
||||
throw err;
|
||||
},
|
||||
},
|
||||
}) as any;
|
||||
|
||||
test("should return true for non-[bot] actor in allowed_bots (pre-API check)", async () => {
|
||||
// The allowed_bots check should happen BEFORE calling the API,
|
||||
// so this should succeed even with a 404-ing mock.
|
||||
const mockOctokit = createMockOctokitThat404s();
|
||||
const context = createContext();
|
||||
context.actor = "Copilot";
|
||||
context.inputs.allowedBots = "copilot,cursor";
|
||||
|
||||
const result = await checkWritePermissions(mockOctokit, context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(coreInfoSpy).toHaveBeenCalledWith(
|
||||
"Actor Copilot is in allowed_bots list, skipping permission check",
|
||||
);
|
||||
});
|
||||
|
||||
test("should return true for non-[bot] actor when allowed_bots is '*' (pre-API check)", async () => {
|
||||
const mockOctokit = createMockOctokitThat404s();
|
||||
const context = createContext();
|
||||
context.actor = "Copilot";
|
||||
context.inputs.allowedBots = "*";
|
||||
|
||||
const result = await checkWritePermissions(mockOctokit, context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true for non-[bot] actor in allowed_bots via 404 fallback", async () => {
|
||||
// Even if somehow we reach the API call (e.g. race condition or
|
||||
// future refactor), the 404 catch path should also check allowed_bots.
|
||||
const mockOctokit = createMockOctokitThat404s();
|
||||
const context = createContext();
|
||||
context.actor = "SomeNewBot";
|
||||
context.inputs.allowedBots = "somenewbot";
|
||||
|
||||
const result = await checkWritePermissions(mockOctokit, context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for non-[bot] actor that 404s and is not in allowed_bots", async () => {
|
||||
const mockOctokit = createMockOctokitThat404s();
|
||||
const context = createContext();
|
||||
context.actor = "Copilot";
|
||||
context.inputs.allowedBots = "cursor";
|
||||
|
||||
const result = await checkWritePermissions(mockOctokit, context);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(coreWarningSpy).toHaveBeenCalledWith(
|
||||
"Non-user actor Copilot is not in allowed_bots list. Add it to allowed_bots or use '*' to allow all bots.",
|
||||
);
|
||||
});
|
||||
|
||||
test("should return false for non-[bot] actor that 404s with empty allowed_bots", async () => {
|
||||
const mockOctokit = createMockOctokitThat404s();
|
||||
const context = createContext();
|
||||
context.actor = "Copilot";
|
||||
context.inputs.allowedBots = "";
|
||||
|
||||
const result = await checkWritePermissions(mockOctokit, context);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("should still throw for non-404 API errors", async () => {
|
||||
const mockOctokit = {
|
||||
repos: {
|
||||
getCollaboratorPermissionLevel: async () => {
|
||||
throw new Error("Internal Server Error");
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const context = createContext();
|
||||
context.actor = "Copilot";
|
||||
context.inputs.allowedBots = "";
|
||||
|
||||
await expect(checkWritePermissions(mockOctokit, context)).rejects.toThrow(
|
||||
"Failed to check permissions for Copilot",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { execFileSync } from "child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "fs";
|
||||
import { dirname, isAbsolute, join } from "path";
|
||||
import { restoreConfigFromBase } from "../src/github/operations/restore-config";
|
||||
|
||||
const CLAUDE_PR_EXCLUDE_PATTERN = "/.claude-pr/";
|
||||
|
||||
describe("restoreConfigFromBase", () => {
|
||||
let originalCwd: string;
|
||||
let tempDir = "";
|
||||
let repoDir: string;
|
||||
let remoteDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd();
|
||||
tempDir = mkdtempSync(join("/tmp", "restore-config-"));
|
||||
repoDir = join(tempDir, "repo");
|
||||
remoteDir = join(tempDir, "origin.git");
|
||||
|
||||
execFileSync("git", ["init", "--bare", remoteDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||
git(["checkout", "-b", "main"]);
|
||||
git(["config", "user.email", "test@example.com"]);
|
||||
git(["config", "user.name", "Test User"]);
|
||||
|
||||
writeRepoFile("CLAUDE.md", "base claude instructions\n");
|
||||
writeRepoFile(
|
||||
".claude/settings.json",
|
||||
`${JSON.stringify({ source: "base" })}\n`,
|
||||
);
|
||||
writeRepoFile("src/index.ts", "export const base = true;\n");
|
||||
|
||||
git(["add", "CLAUDE.md", ".claude/settings.json", "src/index.ts"]);
|
||||
git(["commit", "-m", "base config"]);
|
||||
git(["remote", "add", "origin", remoteDir]);
|
||||
git(["push", "-u", "origin", "main"]);
|
||||
|
||||
git(["checkout", "-b", "pr"]);
|
||||
writeRepoFile("CLAUDE.md", "pr claude instructions\n");
|
||||
writeRepoFile(
|
||||
".claude/settings.json",
|
||||
`${JSON.stringify({ source: "pr" })}\n`,
|
||||
);
|
||||
git(["add", "CLAUDE.md", ".claude/settings.json"]);
|
||||
git(["commit", "-m", "pr config"]);
|
||||
|
||||
process.chdir(repoDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd);
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves PR sensitive files while excluding .claude-pr from broad staging", () => {
|
||||
const gitignoreExistedBefore = existsRepoFile(".gitignore");
|
||||
const gitignoreContentsBefore = gitignoreExistedBefore
|
||||
? readRepoFile(".gitignore")
|
||||
: "";
|
||||
|
||||
restoreConfigFromBase("main");
|
||||
|
||||
expect(readRepoFile(".claude-pr/CLAUDE.md")).toBe(
|
||||
"pr claude instructions\n",
|
||||
);
|
||||
expect(readRepoFile(".claude-pr/.claude/settings.json")).toBe(
|
||||
`${JSON.stringify({ source: "pr" })}\n`,
|
||||
);
|
||||
expect(readRepoFile("CLAUDE.md")).toBe("base claude instructions\n");
|
||||
expect(readRepoFile(".claude/settings.json")).toBe(
|
||||
`${JSON.stringify({ source: "base" })}\n`,
|
||||
);
|
||||
expect(git(["check-ignore", ".claude-pr/CLAUDE.md"]).trim()).toBe(
|
||||
".claude-pr/CLAUDE.md",
|
||||
);
|
||||
expect(countClaudePrExcludeEntries()).toBe(1);
|
||||
|
||||
restoreConfigFromBase("main");
|
||||
|
||||
expect(countClaudePrExcludeEntries()).toBe(1);
|
||||
expect(existsRepoFile(".gitignore")).toBe(gitignoreExistedBefore);
|
||||
if (gitignoreExistedBefore) {
|
||||
expect(readRepoFile(".gitignore")).toBe(gitignoreContentsBefore);
|
||||
}
|
||||
|
||||
writeRepoFile("src/fix.ts", "export const fix = true;\n");
|
||||
git(["add", "-A"]);
|
||||
|
||||
const stagedFiles = git(["diff", "--cached", "--name-only"])
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean);
|
||||
expect(stagedFiles).toContain("src/fix.ts");
|
||||
expect(stagedFiles.some((file) => file.startsWith(".claude-pr/"))).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
git(["commit", "-m", "apply fix"]);
|
||||
|
||||
const committedFiles = git(["show", "--name-only", "--format=", "HEAD"])
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean);
|
||||
expect(committedFiles).toContain("src/fix.ts");
|
||||
expect(committedFiles.some((file) => file.startsWith(".claude-pr/"))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(existsRepoFile(".gitignore")).toBe(gitignoreExistedBefore);
|
||||
if (gitignoreExistedBefore) {
|
||||
expect(readRepoFile(".gitignore")).toBe(gitignoreContentsBefore);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not modify an existing .gitignore", () => {
|
||||
writeRepoFile(".gitignore", "node_modules\n");
|
||||
git(["add", ".gitignore"]);
|
||||
git(["commit", "-m", "add gitignore"]);
|
||||
|
||||
const gitignoreBefore = readRepoFile(".gitignore");
|
||||
|
||||
restoreConfigFromBase("main");
|
||||
|
||||
expect(readRepoFile(".gitignore")).toBe(gitignoreBefore);
|
||||
expect(countClaudePrExcludeEntries()).toBe(1);
|
||||
});
|
||||
|
||||
function git(args: string[]): string {
|
||||
return execFileSync("git", args, {
|
||||
cwd: repoDir,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
function writeRepoFile(path: string, contents: string): void {
|
||||
const fullPath = join(repoDir, path);
|
||||
mkdirSync(dirname(fullPath), { recursive: true });
|
||||
writeFileSync(fullPath, contents);
|
||||
}
|
||||
|
||||
function readRepoFile(path: string): string {
|
||||
return readFileSync(join(repoDir, path), "utf8");
|
||||
}
|
||||
|
||||
function existsRepoFile(path: string): boolean {
|
||||
return existsSync(join(repoDir, path));
|
||||
}
|
||||
|
||||
function countClaudePrExcludeEntries(): number {
|
||||
return readFileSync(getExcludePath(), "utf8")
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line === CLAUDE_PR_EXCLUDE_PATTERN).length;
|
||||
}
|
||||
|
||||
function getExcludePath(): string {
|
||||
const gitPath = git(["rev-parse", "--git-path", "info/exclude"]).trim();
|
||||
return isAbsolute(gitPath) ? gitPath : join(repoDir, gitPath);
|
||||
}
|
||||
});
|
||||
@@ -55,6 +55,15 @@ describe("validateBranchName", () => {
|
||||
expect(() => validateBranchName("fix+issue-123")).not.toThrow();
|
||||
expect(() => validateBranchName("feature+new-thing")).not.toThrow();
|
||||
});
|
||||
|
||||
it("should accept branch names containing , (git-valid, common in title-derived branches)", () => {
|
||||
// Reported in #1300: branches like "feature/a,b" were rejected, even though
|
||||
// git check-ref-format and GitHub both accept commas. Common when branch names
|
||||
// are derived from titles, place names, or external identifiers.
|
||||
expect(() => validateBranchName("feature/a,b")).not.toThrow();
|
||||
expect(() => validateBranchName("feature/paris,france")).not.toThrow();
|
||||
expect(() => validateBranchName("fix/issue-1,2,3")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("command injection attempts", () => {
|
||||
|
||||
Reference in New Issue
Block a user