Skip to content

Commit 7e4bf87

Browse files
authored
feat: add ssh_signing_key input for SSH commit signing (#784)
* feat: add ssh_signing_key input for SSH commit signing Add a new ssh_signing_key input that allows passing an SSH signing key for commit signing, as an alternative to the existing use_commit_signing (which uses GitHub API-based commits). When ssh_signing_key is provided: - Git is configured to use SSH signing (gpg.format=ssh, commit.gpgsign=true) - The key is written to ~/.ssh/claude_signing_key with 0600 permissions - Git CLI commands are used (not MCP file ops) - The key is cleaned up in a post step for security Behavior matrix: | ssh_signing_key | use_commit_signing | Result | |-----------------|-------------------|--------| | not set | false | Regular git, no signing | | not set | true | GitHub API (MCP), verified commits | | set | false | Git CLI with SSH signing | | set | true | Git CLI with SSH signing (ssh_signing_key takes precedence) * docs: add SSH signing key documentation - Update security.md with detailed setup instructions for both signing options - Explain that ssh_signing_key enables full git CLI operations (rebasing, etc.) - Add ssh_signing_key to inputs table in usage.md - Update bot_id/bot_name descriptions to note they're needed for verified commits * fix: address security review feedback for SSH signing - Write SSH key atomically with mode 0o600 (fixes TOCTOU race condition) - Create .ssh directory with mode 0o700 (SSH best practices) - Add input validation for SSH key format - Remove unused chmod import - Add tests for validation logic
1 parent 154d0de commit 7e4bf87

File tree

14 files changed

+458
-12
lines changed

14 files changed

+458
-12
lines changed

action.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ inputs:
8181
description: "Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands"
8282
required: false
8383
default: "false"
84+
ssh_signing_key:
85+
description: "SSH private key for signing commits. When provided, git will be configured to use SSH signing. Takes precedence over use_commit_signing."
86+
required: false
87+
default: ""
8488
bot_id:
8589
description: "GitHub user ID to use for git operations (defaults to Claude's bot ID)"
8690
required: false
@@ -181,6 +185,7 @@ runs:
181185
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
182186
DEFAULT_WORKFLOW_TOKEN: ${{ github.token }}
183187
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
188+
SSH_SIGNING_KEY: ${{ inputs.ssh_signing_key }}
184189
BOT_ID: ${{ inputs.bot_id }}
185190
BOT_NAME: ${{ inputs.bot_name }}
186191
TRACK_PROGRESS: ${{ inputs.track_progress }}
@@ -334,6 +339,12 @@ runs:
334339
echo '```' >> $GITHUB_STEP_SUMMARY
335340
fi
336341
342+
- name: Cleanup SSH signing key
343+
if: always() && inputs.ssh_signing_key != ''
344+
shell: bash
345+
run: |
346+
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/cleanup-ssh-signing.ts
347+
337348
- name: Revoke app token
338349
if: always() && inputs.github_token == '' && steps.prepare.outputs.skipped_due_to_workflow_validation_mismatch != 'true'
339350
shell: bash

docs/security.md

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,64 @@ The following permissions are requested but not yet actively used. These will en
3838

3939
## Commit Signing
4040

41-
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.
41+
By default, commits made by Claude are unsigned. You can enable commit signing using one of two methods:
42+
43+
### Option 1: GitHub API Commit Signing (use_commit_signing)
44+
45+
This uses GitHub's API to create commits, which automatically signs them as verified from the GitHub App:
46+
47+
```yaml
48+
- uses: anthropics/claude-code-action@main
49+
with:
50+
use_commit_signing: true
51+
```
52+
53+
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.
54+
55+
### Option 2: SSH Signing Key (ssh_signing_key)
56+
57+
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.):
58+
59+
```yaml
60+
- uses: anthropics/claude-code-action@main
61+
with:
62+
ssh_signing_key: ${{ secrets.SSH_SIGNING_KEY }}
63+
bot_id: "YOUR_GITHUB_USER_ID"
64+
bot_name: "YOUR_GITHUB_USERNAME"
65+
```
66+
67+
Commits will show as verified and attributed to the GitHub account that owns the signing key.
68+
69+
**Setup steps:**
70+
71+
1. Generate an SSH key pair for signing:
72+
73+
```bash
74+
ssh-keygen -t ed25519 -f ~/.ssh/signing_key -N "" -C "commit signing key"
75+
```
76+
77+
2. Add the **public key** to your GitHub account:
78+
79+
- Go to GitHub → Settings → SSH and GPG keys
80+
- Click "New SSH key"
81+
- Select **Key type: Signing Key** (important)
82+
- Paste the contents of `~/.ssh/signing_key.pub`
83+
84+
3. Add the **private key** to your repository secrets:
85+
86+
- Go to your repo → Settings → Secrets and variables → Actions
87+
- Create a new secret named `SSH_SIGNING_KEY`
88+
- Paste the contents of `~/.ssh/signing_key`
89+
90+
4. Get your GitHub user ID:
91+
92+
```bash
93+
gh api users/YOUR_USERNAME --jq '.id'
94+
```
95+
96+
5. Update your workflow with `bot_id` and `bot_name` matching the account where you added the signing key.
97+
98+
**Note:** If both `ssh_signing_key` and `use_commit_signing` are provided, `ssh_signing_key` takes precedence.
4299

