diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml index 322b12d..beaeef2 100644 --- a/.github/workflows/issue-triage.yml +++ b/.github/workflows/issue-triage.yml @@ -104,3 +104,5 @@ jobs: mcp_config: /tmp/mcp-config/mcp-servers.json timeout_minutes: "5" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/action.yml b/action.yml index 887b160..cbf36c9 100644 --- a/action.yml +++ b/action.yml @@ -130,6 +130,7 @@ runs: ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }} CLAUDE_ARGS: ${{ inputs.claude_args }} MCP_CONFIG: ${{ inputs.mcp_config }} + ALL_INPUTS: ${{ toJson(inputs) }} - name: Install Base Action Dependencies if: steps.prepare.outputs.contains_trigger == 'true' @@ -141,7 +142,8 @@ runs: echo "Base-action dependencies installed" cd - # 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 if: steps.prepare.outputs.contains_trigger == 'true' && inputs.experimental_allowed_domains != '' @@ -168,6 +170,7 @@ runs: INPUT_TIMEOUT_MINUTES: ${{ inputs.timeout_minutes }} INPUT_CLAUDE_ARGS: ${{ steps.prepare.outputs.claude_args }} INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ github.action_path }}/slash-commands + INPUT_ACTION_INPUTS_PRESENT: ${{ steps.prepare.outputs.action_inputs_present }} # Model configuration GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }} @@ -241,7 +244,7 @@ runs: fi - 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 run: | curl -L \ diff --git a/base-action/action.yml b/base-action/action.yml index 2952835..57e245a 100644 --- a/base-action/action.yml +++ b/base-action/action.yml @@ -85,7 +85,7 @@ runs: - name: Install Claude Code 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 shell: bash diff --git a/base-action/src/run-claude.ts b/base-action/src/run-claude.ts index 7c0c104..f916ec4 100644 --- a/base-action/src/run-claude.ts +++ b/base-action/src/run-claude.ts @@ -56,10 +56,16 @@ export function prepareRunConfig( } } + const customEnv: Record = {}; + + if (process.env.INPUT_ACTION_INPUTS_PRESENT) { + customEnv.GITHUB_ACTION_INPUTS = process.env.INPUT_ACTION_INPUTS_PRESENT; + } + return { claudeArgs, promptPath, - env: {}, + env: customEnv, }; } @@ -88,9 +94,11 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) { console.log(`Prompt file size: ${promptSize} bytes`); // Log custom environment variables if any - if (Object.keys(config.env).length > 0) { - const envKeys = Object.keys(config.env).join(", "); - console.log(`Custom environment variables: ${envKeys}`); + const customEnvKeys = Object.keys(config.env).filter( + (key) => key !== "CLAUDE_ACTION_INPUTS_PRESENT", + ); + if (customEnvKeys.length > 0) { + console.log(`Custom environment variables: ${customEnvKeys.join(", ")}`); } // Log custom arguments if any diff --git a/examples/claude.yml b/examples/claude.yml index 53c207a..f2cf262 100644 --- a/examples/claude.yml +++ b/examples/claude.yml @@ -1,4 +1,4 @@ -name: Claude PR Assistant +name: Claude Code on: issue_comment: @@ -11,38 +11,53 @@ on: types: [submitted] jobs: - claude-code-action: + claude: if: | (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' && 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 permissions: - contents: read - pull-requests: read - issues: read + contents: write + pull-requests: write + issues: write id-token: write + actions: read # Required for Claude to read CI results on PRs steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 1 - - name: Run Claude PR Action + - name: Run Claude Code + id: claude uses: anthropics/claude-code-action@beta with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - # Or use OAuth token instead: - # claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - timeout_minutes: "60" - # mode: tag # Default: responds to @claude mentions - # Optional: Restrict network access to specific domains only - # experimental_allowed_domains: | - # .anthropic.com - # .github.com - # api.github.com - # .githubusercontent.com - # bun.sh - # registry.npmjs.org - # .blob.core.windows.net + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1) + # model: "claude-opus-4-1-20250805" + + # Optional: Customize the trigger phrase (default: @claude) + # trigger_phrase: "/claude" + + # Optional: Trigger when specific user is assigned to an issue + # 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 diff --git a/src/create-prompt/index.ts b/src/create-prompt/index.ts index fcff64d..a93d95f 100644 --- a/src/create-prompt/index.ts +++ b/src/create-prompt/index.ts @@ -750,7 +750,7 @@ export async function createPrompt( modeContext.claudeBranch, ); - await mkdir(`${process.env.RUNNER_TEMP}/claude-prompts`, { + await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, { recursive: true, }); @@ -769,7 +769,7 @@ export async function createPrompt( // Write the prompt file await writeFile( - `${process.env.RUNNER_TEMP}/claude-prompts/claude-prompt.txt`, + `${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/claude-prompt.txt`, promptContent, ); diff --git a/src/entrypoints/collect-inputs.ts b/src/entrypoints/collect-inputs.ts new file mode 100644 index 0000000..501a438 --- /dev/null +++ b/src/entrypoints/collect-inputs.ts @@ -0,0 +1,59 @@ +import * as core from "@actions/core"; + +export function collectActionInputsPresence(): void { + const inputDefaults: Record = { + 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; + 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 = {}; + + 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)); +} diff --git a/src/entrypoints/prepare.ts b/src/entrypoints/prepare.ts index d485f82..a406a3b 100644 --- a/src/entrypoints/prepare.ts +++ b/src/entrypoints/prepare.ts @@ -12,9 +12,12 @@ import { createOctokit } from "../github/api/client"; import { parseGitHubContext, isEntityContext } from "../github/context"; import { getMode } from "../modes/registry"; import { prepare } from "../prepare"; +import { collectActionInputsPresence } from "./collect-inputs"; async function run() { try { + collectActionInputsPresence(); + // Parse GitHub context first to enable mode detection const context = parseGitHubContext(); diff --git a/src/github/token.ts b/src/github/token.ts index 234070c..6cb9079 100644 --- a/src/github/token.ts +++ b/src/github/token.ts @@ -31,8 +31,30 @@ async function exchangeForAppToken(oidcToken: string): Promise { const responseJson = (await response.json()) as { error?: { 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( `App token exchange failed: ${response.status} ${response.statusText} - ${responseJson?.error?.message ?? "Unknown error"}`, ); @@ -77,8 +99,9 @@ export async function setupGitHubToken(): Promise { core.setOutput("GITHUB_TOKEN", appToken); return appToken; } catch (error) { + // Only set failed if we get here - workflow validation errors will exit(0) before this 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); } diff --git a/src/github/utils/sanitizer.ts b/src/github/utils/sanitizer.ts index ef5d3cc..83ee096 100644 --- a/src/github/utils/sanitizer.ts +++ b/src/github/utils/sanitizer.ts @@ -58,6 +58,41 @@ export function sanitizeContent(content: string): string { content = stripMarkdownLinkTitles(content); content = stripHiddenAttributes(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; } diff --git a/src/mcp/github-comment-server.ts b/src/mcp/github-comment-server.ts index 18ab6a2..ef6728c 100644 --- a/src/mcp/github-comment-server.ts +++ b/src/mcp/github-comment-server.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import { GITHUB_API_URL } from "../github/api/config"; import { Octokit } from "@octokit/rest"; import { updateClaudeComment } from "../github/operations/comments/update-claude-comment"; +import { sanitizeContent } from "../github/utils/sanitizer"; // Get repository information from environment variables const REPO_OWNER = process.env.REPO_OWNER; @@ -54,11 +55,13 @@ server.tool( const isPullRequestReviewComment = eventName === "pull_request_review_comment"; + const sanitizedBody = sanitizeContent(body); + const result = await updateClaudeComment(octokit, { owner, repo, commentId, - body, + body: sanitizedBody, isPullRequestReviewComment, }); diff --git a/src/mcp/github-inline-comment-server.ts b/src/mcp/github-inline-comment-server.ts index a432466..703cda2 100644 --- a/src/mcp/github-inline-comment-server.ts +++ b/src/mcp/github-inline-comment-server.ts @@ -3,6 +3,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import { createOctokit } from "../github/api/client"; +import { sanitizeContent } from "../github/utils/sanitizer"; // Get repository and PR information from environment variables const REPO_OWNER = process.env.REPO_OWNER; @@ -81,6 +82,9 @@ server.tool( 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 if (!line && !startLine) { throw new Error( @@ -104,7 +108,7 @@ server.tool( owner, repo, pull_number, - body, + body: sanitizedBody, path, side: side || "RIGHT", commit_id: commit_id || pr.data.head.sha, diff --git a/src/modes/agent/index.ts b/src/modes/agent/index.ts index df9471d..379d23b 100644 --- a/src/modes/agent/index.ts +++ b/src/modes/agent/index.ts @@ -43,7 +43,7 @@ export const agentMode: Mode = { async prepare({ context, githubToken }: ModeOptions): Promise { // Create prompt directory - await mkdir(`${process.env.RUNNER_TEMP}/claude-prompts`, { + await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, { recursive: true, }); @@ -53,7 +53,7 @@ export const agentMode: Mode = { `Repository: ${context.repository.owner}/${context.repository.repo}`; await writeFile( - `${process.env.RUNNER_TEMP}/claude-prompts/claude-prompt.txt`, + `${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/claude-prompt.txt`, promptContent, ); diff --git a/test/sanitizer.test.ts b/test/sanitizer.test.ts index f28366a..a89353b 100644 --- a/test/sanitizer.test.ts +++ b/test/sanitizer.test.ts @@ -7,6 +7,7 @@ import { normalizeHtmlEntities, sanitizeContent, stripHtmlComments, + redactGitHubTokens, } from "../src/github/utils/sanitizer"; 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 = ` + + 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("World")).toBe(