Signs git commits with a ~/.claude/sign.key file via SSH signing instead of the ssh-agent.

Signs your git commits with a key file at ~/.claude/sign.key via SSH signing, instead of relying on the ssh-agent.
/plugin install git-sign-key@kwitsch-plugins
tool.call hook — no process is spawned per Bash call) watches for git commit commands. When ~/.claude/sign.key exists, it rewrites the command in place to: git -c gpg.format=ssh -c gpg.ssh.program=ssh-keygen -c user.signingkey='/home/you/.claude/sign.key' -c commit.gpgsign=true commit …
(the hook injects the absolute, $HOME-expanded path, not a literal ~) so the commit is SSH-signed by reading the private key directly from the file. Pinning gpg.ssh.program=ssh-keygen forces git's built-in signer, so both the ssh-agent and any custom signing program are bypassed — see Alternative SSH agents below. Every git commit in the command is rewritten (compound a && b commands and git -C <path> commit / git --no-pager commit included).
~/.claude/sign.key does not exist, commands are left completely untouched (no signing config is injected).The rewrite is best-effort and never blocks a commit: if the command can't be parsed or the rewrite would break shell syntax, the original command runs unchanged. Note, though, that the rewrite injects commit.gpgsign=true, so once the key is present the commit itself will fail (non-zero, no commit created) if signing can't complete — e.g. the key is passphrase-protected, unreadable, or your git/ssh-keygen is too old for SSH signing. Keep the key unencrypted and readable (see below).
The plugin expects an unencrypted SSH private key at ~/.claude/sign.key. "Unencrypted" matters: file-based signing bypasses the agent, so a passphrase would block every commit with an interactive prompt.
# Generate a new ed25519 key with no passphrase, written to ~/.claude/sign.key
ssh-keygen -t ed25519 -C "your_email@example.com" -f ~/.claude/sign.key -N ""
# Lock down the private key
chmod 600 ~/.claude/sign.key
This produces two files:
~/.claude/sign.key — the private key the plugin signs with.~/.claude/sign.key.pub — the public key you register with your Git host.Copy an existing unencrypted private key into place:
cp ~/.ssh/id_ed25519 ~/.claude/sign.key
cp ~/.ssh/id_ed25519.pub ~/.claude/sign.key.pub # optional but recommended
chmod 600 ~/.claude/sign.key
If the source key has a passphrase, strip it for this purpose with ssh-keygen -p -f ~/.claude/sign.key -N "" (understand the security tradeoff first).
Add ~/.claude/sign.key.pub to your Git host as a signing key (not just an auth key):
To let git log --show-signature verify your own commits, add the key to an allowed-signers file:
git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers
printf '%s %s\n' "your_email@example.com" "$(cat ~/.claude/sign.key.pub)" \
>> ~/.config/git/allowed_signers
If your global git config points gpg.ssh.program at a custom signer — most commonly 1Password (gpg.ssh.program = …/op-ssh-sign, including the Windows/WSL wrappers), but also other agent-backed helpers — git would normally hand the signing request to that program. Those programs read keys only from their own store (e.g. the 1Password vault), not from a file path on disk, so without intervention the plugin's on-disk key would be rejected and — because commit.gpgsign=true is forced — every commit would fail.
To stay robust, the hook also injects -c gpg.ssh.program=ssh-keygen, which pins git's built-in signer for the rewritten commit. ssh-keygen reads the private key straight from ~/.claude/sign.key, so signing works regardless of what gpg.ssh.program is set to globally. Your normal config is untouched — only the single rewritten git commit runs with the override.
Consequence to be aware of: while the key file is present, commits the hook rewrites are signed with that file's key, not with your 1Password/Secretive/ Secure-Enclave key. If you want commits signed by your agent-held key, remove ~/.claude/sign.key (the hook then leaves commands untouched and your normal setup signs them).
~/.claude/sign.key is an unencrypted private key — protect it with `chmod600` and never commit it to a repository.
tool.check hook turns the permission prompt into allow for exactly those calls), so those git commit commands run without the usual Bash permission prompt. A deny or ask rule in your settings still wins. Remove the key file to restore the normal prompt.commit.gpgsign=true for the rewritten command, so the commits it touches are signed while the key is present — unless you pass --no-gpg-sign (or -c commit.gpgsign=false after it), which git honours over the injected config. Repos where you deliberately disabled signing are also overridden while the key file exists.git commit, including compound commands (a && b) and $(…)/backtick substitutions, while leaving a git commit that appears inside a quoted argument or commit message untouched.gc, options that take a space-separated value such as git --git-dir /path commit, and a real commit nested inside a double-quoted command substitution ("$(… git commit …)").hooks/register.ts 77 lines1import type { EngineInterface, Register } from "claude-code";
2import { keyPath, probeArgv, rewriteCommand, rewriteNote, sessionWarning } from "./sign.mjs";
3
4// tool_use_ids whose command this module rewrote; tool.check auto-allows exactly those (the old hook's permissionDecision allow).
5const rewritten = new Set<string>();
6
7// Whether `path` is a regular file (`[ -f ]`: links followed); any error means no.
8async function isFile($: EngineInterface, path: string): Promise<boolean> {
9 return $.fs.stat(path).then(
10 (s) => s.kind === "file",
11 () => false,
12 );
13}
14
15// The key path when ~/.claude/sign.key is a regular file, else null.
16async function presentKey($: EngineInterface): Promise<string | null> {
17 const keyFile = keyPath(await $.env.get("HOME"));
18 return (await isFile($, keyFile)) ? keyFile : null;
19}
20
21// The signed form of a Bash command, or null to leave it untouched. Fails open: any error is null.
22async function signedCommand($: EngineInterface, command: string): Promise<{ command: string; keyFile: string } | null> {
23 try {
24 if (command === "") return null;
25 const keyFile = await presentKey($);
26 if (keyFile === null) return null;
27 const signed = rewriteCommand(command, keyFile);
28 if (signed === command) return null;
29 // Safety net: a rewrite that breaks shell syntax is dropped.
30 const check = await $.process.run(["bash", "-n"], { stdin: signed, timeoutMs: 10_000 });
31 return check.exitCode === 0 ? { command: signed, keyFile } : null;
32 } catch {
33 return null;
34 }
35}
36
37// The SessionStart warning, or null. ssh-keygen that cannot run counts as "cannot tell" (silent).
38async function startWarning($: EngineInterface): Promise<string | null> {
39 const keyFile = keyPath(await $.env.get("HOME"));
40 if (!(await isFile($, keyFile))) return sessionWarning(false, null);
41 const probe = await $.process.run(probeArgv(keyFile), { stdin: "", timeoutMs: 10_000 }).catch(() => null);
42 return sessionWarning(true, probe === null ? null : probe.stderr);
43}
44
45export const register: Register = (on) => {
46 on("tool.call", { tool: "Bash" }, async ($, e, next) => {
47 if (e.tool !== "Bash" || typeof e.command !== "string") return next(e);
48 const plan = await signedCommand($, e.command);
49 if (plan === null) return next(e);
50 const id = e.tool_use_id;
51 if (id !== undefined) rewritten.add(id);
52 try {
53 const r = await next({ ...e, command: plan.command });
54 return "deny" in r && r.deny !== undefined ? r : { ...r, context: [...(r.context ?? []), rewriteNote(plan.keyFile)] };
55 } finally {
56 if (id !== undefined) rewritten.delete(id);
57 }
58 });
59
60 // A rewritten commit runs without the Bash permission prompt, as before; a deny from the rules still stands.
61 on("tool.check", { tool: "Bash" }, async (_$, e, next) => {
62 const r = await next(e);
63 // Only the mode's default ask: an ask from a settings rule, a settings hook or an option's own check still prompts.
64 const modeAsk = r.decision === "ask" && r.rule === undefined && r.hook === undefined && r.setting === undefined;
65 return e.tool_use_id !== undefined && rewritten.has(e.tool_use_id) && modeAsk ? { ...r, decision: "allow" as const, reason: "git-sign-key rewrote this git commit for file-based SSH signing" } : r;
66 });
67
68 on("session.start", async ($, e, next) => {
69 const r = await next(e);
70 // A reload, enable or worker respawn mid-session fires session.start again: the warning is already in the transcript.
71 if ((await $.session.turns().catch(() => 0)) > 0) return r;
72 const text = await startWarning($).catch(() => null);
73 if (text !== null) await $.session.append({ message: { type: "user", content: [{ type: "text", text }] } });
74 return r;
75 });
76};
77hooks/sign.mjs 239 lines1#!/usr/bin/env node
2// Pure logic for the git-sign-key mods module (register.ts): the quote-aware
3// `git commit` rewrite and the SessionStart warning texts. Zero-dep, no `$`.
4
5/**
6 * Path of the signing key under a home directory (`$HOME`, possibly unset).
7 * @param {string | undefined} home
8 * @returns {string}
9 */
10export const keyPath = (home) => `${home ?? ""}/.claude/sign.key`;
11
12/**
13 * The signing config injected right after the `git` token of each commit.
14 * gpg.ssh.program=ssh-keygen forces the built-in signer, so a custom signer
15 * (1Password op-ssh-sign, ...) never receives an on-disk key path it cannot read.
16 * The path is single-quoted so spaces survive word-splitting.
17 * @param {string} keyFile
18 * @returns {string}
19 */
20export const signFlags = (keyFile) => `-c gpg.format=ssh -c gpg.ssh.program=ssh-keygen -c user.signingkey='${keyFile}' -c commit.gpgsign=true`;
21
22/**
23 * The additionalContext the model reads after a rewritten call.
24 * @param {string} keyFile
25 * @returns {string}
26 */
27export const rewriteNote = (keyFile) => `git-sign-key: routed commit signing through ${keyFile} (file-based SSH signing via ssh-keygen; ssh-agent and any custom gpg.ssh.program bypassed).`;
28
29const CMD_START = new Set([";", "&", "|", "(", "{", "`", "\n"]);
30const COMMIT_END = new Set([" ", "\t", "\n", ";", "&", "|", ")", "}", "`", "<", ">"]);
31const TOKEN_END = new Set([" ", "\t", ";", "&", "|", "(", "{", "`", "\n", "<", ">"]);
32const ASSIGN = /[A-Za-z_][A-Za-z0-9_]*=/y;
33
34/**
35 * Index just past the blanks (space/tab) starting at `j`.
36 * @param {string} s
37 * @param {number} j
38 * @returns {number}
39 */
40function skipBlanks(s, j) {
41 while (j < s.length && (s[j] === " " || s[j] === "\t")) j++;
42 return j;
43}
44
45/**
46 * Index just past the non-whitespace word starting at `j`.
47 * @param {string} s
48 * @param {number} j
49 * @returns {number}
50 */
51function wordEnd(s, j) {
52 while (j < s.length && s[j] !== " " && s[j] !== "\t" && s[j] !== "\n") j++;
53 return j;
54}
55
56/**
57 * Walks from just after a `git` token over global options (-C <path> / -c k=v
58 * consume a value; --long[=v] does not) to the subcommand.
59 * @param {string} s
60 * @param {number} j
61 * @param {string} marker our own signing-key flag, for idempotency
62 * @returns {{ commit: boolean, already: boolean }}
63 */
64function commitInvocation(s, j, marker) {
65 let already = false;
66 const no = { commit: false, already };
67 while (j < s.length) {
68 j = skipBlanks(s, j);
69 if (j >= s.length || s[j] === "\n") return no;
70 if (s.startsWith("commit", j)) {
71 const after = s[j + 6];
72 return { commit: after === undefined || COMMIT_END.has(after), already };
73 }
74 if (s[j] !== "-") return no;
75 let k = wordEnd(s, j);
76 const tok = s.slice(j, k);
77 // bare '-' / '--' are not global options that introduce a subcommand
78 if (tok === "-" || tok === "--") return no;
79 if (tok.includes(marker)) already = true;
80 j = k;
81 if (tok === "-c" || tok === "-C") {
82 j = skipBlanks(s, j);
83 k = wordEnd(s, j);
84 if (s.slice(j, k).includes(marker)) already = true;
85 j = k;
86 }
87 }
88 return no;
89}
90
91/**
92 * Index just past one quote-aware shell word starting at `i` (steps over a
93 * `NAME=value` assignment prefix whose value may be quoted).
94 * @param {string} s
95 * @param {number} i
96 * @returns {number}
97 */
98function skipToken(s, i) {
99 /** @type {"none" | "single" | "double"} */
100 let q = "none";
101 let esc = false;
102 for (; i < s.length; i++) {
103 const c = s[i];
104 if (q === "single") {
105 if (c === "'") q = "none";
106 } else if (q === "double") {
107 if (esc) esc = false;
108 else if (c === "\\") esc = true;
109 else if (c === '"') q = "none";
110 } else if (esc) esc = false;
111 else if (c === "'") q = "single";
112 else if (c === '"') q = "double";
113 else if (c === "\\") esc = true;
114 else if (TOKEN_END.has(c)) break;
115 }
116 return i;
117}
118
119/**
120 * Inserts the signing flags after the `git` token of every commit invocation at
121 * an UNQUOTED command position (quote/backslash state tracked, so a separator
122 * inside a quoted argument is never a command position). A commit that already
123 * carries our signing-key flag is left as is.
124 * @param {string} command the Bash command
125 * @param {string} keyFile absolute key path
126 * @returns {string} the rewritten command (=== command when nothing changed)
127 */
128export function rewriteCommand(command, keyFile) {
129 const s = command;
130 const flags = signFlags(keyFile);
131 const marker = `user.signingkey='${keyFile}'`;
132 let out = "";
133 let start = 0;
134 /** @type {"none" | "single" | "double"} */
135 let q = "none";
136 let esc = false;
137 let cmdPos = true;
138 let i = 0;
139 while (i < s.length) {
140 const c = s[i];
141 if (q === "single") {
142 if (c === "'") q = "none";
143 i++;
144 continue;
145 }
146 if (q === "double") {
147 if (esc) esc = false;
148 else if (c === "\\") esc = true;
149 else if (c === '"') q = "none";
150 i++;
151 continue;
152 }
153 if (esc) {
154 esc = false;
155 cmdPos = false;
156 i++;
157 continue;
158 }
159 if (c === "'" || c === '"' || c === "\\") {
160 if (c === "'") q = "single";
161 else if (c === '"') q = "double";
162 else esc = true;
163 cmdPos = false;
164 i++;
165 continue;
166 }
167 if (c === " " || c === "\t") {
168 i++; // blanks keep cmdPos so `sep git` matches
169 continue;
170 }
171 if (CMD_START.has(c)) {
172 cmdPos = true;
173 i++;
174 continue;
175 }
176 if (cmdPos) {
177 // An env-var assignment prefix keeps the following word at a command position.
178 ASSIGN.lastIndex = i;
179 if (ASSIGN.test(s)) {
180 i = skipToken(s, i);
181 continue;
182 }
183 // rtk (linux-token-efficiency's proxy) is a transparent wrapper: `rtk git commit` is still a commit.
184 if (s.startsWith("rtk", i) && (s[i + 3] === " " || s[i + 3] === "\t")) {
185 i += 3;
186 continue;
187 }
188 if (s.startsWith("git", i) && (s[i + 3] === " " || s[i + 3] === "\t")) {
189 const inv = commitInvocation(s, i + 3, marker);
190 if (inv.commit) {
191 if (!inv.already) {
192 out += `${s.slice(start, i + 3)} ${flags}`;
193 start = i + 3;
194 }
195 i += 3;
196 cmdPos = false;
197 continue;
198 }
199 }
200 }
201 cmdPos = false;
202 i++;
203 }
204 return out + s.slice(start);
205}
206
207export const MISSING_KEY_WARNING =
208 "⚠️ git-sign-key: no signing key found at ~/.claude/sign.key. Git commits will NOT be rewritten for file-based SSH signing — " +
209 "they fall back to your normal git/ssh-agent setup. Surface this to the user and explain how to enable it: " +
210 '1) ssh-keygen -t ed25519 -C "your_email" -f ~/.claude/sign.key -N "" 2) chmod 600 ~/.claude/sign.key ' +
211 "3) add ~/.claude/sign.key.pub to your Git host as a SIGNING key (GitHub: Settings -> SSH and GPG keys -> New SSH key -> Key type: Signing Key). " +
212 "See the git-sign-key plugin README for full setup and signature verification.";
213
214export const UNUSABLE_KEY_WARNING =
215 "⚠️ git-sign-key: ~/.claude/sign.key cannot be used for non-interactive signing — " +
216 "it is either passphrase-encrypted or has unsafe file permissions. File-based SSH signing bypasses the ssh-agent, so every git commit will FAIL (the hook forces commit.gpgsign=true). " +
217 'Make sure the key is unencrypted (ssh-keygen -p -f ~/.claude/sign.key -N "", understand the security tradeoff) ' +
218 "and locked down (chmod 600 ~/.claude/sign.key). See the git-sign-key plugin README.";
219
220/**
221 * argv that probes whether the key signs non-interactively (empty passphrase).
222 * @param {string} keyFile
223 * @returns {string[]}
224 */
225export const probeArgv = (keyFile) => ["ssh-keygen", "-y", "-f", keyFile, "-P", ""];
226
227/**
228 * The SessionStart warning, or null when silent. Warns only on positive
229 * ssh-keygen stderr signals, so a dummy/invalid key file stays silent.
230 * @param {boolean} keyIsFile whether ~/.claude/sign.key is a regular file
231 * @param {string | null} probeStderr ssh-keygen's stderr, null when it could not run
232 * @returns {string | null}
233 */
234export function sessionWarning(keyIsFile, probeStderr) {
235 if (!keyIsFile) return MISSING_KEY_WARNING;
236 if (probeStderr !== null && /passphrase|decrypt|ermission|too open/.test(probeStderr)) return UNUSABLE_KEY_WARNING;
237 return null;
238}
239