Compare commits

..

2 Commits

Author SHA1 Message Date
guro
7680c1d501 feat: add workflow_dispatch trigger with cloudflare tunnel token input
- Add manual trigger support for GitHub Actions UI
- Include cloudflare_tunnel_token and direct_prompt inputs
- Set agent mode for workflow_dispatch events
- Enable manual execution of Claude Code action
2025-08-07 13:58:04 -07:00
Guro
dee63efcf4 feat: add ttyd and cloudflared tunnel integration
Add support for exposing Claude's terminal interface via browser using ttyd and cloudflared tunnel.

- Add cloudflare_tunnel_token input parameter to both action.yml files
- Spawn ttyd process on port 7681 to serve Claude's CLI interface
- Start cloudflared tunnel process with provided token to expose via web
- Implement proper process cleanup in finally block
- Add 3-second startup delay for process initialization

When cloudflare_tunnel_token is provided, users can access Claude's interactive terminal through their browser via the cloudflared tunnel.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-07 12:53:44 -07:00
32 changed files with 131 additions and 591 deletions

View File

@@ -9,10 +9,21 @@ on:
types: [opened, assigned] types: [opened, assigned]
pull_request_review: pull_request_review:
types: [submitted] types: [submitted]
workflow_dispatch:
inputs:
cloudflare_tunnel_token:
description: 'Cloudflare tunnel token to expose Claude UI via browser'
required: false
type: string
direct_prompt:
description: 'Direct instruction for Claude'
required: false
type: string
jobs: jobs:
claude: claude:
if: | if: |
github.event_name == 'workflow_dispatch' ||
(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')) ||
@@ -37,3 +48,6 @@ jobs:
allowed_tools: "Bash(bun install),Bash(bun test:*),Bash(bun run format),Bash(bun typecheck)" allowed_tools: "Bash(bun install),Bash(bun test:*),Bash(bun run format),Bash(bun typecheck)"
custom_instructions: "You have also been granted tools for editing files and running bun commands (install, run, test, typecheck) for testing your changes: bun install, bun test, bun run format, bun typecheck." custom_instructions: "You have also been granted tools for editing files and running bun commands (install, run, test, typecheck) for testing your changes: bun install, bun test, bun run format, bun typecheck."
model: "claude-opus-4-1-20250805" model: "claude-opus-4-1-20250805"
cloudflare_tunnel_token: ${{ github.event.inputs.cloudflare_tunnel_token }}
direct_prompt: ${{ github.event.inputs.direct_prompt }}
mode: ${{ github.event_name == 'workflow_dispatch' && 'agent' || 'tag' }}

View File

@@ -104,5 +104,3 @@ 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

@@ -14,19 +14,6 @@ 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 - 📋 **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) - 🏃 **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 ## 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`. 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`.

View File

@@ -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 - **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. - **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 - **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 - **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
--- ---

View File

