This commit is contained in:
km-anthropic
2025-08-18 17:10:03 -07:00
14 changed files with 293 additions and 34 deletions

View File

@@ -104,3 +104,5 @@ jobs:
mcp_config: /tmp/mcp-config/mcp-servers.json mcp_config: /tmp/mcp-config/mcp-servers.json
timeout_minutes: "5" timeout_minutes: "5"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -130,6 +130,7 @@ runs:
ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }} ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }}
CLAUDE_ARGS: ${{ inputs.claude_args }} CLAUDE_ARGS: ${{ inputs.claude_args }}
MCP_CONFIG: ${{ inputs.mcp_config }} MCP_CONFIG: ${{ inputs.mcp_config }}
ALL_INPUTS: ${{ toJson(inputs) }}
- name: Install Base Action Dependencies - name: Install Base Action Dependencies
if: steps.prepare.outputs.contains_trigger == 'true' if: steps.prepare.outputs.contains_trigger == 'true'
@@ -141,7 +142,8 @@ runs:
echo "Base-action dependencies installed" echo "Base-action dependencies installed"
cd - cd -
# Install Claude Code globally # Install Claude Code globally
bun install -g @anthropic-ai/claude-code@1.0.77 curl -fsSL https://claude.ai/install.sh | bash -s 1.0.84
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Setup Network Restrictions - name: Setup Network Restrictions
if: steps.prepare.outputs.contains_trigger == 'true' && inputs.experimental_allowed_domains != '' if: steps.prepare.outputs.contains_trigger == 'true' && inputs.experimental_allowed_domains != ''
@@ -168,6 +170,7 @@ runs:
INPUT_TIMEOUT_MINUTES: ${{ inputs.timeout_minutes }} INPUT_TIMEOUT_MINUTES: ${{ inputs.timeout_minutes }}
INPUT_CLAUDE_ARGS: ${{ steps.prepare.outputs.claude_args }} INPUT_CLAUDE_ARGS: ${{ steps.prepare.outputs.claude_args }}
INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ github.action_path }}/slash-commands INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ github.action_path }}/slash-commands
INPUT_ACTION_INPUTS_PRESENT: ${{ steps.prepare.outputs.action_inputs_present }}
# Model configuration # Model configuration
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
@@ -241,7 +244,7 @@ runs:
fi fi
- name: Revoke app token - name: Revoke app token
if: always() && inputs.github_token == '' if: always() && inputs.github_token == '' && steps.prepare.outputs.skipped_due_to_workflow_validation_mismatch != 'true'
shell: bash shell: bash
run: | run: |
curl -L \ curl -L \

View File

@@ -85,7 +85,7 @@ runs:
- name: Install Claude Code - name: Install Claude Code
shell: bash shell: bash
run: bun install -g @anthropic-ai/claude-code@1.0.77 run: curl -fsSL https://claude.ai/install.sh | bash -s 1.0.84
- name: Run Claude Code Action - name: Run Claude Code Action
shell: bash shell: bash

View File

@@ -56,10 +56,16 @@ export function prepareRunConfig(
} }
} }
const customEnv: Record<string, string> = {};
if (process.env.INPUT_ACTION_INPUTS_PRESENT) {
customEnv.GITHUB_ACTION_INPUTS = process.env.INPUT_ACTION_INPUTS_PRESENT;
}
return { return {
claudeArgs, claudeArgs,
promptPath, promptPath,
env: {}, env: customEnv,
}; };
} }
@@ -88,9 +94,11 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
console.log(`Prompt file size: ${promptSize} bytes`); console.log(`Prompt file size: ${promptSize} bytes`);
// Log custom environment variables if any // Log custom environment variables if any
if (Object.keys(config.env).length > 0) { const customEnvKeys = Object.keys(config.env).filter(
const envKeys = Object.keys(config.env).join(", "); (key) => key !== "CLAUDE_ACTION_INPUTS_PRESENT",
console.log(`Custom environment variables: ${envKeys}`); );
if (customEnvKeys.length > 0) {
console.log(`Custom environment variables: ${customEnvKeys.join(", ")}`);
} }
// Log custom arguments if any // Log custom arguments if any

