mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 15:04:13 +08:00
Compare commits
26 Commits
feat/ttyd-
...
ashwin/inp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5491027cea | ||
|
|
8f0a7fe9d3 | ||
|
|
db36412854 | ||
|
|
f05d669d5f | ||
|
|
e89411bb6f | ||
|
|
02e9ed3181 | ||
|
|
78b07473f5 | ||
|
|
f562ed53e2 | ||
|
|
a1507aefdc | ||
|
|
ae66eb6a64 | ||
|
|
432c7cc889 | ||
|
|
0b138d9d49 | ||
|
|
c34e066a3b | ||
|
|
449c6791bd | ||
|
|
2b67ac084b | ||
|
|
76de8a48fc | ||
|
|
a80505bbfb | ||
|
|
af23644a50 | ||
|
|
98e6a902bf | ||
|
|
8b2bd6d04f | ||
|
|
4f4f43f044 | ||
|
|
8a5d751740 | ||
|
|
bc423b47f5 | ||
|
|
6d5c92076b | ||
|
|
fec554fc7c | ||
|
|
59ca6e42d9 |
2
.github/workflows/issue-triage.yml
vendored
2
.github/workflows/issue-triage.yml
vendored
@@ -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 }}
|
||||
|
||||
13
README.md
13
README.md
@@ -14,6 +14,19 @@ A general-purpose [Claude Code](https://claude.ai/code) action for GitHub PRs an
|
||||
- 📋 **Progress Tracking**: Visual progress indicators with checkboxes that dynamically update as Claude completes tasks
|
||||
- 🏃 **Runs on Your Infrastructure**: The action executes entirely on your own GitHub runner (Anthropic API calls go to your chosen provider)
|
||||
|
||||
## ⚠️ **BREAKING CHANGES COMING IN v1.0** ⚠️
|
||||
|
||||
**We're planning a major update that will significantly change how this action works.** The new version will:
|
||||
|
||||
- ✨ Automatically select the appropriate mode (no more `mode` input)
|
||||
- 🔧 Simplify configuration with unified `prompt` and `claude_args`
|
||||
- 🚀 Align more closely with the Claude Code SDK capabilities
|
||||
- 💥 Remove multiple inputs like `direct_prompt`, `custom_instructions`, and others
|
||||
|
||||
**[→ Read the full v1.0 roadmap and provide feedback](https://github.com/anthropics/claude-code-action/discussions/428)**
|
||||
|
||||
---
|
||||
|
||||
## Quickstart
|
||||
|
||||
The easiest way to set up this action is through [Claude Code](https://claude.ai/code) in the terminal. Just open `claude` and run `/install-github-app`.
|
||||
|
||||
@@ -10,7 +10,7 @@ Thank you for trying out the beta of our GitHub Action! This document outlines o
|
||||
- **Support for workflow_dispatch and repository_dispatch events** - Dispatch Claude on events triggered via API from other workflows or from other services
|
||||
- **Ability to disable commit signing** - Option to turn off GPG signing for environments where it's not required. This will enable Claude to use normal `git` bash commands for committing. This will likely become the default behavior once added.
|
||||
- **Better code review behavior** - Support inline comments on specific lines, provide higher quality reviews with more actionable feedback
|
||||
- **Support triggering @claude from bot users** - Allow automation and bot accounts to invoke Claude
|
||||
- ~**Support triggering @claude from bot users** - Allow automation and bot accounts to invoke Claude~
|
||||
- **Customizable base prompts** - Full control over Claude's initial context with template variables like `$PR_COMMENTS`, `$PR_FILES`, etc. Users can replace our default prompt entirely while still accessing key contextual data
|
||||
|
||||
---
|
||||
|
||||
10
action.yml
10
action.yml
@@ -23,6 +23,10 @@ inputs:
|
||||
description: "The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format)"
|
||||
required: false
|
||||
default: "claude/"
|
||||
allowed_bots:
|
||||
description: "Comma-separated list of allowed bot usernames, or '*' to allow all bots. Empty string (default) allows no bots."
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
# Mode configuration
|
||||
mode:
|
||||
@@ -156,6 +160,7 @@ runs:
|
||||
OVERRIDE_PROMPT: ${{ inputs.override_prompt }}
|
||||
MCP_CONFIG: ${{ inputs.mcp_config }}
|
||||
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
|
||||
ALLOWED_BOTS: ${{ inputs.allowed_bots }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
|
||||
DEFAULT_WORKFLOW_TOKEN: ${{ github.token }}
|
||||
@@ -172,7 +177,8 @@ runs:
|
||||
echo "Base-action dependencies installed"
|
||||
cd -
|
||||
# Install Claude Code globally
|
||||
bun install -g @anthropic-ai/claude-code@1.0.70
|
||||
curl -fsSL https://claude.ai/install.sh | bash -s 1.0.83
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Setup Network Restrictions
|
||||
if: steps.prepare.outputs.contains_trigger == 'true' && inputs.experimental_allowed_domains != ''
|
||||
@@ -280,7 +286,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 \
|
||||
|
||||
@@ -118,7 +118,7 @@ runs:
|
||||
|
||||
- name: Install Claude Code
|
||||
shell: bash
|
||||
run: bun install -g @anthropic-ai/claude-code@1.0.70
|
||||
run: curl -fsSL https://claude.ai/install.sh | bash -s 1.0.83
|
||||
|
||||
- name: Run Claude Code Action
|
||||
shell: bash
|
||||
|
||||
@@ -169,6 +169,7 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
||||
...config.env,
|
||||
},
|
||||
});
|
||||
console.log("yolo", process.env);
|
||||
|
||||
// Handle Claude process errors
|
||||
claudeProcess.on("error", (error) => {
|
||||
|
||||
@@ -207,15 +207,8 @@ Claude does **not** have access to execute arbitrary Bash commands by default. I
|
||||
```yaml
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
allowed_tools: |
|
||||
Bash(npm install)
|
||||
Bash(npm run test)
|
||||
Edit
|
||||
Replace
|
||||
NotebookEditCell
|
||||
disallowed_tools: |
|
||||
TaskOutput
|
||||
KillTask
|
||||
allowed_tools: "Bash(npm install),Bash(npm run test),Edit,Replace,NotebookEditCell"
|
||||
disallowed_tools: "TaskOutput,KillTask"
|
||||
# ... other inputs
|
||||
```
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Access Control
|
||||
|
||||
- **Repository Access**: The action can only be triggered by users with write access to the repository
|
||||
- **No Bot Triggers**: GitHub Apps and bots cannot trigger this action
|
||||
- **Bot User Control**: By default, GitHub Apps and bots cannot trigger this action for security reasons. Use the `allowed_bots` parameter to enable specific bots or all bots
|
||||
- **Token Permissions**: The GitHub app receives only a short-lived token scoped specifically to the repository it's operating in
|
||||
- **No Cross-Repository Access**: Each action invocation is limited to the repository where it was triggered
|
||||
- **Limited Scope**: The token cannot access other repositories or perform actions beyond the configured permissions
|
||||
|
||||
@@ -42,6 +42,8 @@ jobs:
|
||||
# Optional: grant additional permissions (requires corresponding GitHub token permissions)
|
||||
# additional_permissions: |
|
||||
# actions: read
|
||||
# Optional: allow bot users to trigger the action
|
||||
# allowed_bots: "dependabot[bot],renovate[bot]"
|
||||
```
|
||||
|
||||
## Inputs
|
||||
@@ -76,6 +78,7 @@ jobs:
|
||||
| `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` |
|
||||
| `allowed_bots` | Comma-separated list of allowed bot usernames, or '\*' to allow all bots. Empty string (default) allows no bots | No | "" |
|
||||
|
||||
\*Required when using direct Anthropic API (default and when not using Bedrock or Vertex)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -836,7 +836,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,
|
||||
});
|
||||
|
||||
@@ -855,7 +855,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,
|
||||
);
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ type BaseContext = {
|
||||
useStickyComment: boolean;
|
||||
additionalPermissions: Map<string, string>;
|
||||
useCommitSigning: boolean;
|
||||
allowedBots: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -136,6 +137,7 @@ export function parseGitHubContext(): GitHubContext {
|
||||
process.env.ADDITIONAL_PERMISSIONS ?? "",
|
||||
),
|
||||
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
|
||||
allowedBots: process.env.ALLOWED_BOTS ?? "",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -31,8 +31,30 @@ async function exchangeForAppToken(oidcToken: string): Promise<string> {
|
||||
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<string> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,9 +21,42 @@ export async function checkHumanActor(
|
||||
|
||||
console.log(`Actor type: ${actorType}`);
|
||||
|
||||
// Check bot permissions if actor is not a User
|
||||
if (actorType !== "User") {
|
||||
const allowedBots = githubContext.inputs.allowedBots;
|
||||
|
||||
// Check if all bots are allowed
|
||||
if (allowedBots.trim() === "*") {
|
||||
console.log(
|
||||
`All bots are allowed, skipping human actor check for: ${githubContext.actor}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse allowed bots list
|
||||
const allowedBotsList = allowedBots
|
||||
.split(",")
|
||||
.map((bot) =>
|
||||
bot
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\[bot\]$/, ""),
|
||||
)
|
||||
.filter((bot) => bot.length > 0);
|
||||
|
||||
const botName = githubContext.actor.toLowerCase().replace(/\[bot\]$/, "");
|
||||
|
||||
// Check if specific bot is allowed
|
||||
if (allowedBotsList.includes(botName)) {
|
||||
console.log(
|
||||
`Bot ${botName} is in allowed list, skipping human actor check`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bot not allowed
|
||||
throw new Error(
|
||||
`Workflow initiated by non-human actor: ${githubContext.actor} (type: ${actorType}).`,
|
||||
`Workflow initiated by non-human actor: ${botName} (type: ${actorType}). Add bot to allowed_bots list or use '*' to allow all bots.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,12 @@ export async function checkWritePermissions(
|
||||
try {
|
||||
core.info(`Checking permissions for actor: ${actor}`);
|
||||
|
||||
// Check if the actor is a GitHub App (bot user)
|
||||
if (actor.endsWith("[bot]")) {
|
||||
core.info(`Actor is a GitHub App: ${actor}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check permissions directly using the permission endpoint
|
||||
const response = await octokit.repos.getCollaboratorPermissionLevel({
|
||||
owner: repository.owner,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -41,12 +42,14 @@ server.tool(
|
||||
),
|
||||
line: z
|
||||
.number()
|
||||
.nonnegative()
|
||||
.optional()
|
||||
.describe(
|
||||
"Line number for single-line comments (required if startLine is not provided)",
|
||||
),
|
||||
startLine: z
|
||||
.number()
|
||||
.nonnegative()
|
||||
.optional()
|
||||
.describe(
|
||||
"Start line for multi-line comments (use with line parameter for the end line)",
|
||||
@@ -79,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(
|
||||
@@ -102,7 +108,7 @@ server.tool(
|
||||
owner,
|
||||
repo,
|
||||
pull_number,
|
||||
body,
|
||||
body: sanitizedBody,
|
||||
path,
|
||||
side: side || "RIGHT",
|
||||
commit_id: commit_id || pr.data.head.sha,
|
||||
|
||||
@@ -45,7 +45,7 @@ export const agentMode: Mode = {
|
||||
|
||||
// TODO: handle by createPrompt (similar to tag and review modes)
|
||||
// Create prompt directory
|
||||
await mkdir(`${process.env.RUNNER_TEMP}/claude-prompts`, {
|
||||
await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, {
|
||||
recursive: true,
|
||||
});
|
||||
// Write the prompt file - the base action requires a prompt_file parameter,
|
||||
@@ -57,7 +57,7 @@ export const agentMode: Mode = {
|
||||
context.inputs.directPrompt ||
|
||||
`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,
|
||||
);
|
||||
|
||||
@@ -80,9 +80,8 @@ export const agentMode: Mode = {
|
||||
...context.inputs.disallowedTools,
|
||||
];
|
||||
|
||||
// Export as INPUT_ prefixed variables for the base action
|
||||
core.exportVariable("INPUT_ALLOWED_TOOLS", allowedTools.join(","));
|
||||
core.exportVariable("INPUT_DISALLOWED_TOOLS", disallowedTools.join(","));
|
||||
core.exportVariable("ALLOWED_TOOLS", allowedTools.join(","));
|
||||
core.exportVariable("DISALLOWED_TOOLS", disallowedTools.join(","));
|
||||
|
||||
// Agent mode uses a minimal MCP configuration
|
||||
// We don't need comment servers or PR-specific tools for automation
|
||||
|
||||
@@ -297,9 +297,8 @@ This ensures users get value from the review even before checking individual inl
|
||||
...context.inputs.disallowedTools,
|
||||
];
|
||||
|
||||
// Export as INPUT_ prefixed variables for the base action
|
||||
core.exportVariable("INPUT_ALLOWED_TOOLS", allowedTools.join(","));
|
||||
core.exportVariable("INPUT_DISALLOWED_TOOLS", disallowedTools.join(","));
|
||||
core.exportVariable("ALLOWED_TOOLS", allowedTools.join(","));
|
||||
core.exportVariable("DISALLOWED_TOOLS", disallowedTools.join(","));
|
||||
|
||||
const additionalMcpConfig = process.env.MCP_CONFIG || "";
|
||||
const mcpConfig = await prepareMcpConfig({
|
||||
|
||||
96
test/actor.test.ts
Normal file
96
test/actor.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { checkHumanActor } from "../src/github/validation/actor";
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import { createMockContext } from "./mockContext";
|
||||
|
||||
function createMockOctokit(userType: string): Octokit {
|
||||
return {
|
||||
users: {
|
||||
getByUsername: async () => ({
|
||||
data: {
|
||||
type: userType,
|
||||
},
|
||||
}),
|
||||
},
|
||||
} as unknown as Octokit;
|
||||
}
|
||||
|
||||
describe("checkHumanActor", () => {
|
||||
test("should pass for human actor", async () => {
|
||||
const mockOctokit = createMockOctokit("User");
|
||||
const context = createMockContext();
|
||||
context.actor = "human-user";
|
||||
|
||||
await expect(
|
||||
checkHumanActor(mockOctokit, context),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("should throw error for bot actor when not allowed", async () => {
|
||||
const mockOctokit = createMockOctokit("Bot");
|
||||
const context = createMockContext();
|
||||
context.actor = "test-bot[bot]";
|
||||
context.inputs.allowedBots = "";
|
||||
|
||||
await expect(checkHumanActor(mockOctokit, context)).rejects.toThrow(
|
||||
"Workflow initiated by non-human actor: test-bot (type: Bot). Add bot to allowed_bots list or use '*' to allow all bots.",
|
||||
);
|
||||
});
|
||||
|
||||
test("should pass for bot actor when all bots allowed", async () => {
|
||||
const mockOctokit = createMockOctokit("Bot");
|
||||
const context = createMockContext();
|
||||
context.actor = "test-bot[bot]";
|
||||
context.inputs.allowedBots = "*";
|
||||
|
||||
await expect(
|
||||
checkHumanActor(mockOctokit, context),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("should pass for specific bot when in allowed list", async () => {
|
||||
const mockOctokit = createMockOctokit("Bot");
|
||||
const context = createMockContext();
|
||||
context.actor = "dependabot[bot]";
|
||||
context.inputs.allowedBots = "dependabot[bot],renovate[bot]";
|
||||
|
||||
await expect(
|
||||
checkHumanActor(mockOctokit, context),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("should pass for specific bot when in allowed list (without [bot])", async () => {
|
||||
const mockOctokit = createMockOctokit("Bot");
|
||||
const context = createMockContext();
|
||||
context.actor = "dependabot[bot]";
|
||||
context.inputs.allowedBots = "dependabot,renovate";
|
||||
|
||||
await expect(
|
||||
checkHumanActor(mockOctokit, context),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("should throw error for bot not in allowed list", async () => {
|
||||
const mockOctokit = createMockOctokit("Bot");
|
||||
const context = createMockContext();
|
||||
context.actor = "other-bot[bot]";
|
||||
context.inputs.allowedBots = "dependabot[bot],renovate[bot]";
|
||||
|
||||
await expect(checkHumanActor(mockOctokit, context)).rejects.toThrow(
|
||||
"Workflow initiated by non-human actor: other-bot (type: Bot). Add bot to allowed_bots list or use '*' to allow all bots.",
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error for bot not in allowed list (without [bot])", async () => {
|
||||
const mockOctokit = createMockOctokit("Bot");
|
||||
const context = createMockContext();
|
||||
context.actor = "other-bot[bot]";
|
||||
context.inputs.allowedBots = "dependabot,renovate";
|
||||
|
||||
await expect(checkHumanActor(mockOctokit, context)).rejects.toThrow(
|
||||
"Workflow initiated by non-human actor: other-bot (type: Bot). Add bot to allowed_bots list or use '*' to allow all bots.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,7 @@ describe("prepareMcpConfig", () => {
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
allowedBots: "",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ const defaultInputs = {
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map<string, string>(),
|
||||
useCommitSigning: false,
|
||||
allowedBots: "",
|
||||
};
|
||||
|
||||
const defaultRepository = {
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test";
|
||||
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
||||
import { agentMode } from "../../src/modes/agent";
|
||||
import type { GitHubContext } from "../../src/github/context";
|
||||
import { createMockContext, createMockAutomationContext } from "../mockContext";
|
||||
import * as core from "@actions/core";
|
||||
|
||||
describe("Agent Mode", () => {
|
||||
let mockContext: GitHubContext;
|
||||
let exportVariableSpy: any;
|
||||
let setOutputSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockAutomationContext({
|
||||
eventName: "workflow_dispatch",
|
||||
});
|
||||
exportVariableSpy = spyOn(core, "exportVariable").mockImplementation(
|
||||
() => {},
|
||||
);
|
||||
setOutputSpy = spyOn(core, "setOutput").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exportVariableSpy?.mockClear();
|
||||
setOutputSpy?.mockClear();
|
||||
exportVariableSpy?.mockRestore();
|
||||
setOutputSpy?.mockRestore();
|
||||
});
|
||||
|
||||
test("agent mode has correct properties", () => {
|
||||
@@ -56,4 +70,67 @@ describe("Agent Mode", () => {
|
||||
expect(agentMode.shouldTrigger(context)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("prepare method sets up tools environment variables correctly", async () => {
|
||||
// Clear any previous calls before this test
|
||||
exportVariableSpy.mockClear();
|
||||
setOutputSpy.mockClear();
|
||||
|
||||
const contextWithCustomTools = createMockAutomationContext({
|
||||
eventName: "workflow_dispatch",
|
||||
});
|
||||
contextWithCustomTools.inputs.allowedTools = ["CustomTool1", "CustomTool2"];
|
||||
contextWithCustomTools.inputs.disallowedTools = ["BadTool"];
|
||||
|
||||
const mockOctokit = {} as any;
|
||||
const result = await agentMode.prepare({
|
||||
context: contextWithCustomTools,
|
||||
octokit: mockOctokit,
|
||||
githubToken: "test-token",
|
||||
});
|
||||
|
||||
// Verify that both ALLOWED_TOOLS and DISALLOWED_TOOLS are set
|
||||
expect(exportVariableSpy).toHaveBeenCalledWith(
|
||||
"ALLOWED_TOOLS",
|
||||
"Edit,MultiEdit,Glob,Grep,LS,Read,Write,CustomTool1,CustomTool2",
|
||||
);
|
||||
expect(exportVariableSpy).toHaveBeenCalledWith(
|
||||
"DISALLOWED_TOOLS",
|
||||
"WebSearch,WebFetch,BadTool",
|
||||
);
|
||||
|
||||
// Verify MCP config is set
|
||||
expect(setOutputSpy).toHaveBeenCalledWith("mcp_config", expect.any(String));
|
||||
|
||||
// Verify return structure
|
||||
expect(result).toEqual({
|
||||
commentId: undefined,
|
||||
branchInfo: {
|
||||
baseBranch: "",
|
||||
currentBranch: "",
|
||||
claudeBranch: undefined,
|
||||
},
|
||||
mcpConfig: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
test("prepare method creates prompt file with correct content", async () => {
|
||||
const contextWithPrompts = createMockAutomationContext({
|
||||
eventName: "workflow_dispatch",
|
||||
});
|
||||
contextWithPrompts.inputs.overridePrompt = "Custom override prompt";
|
||||
contextWithPrompts.inputs.directPrompt =
|
||||
"Direct prompt (should be ignored)";
|
||||
|
||||
const mockOctokit = {} as any;
|
||||
await agentMode.prepare({
|
||||
context: contextWithPrompts,
|
||||
octokit: mockOctokit,
|
||||
githubToken: "test-token",
|
||||
});
|
||||
|
||||
// Note: We can't easily test file creation in this unit test,
|
||||
// but we can verify the method completes without errors
|
||||
expect(setOutputSpy).toHaveBeenCalledWith("mcp_config", expect.any(String));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,6 +73,7 @@ describe("checkWritePermissions", () => {
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
allowedBots: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -126,6 +127,16 @@ describe("checkWritePermissions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("should return true for bot user", async () => {
|
||||
const mockOctokit = createMockOctokit("none");
|
||||
const context = createContext();
|
||||
context.actor = "test-bot[bot]";
|
||||
|
||||
const result = await checkWritePermissions(mockOctokit, context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should throw error when permission check fails", async () => {
|
||||
const error = new Error("API error");
|
||||
const mockOctokit = {
|
||||
|
||||
@@ -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 = `
|
||||
<!-- 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)", () => {
|
||||
it("should remove HTML comments", () => {
|
||||
expect(stripHtmlComments("Hello <!-- example -->World")).toBe(
|
||||
|
||||
@@ -41,6 +41,7 @@ describe("checkContainsTrigger", () => {
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
allowedBots: "",
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(true);
|
||||
@@ -74,6 +75,7 @@ describe("checkContainsTrigger", () => {
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
allowedBots: "",
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(false);
|
||||
@@ -291,6 +293,7 @@ describe("checkContainsTrigger", () => {
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
allowedBots: "",
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(true);
|
||||
@@ -325,6 +328,7 @@ describe("checkContainsTrigger", () => {
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
allowedBots: "",
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(true);
|
||||
@@ -359,6 +363,7 @@ describe("checkContainsTrigger", () => {
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
allowedBots: "",
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(false);
|
||||
|
||||
Reference in New Issue
Block a user