Compare commits

...

11 Commits

Author SHA1 Message Date
Claude
a86ef08036 fix: prevent command injection in workflow example files
Fixes command injection vulnerabilities in example workflow files by using
environment variables instead of direct template expansion in shell commands.

This prevents malicious branch names containing command substitution syntax
like $(cmd) from being executed by the shell.

Files fixed:
- examples/ci-failure-auto-fix.yml: github.event.workflow_run.head_branch
- examples/test-failure-analysis.yml: github.event.workflow_run.name and head_branch
2025-12-16 01:37:30 +00:00
GitHub Actions
9acae263e7 chore: bump Claude Code to 2.0.70 and Agent SDK to 0.1.70 2025-12-15 23:53:03 +00:00
Gor Grigoryan
67bf0594ce feat: add session_id output to enable resuming conversations (#739)
Add a new `session_id` output that exposes the Claude Code session ID,
allowing other workflows or Claude Code instances to resume the
conversation using `--resume <session_id>`.

Changes:
- Add parseAndSetSessionId() function to extract session_id from
  the system.init message in execution output
- Add session_id output to both action.yml and base-action/action.yml
- Add comprehensive tests for the new functionality

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 19:42:54 -08:00
GitHub Actions
b58533dbe0 chore: bump Claude Code version to 2.0.69 2025-12-13 01:00:43 +00:00
GitHub Actions
bda9bf08de chore: bump Claude Code version to 2.0.68 2025-12-12 23:32:49 +00:00
Ashwin Bhat
79b343c094 feat: Make Agent SDK the default execution path (#738)
Change USE_AGENT_SDK to default to true instead of false. The Agent SDK
path is now used by default; set USE_AGENT_SDK=false to use the CLI path.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-12 13:55:16 -08:00
bogini
609c388361 Fix command injection vulnerability in branch setup (#736)
* fix: Prevent command injection in branch operations

Replace Bun shell template literals with Node.js execFileSync to prevent
command injection attacks via malicious branch names. Branch names from
PR data (headRefName) are now validated against a strict whitelist pattern
before use in git commands.

Changes:
- Add validateBranchName() function with strict character whitelist
- Replace $`git ...` shell templates with execGit() using execFileSync
- Validate all branch names before use in git operations

* fix: Address review comments for branch validation security

- Enhanced execGit JSDoc to explain security benefits of execFileSync
- Added comprehensive branch name validation:
  - Leading dash check (prevents option injection)
  - Control characters and special git characters (~^:?*[\])
  - Leading/trailing period checks
  - Trailing slash and consecutive slash checks
- Added -- separator to git checkout commands
- Added 30 unit tests for validateBranchName covering:
  - Valid branch names
  - Command injection attempts
  - Option injection attempts
  - Path traversal attempts
  - Git-specific invalid patterns
  - Control characters and edge cases

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-12 11:30:28 -08:00
GitHub Actions
f0c8eb2980 chore: bump Claude Code version to 2.0.62 2025-12-09 02:12:14 +00:00
ant-soumitr
68a0348c20 fix: Replace direct template expansion of inputs in shell scripts with environment variables (#729)
Replace direct template expansion of user inputs in shell scripts with
environment variables to prevent potential command injection attacks.

Changes:
- sync-base-action.yml: Use $GITHUB_EVENT_NAME and $GITHUB_ACTOR instead of template expansion
- action.yml: Pass path_to_bun_executable and path_to_claude_code_executable through env vars
- base-action/action.yml: Same env var changes for path inputs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-08 12:08:44 -08:00
GitHub Actions
dc06a34646 chore: bump Claude Code version to 2.0.61 2025-12-07 10:47:47 +00:00
Ashwin Bhat
a3bb51dac1 Fix SDK path: add settingSources and default system prompt (#726)
Two fixes for the Agent SDK path (USE_AGENT_SDK=true):

1. Add settingSources to load filesystem settings
   - Without this, CLI-installed plugins aren't available to the SDK
   - Also needed to load CLAUDE.md files from the project

2. Default systemPrompt to claude_code preset
   - Without an explicit systemPrompt, the SDK would use no system prompt
   - Now defaults to { type: "preset", preset: "claude_code" } to match CLI behavior

Also adds logging of SDK options (excluding env) for debugging.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-06 16:52:26 -08:00
15 changed files with 480 additions and 36 deletions

View File

@@ -94,5 +94,5 @@ jobs:
echo "✅ Successfully synced \`base-action\` directory to [anthropics/claude-code-base-action](https://github.com/anthropics/claude-code-base-action)" >> $GITHUB_STEP_SUMMARY echo "✅ Successfully synced \`base-action\` directory to [anthropics/claude-code-base-action](https://github.com/anthropics/claude-code-base-action)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Source commit**: [\`${GITHUB_SHA:0:7}\`](https://github.com/anthropics/claude-code-action/commit/${GITHUB_SHA})" >> $GITHUB_STEP_SUMMARY echo "- **Source commit**: [\`${GITHUB_SHA:0:7}\`](https://github.com/anthropics/claude-code-action/commit/${GITHUB_SHA})" >> $GITHUB_STEP_SUMMARY
echo "- **Triggered by**: ${{ github.event_name }}" >> $GITHUB_STEP_SUMMARY echo "- **Triggered by**: $GITHUB_EVENT_NAME" >> $GITHUB_STEP_SUMMARY
echo "- **Actor**: @${{ github.actor }}" >> $GITHUB_STEP_SUMMARY echo "- **Actor**: @$GITHUB_ACTOR" >> $GITHUB_STEP_SUMMARY

View File

@@ -127,6 +127,9 @@ outputs:
structured_output: structured_output:
description: "JSON string containing all structured output fields when --json-schema is provided in claude_args. Use fromJSON() to parse: fromJSON(steps.id.outputs.structured_output).field_name" description: "JSON string containing all structured output fields when --json-schema is provided in claude_args. Use fromJSON() to parse: fromJSON(steps.id.outputs.structured_output).field_name"
value: ${{ steps.claude-code.outputs.structured_output }} value: ${{ steps.claude-code.outputs.structured_output }}
session_id:
description: "The Claude Code session ID that can be used with --resume to continue this conversation"
value: ${{ steps.claude-code.outputs.session_id }}
runs: runs:
using: "composite" using: "composite"
@@ -140,10 +143,12 @@ runs:
- name: Setup Custom Bun Path - name: Setup Custom Bun Path
if: inputs.path_to_bun_executable != '' if: inputs.path_to_bun_executable != ''
shell: bash shell: bash
env:
PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }}
run: | run: |
echo "Using custom Bun executable: ${{ inputs.path_to_bun_executable }}" echo "Using custom Bun executable: $PATH_TO_BUN_EXECUTABLE"
# Add the directory containing the custom executable to PATH # Add the directory containing the custom executable to PATH
BUN_DIR=$(dirname "${{ inputs.path_to_bun_executable }}") BUN_DIR=$(dirname "$PATH_TO_BUN_EXECUTABLE")
echo "$BUN_DIR" >> "$GITHUB_PATH" echo "$BUN_DIR" >> "$GITHUB_PATH"
- name: Install Dependencies - name: Install Dependencies
@@ -182,6 +187,8 @@ runs:
- name: Install Base Action Dependencies - name: Install Base Action Dependencies
if: steps.prepare.outputs.contains_trigger == 'true' if: steps.prepare.outputs.contains_trigger == 'true'
shell: bash shell: bash
env:
PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
run: | run: |
echo "Installing base-action dependencies..." echo "Installing base-action dependencies..."
cd ${GITHUB_ACTION_PATH}/base-action cd ${GITHUB_ACTION_PATH}/base-action
@@ -190,8 +197,8 @@ runs:
cd - cd -
# Install Claude Code if no custom executable is provided # Install Claude Code if no custom executable is provided
if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
CLAUDE_CODE_VERSION="2.0.60" CLAUDE_CODE_VERSION="2.0.70"
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..."
@@ -210,9 +217,9 @@ runs:
echo "Claude Code installed successfully" echo "Claude Code installed successfully"
echo "$HOME/.local/bin" >> "$GITHUB_PATH" echo "$HOME/.local/bin" >> "$GITHUB_PATH"
else else
echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}" echo "Using custom Claude Code executable: $PATH_TO_CLAUDE_CODE_EXECUTABLE"
# Add the directory containing the custom executable to PATH # Add the directory containing the custom executable to PATH
CLAUDE_DIR=$(dirname "${{ inputs.path_to_claude_code_executable }}") CLAUDE_DIR=$(dirname "$PATH_TO_CLAUDE_CODE_EXECUTABLE")
echo "$CLAUDE_DIR" >> "$GITHUB_PATH" echo "$CLAUDE_DIR" >> "$GITHUB_PATH"
fi fi

View File

@@ -82,6 +82,9 @@ outputs:
structured_output: structured_output:
description: "JSON string containing all structured output fields when --json-schema is provided in claude_args (use fromJSON() or jq to parse)" description: "JSON string containing all structured output fields when --json-schema is provided in claude_args (use fromJSON() or jq to parse)"
value: ${{ steps.run_claude.outputs.structured_output }} value: ${{ steps.run_claude.outputs.structured_output }}
session_id:
description: "The Claude Code session ID that can be used with --resume to continue this conversation"
value: ${{ steps.run_claude.outputs.session_id }}
runs: runs:
using: "composite" using: "composite"
@@ -101,10 +104,12 @@ runs:
- name: Setup Custom Bun Path - name: Setup Custom Bun Path
if: inputs.path_to_bun_executable != '' if: inputs.path_to_bun_executable != ''
shell: bash shell: bash
env:
PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }}
run: | run: |
echo "Using custom Bun executable: ${{ inputs.path_to_bun_executable }}" echo "Using custom Bun executable: $PATH_TO_BUN_EXECUTABLE"
# Add the directory containing the custom executable to PATH # Add the directory containing the custom executable to PATH
BUN_DIR=$(dirname "${{ inputs.path_to_bun_executable }}") BUN_DIR=$(dirname "$PATH_TO_BUN_EXECUTABLE")
echo "$BUN_DIR" >> "$GITHUB_PATH" echo "$BUN_DIR" >> "$GITHUB_PATH"
- name: Install Dependencies - name: Install Dependencies
@@ -115,9 +120,11 @@ runs:
- name: Install Claude Code - name: Install Claude Code
shell: bash shell: bash
env:
PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
run: | run: |
if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
CLAUDE_CODE_VERSION="2.0.60" CLAUDE_CODE_VERSION="2.0.70"
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..."
@@ -135,9 +142,9 @@ runs:
done done
echo "Claude Code installed successfully" echo "Claude Code installed successfully"
else else
echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}" echo "Using custom Claude Code executable: $PATH_TO_CLAUDE_CODE_EXECUTABLE"
# Add the directory containing the custom executable to PATH # Add the directory containing the custom executable to PATH
CLAUDE_DIR=$(dirname "${{ inputs.path_to_claude_code_executable }}") CLAUDE_DIR=$(dirname "$PATH_TO_CLAUDE_CODE_EXECUTABLE")
echo "$CLAUDE_DIR" >> "$GITHUB_PATH" echo "$CLAUDE_DIR" >> "$GITHUB_PATH"
fi fi