View File

@@ -1,4 +1,4 @@
name: Claude PR Assistant name: Claude Code
on: on:
issue_comment: issue_comment:
@@ -11,38 +11,53 @@ on:
types: [submitted] types: [submitted]
jobs: jobs:
claude-code-action: claude:
if: | if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && contains(github.event.issue.body, '@claude')) (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: write
pull-requests: read pull-requests: write
issues: read issues: write
id-token: write id-token: write
actions: read # Required for Claude to read CI results on PRs
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Run Claude PR Action - name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@beta uses: anthropics/claude-code-action@beta
with: with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# Or use OAuth token instead:
# claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} # This is an optional setting that allows Claude to read CI results on PRs
timeout_minutes: "60" additional_permissions: |
# mode: tag # Default: responds to @claude mentions actions: read
# Optional: Restrict network access to specific domains only
# experimental_allowed_domains: | # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1)
# .anthropic.com # model: "claude-opus-4-1-20250805"
# .github.com
# api.github.com # Optional: Customize the trigger phrase (default: @claude)
# .githubusercontent.com # trigger_phrase: "/claude"
# bun.sh
# registry.npmjs.org # Optional: Trigger when specific user is assigned to an issue
# .blob.core.windows.net # assignee_trigger: "claude-bot"
# Optional: Allow Claude to run specific commands
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
# Optional: Add custom instructions for Claude to customize its behavior for your project
# custom_instructions: |
# Follow our coding standards
# Ensure all new code has tests
# Use TypeScript for new files
# Optional: Custom environment variables for Claude
# claude_env: |
# NODE_ENV: test

View File

@@ -750,7 +750,7 @@ export async function createPrompt(
modeContext.claudeBranch, modeContext.claudeBranch,
); );
await mkdir(`${process.env.RUNNER_TEMP}/claude-prompts`, { await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, {
recursive: true, recursive: true,
}); });
@@ -769,7 +769,7 @@ export async function createPrompt(
// Write the prompt file // Write the prompt file
await writeFile( await writeFile(
`${process.env.RUNNER_TEMP}/claude-prompts/claude-prompt.txt`, `${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/claude-prompt.txt`,
promptContent, promptContent,
); );

View File

@@ -0,0 +1,59 @@
import * as core from "@actions/core";
export function collectActionInputsPresence(): void {
const inputDefaults: Record<string, string> = {
trigger_phrase: "@claude",
assignee_trigger: "",
label_trigger: "claude",
base_branch: "",
branch_prefix: "claude/",
allowed_bots: "",
mode: "tag",
model: "",
anthropic_model: "",
fallback_model: "",
allowed_tools: "",
disallowed_tools: "",
custom_instructions: "",
direct_prompt: "",
override_prompt: "",
mcp_config: "",
additional_permissions: "",
claude_env: "",
settings: "",
anthropic_api_key: "",
claude_code_oauth_token: "",
github_token: "",
max_turns: "",
use_sticky_comment: "false",
use_commit_signing: "false",
experimental_allowed_domains: "",
};
const allInputsJson = process.env.ALL_INPUTS;
if (!allInputsJson) {
console.log("ALL_INPUTS environment variable not found");
core.setOutput("action_inputs_present", JSON.stringify({}));
return;
}
let allInputs: Record<string, string>;
try {
allInputs = JSON.parse(allInputsJson);
} catch (e) {
console.error("Failed to parse ALL_INPUTS JSON:", e);
core.setOutput("action_inputs_present", JSON.stringify({}));
return;
}
const presentInputs: Record<string, boolean> = {};
for (const [name, defaultValue] of Object.entries(inputDefaults)) {
const actualValue = allInputs[name] || "";
const isSet = actualValue !== defaultValue;
presentInputs[name] = isSet;
}
core.setOutput("action_inputs_present", JSON.stringify(presentInputs));
}

View File

@@ -12,9 +12,12 @@ import { createOctokit } from "../github/api/client";
import { parseGitHubContext, isEntityContext } from "../github/context"; import { parseGitHubContext, isEntityContext } from "../github/context";
import { getMode } from "../modes/registry"; import { getMode } from "../modes/registry";
import { prepare } from "../prepare"; import { prepare } from "../prepare";
import { collectActionInputsPresence } from "./collect-inputs";
async function run() { async function run() {
try { try {
collectActionInputsPresence();
// Parse GitHub context first to enable mode detection // Parse GitHub context first to enable mode detection
const context = parseGitHubContext(); const context = parseGitHubContext();

View File

@@ -31,8 +31,30 @@ async function exchangeForAppToken(oidcToken: string): Promise<string> {
const responseJson = (await response.json()) as { const responseJson = (await response.json()) as {
error?: { error?: {
message?: string; message?: string;
details?: {
error_code?: string;
};
}; };
type?: string;
message?: string;
}; };
// Check for specific workflow validation error codes that should skip the action
const errorCode = responseJson.error?.details?.error_code;
if (errorCode === "workflow_not_found_on_default_branch") {
const message =
responseJson.message ??
responseJson.error?.message ??
"Workflow validation failed";
core.warning(`Skipping action due to workflow validation: ${message}`);
console.log(
"Action skipped due to workflow validation error. This is expected when adding Claude Code workflows to new repositories or on PRs with workflow changes. If you're seeing this, your workflow will begin working once you merge your PR.",
);
core.setOutput("skipped_due_to_workflow_validation_mismatch", "true");
process.exit(0);
}
console.error( console.error(
`App token exchange failed: ${response.status} ${response.statusText} - ${responseJson?.error?.message ?? "Unknown error"}`, `App token exchange failed: ${response.status} ${response.statusText} - ${responseJson?.error?.message ?? "Unknown error"}`,
); );
@@ -77,8 +99,9 @@ export async function setupGitHubToken(): Promise<string> {
core.setOutput("GITHUB_TOKEN", appToken); core.setOutput("GITHUB_TOKEN", appToken);
return appToken; return appToken;
} catch (error) { } catch (error) {
// Only set failed if we get here - workflow validation errors will exit(0) before this
core.setFailed( core.setFailed(
`Failed to setup GitHub token: ${error}.\n\nIf you instead wish to use this action with a custom GitHub token or custom GitHub app, provide a \`github_token\` in the \`uses\` section of the app in your workflow yml file.`, `Failed to setup GitHub token: ${error}\n\nIf you instead wish to use this action with a custom GitHub token or custom GitHub app, provide a \`github_token\` in the \`uses\` section of the app in your workflow yml file.`,
); );
process.exit(1); process.exit(1);
} }

View File

@@ -58,6 +58,41 @@ export function sanitizeContent(content: string): string {
content = stripMarkdownLinkTitles(content); content = stripMarkdownLinkTitles(content);
content = stripHiddenAttributes(content); content = stripHiddenAttributes(content);
content = normalizeHtmlEntities(content); content = normalizeHtmlEntities(content);
content = redactGitHubTokens(content);
return content;
}
export function redactGitHubTokens(content: string): string {
// GitHub Personal Access Tokens (classic): ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
content = content.replace(
/\bghp_[A-Za-z0-9]{36}\b/g,
"[REDACTED_GITHUB_TOKEN]",
);
// GitHub OAuth tokens: gho_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
content = content.replace(
/\bgho_[A-Za-z0-9]{36}\b/g,
"[REDACTED_GITHUB_TOKEN]",
);
// GitHub installation tokens: ghs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
content = content.replace(
/\bghs_[A-Za-z0-9]{36}\b/g,
"[REDACTED_GITHUB_TOKEN]",
);
// GitHub refresh tokens: ghr_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
content = content.replace(
/\bghr_[A-Za-z0-9]{36}\b/g,
"[REDACTED_GITHUB_TOKEN]",
);
// GitHub fine-grained personal access tokens: github_pat_XXXXXXXXXX (up to 255 chars)
content = content.replace(
/\bgithub_pat_[A-Za-z0-9_]{11,221}\b/g,
"[REDACTED_GITHUB_TOKEN]",
);
return content; return content;
} }

View File

@@ -6,6 +6,7 @@ import { z } from "zod";
import { GITHUB_API_URL } from "../github/api/config"; import { GITHUB_API_URL } from "../github/api/config";
import { Octokit } from "@octokit/rest"; import { Octokit } from "@octokit/rest";
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment"; import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
import { sanitizeContent } from "../github/utils/sanitizer";
// Get repository information from environment variables // Get repository information from environment variables
const REPO_OWNER = process.env.REPO_OWNER; const REPO_OWNER = process.env.REPO_OWNER;
@@ -54,11 +55,13 @@ server.tool(
const isPullRequestReviewComment = const isPullRequestReviewComment =
eventName === "pull_request_review_comment"; eventName === "pull_request_review_comment";
const sanitizedBody = sanitizeContent(body);
const result = await updateClaudeComment(octokit, { const result = await updateClaudeComment(octokit, {
owner, owner,
repo, repo,
commentId, commentId,
body, body: sanitizedBody,
isPullRequestReviewComment, isPullRequestReviewComment,
}); });

View File

@@ -3,6 +3,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod"; import { z } from "zod";
import { createOctokit } from "../github/api/client"; import { createOctokit } from "../github/api/client";
import { sanitizeContent } from "../github/utils/sanitizer";
// Get repository and PR information from environment variables // Get repository and PR information from environment variables
const REPO_OWNER = process.env.REPO_OWNER; const REPO_OWNER = process.env.REPO_OWNER;
@@ -81,6 +82,9 @@ server.tool(
const octokit = createOctokit(githubToken).rest; const octokit = createOctokit(githubToken).rest;
// Sanitize the comment body to remove any potential GitHub tokens
const sanitizedBody = sanitizeContent(body);
// Validate that either line or both startLine and line are provided // Validate that either line or both startLine and line are provided
if (!line && !startLine) { if (!line && !startLine) {
throw new Error( throw new Error(
@@ -104,7 +108,7 @@ server.tool(
owner, owner,
repo, repo,
pull_number, pull_number,
body, body: sanitizedBody,
path, path,
side: side || "RIGHT", side: side || "RIGHT",
commit_id: commit_id || pr.data.head.sha, commit_id: commit_id || pr.data.head.sha,

View File

@@ -43,7 +43,7 @@ export const agentMode: Mode = {
async prepare({ context, githubToken }: ModeOptions): Promise<ModeResult> { async prepare({ context, githubToken }: ModeOptions): Promise<ModeResult> {
// Create prompt directory // Create prompt directory
await mkdir(`${process.env.RUNNER_TEMP}/claude-prompts`, { await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, {
recursive: true, recursive: true,
}); });
@@ -53,7 +53,7 @@ export const agentMode: Mode = {
`Repository: ${context.repository.owner}/${context.repository.repo}`; `Repository: ${context.repository.owner}/${context.repository.repo}`;
await writeFile( await writeFile(
`${process.env.RUNNER_TEMP}/claude-prompts/claude-prompt.txt`, `${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/claude-prompt.txt`,
promptContent, promptContent,
); );

View File

@@ -7,6 +7,7 @@ import {
normalizeHtmlEntities, normalizeHtmlEntities,
sanitizeContent, sanitizeContent,
stripHtmlComments, stripHtmlComments,
redactGitHubTokens,
} from "../src/github/utils/sanitizer"; } from "../src/github/utils/sanitizer";
describe("stripInvisibleCharacters", () => { describe("stripInvisibleCharacters", () => {
@@ -242,6 +243,109 @@ describe("sanitizeContent", () => {
}); });
}); });
describe("redactGitHubTokens", () => {
it("should redact personal access tokens (ghp_)", () => {
const token = "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";
expect(redactGitHubTokens(`Token: ${token}`)).toBe(
"Token: [REDACTED_GITHUB_TOKEN]",
);
expect(redactGitHubTokens(`Here's a token: ${token} in text`)).toBe(
"Here's a token: [REDACTED_GITHUB_TOKEN] in text",
);
});
it("should redact OAuth tokens (gho_)", () => {
const token = "gho_16C7e42F292c6912E7710c838347Ae178B4a";
expect(redactGitHubTokens(`OAuth: ${token}`)).toBe(
"OAuth: [REDACTED_GITHUB_TOKEN]",
);
});
it("should redact installation tokens (ghs_)", () => {
const token = "ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW";
expect(redactGitHubTokens(`Install token: ${token}`)).toBe(
"Install token: [REDACTED_GITHUB_TOKEN]",
);
});
it("should redact refresh tokens (ghr_)", () => {
const token = "ghr_1B4a2e77838347a253e56d7b5253e7d11667";
expect(redactGitHubTokens(`Refresh: ${token}`)).toBe(
"Refresh: [REDACTED_GITHUB_TOKEN]",
);
});
it("should redact fine-grained tokens (github_pat_)", () => {
const token =
"github_pat_11ABCDEFG0example5of9_2nVwvsylpmOLboQwTPTLewDcE621dQ0AAaBBCCDDEEFFHH";
expect(redactGitHubTokens(`Fine-grained: ${token}`)).toBe(
"Fine-grained: [REDACTED_GITHUB_TOKEN]",
);
});
it("should handle tokens in code blocks", () => {
const content = `\`\`\`bash
export GITHUB_TOKEN=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
\`\`\``;
const expected = `\`\`\`bash
export GITHUB_TOKEN=[REDACTED_GITHUB_TOKEN]
\`\`\``;
expect(redactGitHubTokens(content)).toBe(expected);
});
it("should handle multiple tokens in one text", () => {
const content =
"Token 1: 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 and token 2: gho_16C7e42F292c6912E7710c838347Ae178B4a";
expect(redactGitHubTokens(content)).toBe(
"Token 1: [REDACTED_GITHUB_TOKEN] and token 2: [REDACTED_GITHUB_TOKEN]",
);
});
it("should handle tokens in URLs", () => {
const content =
"https://api.github.com/user?access_token=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";
expect(redactGitHubTokens(content)).toBe(
"https://api.github.com/user?access_token=[REDACTED_GITHUB_TOKEN]",
);
});
it("should not redact partial matches or invalid tokens", () => {
const content =
"This is not a token: ghp_short or gho_toolong1234567890123456789012345678901234567890";
expect(redactGitHubTokens(content)).toBe(content);
});
it("should preserve normal text", () => {
const content = "Normal text with no tokens";
expect(redactGitHubTokens(content)).toBe(content);
});
it("should handle edge cases", () => {
expect(redactGitHubTokens("")).toBe("");
expect(redactGitHubTokens("ghp_")).toBe("ghp_");
expect(redactGitHubTokens("github_pat_short")).toBe("github_pat_short");
});
});
describe("sanitizeContent with token redaction", () => {
it("should redact tokens as part of full sanitization", () => {
const content = `
<!-- Hidden comment with token: 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 -->
Here's some text with a token: gho_16C7e42F292c6912E7710c838347Ae178B4a
And invisible chars: test\u200Btoken
`;
const sanitized = sanitizeContent(content);
expect(sanitized).not.toContain("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");
expect(sanitized).not.toContain("gho_16C7e42F292c6912E7710c838347Ae178B4a");
expect(sanitized).not.toContain("<!-- Hidden comment");
expect(sanitized).not.toContain("\u200B");
expect(sanitized).toContain("[REDACTED_GITHUB_TOKEN]");
expect(sanitized).toContain("Here's some text with a token:");
});
});
describe("stripHtmlComments (legacy)", () => { describe("stripHtmlComments (legacy)", () => {
it("should remove HTML comments", () => { it("should remove HTML comments", () => {
expect(stripHtmlComments("Hello <!-- example -->World")).toBe( expect(stripHtmlComments("Hello <!-- example -->World")).toBe(