SLOPSHOPPER

git-sign-key

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

guardprocess
★ 1v1.2.0MITupdated 2026-10-19kwitsch/claude-plugins/plugins/git-sign-key
A shopper browsing a rack in a slop shop
README

git-sign-key

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

Install

/plugin install git-sign-key@kwitsch-plugins

What it does

  • A hook on the Bash tool (an in-process mods 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).

  • When ~/.claude/sign.key does not exist, commands are left completely untouched (no signing config is injected).
  • A session start hook warns you at the start of a session when the key file is missing (with the setup steps below), and also when an existing key looks passphrase-encrypted — which would make every commit fail (see below).

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).

Setting up the signing key

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.

Option A — create a dedicated signing key (recommended)

# 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.

Option B — reuse an existing key

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).

Register the public key for the "Verified" badge

Add ~/.claude/sign.key.pub to your Git host as a signing key (not just an auth key):

  • GitHub: Settings → SSH and GPG keys → New SSH key → Key type: Signing Key.
  • GitLab: Preferences → SSH Keys → add the key with Usage type: Signing.

Verifying signatures locally (optional)

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

Alternative SSH agents (1Password, Secretive, …)

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).

Security notes

  • ~/.claude/sign.key is an unencrypted private key — protect it with `chmod

600` and never commit it to a repository.

  • Anyone with read access to the file can sign commits as you. Use a dedicated key you can revoke independently from your auth keys.
  • While the key file exists, the commits the hook rewrites are auto-allowed (a 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.

Notes & limitations

  • The hook forces 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.
  • It rewrites every real 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.
  • Not rewritten (they fall back to your normal git setup): shell aliases like 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 …)").
  • If another plugin also rewrites Bash commands, the rewrites nest: each sees the command as the one above it passed it on, so the two can still conflict — install only one command-rewriting hook per tool.
Source 2 files
hooks/register.ts 77 lines
1import 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};
77
hooks/sign.mjs 239 lines
1#!/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