View File

@@ -1,11 +1,12 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"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.1.52", "@anthropic-ai/claude-agent-sdk": "^0.1.70",
"shell-quote": "^1.8.3", "shell-quote": "^1.8.3",
}, },
"devDependencies": { "devDependencies": {
@@ -26,7 +27,7 @@
"@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.1.52", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^3.24.1" } }, "sha512-yF8N05+9NRbqYA/h39jQ726HTQFrdXXp7pEfDNKIJ2c4FdWvEjxBA/8ciZIebN6/PyvGDcbEp3yq2Co4rNpg6A=="], "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.1.70", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^3.24.1" } }, "sha512-4jpFPDX8asys6skO1r3Pzh0Fe9nbND2ASYTWuyFB5iN9bWEL6WScTFyGokjql3M2TkEp9ZGuB2YYpTCdaqT9Sw=="],
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],

View File

@@ -11,7 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@actions/core": "^1.10.1", "@actions/core": "^1.10.1",
"@anthropic-ai/claude-agent-sdk": "^0.1.52", "@anthropic-ai/claude-agent-sdk": "^0.1.70",
"shell-quote": "^1.8.3" "shell-quote": "^1.8.3"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -101,7 +101,7 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
env.GITHUB_ACTION_INPUTS = process.env.INPUT_ACTION_INPUTS_PRESENT; env.GITHUB_ACTION_INPUTS = process.env.INPUT_ACTION_INPUTS_PRESENT;
} }
// Build system prompt option // Build system prompt option - default to claude_code preset
let systemPrompt: SdkOptions["systemPrompt"]; let systemPrompt: SdkOptions["systemPrompt"];
if (options.systemPrompt) { if (options.systemPrompt) {
systemPrompt = options.systemPrompt; systemPrompt = options.systemPrompt;
@@ -111,6 +111,12 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
preset: "claude_code", preset: "claude_code",
append: options.appendSystemPrompt, append: options.appendSystemPrompt,
}; };
} else {
// Default to claude_code preset when no custom prompt is specified
systemPrompt = {
type: "preset",
preset: "claude_code",
};
} }
// Build SDK options - use merged tools from both direct options and claudeArgs // Build SDK options - use merged tools from both direct options and claudeArgs
@@ -130,6 +136,9 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
// Note: allowedTools and disallowedTools have been removed from extraArgs to prevent duplicates // Note: allowedTools and disallowedTools have been removed from extraArgs to prevent duplicates
extraArgs, extraArgs,
env, env,
// Load settings from all sources to pick up CLI-installed plugins, CLAUDE.md, etc.
settingSources: ["user", "project", "local"],
}; };
return { return {

View File

@@ -75,6 +75,9 @@ export async function runClaudeWithSdk(
} }
console.log(`Running Claude with prompt from file: ${promptPath}`); console.log(`Running Claude with prompt from file: ${promptPath}`);
// Log SDK options without env (which could contain sensitive data)
const { env, ...optionsToLog } = sdkOptions;
console.log("SDK options:", JSON.stringify(optionsToLog, null, 2));
const messages: SDKMessage[] = []; const messages: SDKMessage[] = [];
let resultMessage: SDKResultMessage | undefined; let resultMessage: SDKResultMessage | undefined;

View File

@@ -124,6 +124,36 @@ export function prepareRunConfig(
}; };
} }
/**
* Parses session_id from execution file and sets GitHub Action output
* Exported for testing
*/
export async function parseAndSetSessionId(
executionFile: string,
): Promise<void> {
try {
const content = await readFile(executionFile, "utf-8");
const messages = JSON.parse(content) as {
type: string;
subtype?: string;
session_id?: string;
}[];
// Find the system.init message which contains session_id
const initMessage = messages.find(
(m) => m.type === "system" && m.subtype === "init",
);
if (initMessage?.session_id) {
core.setOutput("session_id", initMessage.session_id);
core.info(`Set session_id: ${initMessage.session_id}`);
}
} catch (error) {
// Don't fail the action if session_id extraction fails
core.warning(`Failed to extract session_id: ${error}`);
}
}
/** /**
* Parses structured_output from execution file and sets GitHub Action outputs * Parses structured_output from execution file and sets GitHub Action outputs
* Only runs if --json-schema was explicitly provided in claude_args * Only runs if --json-schema was explicitly provided in claude_args
@@ -167,8 +197,8 @@ export async function parseAndSetStructuredOutputs(
} }
export async function runClaude(promptPath: string, options: ClaudeOptions) { export async function runClaude(promptPath: string, options: ClaudeOptions) {
// Feature flag: use SDK path when USE_AGENT_SDK=true // Feature flag: use SDK path by default, set USE_AGENT_SDK=false to use CLI
const useAgentSdk = process.env.USE_AGENT_SDK === "true"; const useAgentSdk = process.env.USE_AGENT_SDK !== "false";
console.log( console.log(
`Using ${useAgentSdk ? "Agent SDK" : "CLI"} path (USE_AGENT_SDK=${process.env.USE_AGENT_SDK ?? "unset"})`, `Using ${useAgentSdk ? "Agent SDK" : "CLI"} path (USE_AGENT_SDK=${process.env.USE_AGENT_SDK ?? "unset"})`,
); );
@@ -368,6 +398,9 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
core.setOutput("execution_file", EXECUTION_FILE); core.setOutput("execution_file", EXECUTION_FILE);
// Extract and set session_id
await parseAndSetSessionId(EXECUTION_FILE);
// Parse and set structured outputs only if user provided --json-schema in claude_args // Parse and set structured outputs only if user provided --json-schema in claude_args
if (hasJsonSchema) { if (hasJsonSchema) {
try { try {

View File

@@ -4,7 +4,10 @@ import { describe, test, expect, afterEach, beforeEach, spyOn } from "bun:test";
import { writeFile, unlink } from "fs/promises"; import { writeFile, unlink } from "fs/promises";
import { tmpdir } from "os"; import { tmpdir } from "os";
import { join } from "path"; import { join } from "path";
import { parseAndSetStructuredOutputs } from "../src/run-claude"; import {
parseAndSetStructuredOutputs,
parseAndSetSessionId,
} from "../src/run-claude";
import * as core from "@actions/core"; import * as core from "@actions/core";
// Mock execution file path // Mock execution file path
@@ -35,16 +38,19 @@ async function createMockExecutionFile(
// Spy on core functions // Spy on core functions
let setOutputSpy: any; let setOutputSpy: any;
let infoSpy: any; let infoSpy: any;
let warningSpy: any;
beforeEach(() => { beforeEach(() => {
setOutputSpy = spyOn(core, "setOutput").mockImplementation(() => {}); setOutputSpy = spyOn(core, "setOutput").mockImplementation(() => {});
infoSpy = spyOn(core, "info").mockImplementation(() => {}); infoSpy = spyOn(core, "info").mockImplementation(() => {});
warningSpy = spyOn(core, "warning").mockImplementation(() => {});
}); });
describe("parseAndSetStructuredOutputs", () => { describe("parseAndSetStructuredOutputs", () => {
afterEach(async () => { afterEach(async () => {
setOutputSpy?.mockRestore(); setOutputSpy?.mockRestore();
infoSpy?.mockRestore(); infoSpy?.mockRestore();
warningSpy?.mockRestore();
try { try {
await unlink(TEST_EXECUTION_FILE); await unlink(TEST_EXECUTION_FILE);
} catch { } catch {
@@ -156,3 +162,66 @@ describe("parseAndSetStructuredOutputs", () => {
); );
}); });
}); });
describe("parseAndSetSessionId", () => {
afterEach(async () => {
setOutputSpy?.mockRestore();
infoSpy?.mockRestore();
warningSpy?.mockRestore();
try {
await unlink(TEST_EXECUTION_FILE);
} catch {
// Ignore if file doesn't exist
}
});
test("should extract session_id from system.init message", async () => {
const messages = [
{ type: "system", subtype: "init", session_id: "test-session-123" },
{ type: "result", cost_usd: 0.01 },
];
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
await parseAndSetSessionId(TEST_EXECUTION_FILE);
expect(setOutputSpy).toHaveBeenCalledWith("session_id", "test-session-123");
expect(infoSpy).toHaveBeenCalledWith("Set session_id: test-session-123");
});
test("should handle missing session_id gracefully", async () => {
const messages = [
{ type: "system", subtype: "init" },
{ type: "result", cost_usd: 0.01 },
];
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
await parseAndSetSessionId(TEST_EXECUTION_FILE);
expect(setOutputSpy).not.toHaveBeenCalled();
});
test("should handle missing system.init message gracefully", async () => {
const messages = [{ type: "result", cost_usd: 0.01 }];
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
await parseAndSetSessionId(TEST_EXECUTION_FILE);
expect(setOutputSpy).not.toHaveBeenCalled();
});
test("should handle malformed JSON gracefully with warning", async () => {
await writeFile(TEST_EXECUTION_FILE, "{ invalid json");
await parseAndSetSessionId(TEST_EXECUTION_FILE);
expect(setOutputSpy).not.toHaveBeenCalled();
expect(warningSpy).toHaveBeenCalled();
});
test("should handle non-existent file gracefully with warning", async () => {
await parseAndSetSessionId("/nonexistent/file.json");
expect(setOutputSpy).not.toHaveBeenCalled();
expect(warningSpy).toHaveBeenCalled();
});
});

View File

@@ -1,12 +1,13 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "@anthropic-ai/claude-code-action", "name": "@anthropic-ai/claude-code-action",
"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.1.52", "@anthropic-ai/claude-agent-sdk": "^0.1.70",
"@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",
@@ -36,7 +37,7 @@
"@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.1.52", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^3.24.1" } }, "sha512-yF8N05+9NRbqYA/h39jQ726HTQFrdXXp7pEfDNKIJ2c4FdWvEjxBA/8ciZIebN6/PyvGDcbEp3yq2Co4rNpg6A=="], "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.1.70", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^3.24.1" } }, "sha512-4jpFPDX8asys6skO1r3Pzh0Fe9nbND2ASYTWuyFB5iN9bWEL6WScTFyGokjql3M2TkEp9ZGuB2YYpTCdaqT9Sw=="],
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],

View File

@@ -35,8 +35,11 @@ jobs:
- name: Create fix branch - name: Create fix branch
id: branch id: branch
env:
SOURCE_BRANCH: ${{ github.event.workflow_run.head_branch }}
RUN_ID: ${{ github.run_id }}
run: | run: |
BRANCH_NAME="claude-auto-fix-ci-${{ github.event.workflow_run.head_branch }}-${{ github.run_id }}" BRANCH_NAME="claude-auto-fix-ci-${SOURCE_BRANCH}-${RUN_ID}"
git checkout -b "$BRANCH_NAME" git checkout -b "$BRANCH_NAME"
echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT

View File

@@ -53,6 +53,8 @@ jobs:
fromJSON(steps.detect.outputs.structured_output).confidence >= 0.7 fromJSON(steps.detect.outputs.structured_output).confidence >= 0.7
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
WORKFLOW_NAME: ${{ github.event.workflow_run.name }}
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
run: | run: |
OUTPUT='${{ steps.detect.outputs.structured_output }}' OUTPUT='${{ steps.detect.outputs.structured_output }}'
CONFIDENCE=$(echo "$OUTPUT" | jq -r '.confidence') CONFIDENCE=$(echo "$OUTPUT" | jq -r '.confidence')
@@ -63,8 +65,8 @@ jobs:
echo "" echo ""
echo "Triggering automatic retry..." echo "Triggering automatic retry..."
gh workflow run "${{ github.event.workflow_run.name }}" \ gh workflow run "$WORKFLOW_NAME" \
--ref "${{ github.event.workflow_run.head_branch }}" --ref "$HEAD_BRANCH"
# Low confidence flaky detection - skip retry # Low confidence flaky detection - skip retry
- name: Low confidence detection - name: Low confidence detection
@@ -83,13 +85,14 @@ jobs:
if: github.event.workflow_run.event == 'pull_request' if: github.event.workflow_run.event == 'pull_request'
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
run: | run: |
OUTPUT='${{ steps.detect.outputs.structured_output }}' OUTPUT='${{ steps.detect.outputs.structured_output }}'
IS_FLAKY=$(echo "$OUTPUT" | jq -r '.is_flaky') IS_FLAKY=$(echo "$OUTPUT" | jq -r '.is_flaky')
CONFIDENCE=$(echo "$OUTPUT" | jq -r '.confidence') CONFIDENCE=$(echo "$OUTPUT" | jq -r '.confidence')
SUMMARY=$(echo "$OUTPUT" | jq -r '.summary') SUMMARY=$(echo "$OUTPUT" | jq -r '.summary')
pr_number=$(gh pr list --head "${{ github.event.workflow_run.head_branch }}" --json number --jq '.[0].number') pr_number=$(gh pr list --head "$HEAD_BRANCH" --json number --jq '.[0].number')
if [ -n "$pr_number" ]; then if [ -n "$pr_number" ]; then
if [ "$IS_FLAKY" = "true" ]; then if [ "$IS_FLAKY" = "true" ]; then

View File

@@ -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.1.52", "@anthropic-ai/claude-agent-sdk": "^0.1.70",
"@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",

View File

@@ -6,13 +6,112 @@
* - For Issues: Create a new branch * - For Issues: Create a new branch
*/ */
import { $ } from "bun"; import { execFileSync } from "child_process";
import * as core from "@actions/core"; import * as core from "@actions/core";
import type { ParsedGitHubContext } from "../context"; import type { ParsedGitHubContext } from "../context";
import type { GitHubPullRequest } from "../types"; import type { GitHubPullRequest } from "../types";
import type { Octokits } from "../api/client"; import type { Octokits } from "../api/client";
import type { FetchDataResult } from "../data/fetcher"; import type { FetchDataResult } from "../data/fetcher";
/**
* Validates a git branch name against a strict whitelist pattern.
* This prevents command injection by ensuring only safe characters are used.
*
* Valid branch names:
* - Start with alphanumeric character (not dash, to prevent option injection)
* - Contain only alphanumeric, forward slash, hyphen, underscore, or period
* - Do not start or end with a period
* - Do not end with a slash
* - Do not contain '..' (path traversal)
* - Do not contain '//' (consecutive slashes)
* - Do not end with '.lock'
* - Do not contain '@{'
* - Do not contain control characters or special git characters (~^:?*[\])
*/
export function validateBranchName(branchName: string): void {
// Check for empty or whitespace-only names
if (!branchName || branchName.trim().length === 0) {
throw new Error("Branch name cannot be empty");
}
// Check for leading dash (prevents option injection like --help, -x)
if (branchName.startsWith("-")) {
throw new Error(
`Invalid branch name: "${branchName}". Branch names cannot start with a dash.`,
);
}
// Check for control characters and special git characters (~^:?*[\])
// eslint-disable-next-line no-control-regex
if (/[\x00-\x1F\x7F ~^:?*[\]\\]/.test(branchName)) {
throw new Error(
`Invalid branch name: "${branchName}". Branch names cannot contain control characters, spaces, or special git characters (~^:?*[\\]).`,
);
}
// Strict whitelist pattern: alphanumeric start, then alphanumeric/slash/hyphen/underscore/period
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, or periods.`,
);
}
// Check for leading/trailing periods
if (branchName.startsWith(".") || branchName.endsWith(".")) {
throw new Error(
`Invalid branch name: "${branchName}". Branch names cannot start or end with a period.`,
);
}
// Check for trailing slash
if (branchName.endsWith("/")) {
throw new Error(
`Invalid branch name: "${branchName}". Branch names cannot end with a slash.`,
);
}
// Check for consecutive slashes
if (branchName.includes("//")) {
throw new Error(
`Invalid branch name: "${branchName}". Branch names cannot contain consecutive slashes.`,
);
}
// Additional git-specific validations
if (branchName.includes("..")) {
throw new Error(
`Invalid branch name: "${branchName}". Branch names cannot contain '..'`,
);
}
if (branchName.endsWith(".lock")) {
throw new Error(
`Invalid branch name: "${branchName}". Branch names cannot end with '.lock'`,
);
}
if (branchName.includes("@{")) {
throw new Error(
`Invalid branch name: "${branchName}". Branch names cannot contain '@{'`,
);
}
}
/**
* Executes a git command safely using execFileSync to avoid shell interpolation.
*
* Security: execFileSync passes arguments directly to the git binary without
* invoking a shell, preventing command injection attacks where malicious input
* could be interpreted as shell commands (e.g., branch names containing `;`, `|`, `&&`).
*
* @param args - Git command arguments (e.g., ["checkout", "branch-name"])
*/
function execGit(args: string[]): void {
execFileSync("git", args, { stdio: "inherit" });
}
export type BranchInfo = { export type BranchInfo = {
baseBranch: string; baseBranch: string;
claudeBranch?: string; claudeBranch?: string;
@@ -53,14 +152,19 @@ export async function setupBranch(
`PR #${entityNumber}: ${commitCount} commits, using fetch depth ${fetchDepth}`, `PR #${entityNumber}: ${commitCount} commits, using fetch depth ${fetchDepth}`,
); );
// Validate branch names before use to prevent command injection
validateBranchName(branchName);
// Execute git commands to checkout PR branch (dynamic depth based on PR size) // Execute git commands to checkout PR branch (dynamic depth based on PR size)
await $`git fetch origin --depth=${fetchDepth} ${branchName}`; // Using execFileSync instead of shell template literals for security
await $`git checkout ${branchName} --`; execGit(["fetch", "origin", `--depth=${fetchDepth}`, branchName]);
execGit(["checkout", branchName, "--"]);
console.log(`Successfully checked out PR branch for PR #${entityNumber}`); console.log(`Successfully checked out PR branch for PR #${entityNumber}`);
// For open PRs, we need to get the base branch of the PR // For open PRs, we need to get the base branch of the PR
const baseBranch = prData.baseRefName; const baseBranch = prData.baseRefName;
validateBranchName(baseBranch);
return { return {
baseBranch, baseBranch,
@@ -118,8 +222,9 @@ export async function setupBranch(
// Ensure we're on the source branch // Ensure we're on the source branch
console.log(`Fetching and checking out source branch: ${sourceBranch}`); console.log(`Fetching and checking out source branch: ${sourceBranch}`);
await $`git fetch origin ${sourceBranch} --depth=1`; validateBranchName(sourceBranch);
await $`git checkout ${sourceBranch}`; execGit(["fetch", "origin", sourceBranch, "--depth=1"]);
execGit(["checkout", sourceBranch, "--"]);
// Set outputs for GitHub Actions // Set outputs for GitHub Actions
core.setOutput("CLAUDE_BRANCH", newBranch); core.setOutput("CLAUDE_BRANCH", newBranch);
@@ -138,11 +243,13 @@ export async function setupBranch(
// Fetch and checkout the source branch first to ensure we branch from the correct base // Fetch and checkout the source branch first to ensure we branch from the correct base
console.log(`Fetching and checking out source branch: ${sourceBranch}`); console.log(`Fetching and checking out source branch: ${sourceBranch}`);
await $`git fetch origin ${sourceBranch} --depth=1`; validateBranchName(sourceBranch);
await $`git checkout ${sourceBranch}`; validateBranchName(newBranch);
execGit(["fetch", "origin", sourceBranch, "--depth=1"]);
execGit(["checkout", sourceBranch, "--"]);
// Create and checkout the new branch from the source branch // Create and checkout the new branch from the source branch
await $`git checkout -b ${newBranch}`; execGit(["checkout", "-b", newBranch]);
console.log( console.log(
`Successfully created and checked out local branch: ${newBranch}`, `Successfully created and checked out local branch: ${newBranch}`,

View File

@@ -0,0 +1,201 @@
import { describe, expect, it } from "bun:test";
import { validateBranchName } from "../src/github/operations/branch";
describe("validateBranchName", () => {
describe("valid branch names", () => {
it("should accept simple alphanumeric names", () => {
expect(() => validateBranchName("main")).not.toThrow();
expect(() => validateBranchName("feature123")).not.toThrow();
expect(() => validateBranchName("Branch1")).not.toThrow();
});
it("should accept names with hyphens", () => {
expect(() => validateBranchName("feature-branch")).not.toThrow();
expect(() => validateBranchName("fix-bug-123")).not.toThrow();
});
it("should accept names with underscores", () => {
expect(() => validateBranchName("feature_branch")).not.toThrow();
expect(() => validateBranchName("fix_bug_123")).not.toThrow();
});
it("should accept names with forward slashes", () => {
expect(() => validateBranchName("feature/new-thing")).not.toThrow();
expect(() => validateBranchName("user/feature/branch")).not.toThrow();
});
it("should accept names with periods", () => {
expect(() => validateBranchName("v1.0.0")).not.toThrow();
expect(() => validateBranchName("release.1.2.3")).not.toThrow();
});
it("should accept typical branch name formats", () => {
expect(() =>
validateBranchName("claude/issue-123-20250101-1234"),
).not.toThrow();
expect(() => validateBranchName("refs/heads/main")).not.toThrow();
expect(() => validateBranchName("bugfix/JIRA-1234")).not.toThrow();
});
});
describe("command injection attempts", () => {
it("should reject shell command substitution with $()", () => {
expect(() => validateBranchName("$(whoami)")).toThrow();
expect(() => validateBranchName("branch-$(rm -rf /)")).toThrow();
expect(() => validateBranchName("test$(cat /etc/passwd)")).toThrow();
});
it("should reject shell command substitution with backticks", () => {
expect(() => validateBranchName("`whoami`")).toThrow();
expect(() => validateBranchName("branch-`rm -rf /`")).toThrow();
});
it("should reject command chaining with semicolons", () => {
expect(() => validateBranchName("branch; rm -rf /")).toThrow();
expect(() => validateBranchName("test;whoami")).toThrow();
});
it("should reject command chaining with &&", () => {
expect(() => validateBranchName("branch && rm -rf /")).toThrow();
expect(() => validateBranchName("test&&whoami")).toThrow();
});
it("should reject command chaining with ||", () => {
expect(() => validateBranchName("branch || rm -rf /")).toThrow();
expect(() => validateBranchName("test||whoami")).toThrow();
});
it("should reject pipe characters", () => {
expect(() => validateBranchName("branch | cat")).toThrow();
expect(() => validateBranchName("test|grep password")).toThrow();
});
it("should reject redirection operators", () => {
expect(() => validateBranchName("branch > /etc/passwd")).toThrow();
expect(() => validateBranchName("branch < input")).toThrow();
expect(() => validateBranchName("branch >> file")).toThrow();
});
});
describe("option injection attempts", () => {
it("should reject branch names starting with dash", () => {
expect(() => validateBranchName("-x")).toThrow(
/cannot start with a dash/,
);
expect(() => validateBranchName("--help")).toThrow(
/cannot start with a dash/,
);
expect(() => validateBranchName("-")).toThrow(/cannot start with a dash/);
expect(() => validateBranchName("--version")).toThrow(
/cannot start with a dash/,
);
expect(() => validateBranchName("-rf")).toThrow(
/cannot start with a dash/,
);
});
});
describe("path traversal attempts", () => {
it("should reject double dot sequences", () => {
expect(() => validateBranchName("../../../etc")).toThrow();
expect(() => validateBranchName("branch/../secret")).toThrow(/'\.\.'$/);
expect(() => validateBranchName("a..b")).toThrow(/'\.\.'$/);
});
});
describe("git-specific invalid patterns", () => {
it("should reject @{ sequence", () => {
expect(() => validateBranchName("branch@{1}")).toThrow(/@{/);
expect(() => validateBranchName("HEAD@{yesterday}")).toThrow(/@{/);
});
it("should reject .lock suffix", () => {
expect(() => validateBranchName("branch.lock")).toThrow(/\.lock/);
expect(() => validateBranchName("feature.lock")).toThrow(/\.lock/);
});
it("should reject consecutive slashes", () => {
expect(() => validateBranchName("feature//branch")).toThrow(
/consecutive slashes/,
);
expect(() => validateBranchName("a//b//c")).toThrow(
/consecutive slashes/,
);
});
it("should reject trailing slashes", () => {
expect(() => validateBranchName("feature/")).toThrow(
/cannot end with a slash/,
);
expect(() => validateBranchName("branch/")).toThrow(
/cannot end with a slash/,
);
});
it("should reject leading periods", () => {
expect(() => validateBranchName(".hidden")).toThrow();
});
it("should reject trailing periods", () => {
expect(() => validateBranchName("branch.")).toThrow(
/cannot start or end with a period/,
);
});
it("should reject special git refspec characters", () => {
expect(() => validateBranchName("branch~1")).toThrow();
expect(() => validateBranchName("branch^2")).toThrow();
expect(() => validateBranchName("branch:ref")).toThrow();
expect(() => validateBranchName("branch?")).toThrow();
expect(() => validateBranchName("branch*")).toThrow();
expect(() => validateBranchName("branch[0]")).toThrow();
expect(() => validateBranchName("branch\\path")).toThrow();
});
});
describe("control characters and special characters", () => {
it("should reject null bytes", () => {
expect(() => validateBranchName("branch\x00name")).toThrow();
});
it("should reject other control characters", () => {
expect(() => validateBranchName("branch\x01name")).toThrow();
expect(() => validateBranchName("branch\x1Fname")).toThrow();
expect(() => validateBranchName("branch\x7Fname")).toThrow();
});
it("should reject spaces", () => {
expect(() => validateBranchName("branch name")).toThrow();
expect(() => validateBranchName("feature branch")).toThrow();
});
it("should reject newlines and tabs", () => {
expect(() => validateBranchName("branch\nname")).toThrow();
expect(() => validateBranchName("branch\tname")).toThrow();
});
});
describe("empty and whitespace", () => {
it("should reject empty strings", () => {
expect(() => validateBranchName("")).toThrow(/cannot be empty/);
});
it("should reject whitespace-only strings", () => {
expect(() => validateBranchName(" ")).toThrow();
expect(() => validateBranchName("\t\n")).toThrow();
});
});
describe("edge cases", () => {
it("should accept single alphanumeric character", () => {
expect(() => validateBranchName("a")).not.toThrow();
expect(() => validateBranchName("1")).not.toThrow();
});
it("should reject single special characters", () => {
expect(() => validateBranchName(".")).toThrow();
expect(() => validateBranchName("/")).toThrow();
expect(() => validateBranchName("-")).toThrow();
});
});
});