mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 23:14:13 +08:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6337623ebb | ||
|
|
6d79044f1d | ||
|
|
a7e4c51380 | ||
|
|
7febbb006b | ||
|
|
798cf0988d | ||
|
|
8458f4399d | ||
|
|
f9b2917716 | ||
|
|
f092d4cefd | ||
|
|
c2edeab4c3 | ||
|
|
4318310481 | ||
|
|
11571151c4 | ||
|
|
70193f466c | ||
|
|
9db20ef677 |
2
.github/workflows/claude-review.yml
vendored
2
.github/workflows/claude-review.yml
vendored
@@ -2,7 +2,7 @@ name: PR Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, ready_for_review, reopened]
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
review:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# Claude Code Action
|
||||
|
||||
A general-purpose [Claude Code](https://claude.ai/code) action for GitHub PRs and issues that can answer questions and implement code changes. This action intelligently detects when to activate based on your workflow context—whether responding to @claude mentions, issue assignments, or executing automation tasks with explicit prompts. It supports multiple authentication methods including Anthropic direct API, Amazon Bedrock, and Google Vertex AI.
|
||||
A general-purpose [Claude Code](https://claude.ai/code) action for GitHub PRs and issues that can answer questions and implement code changes. This action intelligently detects when to activate based on your workflow context—whether responding to @claude mentions, issue assignments, or executing automation tasks with explicit prompts. It supports multiple authentication methods including Anthropic direct API, Amazon Bedrock, Google Vertex AI, and Microsoft Foundry.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -30,7 +30,7 @@ This command will guide you through setting up the GitHub app and required secre
|
||||
**Note**:
|
||||
|
||||
- You must be a repository admin to install the GitHub app and add secrets
|
||||
- This quickstart method is only available for direct Anthropic API users. For AWS Bedrock or Google Vertex AI setup, see [docs/cloud-providers.md](./docs/cloud-providers.md).
|
||||
- This quickstart method is only available for direct Anthropic API users. For AWS Bedrock, Google Vertex AI, or Microsoft Foundry setup, see [docs/cloud-providers.md](./docs/cloud-providers.md).
|
||||
|
||||
## 📚 Solutions & Use Cases
|
||||
|
||||
@@ -57,7 +57,7 @@ Each solution includes complete working examples, configuration details, and exp
|
||||
- [Custom Automations](./docs/custom-automations.md) - Examples of automated workflows and custom prompts
|
||||
- [Configuration](./docs/configuration.md) - MCP servers, permissions, environment variables, and advanced settings
|
||||
- [Experimental Features](./docs/experimental.md) - Execution modes and network restrictions
|
||||
- [Cloud Providers](./docs/cloud-providers.md) - AWS Bedrock and Google Vertex AI setup
|
||||
- [Cloud Providers](./docs/cloud-providers.md) - AWS Bedrock, Google Vertex AI, and Microsoft Foundry setup
|
||||
- [Capabilities & Limitations](./docs/capabilities-and-limitations.md) - What Claude can and cannot do
|
||||
- [Security](./docs/security.md) - Access control, permissions, and commit signing
|
||||
- [FAQ](./docs/faq.md) - Common questions and troubleshooting
|
||||
|
||||
47
action.yml
47
action.yml
@@ -44,7 +44,7 @@ inputs:
|
||||
|
||||
# Auth configuration
|
||||
anthropic_api_key:
|
||||
description: "Anthropic API key (required for direct API, not needed for Bedrock/Vertex)"
|
||||
description: "Anthropic API key (required for direct API, not needed for Bedrock/Vertex/Foundry)"
|
||||
required: false
|
||||
claude_code_oauth_token:
|
||||
description: "Claude Code OAuth token (alternative to anthropic_api_key)"
|
||||
@@ -60,6 +60,10 @@ inputs:
|
||||
description: "Use Google Vertex AI with OIDC authentication instead of direct Anthropic API"
|
||||
required: false
|
||||
default: "false"
|
||||
use_foundry:
|
||||
description: "Use Microsoft Foundry with OIDC authentication instead of direct Anthropic API"
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
claude_args:
|
||||
description: "Additional arguments to pass directly to Claude CLI"
|
||||
@@ -89,10 +93,6 @@ inputs:
|
||||
description: "Force tag mode with tracking comments for pull_request and issue events. Only applicable to pull_request (opened, synchronize, ready_for_review, reopened) and issue (opened, edited, labeled, assigned) events."
|
||||
required: false
|
||||
default: "false"
|
||||
experimental_allowed_domains:
|
||||
description: "Restrict network access to these domains only (newline-separated). If not set, no restrictions are applied. Provider domains are auto-detected."
|
||||
required: false
|
||||
default: ""
|
||||
path_to_claude_code_executable:
|
||||
description: "Optional path to a custom Claude Code executable. If provided, skips automatic installation and uses this executable instead. WARNING: Using an older version may cause problems if the action begins taking advantage of new Claude Code features. This input is typically not needed unless you're debugging something specific or have unique needs in your environment."
|
||||
required: false
|
||||
@@ -191,8 +191,23 @@ runs:
|
||||
|
||||
# Install Claude Code if no custom executable is provided
|
||||
if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then
|
||||
echo "Installing Claude Code..."
|
||||
curl -fsSL https://claude.ai/install.sh | bash -s 2.0.45
|
||||
CLAUDE_CODE_VERSION="2.0.55"
|
||||
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
||||
for attempt in 1 2 3; do
|
||||
echo "Installation attempt $attempt..."
|
||||
if command -v timeout &> /dev/null; then
|
||||
timeout 120 bash -c "curl -fsSL https://claude.ai/install.sh | bash -s -- $CLAUDE_CODE_VERSION" && break
|
||||
else
|
||||
curl -fsSL https://claude.ai/install.sh | bash -s -- "$CLAUDE_CODE_VERSION" && break
|
||||
fi
|
||||
if [ $attempt -eq 3 ]; then
|
||||
echo "Failed to install Claude Code after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
echo "Installation failed, retrying..."
|
||||
sleep 5
|
||||
done
|
||||
echo "Claude Code installed successfully"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
else
|
||||
echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}"
|
||||
@@ -201,15 +216,6 @@ runs:
|
||||
echo "$CLAUDE_DIR" >> "$GITHUB_PATH"
|
||||
fi
|
||||
|
||||
- name: Setup Network Restrictions
|
||||
if: steps.prepare.outputs.contains_trigger == 'true' && inputs.experimental_allowed_domains != ''
|
||||
shell: bash
|
||||
run: |
|
||||
chmod +x ${GITHUB_ACTION_PATH}/scripts/setup-network-restrictions.sh
|
||||
${GITHUB_ACTION_PATH}/scripts/setup-network-restrictions.sh
|
||||
env:
|
||||
EXPERIMENTAL_ALLOWED_DOMAINS: ${{ inputs.experimental_allowed_domains }}
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude-code
|
||||
if: steps.prepare.outputs.contains_trigger == 'true'
|
||||
@@ -244,12 +250,14 @@ runs:
|
||||
ANTHROPIC_CUSTOM_HEADERS: ${{ env.ANTHROPIC_CUSTOM_HEADERS }}
|
||||
CLAUDE_CODE_USE_BEDROCK: ${{ inputs.use_bedrock == 'true' && '1' || '' }}
|
||||
CLAUDE_CODE_USE_VERTEX: ${{ inputs.use_vertex == 'true' && '1' || '' }}
|
||||
CLAUDE_CODE_USE_FOUNDRY: ${{ inputs.use_foundry == 'true' && '1' || '' }}
|
||||
|
||||
# AWS configuration
|
||||
AWS_REGION: ${{ env.AWS_REGION }}
|
||||
AWS_ACCESS_KEY_ID: ${{ env.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ env.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_SESSION_TOKEN: ${{ env.AWS_SESSION_TOKEN }}
|
||||
AWS_BEARER_TOKEN_BEDROCK: ${{ env.AWS_BEARER_TOKEN_BEDROCK }}
|
||||
ANTHROPIC_BEDROCK_BASE_URL: ${{ env.ANTHROPIC_BEDROCK_BASE_URL || (env.AWS_REGION && format('https://bedrock-runtime.{0}.amazonaws.com', env.AWS_REGION)) }}
|
||||
|
||||
# GCP configuration
|
||||
@@ -263,6 +271,13 @@ runs:
|
||||
VERTEX_REGION_CLAUDE_3_5_SONNET: ${{ env.VERTEX_REGION_CLAUDE_3_5_SONNET }}
|
||||
VERTEX_REGION_CLAUDE_3_7_SONNET: ${{ env.VERTEX_REGION_CLAUDE_3_7_SONNET }}
|
||||
|
||||
# Microsoft Foundry configuration
|
||||
ANTHROPIC_FOUNDRY_RESOURCE: ${{ env.ANTHROPIC_FOUNDRY_RESOURCE }}
|
||||
ANTHROPIC_FOUNDRY_BASE_URL: ${{ env.ANTHROPIC_FOUNDRY_BASE_URL }}
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: ${{ env.ANTHROPIC_DEFAULT_SONNET_MODEL }}
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ env.ANTHROPIC_DEFAULT_HAIKU_MODEL }}
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ env.ANTHROPIC_DEFAULT_OPUS_MODEL }}
|
||||
|
||||
- name: Update comment with job link
|
||||
if: steps.prepare.outputs.contains_trigger == 'true' && steps.prepare.outputs.claude_comment_id && always()
|
||||
shell: bash
|
||||
|
||||
@@ -42,6 +42,10 @@ inputs:
|
||||
description: "Use Google Vertex AI with OIDC authentication instead of direct Anthropic API"
|
||||
required: false
|
||||
default: "false"
|
||||
use_foundry:
|
||||
description: "Use Microsoft Foundry with OIDC authentication instead of direct Anthropic API"
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
use_node_cache:
|
||||
description: "Whether to use Node.js dependency caching (set to true only for Node.js projects with lock files)"
|
||||
@@ -113,8 +117,23 @@ runs:
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then
|
||||
echo "Installing Claude Code..."
|
||||
curl -fsSL https://claude.ai/install.sh | bash -s 2.0.45
|
||||
CLAUDE_CODE_VERSION="2.0.55"
|
||||
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
||||
for attempt in 1 2 3; do
|
||||
echo "Installation attempt $attempt..."
|
||||
if command -v timeout &> /dev/null; then
|
||||
timeout 120 bash -c "curl -fsSL https://claude.ai/install.sh | bash -s -- $CLAUDE_CODE_VERSION" && break
|
||||
else
|
||||
curl -fsSL https://claude.ai/install.sh | bash -s -- "$CLAUDE_CODE_VERSION" && break
|
||||
fi
|
||||
if [ $attempt -eq 3 ]; then
|
||||
echo "Failed to install Claude Code after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
echo "Installation failed, retrying..."
|
||||
sleep 5
|
||||
done
|
||||
echo "Claude Code installed successfully"
|
||||
else
|
||||
echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}"
|
||||
# Add the directory containing the custom executable to PATH
|
||||
@@ -153,12 +172,14 @@ runs:
|
||||
# Only set provider flags if explicitly true, since any value (including "false") is truthy
|
||||
CLAUDE_CODE_USE_BEDROCK: ${{ inputs.use_bedrock == 'true' && '1' || '' }}
|
||||
CLAUDE_CODE_USE_VERTEX: ${{ inputs.use_vertex == 'true' && '1' || '' }}
|
||||
CLAUDE_CODE_USE_FOUNDRY: ${{ inputs.use_foundry == 'true' && '1' || '' }}
|
||||
|
||||
# AWS configuration
|
||||
AWS_REGION: ${{ env.AWS_REGION }}
|
||||
AWS_ACCESS_KEY_ID: ${{ env.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ env.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_SESSION_TOKEN: ${{ env.AWS_SESSION_TOKEN }}
|
||||
AWS_BEARER_TOKEN_BEDROCK: ${{ env.AWS_BEARER_TOKEN_BEDROCK }}
|
||||
ANTHROPIC_BEDROCK_BASE_URL: ${{ env.ANTHROPIC_BEDROCK_BASE_URL || (env.AWS_REGION && format('https://bedrock-runtime.{0}.amazonaws.com', env.AWS_REGION)) }}
|
||||
|
||||
# GCP configuration
|
||||
@@ -166,3 +187,10 @@ runs:
|
||||
CLOUD_ML_REGION: ${{ env.CLOUD_ML_REGION }}
|
||||
GOOGLE_APPLICATION_CREDENTIALS: ${{ env.GOOGLE_APPLICATION_CREDENTIALS }}
|
||||
ANTHROPIC_VERTEX_BASE_URL: ${{ env.ANTHROPIC_VERTEX_BASE_URL }}
|
||||
|
||||
# Microsoft Foundry configuration
|
||||
ANTHROPIC_FOUNDRY_RESOURCE: ${{ env.ANTHROPIC_FOUNDRY_RESOURCE }}
|
||||
ANTHROPIC_FOUNDRY_BASE_URL: ${{ env.ANTHROPIC_FOUNDRY_BASE_URL }}
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: ${{ env.ANTHROPIC_DEFAULT_SONNET_MODEL }}
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ env.ANTHROPIC_DEFAULT_HAIKU_MODEL }}
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ env.ANTHROPIC_DEFAULT_OPUS_MODEL }}
|
||||
|
||||
@@ -1,39 +1,50 @@
|
||||
/**
|
||||
* Validates the environment variables required for running Claude Code
|
||||
* based on the selected provider (Anthropic API, AWS Bedrock, or Google Vertex AI)
|
||||
* based on the selected provider (Anthropic API, AWS Bedrock, Google Vertex AI, or Microsoft Foundry)
|
||||
*/
|
||||
export function validateEnvironmentVariables() {
|
||||
const useBedrock = process.env.CLAUDE_CODE_USE_BEDROCK === "1";
|
||||
const useVertex = process.env.CLAUDE_CODE_USE_VERTEX === "1";
|
||||
const useFoundry = process.env.CLAUDE_CODE_USE_FOUNDRY === "1";
|
||||
const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
|
||||
const claudeCodeOAuthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
|
||||
const errors: string[] = [];
|
||||
|
||||
if (useBedrock && useVertex) {
|
||||
// Check for mutual exclusivity between providers
|
||||
const activeProviders = [useBedrock, useVertex, useFoundry].filter(Boolean);
|
||||
if (activeProviders.length > 1) {
|
||||
errors.push(
|
||||
"Cannot use both Bedrock and Vertex AI simultaneously. Please set only one provider.",
|
||||
"Cannot use multiple providers simultaneously. Please set only one of: CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX, or CLAUDE_CODE_USE_FOUNDRY.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!useBedrock && !useVertex) {
|
||||
if (!useBedrock && !useVertex && !useFoundry) {
|
||||
if (!anthropicApiKey && !claudeCodeOAuthToken) {
|
||||
errors.push(
|
||||
"Either ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN is required when using direct Anthropic API.",
|
||||
);
|
||||
}
|
||||
} else if (useBedrock) {
|
||||
const requiredBedrockVars = {
|
||||
AWS_REGION: process.env.AWS_REGION,
|
||||
AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY,
|
||||
};
|
||||
const awsRegion = process.env.AWS_REGION;
|
||||
const awsAccessKeyId = process.env.AWS_ACCESS_KEY_ID;
|
||||
const awsSecretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
|
||||
const awsBearerToken = process.env.AWS_BEARER_TOKEN_BEDROCK;
|
||||
|
||||
Object.entries(requiredBedrockVars).forEach(([key, value]) => {
|
||||
if (!value) {
|
||||
errors.push(`${key} is required when using AWS Bedrock.`);
|
||||
}
|
||||
});
|
||||
// AWS_REGION is always required for Bedrock
|
||||
if (!awsRegion) {
|
||||
errors.push("AWS_REGION is required when using AWS Bedrock.");
|
||||
}
|
||||
|
||||
// Either bearer token OR access key credentials must be provided
|
||||
const hasAccessKeyCredentials = awsAccessKeyId && awsSecretAccessKey;
|
||||
const hasBearerToken = awsBearerToken;
|
||||
|
||||
if (!hasAccessKeyCredentials && !hasBearerToken) {
|
||||
errors.push(
|
||||
"Either AWS_BEARER_TOKEN_BEDROCK or both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are required when using AWS Bedrock.",
|
||||
);
|
||||
}
|
||||
} else if (useVertex) {
|
||||
const requiredVertexVars = {
|
||||
ANTHROPIC_VERTEX_PROJECT_ID: process.env.ANTHROPIC_VERTEX_PROJECT_ID,
|
||||
@@ -45,6 +56,16 @@ export function validateEnvironmentVariables() {
|
||||
errors.push(`${key} is required when using Google Vertex AI.`);
|
||||
}
|
||||
});
|
||||
} else if (useFoundry) {
|
||||
const foundryResource = process.env.ANTHROPIC_FOUNDRY_RESOURCE;
|
||||
const foundryBaseUrl = process.env.ANTHROPIC_FOUNDRY_BASE_URL;
|
||||
|
||||
// Either resource name or base URL is required
|
||||
if (!foundryResource && !foundryBaseUrl) {
|
||||
errors.push(
|
||||
"Either ANTHROPIC_FOUNDRY_RESOURCE or ANTHROPIC_FOUNDRY_BASE_URL is required when using Microsoft Foundry.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
|
||||
@@ -13,15 +13,19 @@ describe("validateEnvironmentVariables", () => {
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.CLAUDE_CODE_USE_BEDROCK;
|
||||
delete process.env.CLAUDE_CODE_USE_VERTEX;
|
||||
delete process.env.CLAUDE_CODE_USE_FOUNDRY;
|
||||
delete process.env.AWS_REGION;
|
||||
delete process.env.AWS_ACCESS_KEY_ID;
|
||||
delete process.env.AWS_SECRET_ACCESS_KEY;
|
||||
delete process.env.AWS_SESSION_TOKEN;
|
||||
delete process.env.AWS_BEARER_TOKEN_BEDROCK;
|
||||
delete process.env.ANTHROPIC_BEDROCK_BASE_URL;
|
||||
delete process.env.ANTHROPIC_VERTEX_PROJECT_ID;
|
||||
delete process.env.CLOUD_ML_REGION;
|
||||
delete process.env.GOOGLE_APPLICATION_CREDENTIALS;
|
||||
delete process.env.ANTHROPIC_VERTEX_BASE_URL;
|
||||
delete process.env.ANTHROPIC_FOUNDRY_RESOURCE;
|
||||
delete process.env.ANTHROPIC_FOUNDRY_BASE_URL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -92,31 +96,58 @@ describe("validateEnvironmentVariables", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("should fail when AWS_ACCESS_KEY_ID is missing", () => {
|
||||
test("should fail when only AWS_SECRET_ACCESS_KEY is provided without bearer token", () => {
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
process.env.AWS_REGION = "us-east-1";
|
||||
process.env.AWS_SECRET_ACCESS_KEY = "test-secret-key";
|
||||
|
||||
expect(() => validateEnvironmentVariables()).toThrow(
|
||||
"AWS_ACCESS_KEY_ID is required when using AWS Bedrock.",
|
||||
"Either AWS_BEARER_TOKEN_BEDROCK or both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are required when using AWS Bedrock.",
|
||||
);
|
||||
});
|
||||
|
||||
test("should fail when AWS_SECRET_ACCESS_KEY is missing", () => {
|
||||
test("should fail when only AWS_ACCESS_KEY_ID is provided without bearer token", () => {
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
process.env.AWS_REGION = "us-east-1";
|
||||
process.env.AWS_ACCESS_KEY_ID = "test-access-key";
|
||||
|
||||
expect(() => validateEnvironmentVariables()).toThrow(
|
||||
"AWS_SECRET_ACCESS_KEY is required when using AWS Bedrock.",
|
||||
"Either AWS_BEARER_TOKEN_BEDROCK or both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are required when using AWS Bedrock.",
|
||||
);
|
||||
});
|
||||
|
||||
test("should report all missing Bedrock variables", () => {
|
||||
test("should pass when AWS_BEARER_TOKEN_BEDROCK is provided instead of access keys", () => {
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
process.env.AWS_REGION = "us-east-1";
|
||||
process.env.AWS_BEARER_TOKEN_BEDROCK = "test-bearer-token";
|
||||
|
||||
expect(() => validateEnvironmentVariables()).not.toThrow();
|
||||
});
|
||||
|
||||
test("should pass when both bearer token and access keys are provided", () => {
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
process.env.AWS_REGION = "us-east-1";
|
||||
process.env.AWS_BEARER_TOKEN_BEDROCK = "test-bearer-token";
|
||||
process.env.AWS_ACCESS_KEY_ID = "test-access-key";
|
||||
process.env.AWS_SECRET_ACCESS_KEY = "test-secret-key";
|
||||
|
||||
expect(() => validateEnvironmentVariables()).not.toThrow();
|
||||
});
|
||||
|
||||
test("should fail when no authentication method is provided", () => {
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
process.env.AWS_REGION = "us-east-1";
|
||||
|
||||
expect(() => validateEnvironmentVariables()).toThrow(
|
||||
"Either AWS_BEARER_TOKEN_BEDROCK or both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are required when using AWS Bedrock.",
|
||||
);
|
||||
});
|
||||
|
||||
test("should report missing region and authentication", () => {
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
|
||||
expect(() => validateEnvironmentVariables()).toThrow(
|
||||
/AWS_REGION is required when using AWS Bedrock.*AWS_ACCESS_KEY_ID is required when using AWS Bedrock.*AWS_SECRET_ACCESS_KEY is required when using AWS Bedrock/s,
|
||||
/AWS_REGION is required when using AWS Bedrock.*Either AWS_BEARER_TOKEN_BEDROCK or both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are required when using AWS Bedrock/s,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -167,6 +198,56 @@ describe("validateEnvironmentVariables", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Microsoft Foundry", () => {
|
||||
test("should pass when ANTHROPIC_FOUNDRY_RESOURCE is provided", () => {
|
||||
process.env.CLAUDE_CODE_USE_FOUNDRY = "1";
|
||||
process.env.ANTHROPIC_FOUNDRY_RESOURCE = "test-resource";
|
||||
|
||||
expect(() => validateEnvironmentVariables()).not.toThrow();
|
||||
});
|
||||
|
||||
test("should pass when ANTHROPIC_FOUNDRY_BASE_URL is provided", () => {
|
||||
process.env.CLAUDE_CODE_USE_FOUNDRY = "1";
|
||||
process.env.ANTHROPIC_FOUNDRY_BASE_URL =
|
||||
"https://test-resource.services.ai.azure.com";
|
||||
|
||||
expect(() => validateEnvironmentVariables()).not.toThrow();
|
||||
});
|
||||
|
||||
test("should pass when both resource and base URL are provided", () => {
|
||||
process.env.CLAUDE_CODE_USE_FOUNDRY = "1";
|
||||
process.env.ANTHROPIC_FOUNDRY_RESOURCE = "test-resource";
|
||||
process.env.ANTHROPIC_FOUNDRY_BASE_URL =
|
||||
"https://custom.services.ai.azure.com";
|
||||
|
||||
expect(() => validateEnvironmentVariables()).not.toThrow();
|
||||
});
|
||||
|
||||
test("should construct Foundry base URL from resource name when ANTHROPIC_FOUNDRY_BASE_URL is not provided", () => {
|
||||
// This test verifies our action.yml change, which constructs:
|
||||
// ANTHROPIC_FOUNDRY_BASE_URL: ${{ env.ANTHROPIC_FOUNDRY_BASE_URL || (env.ANTHROPIC_FOUNDRY_RESOURCE && format('https://{0}.services.ai.azure.com', env.ANTHROPIC_FOUNDRY_RESOURCE)) }}
|
||||
|
||||
process.env.CLAUDE_CODE_USE_FOUNDRY = "1";
|
||||
process.env.ANTHROPIC_FOUNDRY_RESOURCE = "my-foundry-resource";
|
||||
// ANTHROPIC_FOUNDRY_BASE_URL is intentionally not set
|
||||
|
||||
// The actual URL construction happens in the composite action in action.yml
|
||||
// This test is a placeholder to document the behavior
|
||||
expect(() => validateEnvironmentVariables()).not.toThrow();
|
||||
|
||||
// In the actual action, ANTHROPIC_FOUNDRY_BASE_URL would be:
|
||||
// https://my-foundry-resource.services.ai.azure.com
|
||||
});
|
||||
|
||||
test("should fail when neither ANTHROPIC_FOUNDRY_RESOURCE nor ANTHROPIC_FOUNDRY_BASE_URL is provided", () => {
|
||||
process.env.CLAUDE_CODE_USE_FOUNDRY = "1";
|
||||
|
||||
expect(() => validateEnvironmentVariables()).toThrow(
|
||||
"Either ANTHROPIC_FOUNDRY_RESOURCE or ANTHROPIC_FOUNDRY_BASE_URL is required when using Microsoft Foundry.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Multiple providers", () => {
|
||||
test("should fail when both Bedrock and Vertex are enabled", () => {
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
@@ -179,7 +260,51 @@ describe("validateEnvironmentVariables", () => {
|
||||
process.env.CLOUD_ML_REGION = "us-central1";
|
||||
|
||||
expect(() => validateEnvironmentVariables()).toThrow(
|
||||
"Cannot use both Bedrock and Vertex AI simultaneously. Please set only one provider.",
|
||||
"Cannot use multiple providers simultaneously. Please set only one of: CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX, or CLAUDE_CODE_USE_FOUNDRY.",
|
||||
);
|
||||
});
|
||||
|
||||
test("should fail when both Bedrock and Foundry are enabled", () => {
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
process.env.CLAUDE_CODE_USE_FOUNDRY = "1";
|
||||
// Provide all required vars to isolate the mutual exclusion error
|
||||
process.env.AWS_REGION = "us-east-1";
|
||||
process.env.AWS_ACCESS_KEY_ID = "test-access-key";
|
||||
process.env.AWS_SECRET_ACCESS_KEY = "test-secret-key";
|
||||
process.env.ANTHROPIC_FOUNDRY_RESOURCE = "test-resource";
|
||||
|
||||
expect(() => validateEnvironmentVariables()).toThrow(
|
||||
"Cannot use multiple providers simultaneously. Please set only one of: CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX, or CLAUDE_CODE_USE_FOUNDRY.",
|
||||
);
|
||||
});
|
||||
|
||||
test("should fail when both Vertex and Foundry are enabled", () => {
|
||||
process.env.CLAUDE_CODE_USE_VERTEX = "1";
|
||||
process.env.CLAUDE_CODE_USE_FOUNDRY = "1";
|
||||
// Provide all required vars to isolate the mutual exclusion error
|
||||
process.env.ANTHROPIC_VERTEX_PROJECT_ID = "test-project";
|
||||
process.env.CLOUD_ML_REGION = "us-central1";
|
||||
process.env.ANTHROPIC_FOUNDRY_RESOURCE = "test-resource";
|
||||
|
||||
expect(() => validateEnvironmentVariables()).toThrow(
|
||||
"Cannot use multiple providers simultaneously. Please set only one of: CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX, or CLAUDE_CODE_USE_FOUNDRY.",
|
||||
);
|
||||
});
|
||||
|
||||
test("should fail when all three providers are enabled", () => {
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
process.env.CLAUDE_CODE_USE_VERTEX = "1";
|
||||
process.env.CLAUDE_CODE_USE_FOUNDRY = "1";
|
||||
// Provide all required vars to isolate the mutual exclusion error
|
||||
process.env.AWS_REGION = "us-east-1";
|
||||
process.env.AWS_ACCESS_KEY_ID = "test-access-key";
|
||||
process.env.AWS_SECRET_ACCESS_KEY = "test-secret-key";
|
||||
process.env.ANTHROPIC_VERTEX_PROJECT_ID = "test-project";
|
||||
process.env.CLOUD_ML_REGION = "us-central1";
|
||||
process.env.ANTHROPIC_FOUNDRY_RESOURCE = "test-resource";
|
||||
|
||||
expect(() => validateEnvironmentVariables()).toThrow(
|
||||
"Cannot use multiple providers simultaneously. Please set only one of: CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX, or CLAUDE_CODE_USE_FOUNDRY.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -204,10 +329,7 @@ describe("validateEnvironmentVariables", () => {
|
||||
" - AWS_REGION is required when using AWS Bedrock.",
|
||||
);
|
||||
expect(error!.message).toContain(
|
||||
" - AWS_ACCESS_KEY_ID is required when using AWS Bedrock.",
|
||||
);
|
||||
expect(error!.message).toContain(
|
||||
" - AWS_SECRET_ACCESS_KEY is required when using AWS Bedrock.",
|
||||
" - Either AWS_BEARER_TOKEN_BEDROCK or both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are required when using AWS Bedrock.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
# Cloud Providers
|
||||
|
||||
You can authenticate with Claude using any of these three methods:
|
||||
You can authenticate with Claude using any of these four methods:
|
||||
|
||||
1. Direct Anthropic API (default)
|
||||
2. Amazon Bedrock with OIDC authentication
|
||||
3. Google Vertex AI with OIDC authentication
|
||||
4. Microsoft Foundry with OIDC authentication
|
||||
|
||||
For detailed setup instructions for AWS Bedrock and Google Vertex AI, see the [official documentation](https://docs.anthropic.com/en/docs/claude-code/github-actions#using-with-aws-bedrock-%26-google-vertex-ai).
|
||||
|
||||
**Note**:
|
||||
|
||||
- Bedrock and Vertex use OIDC authentication exclusively
|
||||
- Bedrock, Vertex, and Microsoft Foundry use OIDC authentication exclusively
|
||||
- AWS Bedrock automatically uses cross-region inference profiles for certain models
|
||||
- For cross-region inference profile models, you need to request and be granted access to the Claude models in all regions that the inference profile uses
|
||||
|
||||
@@ -40,11 +41,19 @@ Use provider-specific model names based on your chosen provider:
|
||||
claude_args: |
|
||||
--model claude-4-0-sonnet@20250805
|
||||
# ... other inputs
|
||||
|
||||
# For Microsoft Foundry with OIDC
|
||||
- uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
use_foundry: "true"
|
||||
claude_args: |
|
||||
--model claude-sonnet-4-5
|
||||
# ... other inputs
|
||||
```
|
||||
|
||||
## OIDC Authentication for Bedrock and Vertex
|
||||
## OIDC Authentication for Cloud Providers
|
||||
|
||||
Both AWS Bedrock and GCP Vertex AI require OIDC authentication.
|
||||
AWS Bedrock, GCP Vertex AI, and Microsoft Foundry all support OIDC authentication.
|
||||
|
||||
```yaml
|
||||
# For AWS Bedrock with OIDC
|
||||
@@ -97,3 +106,36 @@ Both AWS Bedrock and GCP Vertex AI require OIDC authentication.
|
||||
permissions:
|
||||
id-token: write # Required for OIDC
|
||||
```
|
||||
|
||||
```yaml
|
||||
# For Microsoft Foundry with OIDC
|
||||
- name: Authenticate to Azure
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
use_foundry: "true"
|
||||
claude_args: |
|
||||
--model claude-sonnet-4-5
|
||||
# ... other inputs
|
||||
env:
|
||||
ANTHROPIC_FOUNDRY_BASE_URL: https://my-resource.services.ai.azure.com
|
||||
|
||||
permissions:
|
||||
id-token: write # Required for OIDC
|
||||
```
|
||||
|
||||
## Microsoft Foundry Setup
|
||||
|
||||
For detailed setup instructions for Microsoft Foundry, see the [official documentation](https://docs.anthropic.com/en/docs/claude-code/microsoft-foundry).
|
||||
|
||||
@@ -61,68 +61,3 @@ For specialized use cases, you can fine-tune behavior using `claude_args`:
|
||||
--system-prompt "You are a code review specialist"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
```
|
||||
|
||||
## Network Restrictions
|
||||
|
||||
For enhanced security, you can restrict Claude's network access to specific domains only. This feature is particularly useful for:
|
||||
|
||||
- Enterprise environments with strict security policies
|
||||
- Preventing access to external services
|
||||
- Limiting Claude to only your internal APIs and services
|
||||
|
||||
When `experimental_allowed_domains` is set, Claude can only access the domains you explicitly list. You'll need to include the appropriate provider domains based on your authentication method.
|
||||
|
||||
### Provider-Specific Examples
|
||||
|
||||
#### If using Anthropic API or subscription
|
||||
|
||||
```yaml
|
||||
- uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
# Or: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
experimental_allowed_domains: |
|
||||
.anthropic.com
|
||||
```
|
||||
|
||||
#### If using AWS Bedrock
|
||||
|
||||
```yaml
|
||||
- uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
use_bedrock: "true"
|
||||
experimental_allowed_domains: |
|
||||
bedrock.*.amazonaws.com
|
||||
bedrock-runtime.*.amazonaws.com
|
||||
```
|
||||
|
||||
#### If using Google Vertex AI
|
||||
|
||||
```yaml
|
||||
- uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
use_vertex: "true"
|
||||
experimental_allowed_domains: |
|
||||
*.googleapis.com
|
||||
vertexai.googleapis.com
|
||||
```
|
||||
|
||||
### Common GitHub Domains
|
||||
|
||||
In addition to your provider domains, you may need to include GitHub-related domains. For GitHub.com users, common domains include:
|
||||
|
||||
```yaml
|
||||
- uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
experimental_allowed_domains: |
|
||||
.anthropic.com # For Anthropic API
|
||||
.github.com
|
||||
.githubusercontent.com
|
||||
ghcr.io
|
||||
.blob.core.windows.net
|
||||
```
|
||||
|
||||
For GitHub Enterprise users, replace the GitHub.com domains above with your enterprise domains (e.g., `.github.company.com`, `packages.company.com`, etc.).
|
||||
|
||||
To determine which domains your workflow needs, you can temporarily run without restrictions and monitor the network requests, or check your GitHub Enterprise configuration for the specific services you use.
|
||||
|
||||
@@ -38,7 +38,7 @@ The following permissions are requested but not yet actively used. These will en
|
||||
|
||||
## Commit Signing
|
||||
|
||||
All commits made by Claude through this action are automatically signed with commit signatures. This ensures the authenticity and integrity of commits, providing a verifiable trail of changes made by the action.
|
||||
Commits made by Claude through this action are no longer automatically signed with commit signatures. To enable commit signing set `use_commit_signing: True` in the workflow(s). This ensures the authenticity and integrity of commits, providing a verifiable trail of changes made by the action.
|
||||
|
||||
## ⚠️ Authentication Protection
|
||||
|
||||
|
||||
@@ -70,7 +70,6 @@ jobs:
|
||||
| `branch_prefix` | The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format) | No | `claude/` |
|
||||
| `settings` | Claude Code settings as JSON string or path to settings JSON file | No | "" |
|
||||
| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" |
|
||||
| `experimental_allowed_domains` | Restrict network access to these domains only (newline-separated). | No | "" |
|
||||
| `use_commit_signing` | Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands | No | `false` |
|
||||
| `bot_id` | GitHub user ID to use for git operations (defaults to Claude's bot ID) | No | `41898282` |
|
||||
| `bot_name` | GitHub username to use for git operations (defaults to Claude's bot name) | No | `claude[bot]` |
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Setup Network Restrictions with Squid Proxy
|
||||
# This script sets up a Squid proxy to restrict network access to whitelisted domains only.
|
||||
|
||||
set -e
|
||||
|
||||
# Check if experimental_allowed_domains is provided
|
||||
if [ -z "$EXPERIMENTAL_ALLOWED_DOMAINS" ]; then
|
||||
echo "ERROR: EXPERIMENTAL_ALLOWED_DOMAINS environment variable is required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check required environment variables
|
||||
if [ -z "$RUNNER_TEMP" ]; then
|
||||
echo "ERROR: RUNNER_TEMP environment variable is required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$GITHUB_ENV" ]; then
|
||||
echo "ERROR: GITHUB_ENV environment variable is required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Setting up network restrictions with Squid proxy..."
|
||||
|
||||
SQUID_START_TIME=$(date +%s.%N)
|
||||
|
||||
# Create whitelist file
|
||||
echo "$EXPERIMENTAL_ALLOWED_DOMAINS" > $RUNNER_TEMP/whitelist.txt
|
||||
|
||||
# Ensure each domain has proper format
|
||||
# If domain doesn't start with a dot and isn't an IP, add the dot for subdomain matching
|
||||
mv $RUNNER_TEMP/whitelist.txt $RUNNER_TEMP/whitelist.txt.orig
|
||||
while IFS= read -r domain; do
|
||||
if [ -n "$domain" ]; then
|
||||
# Trim whitespace
|
||||
domain=$(echo "$domain" | xargs)
|
||||
# If it's not empty and doesn't start with a dot, add one
|
||||
if [[ "$domain" != .* ]] && [[ ! "$domain" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo ".$domain" >> $RUNNER_TEMP/whitelist.txt
|
||||
else
|
||||
echo "$domain" >> $RUNNER_TEMP/whitelist.txt
|
||||
fi
|
||||
fi
|
||||
done < $RUNNER_TEMP/whitelist.txt.orig
|
||||
|
||||
# Create Squid config with whitelist
|
||||
echo "http_port 3128" > $RUNNER_TEMP/squid.conf
|
||||
echo "" >> $RUNNER_TEMP/squid.conf
|
||||
echo "# Define ACLs" >> $RUNNER_TEMP/squid.conf
|
||||
echo "acl whitelist dstdomain \"/etc/squid/whitelist.txt\"" >> $RUNNER_TEMP/squid.conf
|
||||
echo "acl localnet src 127.0.0.1/32" >> $RUNNER_TEMP/squid.conf
|
||||
echo "acl localnet src 172.17.0.0/16" >> $RUNNER_TEMP/squid.conf
|
||||
echo "acl SSL_ports port 443" >> $RUNNER_TEMP/squid.conf
|
||||
echo "acl Safe_ports port 80" >> $RUNNER_TEMP/squid.conf
|
||||
echo "acl Safe_ports port 443" >> $RUNNER_TEMP/squid.conf
|
||||
echo "acl CONNECT method CONNECT" >> $RUNNER_TEMP/squid.conf
|
||||
echo "" >> $RUNNER_TEMP/squid.conf
|
||||
echo "# Deny requests to certain unsafe ports" >> $RUNNER_TEMP/squid.conf
|
||||
echo "http_access deny !Safe_ports" >> $RUNNER_TEMP/squid.conf
|
||||
echo "" >> $RUNNER_TEMP/squid.conf
|
||||
echo "# Only allow CONNECT to SSL ports" >> $RUNNER_TEMP/squid.conf
|
||||
echo "http_access deny CONNECT !SSL_ports" >> $RUNNER_TEMP/squid.conf
|
||||
echo "" >> $RUNNER_TEMP/squid.conf
|
||||
echo "# Allow localhost" >> $RUNNER_TEMP/squid.conf
|
||||
echo "http_access allow localhost" >> $RUNNER_TEMP/squid.conf
|
||||
echo "" >> $RUNNER_TEMP/squid.conf
|
||||
echo "# Allow localnet access to whitelisted domains" >> $RUNNER_TEMP/squid.conf
|
||||
echo "http_access allow localnet whitelist" >> $RUNNER_TEMP/squid.conf
|
||||
echo "" >> $RUNNER_TEMP/squid.conf
|
||||
echo "# Deny everything else" >> $RUNNER_TEMP/squid.conf
|
||||
echo "http_access deny all" >> $RUNNER_TEMP/squid.conf
|
||||
|
||||
echo "Starting Squid proxy..."
|
||||
# First, remove any existing container
|
||||
sudo docker rm -f squid-proxy 2>/dev/null || true
|
||||
|
||||
# Ensure whitelist file is not empty (Squid fails with empty files)
|
||||
if [ ! -s "$RUNNER_TEMP/whitelist.txt" ]; then
|
||||
echo "WARNING: Whitelist file is empty, adding a dummy entry"
|
||||
echo ".example.com" >> $RUNNER_TEMP/whitelist.txt
|
||||
fi
|
||||
|
||||
# Use sudo to prevent Claude from stopping the container
|
||||
CONTAINER_ID=$(sudo docker run -d \
|
||||
--name squid-proxy \
|
||||
-p 127.0.0.1:3128:3128 \
|
||||
-v $RUNNER_TEMP/squid.conf:/etc/squid/squid.conf:ro \
|
||||
-v $RUNNER_TEMP/whitelist.txt:/etc/squid/whitelist.txt:ro \
|
||||
ubuntu/squid:latest 2>&1) || {
|
||||
echo "ERROR: Failed to start Squid container"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Wait for proxy to be ready (usually < 1 second)
|
||||
READY=false
|
||||
for i in {1..30}; do
|
||||
if nc -z 127.0.0.1 3128 2>/dev/null; then
|
||||
TOTAL_TIME=$(echo "scale=3; $(date +%s.%N) - $SQUID_START_TIME" | bc)
|
||||
echo "Squid proxy ready in ${TOTAL_TIME}s"
|
||||
READY=true
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
if [ "$READY" != "true" ]; then
|
||||
echo "ERROR: Squid proxy failed to start within 3 seconds"
|
||||
echo "Container logs:"
|
||||
sudo docker logs squid-proxy 2>&1 || true
|
||||
echo "Container status:"
|
||||
sudo docker ps -a | grep squid-proxy || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Set proxy environment variables
|
||||
echo "http_proxy=http://127.0.0.1:3128" >> $GITHUB_ENV
|
||||
echo "https_proxy=http://127.0.0.1:3128" >> $GITHUB_ENV
|
||||
echo "HTTP_PROXY=http://127.0.0.1:3128" >> $GITHUB_ENV
|
||||
echo "HTTPS_PROXY=http://127.0.0.1:3128" >> $GITHUB_ENV
|
||||
|
||||
echo "Network restrictions setup completed successfully"
|
||||
@@ -26,7 +26,6 @@ export function collectActionInputsPresence(): void {
|
||||
max_turns: "",
|
||||
use_sticky_comment: "false",
|
||||
use_commit_signing: "false",
|
||||
experimental_allowed_domains: "",
|
||||
};
|
||||
|
||||
const allInputsJson = process.env.ALL_INPUTS;
|
||||
|
||||
@@ -13,6 +13,8 @@ export const PR_QUERY = `
|
||||
headRefName
|
||||
headRefOid
|
||||
createdAt
|
||||
updatedAt
|
||||
lastEditedAt
|
||||
additions
|
||||
deletions
|
||||
state
|
||||
@@ -96,6 +98,8 @@ export const ISSUE_QUERY = `
|
||||
login
|
||||
}
|
||||
createdAt
|
||||
updatedAt
|
||||
lastEditedAt
|
||||
state
|
||||
comments(first: 100) {
|
||||
nodes {
|
||||
|
||||
@@ -107,6 +107,38 @@ export function filterReviewsToTriggerTime<
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the issue/PR body was edited after the trigger time.
|
||||
* This prevents a race condition where an attacker could edit the issue/PR body
|
||||
* between when an authorized user triggered Claude and when Claude processes the request.
|
||||
*
|
||||
* @param contextData - The PR or issue data containing body and edit timestamps
|
||||
* @param triggerTime - ISO timestamp of when the trigger event occurred
|
||||
* @returns true if the body is safe to use, false if it was edited after trigger
|
||||
*/
|
||||
export function isBodySafeToUse(
|
||||
contextData: { createdAt: string; updatedAt?: string; lastEditedAt?: string },
|
||||
triggerTime: string | undefined,
|
||||
): boolean {
|
||||
// If no trigger time is available, we can't validate - allow the body
|
||||
// This maintains backwards compatibility for triggers that don't have timestamps
|
||||
if (!triggerTime) return true;
|
||||
|
||||
const triggerTimestamp = new Date(triggerTime).getTime();
|
||||
|
||||
// Check if the body was edited after the trigger
|
||||
// Use lastEditedAt if available (more accurate for body edits), otherwise fall back to updatedAt
|
||||
const lastEditTime = contextData.lastEditedAt || contextData.updatedAt;
|
||||
if (lastEditTime) {
|
||||
const lastEditTimestamp = new Date(lastEditTime).getTime();
|
||||
if (lastEditTimestamp >= triggerTimestamp) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
type FetchDataParams = {
|
||||
octokits: Octokits;
|
||||
repository: string;
|
||||
@@ -273,9 +305,13 @@ export async function fetchGitHubData({
|
||||
body: c.body,
|
||||
}));
|
||||
|
||||
// Add the main issue/PR body if it has content
|
||||
const mainBody: CommentWithImages[] = contextData.body
|
||||
? [
|
||||
// Add the main issue/PR body if it has content and wasn't edited after trigger
|
||||
// This prevents a TOCTOU race condition where an attacker could edit the body
|
||||
// between when an authorized user triggered Claude and when Claude processes the request
|
||||
let mainBody: CommentWithImages[] = [];
|
||||
if (contextData.body) {
|
||||
if (isBodySafeToUse(contextData, triggerTime)) {
|
||||
mainBody = [
|
||||
{
|
||||
...(isPR
|
||||
? {
|
||||
@@ -289,8 +325,14 @@ export async function fetchGitHubData({
|
||||
body: contextData.body,
|
||||
}),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
];
|
||||
} else {
|
||||
console.warn(
|
||||
`Security: ${isPR ? "PR" : "Issue"} #${prNumber} body was edited after the trigger event. ` +
|
||||
`Excluding body content to prevent potential injection attacks.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const allComments = [
|
||||
...mainBody,
|
||||
|
||||
@@ -58,6 +58,8 @@ export type GitHubPullRequest = {
|
||||
headRefName: string;
|
||||
headRefOid: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
lastEditedAt?: string;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
state: string;
|
||||
@@ -83,6 +85,8 @@ export type GitHubIssue = {
|
||||
body: string;
|
||||
author: GitHubAuthor;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
lastEditedAt?: string;
|
||||
state: string;
|
||||
comments: {
|
||||
nodes: GitHubComment[];
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
fetchGitHubData,
|
||||
filterCommentsToTriggerTime,
|
||||
filterReviewsToTriggerTime,
|
||||
isBodySafeToUse,
|
||||
} from "../src/github/data/fetcher";
|
||||
import {
|
||||
createMockContext,
|
||||
@@ -371,6 +372,139 @@ describe("filterReviewsToTriggerTime", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isBodySafeToUse", () => {
|
||||
const triggerTime = "2024-01-15T12:00:00Z";
|
||||
|
||||
const createMockContextData = (
|
||||
createdAt: string,
|
||||
updatedAt?: string,
|
||||
lastEditedAt?: string,
|
||||
) => ({
|
||||
createdAt,
|
||||
updatedAt,
|
||||
lastEditedAt,
|
||||
});
|
||||
|
||||
describe("body edit time validation", () => {
|
||||
it("should return true when body was never edited", () => {
|
||||
const contextData = createMockContextData("2024-01-15T10:00:00Z");
|
||||
expect(isBodySafeToUse(contextData, triggerTime)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when body was edited before trigger time", () => {
|
||||
const contextData = createMockContextData(
|
||||
"2024-01-15T10:00:00Z",
|
||||
"2024-01-15T11:00:00Z",
|
||||
"2024-01-15T11:30:00Z",
|
||||
);
|
||||
expect(isBodySafeToUse(contextData, triggerTime)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when body was edited after trigger time (using updatedAt)", () => {
|
||||
const contextData = createMockContextData(
|
||||
"2024-01-15T10:00:00Z",
|
||||
"2024-01-15T13:00:00Z",
|
||||
);
|
||||
expect(isBodySafeToUse(contextData, triggerTime)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when body was edited after trigger time (using lastEditedAt)", () => {
|
||||
const contextData = createMockContextData(
|
||||
"2024-01-15T10:00:00Z",
|
||||
undefined,
|
||||
"2024-01-15T13:00:00Z",
|
||||
);
|
||||
expect(isBodySafeToUse(contextData, triggerTime)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when body was edited exactly at trigger time", () => {
|
||||
const contextData = createMockContextData(
|
||||
"2024-01-15T10:00:00Z",
|
||||
"2024-01-15T12:00:00Z",
|
||||
);
|
||||
expect(isBodySafeToUse(contextData, triggerTime)).toBe(false);
|
||||
});
|
||||
|
||||
it("should prioritize lastEditedAt over updatedAt", () => {
|
||||
// updatedAt is after trigger, but lastEditedAt is before - should be safe
|
||||
const contextData = createMockContextData(
|
||||
"2024-01-15T10:00:00Z",
|
||||
"2024-01-15T13:00:00Z", // updatedAt after trigger
|
||||
"2024-01-15T11:00:00Z", // lastEditedAt before trigger
|
||||
);
|
||||
expect(isBodySafeToUse(contextData, triggerTime)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should return true when no trigger time is provided (backward compatibility)", () => {
|
||||
const contextData = createMockContextData(
|
||||
"2024-01-15T10:00:00Z",
|
||||
"2024-01-15T13:00:00Z", // Would normally fail
|
||||
"2024-01-15T14:00:00Z", // Would normally fail
|
||||
);
|
||||
expect(isBodySafeToUse(contextData, undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle millisecond precision correctly", () => {
|
||||
// Edit 1ms after trigger - should be unsafe
|
||||
const contextData = createMockContextData(
|
||||
"2024-01-15T10:00:00Z",
|
||||
"2024-01-15T12:00:00.001Z",
|
||||
);
|
||||
expect(isBodySafeToUse(contextData, triggerTime)).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle edit 1ms before trigger - should be safe", () => {
|
||||
const contextData = createMockContextData(
|
||||
"2024-01-15T10:00:00Z",
|
||||
"2024-01-15T11:59:59.999Z",
|
||||
);
|
||||
expect(isBodySafeToUse(contextData, triggerTime)).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle various ISO timestamp formats", () => {
|
||||
const contextData1 = createMockContextData(
|
||||
"2024-01-15T10:00:00Z",
|
||||
"2024-01-15T11:00:00Z",
|
||||
);
|
||||
const contextData2 = createMockContextData(
|
||||
"2024-01-15T10:00:00+00:00",
|
||||
"2024-01-15T11:00:00+00:00",
|
||||
);
|
||||
const contextData3 = createMockContextData(
|
||||
"2024-01-15T10:00:00.000Z",
|
||||
"2024-01-15T11:00:00.000Z",
|
||||
);
|
||||
|
||||
expect(isBodySafeToUse(contextData1, triggerTime)).toBe(true);
|
||||
expect(isBodySafeToUse(contextData2, triggerTime)).toBe(true);
|
||||
expect(isBodySafeToUse(contextData3, triggerTime)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("security scenarios", () => {
|
||||
it("should detect race condition attack - body edited between trigger and processing", () => {
|
||||
// Simulates: Owner triggers @claude at 12:00, attacker edits body at 12:00:30
|
||||
const contextData = createMockContextData(
|
||||
"2024-01-15T10:00:00Z", // Issue created
|
||||
"2024-01-15T12:00:30Z", // Body edited after trigger
|
||||
);
|
||||
expect(isBodySafeToUse(contextData, "2024-01-15T12:00:00Z")).toBe(false);
|
||||
});
|
||||
|
||||
it("should allow body that was stable at trigger time", () => {
|
||||
// Body was last edited well before the trigger
|
||||
const contextData = createMockContextData(
|
||||
"2024-01-15T10:00:00Z",
|
||||
"2024-01-15T10:30:00Z",
|
||||
"2024-01-15T10:30:00Z",
|
||||
);
|
||||
expect(isBodySafeToUse(contextData, "2024-01-15T12:00:00Z")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchGitHubData integration with time filtering", () => {
|
||||
it("should filter comments based on trigger time when provided", async () => {
|
||||
const mockOctokits = {
|
||||
@@ -696,4 +830,119 @@ describe("fetchGitHubData integration with time filtering", () => {
|
||||
// All three comments should be included as they're all before trigger time
|
||||
expect(result.comments.length).toBe(3);
|
||||
});
|
||||
|
||||
it("should exclude issue body when edited after trigger time (TOCTOU protection)", async () => {
|
||||
const mockOctokits = {
|
||||
graphql: jest.fn().mockResolvedValue({
|
||||
repository: {
|
||||
issue: {
|
||||
number: 555,
|
||||
title: "Test Issue",
|
||||
body: "Malicious body edited after trigger",
|
||||
author: { login: "attacker" },
|
||||
createdAt: "2024-01-15T10:00:00Z",
|
||||
updatedAt: "2024-01-15T12:30:00Z", // Edited after trigger
|
||||
lastEditedAt: "2024-01-15T12:30:00Z", // Edited after trigger
|
||||
comments: { nodes: [] },
|
||||
},
|
||||
},
|
||||
user: { login: "trigger-user" },
|
||||
}),
|
||||
rest: jest.fn() as any,
|
||||
};
|
||||
|
||||
const result = await fetchGitHubData({
|
||||
octokits: mockOctokits as any,
|
||||
repository: "test-owner/test-repo",
|
||||
prNumber: "555",
|
||||
isPR: false,
|
||||
triggerUsername: "trigger-user",
|
||||
triggerTime: "2024-01-15T12:00:00Z",
|
||||
});
|
||||
|
||||
// The body should be excluded from image processing due to TOCTOU protection
|
||||
// We can verify this by checking that issue_body is NOT in the imageUrlMap keys
|
||||
const hasIssueBodyInMap = Array.from(result.imageUrlMap.keys()).some(
|
||||
(key) => key.includes("issue_body"),
|
||||
);
|
||||
expect(hasIssueBodyInMap).toBe(false);
|
||||
});
|
||||
|
||||
it("should include issue body when not edited after trigger time", async () => {
|
||||
const mockOctokits = {
|
||||
graphql: jest.fn().mockResolvedValue({
|
||||
repository: {
|
||||
issue: {
|
||||
number: 666,
|
||||
title: "Test Issue",
|
||||
body: "Safe body not edited after trigger",
|
||||
author: { login: "author" },
|
||||
createdAt: "2024-01-15T10:00:00Z",
|
||||
updatedAt: "2024-01-15T11:00:00Z", // Edited before trigger
|
||||
lastEditedAt: "2024-01-15T11:00:00Z", // Edited before trigger
|
||||
comments: { nodes: [] },
|
||||
},
|
||||
},
|
||||
user: { login: "trigger-user" },
|
||||
}),
|
||||
rest: jest.fn() as any,
|
||||
};
|
||||
|
||||
const result = await fetchGitHubData({
|
||||
octokits: mockOctokits as any,
|
||||
repository: "test-owner/test-repo",
|
||||
prNumber: "666",
|
||||
isPR: false,
|
||||
triggerUsername: "trigger-user",
|
||||
triggerTime: "2024-01-15T12:00:00Z",
|
||||
});
|
||||
|
||||
// The contextData should still contain the body
|
||||
expect(result.contextData.body).toBe("Safe body not edited after trigger");
|
||||
});
|
||||
|
||||
it("should exclude PR body when edited after trigger time (TOCTOU protection)", async () => {
|
||||
const mockOctokits = {
|
||||
graphql: jest.fn().mockResolvedValue({
|
||||
repository: {
|
||||
pullRequest: {
|
||||
number: 777,
|
||||
title: "Test PR",
|
||||
body: "Malicious PR body edited after trigger",
|
||||
author: { login: "attacker" },
|
||||
baseRefName: "main",
|
||||
headRefName: "feature",
|
||||
headRefOid: "abc123",
|
||||
createdAt: "2024-01-15T10:00:00Z",
|
||||
updatedAt: "2024-01-15T12:30:00Z", // Edited after trigger
|
||||
lastEditedAt: "2024-01-15T12:30:00Z", // Edited after trigger
|
||||
additions: 10,
|
||||
deletions: 5,
|
||||
state: "OPEN",
|
||||
commits: { totalCount: 1, nodes: [] },
|
||||
files: { nodes: [] },
|
||||
comments: { nodes: [] },
|
||||
reviews: { nodes: [] },
|
||||
},
|
||||
},
|
||||
user: { login: "trigger-user" },
|
||||
}),
|
||||
rest: jest.fn() as any,
|
||||
};
|
||||
|
||||
const result = await fetchGitHubData({
|
||||
octokits: mockOctokits as any,
|
||||
repository: "test-owner/test-repo",
|
||||
prNumber: "777",
|
||||
isPR: true,
|
||||
triggerUsername: "trigger-user",
|
||||
triggerTime: "2024-01-15T12:00:00Z",
|
||||
});
|
||||
|
||||
// The body should be excluded from image processing due to TOCTOU protection
|
||||
const hasPrBodyInMap = Array.from(result.imageUrlMap.keys()).some((key) =>
|
||||
key.includes("pr_body"),
|
||||
);
|
||||
expect(hasPrBodyInMap).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user