Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ inputs:
description: "Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands"
required: false
default: "false"
ssh_signing_key:
description: "SSH private key for signing commits. When provided, git will be configured to use SSH signing. Takes precedence over use_commit_signing."
required: false
default: ""
bot_id:
description: "GitHub user ID to use for git operations (defaults to Claude's bot ID)"
required: false
Expand Down Expand Up @@ -181,6 +185,7 @@ runs:
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
DEFAULT_WORKFLOW_TOKEN: ${{ github.token }}
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
SSH_SIGNING_KEY: ${{ inputs.ssh_signing_key }}
BOT_ID: ${{ inputs.bot_id }}
BOT_NAME: ${{ inputs.bot_name }}
TRACK_PROGRESS: ${{ inputs.track_progress }}
Expand Down Expand Up @@ -334,6 +339,12 @@ runs:
echo '```' >> $GITHUB_STEP_SUMMARY
fi

- name: Cleanup SSH signing key
if: always() && inputs.ssh_signing_key != ''
shell: bash
run: |
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/cleanup-ssh-signing.ts

- name: Revoke app token
if: always() && inputs.github_token == '' && steps.prepare.outputs.skipped_due_to_workflow_validation_mismatch != 'true'
shell: bash
Expand Down
59 changes: 58 additions & 1 deletion docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,64 @@ The following permissions are requested but not yet actively used. These will en

## Commit Signing

Commits made by Claude through this action are no longer automatically signed with commit signatures. To enable commit signing set `use_commit_signing: True` in the workflow(s). This ensures the authenticity and integrity of commits, providing a verifiable trail of changes made by the action.
By default, commits made by Claude are unsigned. You can enable commit signing using one of two methods:

### Option 1: GitHub API Commit Signing (use_commit_signing)

This uses GitHub's API to create commits, which automatically signs them as verified from the GitHub App:

```yaml
- uses: anthropics/claude-code-action@main
with:
use_commit_signing: true
```

This is the simplest option and requires no additional setup. However, because it uses the GitHub API instead of git CLI, it cannot perform complex git operations like rebasing, cherry-picking, or interactive history manipulation.

### Option 2: SSH Signing Key (ssh_signing_key)

This uses an SSH key to sign commits via git CLI. Use this option when you need both signed commits AND standard git operations (rebasing, cherry-picking, etc.):

```yaml
- uses: anthropics/claude-code-action@main
with:
ssh_signing_key: ${{ secrets.SSH_SIGNING_KEY }}
bot_id: "YOUR_GITHUB_USER_ID"
bot_name: "YOUR_GITHUB_USERNAME"
```

Commits will show as verified and attributed to the GitHub account that owns the signing key.

**Setup steps:**

1. Generate an SSH key pair for signing:

```bash
ssh-keygen -t ed25519 -f ~/.ssh/signing_key -N "" -C "commit signing key"
```

2. Add the **public key** to your GitHub account:

- Go to GitHub → Settings → SSH and GPG keys
- Click "New SSH key"
- Select **Key type: Signing Key** (important)
- Paste the contents of `~/.ssh/signing_key.pub`

3. Add the **private key** to your repository secrets:

- Go to your repo → Settings → Secrets and variables → Actions
- Create a new secret named `SSH_SIGNING_KEY`
- Paste the contents of `~/.ssh/signing_key`

4. Get your GitHub user ID:

```bash
gh api users/YOUR_USERNAME --jq '.id'
```

5. Update your workflow with `bot_id` and `bot_name` matching the account where you added the signing key.

**Note:** If both `ssh_signing_key` and `use_commit_signing` are provided, `ssh_signing_key` takes precedence.

## ⚠️ Authentication Protection