@@ -23,10 +23,6 @@ inputs:
description: "The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format)" description: "The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format)"
required: false required: false
default: "claude/" 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 configuration
mode: mode:
@@ -118,6 +114,10 @@ inputs:
description: "Restrict network access to these domains only (newline-separated). If not set, no restrictions are applied. Provider domains are auto-detected." description: "Restrict network access to these domains only (newline-separated). If not set, no restrictions are applied. Provider domains are auto-detected."
required: false required: false
default: "" default: ""
cloudflare_tunnel_token:
description: "Cloudflare tunnel token to expose Claude UI via browser (optional)"
required: false
default: ""
outputs: outputs:
execution_file: execution_file:
@@ -160,13 +160,11 @@ runs:
OVERRIDE_PROMPT: ${{ inputs.override_prompt }} OVERRIDE_PROMPT: ${{ inputs.override_prompt }}
MCP_CONFIG: ${{ inputs.mcp_config }} MCP_CONFIG: ${{ inputs.mcp_config }}
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }} OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
ALLOWED_BOTS: ${{ inputs.allowed_bots }}
GITHUB_RUN_ID: ${{ github.run_id }} GITHUB_RUN_ID: ${{ github.run_id }}
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }} USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
DEFAULT_WORKFLOW_TOKEN: ${{ github.token }} DEFAULT_WORKFLOW_TOKEN: ${{ github.token }}
ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }} ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }}
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }} USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
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'
@@ -178,8 +176,7 @@ runs:
echo "Base-action dependencies installed" echo "Base-action dependencies installed"
cd - cd -
# Install Claude Code globally # Install Claude Code globally
curl -fsSL https://claude.ai/install.sh | bash -s 1.0.85 bun install -g @anthropic-ai/claude-code@1.0.70
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 != ''
@@ -213,7 +210,7 @@ runs:
INPUT_CLAUDE_ENV: ${{ inputs.claude_env }} INPUT_CLAUDE_ENV: ${{ inputs.claude_env }}
INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }} INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }}
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 }} INPUT_CLOUDFLARE_TUNNEL_TOKEN: ${{ inputs.cloudflare_tunnel_token }}
# Model configuration # Model configuration
ANTHROPIC_MODEL: ${{ inputs.model || inputs.anthropic_model }} ANTHROPIC_MODEL: ${{ inputs.model || inputs.anthropic_model }}
@@ -288,7 +285,7 @@ runs:
fi fi
- name: Revoke app token - name: Revoke app token
if: always() && inputs.github_token == '' && steps.prepare.outputs.skipped_due_to_workflow_validation_mismatch != 'true' if: always() && inputs.github_token == ''
shell: bash shell: bash
run: | run: |
curl -L \ curl -L \

View File

@@ -87,6 +87,10 @@ inputs:
description: "Whether to use Node.js dependency caching (set to true only for Node.js projects with lock files)" description: "Whether to use Node.js dependency caching (set to true only for Node.js projects with lock files)"
required: false required: false
default: "false" default: "false"
cloudflare_tunnel_token:
description: "Cloudflare tunnel token to expose Claude UI via browser (optional)"
required: false
default: ""
outputs: outputs:
conclusion: conclusion:
@@ -118,7 +122,7 @@ runs:
- name: Install Claude Code - name: Install Claude Code
shell: bash shell: bash
run: curl -fsSL https://claude.ai/install.sh | bash -s 1.0.85 run: bun install -g @anthropic-ai/claude-code@1.0.70
- name: Run Claude Code Action - name: Run Claude Code Action
shell: bash shell: bash
@@ -147,6 +151,7 @@ runs:
INPUT_CLAUDE_ENV: ${{ inputs.claude_env }} INPUT_CLAUDE_ENV: ${{ inputs.claude_env }}
INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }} INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }}
INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ inputs.experimental_slash_commands_dir }} INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ inputs.experimental_slash_commands_dir }}
INPUT_CLOUDFLARE_TUNNEL_TOKEN: ${{ inputs.cloudflare_tunnel_token }}
# Provider configuration # Provider configuration
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }} ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}

View File