43100
## ⚠️ Authentication Protection
44101

docs/usage.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,10 @@ jobs:
7171
| `branch_prefix` | The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format) | No | `claude/` |
7272
| `settings` | Claude Code settings as JSON string or path to settings JSON file | No | "" |
7373
| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" |
74-
| `use_commit_signing` | Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands | No | `false` |
75-
| `bot_id` | GitHub user ID to use for git operations (defaults to Claude's bot ID) | No | `41898282` |
76-
| `bot_name` | GitHub username to use for git operations (defaults to Claude's bot name) | No | `claude[bot]` |
74+
| `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` |
75+
| `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 | "" |
76+
| `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` |
77+
| `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]` |
7778
| `allowed_bots` | Comma-separated list of allowed bot usernames, or '\*' to allow all bots. Empty string (default) allows no bots | No | "" |
7879
| `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 | "" |
7980
| `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 | "" |
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/usr/bin/env bun
2+
3+
/**
4+
* Cleanup SSH signing key after action completes
5+
* This is run as a post step for security purposes
6+
*/
7+
8+
import { cleanupSshSigning } from "../github/operations/git-config";
9+
10+
async function run() {
11+
try {
12+
await cleanupSshSigning();
13+
} catch (error) {
14+
// Don't fail the action if cleanup fails, just log it
15+
console.error("Failed to cleanup SSH signing key:", error);
16+
}
17+
}
18+
19+
if (import.meta.main) {
20+
run();
21+
}

src/entrypoints/collect-inputs.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export function collectActionInputsPresence(): void {
2626
max_turns: "",
2727
use_sticky_comment: "false",
2828
use_commit_signing: "false",
29+
ssh_signing_key: "",
2930
};
3031

3132
const allInputsJson = process.env.ALL_INPUTS;

src/github/context.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ type BaseContext = {
9090
branchPrefix: string;
9191
useStickyComment: boolean;
9292
useCommitSigning: boolean;
93+
sshSigningKey: string;
9394
botId: string;
9495
botName: string;
9596
allowedBots: string;
@@ -146,6 +147,7 @@ export function parseGitHubContext(): GitHubContext {
146147
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
147148
useStickyComment: process.env.USE_STICKY_COMMENT === "true",
148149
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
150+
sshSigningKey: process.env.SSH_SIGNING_KEY || "",
149151
botId: process.env.BOT_ID ?? String(CLAUDE_APP_BOT_ID),
150152
botName: process.env.BOT_NAME ?? CLAUDE_BOT_LOGIN,
151153
allowedBots: process.env.ALLOWED_BOTS ?? "",

src/github/operations/git-config.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,14 @@
66
*/
77

88
import { $ } from "bun";
9+
import { mkdir, writeFile, rm } from "fs/promises";
10+
import { join } from "path";
11+
import { homedir } from "os";
912
import type { GitHubContext } from "../context";
1013
import { GITHUB_SERVER_URL } from "../api/config";
1114

15+
const SSH_SIGNING_KEY_PATH = join(homedir(), ".ssh", "claude_signing_key");
16+
1217
type GitUser = {
1318
login: string;
1419
id: number;
@@ -54,3 +59,50 @@ export async function configureGitAuth(
5459

5560
console.log("Git authentication configured successfully");
5661
}
62+
63+
/**
64+
* Configure git to use SSH signing for commits
65+
* This is an alternative to GitHub API-based commit signing (use_commit_signing)
66+
*/
67+
export async function setupSshSigning(sshSigningKey: string): Promise<void> {
68+
console.log("Configuring SSH signing for commits...");
69+
70+
// Validate SSH key format
71+
if (!sshSigningKey.trim()) {
72+
throw new Error("SSH signing key cannot be empty");
73+
}
74+
if (
75+
!sshSigningKey.includes("BEGIN") ||
76+
!sshSigningKey.includes("PRIVATE KEY")
77+
) {
78+
throw new Error("Invalid SSH private key format");
79+
}
80+
81+
// Create .ssh directory with secure permissions (700)
82+
const sshDir = join(homedir(), ".ssh");
83+
await mkdir(sshDir, { recursive: true, mode: 0o700 });
84+
85+
// Write the signing key atomically with secure permissions (600)
86+
await writeFile(SSH_SIGNING_KEY_PATH, sshSigningKey, { mode: 0o600 });
87+
console.log(`✓ SSH signing key written to ${SSH_SIGNING_KEY_PATH}`);
88+
89+
// Configure git to use SSH signing
90+
await $`git config gpg.format ssh`;
91+
await $`git config user.signingkey ${SSH_SIGNING_KEY_PATH}`;
92+
await $`git config commit.gpgsign true`;
93+
94+
console.log("✓ Git configured to use SSH signing for commits");
95+
}
96+
97+
/**
98+
* Clean up the SSH signing key file
99+
* Should be called in the post step for security
100+
*/
101+
export async function cleanupSshSigning(): Promise<void> {
102+
try {
103+
await rm(SSH_SIGNING_KEY_PATH, { force: true });
104+
console.log("✓ SSH signing key cleaned up");
105+
} catch (error) {
106+
console.log("No SSH signing key to clean up");
107+
}
108+
}

src/modes/agent/index.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import type { Mode, ModeOptions, ModeResult } from "../types";
44
import type { PreparedContext } from "../../create-prompt/types";
55
import { prepareMcpConfig } from "../../mcp/install-mcp-server";
66
import { parseAllowedTools } from "./parse-tools";
7-
import { configureGitAuth } from "../../github/operations/git-config";
7+
import {
8+
configureGitAuth,
9+
setupSshSigning,
10+
} from "../../github/operations/git-config";
811
import type { GitHubContext } from "../../github/context";
912
import { isEntityContext } from "../../github/context";
1013

@@ -79,7 +82,27 @@ export const agentMode: Mode = {
7982

8083
async prepare({ context, githubToken }: ModeOptions): Promise<ModeResult> {
8184
// Configure git authentication for agent mode (same as tag mode)
82-
if (!context.inputs.useCommitSigning) {
85+
// SSH signing takes precedence if provided
86+
const useSshSigning = !!context.inputs.sshSigningKey;
87+
const useApiCommitSigning =
88+
context.inputs.useCommitSigning && !useSshSigning;
89+
90+
if (useSshSigning) {
91+
// Setup SSH signing for commits
92+
await setupSshSigning(context.inputs.sshSigningKey);
93+
94+
// Still configure git auth for push operations (user/email and remote URL)
95+
const user = {
96+
login: context.inputs.botName,
97+
id: parseInt(context.inputs.botId),
98+
};
99+
try {
100+
await configureGitAuth(githubToken, context, user);
101+
} catch (error) {
102+
console.error("Failed to configure git authentication:", error);
103+
// Continue anyway - git operations may still work with default config
104+
}
105+
} else if (!useApiCommitSigning) {
83106
// Use bot_id and bot_name from inputs directly
84107
const user = {
85108
login: context.inputs.botName,

src/modes/tag/index.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { checkContainsTrigger } from "../../github/validation/trigger";
44
import { checkHumanActor } from "../../github/validation/actor";
55
import { createInitialComment } from "../../github/operations/comments/create-initial";
66
import { setupBranch } from "../../github/operations/branch";
7-
import { configureGitAuth } from "../../github/operations/git-config";
7+
import {
8+
configureGitAuth,
9+
setupSshSigning,
10+
} from "../../github/operations/git-config";
811
import { prepareMcpConfig } from "../../mcp/install-mcp-server";
912
import {
1013
fetchGitHubData,
@@ -88,8 +91,28 @@ export const tagMode: Mode = {
8891
// Setup branch
8992
const branchInfo = await setupBranch(octokit, githubData, context);
9093

91-
// Configure git authentication if not using commit signing
92-
if (!context.inputs.useCommitSigning) {
94+
// Configure git authentication
95+
// SSH signing takes precedence if provided
96+
const useSshSigning = !!context.inputs.sshSigningKey;
97+
const useApiCommitSigning =
98+
context.inputs.useCommitSigning && !useSshSigning;
99+
100+
if (useSshSigning) {
101+
// Setup SSH signing for commits
102+
await setupSshSigning(context.inputs.sshSigningKey);
103+
104+
// Still configure git auth for push operations (user/email and remote URL)
105+
const user = {
106+
login: context.inputs.botName,
107+
id: parseInt(context.inputs.botId),
108+
};
109+
try {
110+
await configureGitAuth(githubToken, context, user);
111+
} catch (error) {
112+
console.error("Failed to configure git authentication:", error);
113+
throw error;
114+
}
115+
} else if (!useApiCommitSigning) {
93116
// Use bot_id and bot_name from inputs directly
94117
const user = {
95118
login: context.inputs.botName,
@@ -135,8 +158,9 @@ export const tagMode: Mode = {
135158
...userAllowedMCPTools,
136159
];
137160

138-
// Add git commands when not using commit signing
139-
if (!context.inputs.useCommitSigning) {
161+
// Add git commands when using git CLI (no API commit signing, or SSH signing)
162+
// SSH signing still uses git CLI, just with signing enabled
163+
if (!useApiCommitSigning) {
140164
tagModeTools.push(
141165
"Bash(git add:*)",
142166
"Bash(git commit:*)",
@@ -147,7 +171,7 @@ export const tagMode: Mode = {
147171
"Bash(git rm:*)",
148172
);
149173
} else {
150-
// When using commit signing, use MCP file ops tools
174+
// When using API commit signing, use MCP file ops tools
151175
tagModeTools.push(
152176
"mcp__github_file_ops__commit_files",
153177
"mcp__github_file_ops__delete_files",

test/install-mcp-server.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ describe("prepareMcpConfig", () => {
3232
branchPrefix: "",
3333
useStickyComment: false,
3434
useCommitSigning: false,
35+
sshSigningKey: "",
3536
botId: String(CLAUDE_APP_BOT_ID),
3637
botName: CLAUDE_BOT_LOGIN,
3738
allowedBots: "",

0 commit comments

Comments
 (0)