Expand Down
7 changes: 4 additions & 3 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,10 @@ jobs:
| `branch_prefix` | The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format) | No | `claude/` |
| `settings` | Claude Code settings as JSON string or path to settings JSON file | No | "" |
| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" |
| `use_commit_signing` | Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands | No | `false` |
| `bot_id` | GitHub user ID to use for git operations (defaults to Claude's bot ID) | No | `41898282` |
| `bot_name` | GitHub username to use for git operations (defaults to Claude's bot name) | No | `claude[bot]` |
| `use_commit_signing` | Enable commit signing using GitHub's API. Simple but cannot perform complex git operations like rebasing. See [Security](./security.md#commit-signing) | No | `false` |
| `ssh_signing_key` | SSH private key for signing commits. Enables signed commits with full git CLI support (rebasing, etc.). See [Security](./security.md#commit-signing) | No | "" |
| `bot_id` | GitHub user ID to use for git operations (defaults to Claude's bot ID). Required with `ssh_signing_key` for verified commits | No | `41898282` |
| `bot_name` | GitHub username to use for git operations (defaults to Claude's bot name). Required with `ssh_signing_key` for verified commits | No | `claude[bot]` |
| `allowed_bots` | Comma-separated list of allowed bot usernames, or '\*' to allow all bots. Empty string (default) allows no bots | No | "" |
| `allowed_non_write_users` | **⚠️ RISKY**: Comma-separated list of usernames to allow without write permissions, or '\*' for all users. Only works with `github_token` input. See [Security](./security.md) | No | "" |
| `path_to_claude_code_executable` | Optional path to a custom Claude Code executable. Skips automatic installation. Useful for Nix, custom containers, or specialized environments | No | "" |
Expand Down
21 changes: 21 additions & 0 deletions src/entrypoints/cleanup-ssh-signing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/usr/bin/env bun

/**
* Cleanup SSH signing key after action completes
* This is run as a post step for security purposes
*/

import { cleanupSshSigning } from "../github/operations/git-config";

async function run() {
try {
await cleanupSshSigning();
} catch (error) {
// Don't fail the action if cleanup fails, just log it
console.error("Failed to cleanup SSH signing key:", error);
}
}

if (import.meta.main) {
run();
}
1 change: 1 addition & 0 deletions src/entrypoints/collect-inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export function collectActionInputsPresence(): void {
max_turns: "",
use_sticky_comment: "false",
use_commit_signing: "false",
ssh_signing_key: "",
};

const allInputsJson = process.env.ALL_INPUTS;
Expand Down
2 changes: 2 additions & 0 deletions src/github/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ type BaseContext = {
branchPrefix: string;
useStickyComment: boolean;
useCommitSigning: boolean;
sshSigningKey: string;
botId: string;
botName: string;
allowedBots: string;
Expand Down Expand Up @@ -146,6 +147,7 @@ export function parseGitHubContext(): GitHubContext {
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
useStickyComment: process.env.USE_STICKY_COMMENT === "true",
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
sshSigningKey: process.env.SSH_SIGNING_KEY || "",
botId: process.env.BOT_ID ?? String(CLAUDE_APP_BOT_ID),
botName: process.env.BOT_NAME ?? CLAUDE_BOT_LOGIN,
allowedBots: process.env.ALLOWED_BOTS ?? "",
Expand Down
52 changes: 52 additions & 0 deletions src/github/operations/git-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@
*/

import { $ } from "bun";
import { mkdir, writeFile, rm } from "fs/promises";
import { join } from "path";
import { homedir } from "os";
import type { GitHubContext } from "../context";
import { GITHUB_SERVER_URL } from "../api/config";

const SSH_SIGNING_KEY_PATH = join(homedir(), ".ssh", "claude_signing_key");

type GitUser = {
login: string;
id: number;
Expand Down Expand Up @@ -54,3 +59,50 @@ export async function configureGitAuth(

console.log("Git authentication configured successfully");
}

/**
* Configure git to use SSH signing for commits
* This is an alternative to GitHub API-based commit signing (use_commit_signing)
*/
export async function setupSshSigning(sshSigningKey: string): Promise<void> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Input Validation Missing

The SSH key should be validated before use to catch misconfigurations early and prevent potential injection issues.

Recommendation:

export async function setupSshSigning(sshSigningKey: string): Promise<void> {
  if (!sshSigningKey.trim()) {
    throw new Error("SSH signing key cannot be empty");
  }
  
  if (!sshSigningKey.includes("BEGIN") || !sshSigningKey.includes("PRIVATE KEY")) {
    throw new Error("Invalid SSH private key format");
  }
  
  // ... rest of function
}

console.log("Configuring SSH signing for commits...");

// Validate SSH key format
if (!sshSigningKey.trim()) {
throw new Error("SSH signing key cannot be empty");
}
if (
!sshSigningKey.includes("BEGIN") ||
!sshSigningKey.includes("PRIVATE KEY")
) {
throw new Error("Invalid SSH private key format");
}

// Create .ssh directory with secure permissions (700)
const sshDir = join(homedir(), ".ssh");
await mkdir(sshDir, { recursive: true, mode: 0o700 });

// Write the signing key atomically with secure permissions (600)
await writeFile(SSH_SIGNING_KEY_PATH, sshSigningKey, { mode: 0o600 });
console.log(`✓ SSH signing key written to ${SSH_SIGNING_KEY_PATH}`);

// Configure git to use SSH signing
await $`git config gpg.format ssh`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Performance: Sequential Git Config Commands

These three independent git config operations can be parallelized to save ~20-60ms per run:

await Promise.all([
  $`git config gpg.format ssh`,
  $`git config user.signingkey ${SSH_SIGNING_KEY_PATH}`,
  $`git config commit.gpgsign true`,
]);

await $`git config user.signingkey ${SSH_SIGNING_KEY_PATH}`;
await $`git config commit.gpgsign true`;

console.log("✓ Git configured to use SSH signing for commits");
}

/**
* Clean up the SSH signing key file
* Should be called in the post step for security
*/
export async function cleanupSshSigning(): Promise<void> {
try {
await rm(SSH_SIGNING_KEY_PATH, { force: true });
console.log("✓ SSH signing key cleaned up");
} catch (error) {
console.log("No SSH signing key to clean up");
}
}
27 changes: 25 additions & 2 deletions src/modes/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import type { Mode, ModeOptions, ModeResult } from "../types";
import type { PreparedContext } from "../../create-prompt/types";
import { prepareMcpConfig } from "../../mcp/install-mcp-server";
import { parseAllowedTools } from "./parse-tools";
import { configureGitAuth } from "../../github/operations/git-config";
import {
configureGitAuth,
setupSshSigning,
} from "../../github/operations/git-config";
import type { GitHubContext } from "../../github/context";
import { isEntityContext } from "../../github/context";

Expand Down Expand Up @@ -79,7 +82,27 @@ export const agentMode: Mode = {

async prepare({ context, githubToken }: ModeOptions): Promise<ModeResult> {
// Configure git authentication for agent mode (same as tag mode)
if (!context.inputs.useCommitSigning) {
// SSH signing takes precedence if provided
const useSshSigning = !!context.inputs.sshSigningKey;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Duplication

This SSH signing detection and git configuration logic (lines 86-118) is duplicated in tag mode (lines 96-128 in src/modes/tag/index.ts). This violates DRY principles and creates maintenance burden - note that error handling has already diverged between the two.

Recommendation: Extract this logic into a shared function like configureGitForMode(context, githubToken, throwOnError) in git-config.ts.

const useApiCommitSigning =
context.inputs.useCommitSigning && !useSshSigning;

if (useSshSigning) {
// Setup SSH signing for commits
await setupSshSigning(context.inputs.sshSigningKey);

// Still configure git auth for push operations (user/email and remote URL)
const user = {
login: context.inputs.botName,
id: parseInt(context.inputs.botId),
};
try {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent Error Handling

Agent mode swallows git configuration errors with a comment "Continue anyway", while tag mode (lines 109-114) properly throws errors. This inconsistency could lead to confusing failures later.

Recommendation: Either make both modes consistent, or document why agent mode should continue on error. Users deserve clear error messages when git setup fails.

await configureGitAuth(githubToken, context, user);
} catch (error) {
console.error("Failed to configure git authentication:", error);
// Continue anyway - git operations may still work with default config
}
} else if (!useApiCommitSigning) {
// Use bot_id and bot_name from inputs directly
const user = {
login: context.inputs.botName,
Expand Down
36 changes: 30 additions & 6 deletions src/modes/tag/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { checkContainsTrigger } from "../../github/validation/trigger";
import { checkHumanActor } from "../../github/validation/actor";
import { createInitialComment } from "../../github/operations/comments/create-initial";
import { setupBranch } from "../../github/operations/branch";
import { configureGitAuth } from "../../github/operations/git-config";
import {
configureGitAuth,
setupSshSigning,
} from "../../github/operations/git-config";
import { prepareMcpConfig } from "../../mcp/install-mcp-server";
import {
fetchGitHubData,
Expand Down Expand Up @@ -88,8 +91,28 @@ export const tagMode: Mode = {
// Setup branch
const branchInfo = await setupBranch(octokit, githubData, context);

// Configure git authentication if not using commit signing
if (!context.inputs.useCommitSigning) {
// Configure git authentication
// SSH signing takes precedence if provided
const useSshSigning = !!context.inputs.sshSigningKey;
const useApiCommitSigning =
context.inputs.useCommitSigning && !useSshSigning;

if (useSshSigning) {
// Setup SSH signing for commits
await setupSshSigning(context.inputs.sshSigningKey);

// Still configure git auth for push operations (user/email and remote URL)
const user = {
login: context.inputs.botName,
id: parseInt(context.inputs.botId),
};
try {
await configureGitAuth(githubToken, context, user);
} catch (error) {
console.error("Failed to configure git authentication:", error);
throw error;
}
} else if (!useApiCommitSigning) {
// Use bot_id and bot_name from inputs directly
const user = {
login: context.inputs.botName,
Expand Down Expand Up @@ -135,8 +158,9 @@ export const tagMode: Mode = {
...userAllowedMCPTools,
];

// Add git commands when not using commit signing
if (!context.inputs.useCommitSigning) {
// Add git commands when using git CLI (no API commit signing, or SSH signing)
// SSH signing still uses git CLI, just with signing enabled
if (!useApiCommitSigning) {
tagModeTools.push(
"Bash(git add:*)",
"Bash(git commit:*)",
Expand All @@ -147,7 +171,7 @@ export const tagMode: Mode = {
"Bash(git rm:*)",
);
} else {
// When using commit signing, use MCP file ops tools
// When using API commit signing, use MCP file ops tools
tagModeTools.push(
"mcp__github_file_ops__commit_files",
"mcp__github_file_ops__delete_files",
Expand Down
1 change: 1 addition & 0 deletions test/install-mcp-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ describe("prepareMcpConfig", () => {
branchPrefix: "",
useStickyComment: false,
useCommitSigning: false,
sshSigningKey: "",
botId: String(CLAUDE_APP_BOT_ID),
botName: CLAUDE_BOT_LOGIN,
allowedBots: "",
Expand Down
1 change: 1 addition & 0 deletions test/mockContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const defaultInputs = {
branchPrefix: "claude/",
useStickyComment: false,
useCommitSigning: false,
sshSigningKey: "",
botId: String(CLAUDE_APP_BOT_ID),
botName: CLAUDE_BOT_LOGIN,
allowedBots: "",
Expand Down
1 change: 1 addition & 0 deletions test/modes/detector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ describe("detectMode with enhanced routing", () => {
branchPrefix: "claude/",
useStickyComment: false,
useCommitSigning: false,
sshSigningKey: "",
botId: "123456",
botName: "claude-bot",
allowedBots: "",
Expand Down
1 change: 1 addition & 0 deletions test/permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ describe("checkWritePermissions", () => {
branchPrefix: "claude/",
useStickyComment: false,
useCommitSigning: false,
sshSigningKey: "",
botId: String(CLAUDE_APP_BOT_ID),
botName: CLAUDE_BOT_LOGIN,
allowedBots: "",
Expand Down
Loading
Loading