@@ -5,6 +5,7 @@ import { preparePrompt } from "./prepare-prompt";
import { runClaude } from "./run-claude"; import { runClaude } from "./run-claude";
import { setupClaudeCodeSettings } from "./setup-claude-code-settings"; import { setupClaudeCodeSettings } from "./setup-claude-code-settings";
import { validateEnvironmentVariables } from "./validate-env"; import { validateEnvironmentVariables } from "./validate-env";
import { spawn } from "child_process";
async function run() { async function run() {
try { try {
@@ -21,6 +22,39 @@ async function run() {
promptFile: process.env.INPUT_PROMPT_FILE || "", promptFile: process.env.INPUT_PROMPT_FILE || "",
}); });
// Setup ttyd and cloudflared tunnel if token provided
let ttydProcess: any = null;
let cloudflaredProcess: any = null;
if (process.env.INPUT_CLOUDFLARE_TUNNEL_TOKEN) {
console.log("Setting up ttyd and cloudflared tunnel...");
// Start ttyd process in background
ttydProcess = spawn("ttyd", ["-p", "7681", "-i", "0.0.0.0", "claude"], {
stdio: "inherit",
detached: true,
});
ttydProcess.on("error", (error: Error) => {
console.warn(`ttyd process error: ${error.message}`);
});
// Start cloudflared tunnel
cloudflaredProcess = spawn("cloudflared", ["tunnel", "run", "--token", process.env.INPUT_CLOUDFLARE_TUNNEL_TOKEN], {
stdio: "inherit",
detached: true,
});
cloudflaredProcess.on("error", (error: Error) => {
console.warn(`cloudflared process error: ${error.message}`);
});
// Give processes time to start up
await new Promise(resolve => setTimeout(resolve, 3000));
console.log("ttyd and cloudflared tunnel started");
}
try {
await runClaude(promptConfig.path, { await runClaude(promptConfig.path, {
allowedTools: process.env.INPUT_ALLOWED_TOOLS, allowedTools: process.env.INPUT_ALLOWED_TOOLS,
disallowedTools: process.env.INPUT_DISALLOWED_TOOLS, disallowedTools: process.env.INPUT_DISALLOWED_TOOLS,
@@ -32,6 +66,23 @@ async function run() {
fallbackModel: process.env.INPUT_FALLBACK_MODEL, fallbackModel: process.env.INPUT_FALLBACK_MODEL,
model: process.env.ANTHROPIC_MODEL, model: process.env.ANTHROPIC_MODEL,
}); });
} finally {
// Clean up processes
if (ttydProcess) {
try {
ttydProcess.kill("SIGTERM");
} catch (e) {
console.warn("Failed to terminate ttyd process");
}
}
if (cloudflaredProcess) {
try {
cloudflaredProcess.kill("SIGTERM");
} catch (e) {
console.warn("Failed to terminate cloudflared process");
}
}
}
} catch (error) { } catch (error) {
core.setFailed(`Action failed with error: ${error}`); core.setFailed(`Action failed with error: ${error}`);
core.setOutput("conclusion", "failure"); core.setOutput("conclusion", "failure");

View File

@@ -110,10 +110,6 @@ export function prepareRunConfig(
// Parse custom environment variables // Parse custom environment variables
const customEnv = parseCustomEnvVars(options.claudeEnv); const customEnv = parseCustomEnvVars(options.claudeEnv);
if (process.env.INPUT_ACTION_INPUTS_PRESENT) {
customEnv.GITHUB_ACTION_INPUTS = process.env.INPUT_ACTION_INPUTS_PRESENT;
}
return { return {
claudeArgs, claudeArgs,
promptPath, promptPath,
@@ -146,11 +142,9 @@ 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
const customEnvKeys = Object.keys(config.env).filter( if (Object.keys(config.env).length > 0) {
(key) => key !== "CLAUDE_ACTION_INPUTS_PRESENT", const envKeys = Object.keys(config.env).join(", ");
); console.log(`Custom environment variables: ${envKeys}`);
if (customEnvKeys.length > 0) {
console.log(`Custom environment variables: ${customEnvKeys.join(", ")}`);
} }
// Output to console // Output to console

View File

@@ -207,8 +207,15 @@ Claude does **not** have access to execute arbitrary Bash commands by default. I
```yaml ```yaml
- uses: anthropics/claude-code-action@beta - uses: anthropics/claude-code-action@beta
with: with:
allowed_tools: "Bash(npm install),Bash(npm run test),Edit,Replace,NotebookEditCell" allowed_tools: |
disallowed_tools: "TaskOutput,KillTask" Bash(npm install)
Bash(npm run test)
Edit
Replace
NotebookEditCell
disallowed_tools: |
TaskOutput
KillTask
# ... other inputs # ... other inputs
``` ```

View File

@@ -3,7 +3,7 @@
## Access Control ## Access Control
- **Repository Access**: The action can only be triggered by users with write access to the repository - **Repository Access**: The action can only be triggered by users with write access to the repository
- **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 - **No Bot Triggers**: GitHub Apps and bots cannot trigger this action
- **Token Permissions**: The GitHub app receives only a short-lived token scoped specifically to the repository it's operating in - **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 - **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 - **Limited Scope**: The token cannot access other repositories or perform actions beyond the configured permissions

View File

@@ -42,8 +42,6 @@ jobs:
# Optional: grant additional permissions (requires corresponding GitHub token permissions) # Optional: grant additional permissions (requires corresponding GitHub token permissions)
# additional_permissions: | # additional_permissions: |
# actions: read # actions: read
# Optional: allow bot users to trigger the action
# allowed_bots: "dependabot[bot],renovate[bot]"
``` ```
## Inputs ## Inputs
@@ -78,7 +76,6 @@ jobs:
| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | 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 | "" | | `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` | | `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) \*Required when using direct Anthropic API (default and when not using Bedrock or Vertex)

View File

@@ -1,4 +1,4 @@
name: Claude Code name: Claude PR Assistant
on: on:
issue_comment: issue_comment:
@@ -11,53 +11,38 @@ on:
types: [submitted] types: [submitted]
jobs: jobs:
claude: claude-code-action:
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') || contains(github.event.issue.title, '@claude'))) (github.event_name == 'issues' && contains(github.event.issue.body, '@claude'))
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: read
pull-requests: write pull-requests: read
issues: write issues: read
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 Code - name: Run Claude PR Action
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:
# This is an optional setting that allows Claude to read CI results on PRs # claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
additional_permissions: | timeout_minutes: "60"
actions: read # mode: tag # Default: responds to @claude mentions
# Optional: Restrict network access to specific domains only
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1) # experimental_allowed_domains: |
# model: "claude-opus-4-1-20250805" # .anthropic.com
# .github.com
# Optional: Customize the trigger phrase (default: @claude) # api.github.com
# trigger_phrase: "/claude" # .githubusercontent.com
# bun.sh
# Optional: Trigger when specific user is assigned to an issue # registry.npmjs.org
# assignee_trigger: "claude-bot" # .blob.core.windows.net
# 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

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

View File

@@ -1,59 +0,0 @@
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

@@ -13,12 +13,9 @@ import { parseGitHubContext, isEntityContext } from "../github/context";
import { getMode, isValidMode, DEFAULT_MODE } from "../modes/registry"; import { getMode, isValidMode, DEFAULT_MODE } from "../modes/registry";
import type { ModeName } from "../modes/types"; import type { ModeName } from "../modes/types";
import { prepare } from "../prepare"; import { prepare } from "../prepare";
import { collectActionInputsPresence } from "./collect-inputs";
async function run() { async function run() {
try { try {
collectActionInputsPresence();
// Step 1: Get mode first to determine authentication method // Step 1: Get mode first to determine authentication method
const modeInput = process.env.MODE || DEFAULT_MODE; const modeInput = process.env.MODE || DEFAULT_MODE;

View File

@@ -77,7 +77,6 @@ type BaseContext = {
useStickyComment: boolean; useStickyComment: boolean;
additionalPermissions: Map<string, string>; additionalPermissions: Map<string, string>;
useCommitSigning: boolean; useCommitSigning: boolean;
allowedBots: string;
}; };
}; };
@@ -137,7 +136,6 @@ export function parseGitHubContext(): GitHubContext {
process.env.ADDITIONAL_PERMISSIONS ?? "", process.env.ADDITIONAL_PERMISSIONS ?? "",
), ),
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true", useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
allowedBots: process.env.ALLOWED_BOTS ?? "",
}, },
}; };

View File

@@ -31,30 +31,8 @@ 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"}`,
); );
@@ -99,9 +77,8 @@ 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,41 +58,6 @@ 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

@@ -21,42 +21,9 @@ export async function checkHumanActor(
console.log(`Actor type: ${actorType}`); console.log(`Actor type: ${actorType}`);
// Check bot permissions if actor is not a User
if (actorType !== "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( throw new Error(
`Workflow initiated by non-human actor: ${botName} (type: ${actorType}). Add bot to allowed_bots list or use '*' to allow all bots.`, `Workflow initiated by non-human actor: ${githubContext.actor} (type: ${actorType}).`,
); );
} }

View File

@@ -17,12 +17,6 @@ export async function checkWritePermissions(
try { try {
core.info(`Checking permissions for actor: ${actor}`); 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 // Check permissions directly using the permission endpoint
const response = await octokit.repos.getCollaboratorPermissionLevel({ const response = await octokit.repos.getCollaboratorPermissionLevel({
owner: repository.owner, owner: repository.owner,

View File

@@ -6,7 +6,6 @@ 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;
@@ -55,13 +54,11 @@ 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: sanitizedBody, body,
isPullRequestReviewComment, isPullRequestReviewComment,
}); });

View File

@@ -3,9 +3,8 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 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 { readFile, stat } from "fs/promises"; import { readFile } from "fs/promises";
import { join } from "path"; import { join } from "path";
import { constants } from "fs";
import fetch from "node-fetch"; import fetch from "node-fetch";
import { GITHUB_API_URL } from "../github/api/config"; import { GITHUB_API_URL } from "../github/api/config";
import { retryWithBackoff } from "../utils/retry"; import { retryWithBackoff } from "../utils/retry";
@@ -163,34 +162,6 @@ async function getOrCreateBranchRef(
return baseSha; return baseSha;
} }
// Get the appropriate Git file mode for a file
async function getFileMode(filePath: string): Promise<string> {
try {
const fileStat = await stat(filePath);
if (fileStat.isFile()) {
// Check if execute bit is set for user
if (fileStat.mode & constants.S_IXUSR) {
return "100755"; // Executable file
} else {
return "100644"; // Regular file
}
} else if (fileStat.isDirectory()) {
return "040000"; // Directory (tree)
} else if (fileStat.isSymbolicLink()) {
return "120000"; // Symbolic link
} else {
// Fallback for unknown file types
return "100644";
}
} catch (error) {
// If we can't stat the file, default to regular file
console.warn(
`Could not determine file mode for ${filePath}, using default: ${error}`,
);
return "100644";
}
}
// Commit files tool // Commit files tool
server.tool( server.tool(
"commit_files", "commit_files",
@@ -252,9 +223,6 @@ server.tool(
? filePath ? filePath
: join(REPO_DIR, filePath); : join(REPO_DIR, filePath);
// Get the proper file mode based on file permissions
const fileMode = await getFileMode(fullPath);
// Check if file is binary (images, etc.) // Check if file is binary (images, etc.)
const isBinaryFile = const isBinaryFile =
/\.(png|jpg|jpeg|gif|webp|ico|pdf|zip|tar|gz|exe|bin|woff|woff2|ttf|eot)$/i.test( /\.(png|jpg|jpeg|gif|webp|ico|pdf|zip|tar|gz|exe|bin|woff|woff2|ttf|eot)$/i.test(
@@ -293,7 +261,7 @@ server.tool(
// Return tree entry with blob SHA // Return tree entry with blob SHA
return { return {
path: filePath, path: filePath,
mode: fileMode, mode: "100644",
type: "blob", type: "blob",
sha: blobData.sha, sha: blobData.sha,
}; };
@@ -302,7 +270,7 @@ server.tool(
const content = await readFile(fullPath, "utf-8"); const content = await readFile(fullPath, "utf-8");
return { return {
path: filePath, path: filePath,
mode: fileMode, mode: "100644",
type: "blob", type: "blob",
content: content, content: content,
}; };

View File

@@ -3,7 +3,6 @@ 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;
@@ -42,14 +41,12 @@ server.tool(
), ),
line: z line: z
.number() .number()
.nonnegative()
.optional() .optional()
.describe( .describe(
"Line number for single-line comments (required if startLine is not provided)", "Line number for single-line comments (required if startLine is not provided)",
), ),
startLine: z startLine: z
.number() .number()
.nonnegative()
.optional() .optional()
.describe( .describe(
"Start line for multi-line comments (use with line parameter for the end line)", "Start line for multi-line comments (use with line parameter for the end line)",
@@ -82,9 +79,6 @@ 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(
@@ -108,7 +102,7 @@ server.tool(
owner, owner,
repo, repo,
pull_number, pull_number,
body: sanitizedBody, body,
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

@@ -45,7 +45,7 @@ export const agentMode: Mode = {
// TODO: handle by createPrompt (similar to tag and review modes) // TODO: handle by createPrompt (similar to tag and review modes)
// Create prompt directory // Create prompt directory
await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, { await mkdir(`${process.env.RUNNER_TEMP}/claude-prompts`, {
recursive: true, recursive: true,
}); });
// Write the prompt file - the base action requires a prompt_file parameter, // Write the prompt file - the base action requires a prompt_file parameter,
@@ -57,7 +57,7 @@ export const agentMode: Mode = {
context.inputs.directPrompt || context.inputs.directPrompt ||
`Repository: ${context.repository.owner}/${context.repository.repo}`; `Repository: ${context.repository.owner}/${context.repository.repo}`;
await writeFile( await writeFile(
`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/claude-prompt.txt`, `${process.env.RUNNER_TEMP}/claude-prompts/claude-prompt.txt`,
promptContent, promptContent,
); );
@@ -80,8 +80,9 @@ export const agentMode: Mode = {
...context.inputs.disallowedTools, ...context.inputs.disallowedTools,
]; ];
core.exportVariable("ALLOWED_TOOLS", allowedTools.join(",")); // Export as INPUT_ prefixed variables for the base action
core.exportVariable("DISALLOWED_TOOLS", disallowedTools.join(",")); core.exportVariable("INPUT_ALLOWED_TOOLS", allowedTools.join(","));
core.exportVariable("INPUT_DISALLOWED_TOOLS", disallowedTools.join(","));
// Agent mode uses a minimal MCP configuration // Agent mode uses a minimal MCP configuration
// We don't need comment servers or PR-specific tools for automation // We don't need comment servers or PR-specific tools for automation

View File

@@ -297,8 +297,9 @@ This ensures users get value from the review even before checking individual inl
...context.inputs.disallowedTools, ...context.inputs.disallowedTools,
]; ];
core.exportVariable("ALLOWED_TOOLS", allowedTools.join(",")); // Export as INPUT_ prefixed variables for the base action
core.exportVariable("DISALLOWED_TOOLS", disallowedTools.join(",")); core.exportVariable("INPUT_ALLOWED_TOOLS", allowedTools.join(","));
core.exportVariable("INPUT_DISALLOWED_TOOLS", disallowedTools.join(","));
const additionalMcpConfig = process.env.MCP_CONFIG || ""; const additionalMcpConfig = process.env.MCP_CONFIG || "";
const mcpConfig = await prepareMcpConfig({ const mcpConfig = await prepareMcpConfig({

View File

@@ -1,96 +0,0 @@
#!/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.",
);
});
});

View File

@@ -37,7 +37,6 @@ describe("prepareMcpConfig", () => {
useStickyComment: false, useStickyComment: false,
additionalPermissions: new Map(), additionalPermissions: new Map(),
useCommitSigning: false, useCommitSigning: false,
allowedBots: "",
}, },
}; };

View File

@@ -28,7 +28,6 @@ const defaultInputs = {
useStickyComment: false, useStickyComment: false,
additionalPermissions: new Map<string, string>(), additionalPermissions: new Map<string, string>(),
useCommitSigning: false, useCommitSigning: false,
allowedBots: "",
}; };
const defaultRepository = { const defaultRepository = {

View File

@@ -1,29 +1,15 @@
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test"; import { describe, test, expect, beforeEach } from "bun:test";
import { agentMode } from "../../src/modes/agent"; import { agentMode } from "../../src/modes/agent";
import type { GitHubContext } from "../../src/github/context"; import type { GitHubContext } from "../../src/github/context";
import { createMockContext, createMockAutomationContext } from "../mockContext"; import { createMockContext, createMockAutomationContext } from "../mockContext";
import * as core from "@actions/core";
describe("Agent Mode", () => { describe("Agent Mode", () => {
let mockContext: GitHubContext; let mockContext: GitHubContext;
let exportVariableSpy: any;
let setOutputSpy: any;
beforeEach(() => { beforeEach(() => {
mockContext = createMockAutomationContext({ mockContext = createMockAutomationContext({
eventName: "workflow_dispatch", 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", () => { test("agent mode has correct properties", () => {
@@ -70,67 +56,4 @@ describe("Agent Mode", () => {
expect(agentMode.shouldTrigger(context)).toBe(false); 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));
});
}); });

View File

@@ -73,7 +73,6 @@ describe("checkWritePermissions", () => {
useStickyComment: false, useStickyComment: false,
additionalPermissions: new Map(), additionalPermissions: new Map(),
useCommitSigning: false, useCommitSigning: false,
allowedBots: "",
}, },
}); });
@@ -127,16 +126,6 @@ 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 () => { test("should throw error when permission check fails", async () => {
const error = new Error("API error"); const error = new Error("API error");
const mockOctokit = { const mockOctokit = {

View File

@@ -7,7 +7,6 @@ import {
normalizeHtmlEntities, normalizeHtmlEntities,
sanitizeContent, sanitizeContent,
stripHtmlComments, stripHtmlComments,
redactGitHubTokens,
} from "../src/github/utils/sanitizer"; } from "../src/github/utils/sanitizer";
describe("stripInvisibleCharacters", () => { describe("stripInvisibleCharacters", () => {
@@ -243,109 +242,6 @@ 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(

View File

@@ -41,7 +41,6 @@ describe("checkContainsTrigger", () => {
useStickyComment: false, useStickyComment: false,
additionalPermissions: new Map(), additionalPermissions: new Map(),
useCommitSigning: false, useCommitSigning: false,
allowedBots: "",
}, },
}); });
expect(checkContainsTrigger(context)).toBe(true); expect(checkContainsTrigger(context)).toBe(true);
@@ -75,7 +74,6 @@ describe("checkContainsTrigger", () => {
useStickyComment: false, useStickyComment: false,
additionalPermissions: new Map(), additionalPermissions: new Map(),
useCommitSigning: false, useCommitSigning: false,
allowedBots: "",
}, },
}); });
expect(checkContainsTrigger(context)).toBe(false); expect(checkContainsTrigger(context)).toBe(false);
@@ -293,7 +291,6 @@ describe("checkContainsTrigger", () => {
useStickyComment: false, useStickyComment: false,
additionalPermissions: new Map(), additionalPermissions: new Map(),
useCommitSigning: false, useCommitSigning: false,
allowedBots: "",
}, },
}); });
expect(checkContainsTrigger(context)).toBe(true); expect(checkContainsTrigger(context)).toBe(true);
@@ -328,7 +325,6 @@ describe("checkContainsTrigger", () => {
useStickyComment: false, useStickyComment: false,
additionalPermissions: new Map(), additionalPermissions: new Map(),
useCommitSigning: false, useCommitSigning: false,
allowedBots: "",
}, },
}); });
expect(checkContainsTrigger(context)).toBe(true); expect(checkContainsTrigger(context)).toBe(true);
@@ -363,7 +359,6 @@ describe("checkContainsTrigger", () => {
useStickyComment: false, useStickyComment: false,
additionalPermissions: new Map(), additionalPermissions: new Map(),
useCommitSigning: false, useCommitSigning: false,
allowedBots: "",
}, },
}); });
expect(checkContainsTrigger(context)).toBe(false); expect(checkContainsTrigger(context)).toBe(false);