mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-22 14:24:13 +08:00
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
This commit is contained in:
11
action.yml
11
action.yml
@@ -81,6 +81,10 @@ inputs:
|
|||||||
description: "Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands"
|
description: "Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands"
|
||||||
required: false
|
required: false
|
||||||
default: "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:
|
bot_id:
|
||||||
description: "GitHub user ID to use for git operations (defaults to Claude's bot ID)"
|
description: "GitHub user ID to use for git operations (defaults to Claude's bot ID)"
|
||||||
required: false
|
required: false
|
||||||
@@ -181,6 +185,7 @@ runs:
|
|||||||
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
|
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
|
||||||
DEFAULT_WORKFLOW_TOKEN: ${{ github.token }}
|
DEFAULT_WORKFLOW_TOKEN: ${{ github.token }}
|
||||||
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
|
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
|
||||||
|
SSH_SIGNING_KEY: ${{ inputs.ssh_signing_key }}
|
||||||
BOT_ID: ${{ inputs.bot_id }}
|
BOT_ID: ${{ inputs.bot_id }}
|
||||||
BOT_NAME: ${{ inputs.bot_name }}
|
BOT_NAME: ${{ inputs.bot_name }}
|
||||||
TRACK_PROGRESS: ${{ inputs.track_progress }}
|
TRACK_PROGRESS: ${{ inputs.track_progress }}
|
||||||
@@ -334,6 +339,12 @@ runs:
|
|||||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
fi
|
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
|
- name: Revoke app token
|
||||||
if: always() && inputs.github_token == '' && steps.prepare.outputs.skipped_due_to_workflow_validation_mismatch != 'true'
|
if: always() && inputs.github_token == '' && steps.prepare.outputs.skipped_due_to_workflow_validation_mismatch != 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -38,7 +38,64 @@ The following permissions are requested but not yet actively used. These will en
|
|||||||
|
|
||||||
## Commit Signing
|
## 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
|
## ⚠️ Authentication Protection
|
||||||
|
|
||||||
|
|||||||
@@ -71,9 +71,10 @@ jobs:
|
|||||||
| `branch_prefix` | The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format) | No | `claude/` |
|
| `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 | "" |
|
| `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 | "" |
|
| `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` |
|
| `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` |
|
||||||
| `bot_id` | GitHub user ID to use for git operations (defaults to Claude's bot ID) | No | `41898282` |
|
| `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_name` | GitHub username to use for git operations (defaults to Claude's bot name) | No | `claude[bot]` |
|
| `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_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 | "" |
|
| `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 | "" |
|
| `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 | "" |
|
||||||
|
|||||||
21
src/entrypoints/cleanup-ssh-signing.ts
Normal file
21
src/entrypoints/cleanup-ssh-signing.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ export function collectActionInputsPresence(): void {
|
|||||||
max_turns: "",
|
max_turns: "",
|
||||||
use_sticky_comment: "false",
|
use_sticky_comment: "false",
|
||||||
use_commit_signing: "false",
|
use_commit_signing: "false",
|
||||||
|
ssh_signing_key: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const allInputsJson = process.env.ALL_INPUTS;
|
const allInputsJson = process.env.ALL_INPUTS;
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ type BaseContext = {
|
|||||||
branchPrefix: string;
|
branchPrefix: string;
|
||||||
useStickyComment: boolean;
|
useStickyComment: boolean;
|
||||||
useCommitSigning: boolean;
|
useCommitSigning: boolean;
|
||||||
|
sshSigningKey: string;
|
||||||
botId: string;
|
botId: string;
|
||||||
botName: string;
|
botName: string;
|
||||||
allowedBots: string;
|
allowedBots: string;
|
||||||
@@ -146,6 +147,7 @@ export function parseGitHubContext(): GitHubContext {
|
|||||||
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
|
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
|
||||||
useStickyComment: process.env.USE_STICKY_COMMENT === "true",
|
useStickyComment: process.env.USE_STICKY_COMMENT === "true",
|
||||||
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
|
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
|
||||||
|
sshSigningKey: process.env.SSH_SIGNING_KEY || "",
|
||||||
botId: process.env.BOT_ID ?? String(CLAUDE_APP_BOT_ID),
|
botId: process.env.BOT_ID ?? String(CLAUDE_APP_BOT_ID),
|
||||||
botName: process.env.BOT_NAME ?? CLAUDE_BOT_LOGIN,
|
botName: process.env.BOT_NAME ?? CLAUDE_BOT_LOGIN,
|
||||||
allowedBots: process.env.ALLOWED_BOTS ?? "",
|
allowedBots: process.env.ALLOWED_BOTS ?? "",
|
||||||
|
|||||||
@@ -6,9 +6,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { $ } from "bun";
|
import { $ } from "bun";
|
||||||
|
import { mkdir, writeFile, rm } from "fs/promises";
|
||||||
|
import { join } from "path";
|
||||||
|
import { homedir } from "os";
|
||||||
import type { GitHubContext } from "../context";
|
import type { GitHubContext } from "../context";
|
||||||
import { GITHUB_SERVER_URL } from "../api/config";
|
import { GITHUB_SERVER_URL } from "../api/config";
|
||||||
|
|
||||||
|
const SSH_SIGNING_KEY_PATH = join(homedir(), ".ssh", "claude_signing_key");
|
||||||
|
|
||||||
type GitUser = {
|
type GitUser = {
|
||||||
login: string;
|
login: string;
|
||||||
id: number;
|
id: number;
|
||||||
@@ -54,3 +59,50 @@ export async function configureGitAuth(
|
|||||||
|
|
||||||
console.log("Git authentication configured successfully");
|
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> {
|
||||||
|
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`;
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import type { Mode, ModeOptions, ModeResult } from "../types";
|
|||||||
import type { PreparedContext } from "../../create-prompt/types";
|
import type { PreparedContext } from "../../create-prompt/types";
|
||||||
import { prepareMcpConfig } from "../../mcp/install-mcp-server";
|
import { prepareMcpConfig } from "../../mcp/install-mcp-server";
|
||||||
import { parseAllowedTools } from "./parse-tools";
|
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 type { GitHubContext } from "../../github/context";
|
||||||
import { isEntityContext } from "../../github/context";
|
import { isEntityContext } from "../../github/context";
|
||||||
|
|
||||||
@@ -79,7 +82,27 @@ export const agentMode: Mode = {
|
|||||||
|
|
||||||
async prepare({ context, githubToken }: ModeOptions): Promise<ModeResult> {
|
async prepare({ context, githubToken }: ModeOptions): Promise<ModeResult> {
|
||||||
// Configure git authentication for agent mode (same as tag mode)
|
// 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;
|
||||||
|
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);
|
||||||
|
// Continue anyway - git operations may still work with default config
|
||||||
|
}
|
||||||
|
} else if (!useApiCommitSigning) {
|
||||||
// Use bot_id and bot_name from inputs directly
|
// Use bot_id and bot_name from inputs directly
|
||||||
const user = {
|
const user = {
|
||||||
login: context.inputs.botName,
|
login: context.inputs.botName,
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { checkContainsTrigger } from "../../github/validation/trigger";
|
|||||||
import { checkHumanActor } from "../../github/validation/actor";
|
import { checkHumanActor } from "../../github/validation/actor";
|
||||||
import { createInitialComment } from "../../github/operations/comments/create-initial";
|
import { createInitialComment } from "../../github/operations/comments/create-initial";
|
||||||
import { setupBranch } from "../../github/operations/branch";
|
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 { prepareMcpConfig } from "../../mcp/install-mcp-server";
|
||||||
import {
|
import {
|
||||||
fetchGitHubData,
|
fetchGitHubData,
|
||||||
@@ -88,8 +91,28 @@ export const tagMode: Mode = {
|
|||||||
// Setup branch
|
// Setup branch
|
||||||
const branchInfo = await setupBranch(octokit, githubData, context);
|
const branchInfo = await setupBranch(octokit, githubData, context);
|
||||||
|
|
||||||
// Configure git authentication if not using commit signing
|
// Configure git authentication
|
||||||
if (!context.inputs.useCommitSigning) {
|
// 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
|
// Use bot_id and bot_name from inputs directly
|
||||||
const user = {
|
const user = {
|
||||||
login: context.inputs.botName,
|
login: context.inputs.botName,
|
||||||
@@ -135,8 +158,9 @@ export const tagMode: Mode = {
|
|||||||
...userAllowedMCPTools,
|
...userAllowedMCPTools,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add git commands when not using commit signing
|
// Add git commands when using git CLI (no API commit signing, or SSH signing)
|
||||||
if (!context.inputs.useCommitSigning) {
|
// SSH signing still uses git CLI, just with signing enabled
|
||||||
|
if (!useApiCommitSigning) {
|
||||||
tagModeTools.push(
|
tagModeTools.push(
|
||||||
"Bash(git add:*)",
|
"Bash(git add:*)",
|
||||||
"Bash(git commit:*)",
|
"Bash(git commit:*)",
|
||||||
@@ -147,7 +171,7 @@ export const tagMode: Mode = {
|
|||||||
"Bash(git rm:*)",
|
"Bash(git rm:*)",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// When using commit signing, use MCP file ops tools
|
// When using API commit signing, use MCP file ops tools
|
||||||
tagModeTools.push(
|
tagModeTools.push(
|
||||||
"mcp__github_file_ops__commit_files",
|
"mcp__github_file_ops__commit_files",
|
||||||
"mcp__github_file_ops__delete_files",
|
"mcp__github_file_ops__delete_files",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
branchPrefix: "",
|
branchPrefix: "",
|
||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
useCommitSigning: false,
|
useCommitSigning: false,
|
||||||
|
sshSigningKey: "",
|
||||||
botId: String(CLAUDE_APP_BOT_ID),
|
botId: String(CLAUDE_APP_BOT_ID),
|
||||||
botName: CLAUDE_BOT_LOGIN,
|
botName: CLAUDE_BOT_LOGIN,
|
||||||
allowedBots: "",
|
allowedBots: "",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const defaultInputs = {
|
|||||||
branchPrefix: "claude/",
|
branchPrefix: "claude/",
|
||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
useCommitSigning: false,
|
useCommitSigning: false,
|
||||||
|
sshSigningKey: "",
|
||||||
botId: String(CLAUDE_APP_BOT_ID),
|
botId: String(CLAUDE_APP_BOT_ID),
|
||||||
botName: CLAUDE_BOT_LOGIN,
|
botName: CLAUDE_BOT_LOGIN,
|
||||||
allowedBots: "",
|
allowedBots: "",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ describe("detectMode with enhanced routing", () => {
|
|||||||
branchPrefix: "claude/",
|
branchPrefix: "claude/",
|
||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
useCommitSigning: false,
|
useCommitSigning: false,
|
||||||
|
sshSigningKey: "",
|
||||||
botId: "123456",
|
botId: "123456",
|
||||||
botName: "claude-bot",
|
botName: "claude-bot",
|
||||||
allowedBots: "",
|
allowedBots: "",
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ describe("checkWritePermissions", () => {
|
|||||||
branchPrefix: "claude/",
|
branchPrefix: "claude/",
|
||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
useCommitSigning: false,
|
useCommitSigning: false,
|
||||||
|
sshSigningKey: "",
|
||||||
botId: String(CLAUDE_APP_BOT_ID),
|
botId: String(CLAUDE_APP_BOT_ID),
|
||||||
botName: CLAUDE_BOT_LOGIN,
|
botName: CLAUDE_BOT_LOGIN,
|
||||||
allowedBots: "",
|
allowedBots: "",
|
||||||
|
|||||||
250
test/ssh-signing.test.ts
Normal file
250
test/ssh-signing.test.ts
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
import {
|
||||||
|
describe,
|
||||||
|
test,
|
||||||
|
expect,
|
||||||
|
afterEach,
|
||||||
|
beforeAll,
|
||||||
|
afterAll,
|
||||||
|
} from "bun:test";
|
||||||
|
import { mkdir, writeFile, rm, readFile, stat } from "fs/promises";
|
||||||
|
import { join } from "path";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
|
||||||
|
describe("SSH Signing", () => {
|
||||||
|
// Use a temp directory for tests
|
||||||
|
const testTmpDir = join(tmpdir(), "claude-ssh-signing-test");
|
||||||
|
const testSshDir = join(testTmpDir, ".ssh");
|
||||||
|
const testKeyPath = join(testSshDir, "claude_signing_key");
|
||||||
|
const testKey =
|
||||||
|
"-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key-content\n-----END OPENSSH PRIVATE KEY-----";
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await mkdir(testTmpDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await rm(testTmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// Clean up test key if it exists
|
||||||
|
try {
|
||||||
|
await rm(testKeyPath, { force: true });
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setupSshSigning file operations", () => {
|
||||||
|
test("should write key file atomically with correct permissions", async () => {
|
||||||
|
// Create the directory with secure permissions (same as setupSshSigning does)
|
||||||
|
await mkdir(testSshDir, { recursive: true, mode: 0o700 });
|
||||||
|
|
||||||
|
// Write key atomically with proper permissions (same as setupSshSigning does)
|
||||||
|
await writeFile(testKeyPath, testKey, { mode: 0o600 });
|
||||||
|
|
||||||
|
// Verify key was written
|
||||||
|
const keyContent = await readFile(testKeyPath, "utf-8");
|
||||||
|
expect(keyContent).toBe(testKey);
|
||||||
|
|
||||||
|
// Verify permissions (0o600 = 384 in decimal for permission bits only)
|
||||||
|
const stats = await stat(testKeyPath);
|
||||||
|
const permissions = stats.mode & 0o777; // Get only permission bits
|
||||||
|
expect(permissions).toBe(0o600);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should create .ssh directory with secure permissions", async () => {
|
||||||
|
// Clean up first
|
||||||
|
await rm(testSshDir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
// Create directory with secure permissions (same as setupSshSigning does)
|
||||||
|
await mkdir(testSshDir, { recursive: true, mode: 0o700 });
|
||||||
|
|
||||||
|
// Verify directory exists
|
||||||
|
const dirStats = await stat(testSshDir);
|
||||||
|
expect(dirStats.isDirectory()).toBe(true);
|
||||||
|
|
||||||
|
// Verify directory permissions
|
||||||
|
const dirPermissions = dirStats.mode & 0o777;
|
||||||
|
expect(dirPermissions).toBe(0o700);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setupSshSigning validation", () => {
|
||||||
|
test("should reject empty SSH key", () => {
|
||||||
|
const emptyKey = "";
|
||||||
|
expect(() => {
|
||||||
|
if (!emptyKey.trim()) {
|
||||||
|
throw new Error("SSH signing key cannot be empty");
|
||||||
|
}
|
||||||
|
}).toThrow("SSH signing key cannot be empty");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reject whitespace-only SSH key", () => {
|
||||||
|
const whitespaceKey = " \n\t ";
|
||||||
|
expect(() => {
|
||||||
|
if (!whitespaceKey.trim()) {
|
||||||
|
throw new Error("SSH signing key cannot be empty");
|
||||||
|
}
|
||||||
|
}).toThrow("SSH signing key cannot be empty");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reject invalid SSH key format", () => {
|
||||||
|
const invalidKey = "not a valid key";
|
||||||
|
expect(() => {
|
||||||
|
if (
|
||||||
|
!invalidKey.includes("BEGIN") ||
|
||||||
|
!invalidKey.includes("PRIVATE KEY")
|
||||||
|
) {
|
||||||
|
throw new Error("Invalid SSH private key format");
|
||||||
|
}
|
||||||
|
}).toThrow("Invalid SSH private key format");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should accept valid SSH key format", () => {
|
||||||
|
const validKey =
|
||||||
|
"-----BEGIN OPENSSH PRIVATE KEY-----\nkey-content\n-----END OPENSSH PRIVATE KEY-----";
|
||||||
|
expect(() => {
|
||||||
|
if (!validKey.trim()) {
|
||||||
|
throw new Error("SSH signing key cannot be empty");
|
||||||
|
}
|
||||||
|
if (!validKey.includes("BEGIN") || !validKey.includes("PRIVATE KEY")) {
|
||||||
|
throw new Error("Invalid SSH private key format");
|
||||||
|
}
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cleanupSshSigning file operations", () => {
|
||||||
|
test("should remove the signing key file", async () => {
|
||||||
|
// Create the key file first
|
||||||
|
await mkdir(testSshDir, { recursive: true });
|
||||||
|
await writeFile(testKeyPath, testKey, { mode: 0o600 });
|
||||||
|
|
||||||
|
// Verify it exists
|
||||||
|
const existsBefore = await stat(testKeyPath)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
expect(existsBefore).toBe(true);
|
||||||
|
|
||||||
|
// Clean up (same operation as cleanupSshSigning)
|
||||||
|
await rm(testKeyPath, { force: true });
|
||||||
|
|
||||||
|
// Verify it's gone
|
||||||
|
const existsAfter = await stat(testKeyPath)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
expect(existsAfter).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not throw if key file does not exist", async () => {
|
||||||
|
// Make sure file doesn't exist
|
||||||
|
await rm(testKeyPath, { force: true });
|
||||||
|
|
||||||
|
// Should not throw (rm with force: true doesn't throw on missing files)
|
||||||
|
await expect(rm(testKeyPath, { force: true })).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SSH Signing Mode Detection", () => {
|
||||||
|
test("sshSigningKey should take precedence over useCommitSigning", () => {
|
||||||
|
// When both are set, SSH signing takes precedence
|
||||||
|
const sshSigningKey = "test-key";
|
||||||
|
const useCommitSigning = true;
|
||||||
|
|
||||||
|
const useSshSigning = !!sshSigningKey;
|
||||||
|
const useApiCommitSigning = useCommitSigning && !useSshSigning;
|
||||||
|
|
||||||
|
expect(useSshSigning).toBe(true);
|
||||||
|
expect(useApiCommitSigning).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("useCommitSigning should work when sshSigningKey is not set", () => {
|
||||||
|
const sshSigningKey = "";
|
||||||
|
const useCommitSigning = true;
|
||||||
|
|
||||||
|
const useSshSigning = !!sshSigningKey;
|
||||||
|
const useApiCommitSigning = useCommitSigning && !useSshSigning;
|
||||||
|
|
||||||
|
expect(useSshSigning).toBe(false);
|
||||||
|
expect(useApiCommitSigning).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("neither signing method when both are false/empty", () => {
|
||||||
|
const sshSigningKey = "";
|
||||||
|
const useCommitSigning = false;
|
||||||
|
|
||||||
|
const useSshSigning = !!sshSigningKey;
|
||||||
|
const useApiCommitSigning = useCommitSigning && !useSshSigning;
|
||||||
|
|
||||||
|
expect(useSshSigning).toBe(false);
|
||||||
|
expect(useApiCommitSigning).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("git CLI tools should be used when sshSigningKey is set", () => {
|
||||||
|
// This tests the logic in tag mode for tool selection
|
||||||
|
const sshSigningKey = "test-key";
|
||||||
|
const useCommitSigning = true; // Even if this is true
|
||||||
|
|
||||||
|
const useSshSigning = !!sshSigningKey;
|
||||||
|
const useApiCommitSigning = useCommitSigning && !useSshSigning;
|
||||||
|
|
||||||
|
// When SSH signing is used, we should use git CLI (not API)
|
||||||
|
const shouldUseGitCli = !useApiCommitSigning;
|
||||||
|
expect(shouldUseGitCli).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MCP file ops should only be used with API commit signing", () => {
|
||||||
|
// Case 1: API commit signing
|
||||||
|
{
|
||||||
|
const sshSigningKey = "";
|
||||||
|
const useCommitSigning = true;
|
||||||
|
|
||||||
|
const useSshSigning = !!sshSigningKey;
|
||||||
|
const useApiCommitSigning = useCommitSigning && !useSshSigning;
|
||||||
|
|
||||||
|
expect(useApiCommitSigning).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 2: SSH signing (should NOT use API)
|
||||||
|
{
|
||||||
|
const sshSigningKey = "test-key";
|
||||||
|
const useCommitSigning = true;
|
||||||
|
|
||||||
|
const useSshSigning = !!sshSigningKey;
|
||||||
|
const useApiCommitSigning = useCommitSigning && !useSshSigning;
|
||||||
|
|
||||||
|
expect(useApiCommitSigning).toBe(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 3: No signing (should NOT use API)
|
||||||
|
{
|
||||||
|
const sshSigningKey = "";
|
||||||
|
const useCommitSigning = false;
|
||||||
|
|
||||||
|
const useSshSigning = !!sshSigningKey;
|
||||||
|
const useApiCommitSigning = useCommitSigning && !useSshSigning;
|
||||||
|
|
||||||
|
expect(useApiCommitSigning).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Context parsing", () => {
|
||||||
|
test("sshSigningKey should be parsed from environment", () => {
|
||||||
|
// Test that context.ts parses SSH_SIGNING_KEY correctly
|
||||||
|
const testCases = [
|
||||||
|
{ env: "test-key", expected: "test-key" },
|
||||||
|
{ env: "", expected: "" },
|
||||||
|
{ env: undefined, expected: "" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { env, expected } of testCases) {
|
||||||
|
const result = env || "";
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user