From 7e4bf87b1c28d519b842e6b1b02c031f99983d09 Mon Sep 17 00:00:00 2001 From: Ashwin Bhat Date: Fri, 2 Jan 2026 10:37:25 -0800 Subject: [PATCH] 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 --- action.yml | 11 ++ docs/security.md | 59 +++++- docs/usage.md | 7 +- src/entrypoints/cleanup-ssh-signing.ts | 21 +++ src/entrypoints/collect-inputs.ts | 1 + src/github/context.ts | 2 + src/github/operations/git-config.ts | 52 +++++ src/modes/agent/index.ts | 27 ++- src/modes/tag/index.ts | 36 +++- test/install-mcp-server.test.ts | 1 + test/mockContext.ts | 1 + test/modes/detector.test.ts | 1 + test/permissions.test.ts | 1 + test/ssh-signing.test.ts | 250 +++++++++++++++++++++++++ 14 files changed, 458 insertions(+), 12 deletions(-) create mode 100644 src/entrypoints/cleanup-ssh-signing.ts create mode 100644 test/ssh-signing.test.ts diff --git a/action.yml b/action.yml index e0ce364..f4f8fed 100644 --- a/action.yml +++ b/action.yml @@ -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 @@ -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 }} @@ -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 diff --git a/docs/security.md b/docs/security.md index ace3530..802c7f5 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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 diff --git a/docs/usage.md b/docs/usage.md index 96f44ed..3e55a3d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -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 | "" | diff --git a/src/entrypoints/cleanup-ssh-signing.ts b/src/entrypoints/cleanup-ssh-signing.ts new file mode 100644 index 0000000..d65b437 --- /dev/null +++ b/src/entrypoints/cleanup-ssh-signing.ts @@ -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(); +} diff --git a/src/entrypoints/collect-inputs.ts b/src/entrypoints/collect-inputs.ts index 6974e34..0d240a6 100644 --- a/src/entrypoints/collect-inputs.ts +++ b/src/entrypoints/collect-inputs.ts @@ -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; diff --git a/src/github/context.ts b/src/github/context.ts index 90fba9d..b971aee 100644 --- a/src/github/context.ts +++ b/src/github/context.ts @@ -90,6 +90,7 @@ type BaseContext = { branchPrefix: string; useStickyComment: boolean; useCommitSigning: boolean; + sshSigningKey: string; botId: string; botName: string; allowedBots: string; @@ -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 ?? "", diff --git a/src/github/operations/git-config.ts b/src/github/operations/git-config.ts index 8244e95..733744f 100644 --- a/src/github/operations/git-config.ts +++ b/src/github/operations/git-config.ts @@ -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; @@ -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 { + 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 { + 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"); + } +} diff --git a/src/modes/agent/index.ts b/src/modes/agent/index.ts index 4bcd4aa..1b992a7 100644 --- a/src/modes/agent/index.ts +++ b/src/modes/agent/index.ts @@ -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"; @@ -79,7 +82,27 @@ export const agentMode: Mode = { async prepare({ context, githubToken }: ModeOptions): Promise { // 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 const user = { login: context.inputs.botName, diff --git a/src/modes/tag/index.ts b/src/modes/tag/index.ts index be7df09..f82337e 100644 --- a/src/modes/tag/index.ts +++ b/src/modes/tag/index.ts @@ -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, @@ -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, @@ -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:*)", @@ -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", diff --git a/test/install-mcp-server.test.ts b/test/install-mcp-server.test.ts index 4943be1..a50d46f 100644 --- a/test/install-mcp-server.test.ts +++ b/test/install-mcp-server.test.ts @@ -32,6 +32,7 @@ describe("prepareMcpConfig", () => { branchPrefix: "", useStickyComment: false, useCommitSigning: false, + sshSigningKey: "", botId: String(CLAUDE_APP_BOT_ID), botName: CLAUDE_BOT_LOGIN, allowedBots: "", diff --git a/test/mockContext.ts b/test/mockContext.ts index 5fb6761..1a4983b 100644 --- a/test/mockContext.ts +++ b/test/mockContext.ts @@ -20,6 +20,7 @@ const defaultInputs = { branchPrefix: "claude/", useStickyComment: false, useCommitSigning: false, + sshSigningKey: "", botId: String(CLAUDE_APP_BOT_ID), botName: CLAUDE_BOT_LOGIN, allowedBots: "", diff --git a/test/modes/detector.test.ts b/test/modes/detector.test.ts index 199f094..c539b80 100644 --- a/test/modes/detector.test.ts +++ b/test/modes/detector.test.ts @@ -20,6 +20,7 @@ describe("detectMode with enhanced routing", () => { branchPrefix: "claude/", useStickyComment: false, useCommitSigning: false, + sshSigningKey: "", botId: "123456", botName: "claude-bot", allowedBots: "", diff --git a/test/permissions.test.ts b/test/permissions.test.ts index 5048bb4..557f7ca 100644 --- a/test/permissions.test.ts +++ b/test/permissions.test.ts @@ -68,6 +68,7 @@ describe("checkWritePermissions", () => { branchPrefix: "claude/", useStickyComment: false, useCommitSigning: false, + sshSigningKey: "", botId: String(CLAUDE_APP_BOT_ID), botName: CLAUDE_BOT_LOGIN, allowedBots: "", diff --git a/test/ssh-signing.test.ts b/test/ssh-signing.test.ts new file mode 100644 index 0000000..ffb02ae --- /dev/null +++ b/test/ssh-signing.test.ts @@ -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); + } + }); +});