Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions plugins/gpg-pinentry-guard/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "gpg-pinentry-guard",
"version": "1.0.0",
"description": "PreToolUse hook that prevents broken GPG pinentry prompts by detecting when git signing would launch a terminal-based pinentry that conflicts with Claude Code's terminal control"
}
75 changes: 75 additions & 0 deletions plugins/gpg-pinentry-guard/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# gpg-pinentry-guard

A Claude Code plugin that prevents broken GPG pinentry prompts during git commits.

## The Problem

When `commit.gpgsign=true` is configured and the GPG passphrase is not cached, `git commit` triggers `gpg-agent` which spawns a pinentry program to prompt for the passphrase. Terminal-based pinentry variants (`pinentry-curses`, `pinentry-tty`) open `/dev/tty` directly to read input.

Claude Code's Ink renderer holds exclusive control of the terminal's keyboard input. When pinentry tries to read from the same terminal, keystrokes are captured by Claude Code instead of pinentry, resulting in:

- Garbled input in the pinentry prompt
- `gpg: signing failed: No passphrase given`
- A broken commit that wastes time

## What This Plugin Does

This plugin installs a **PreToolUse hook** on the Bash tool that:

1. Detects git commands that trigger GPG signing (`git commit`, `git tag -s`, `git merge -S`)
2. Checks if GPG signing is enabled via config (`commit.gpgsign`, `tag.gpgsign`, `merge.gpgsign`)
3. Checks if the pinentry program is terminal-based (skips for GUI pinentry)
4. Checks if the passphrase is already cached in `gpg-agent` (skips if cached)
5. **Blocks the command** with actionable guidance if a broken pinentry prompt would occur

## Installation

Install as a Claude Code plugin:

```bash
claude plugin add /path/to/gpg-pinentry-guard
```

Or copy to your plugins directory:

```bash
cp -r gpg-pinentry-guard ~/.claude/plugins/
```

## Workarounds (Without This Plugin)

If you prefer not to install this plugin, you can work around the issue:

### Cache passphrase before starting Claude Code

```bash
echo "test" | gpg --clearsign > /dev/null
```

### Switch to a GUI pinentry

```bash
# ~/.gnupg/gpg-agent.conf
pinentry-program /usr/bin/pinentry-gnome3
```

Then reload: `gpgconf --reload gpg-agent`

### Increase cache timeout

```bash
# ~/.gnupg/gpg-agent.conf
default-cache-ttl 86400
max-cache-ttl 86400
```

Then reload: `gpgconf --reload gpg-agent`

## Requirements

- `git`, `gpg`, `jq` (standard on most systems)
- `gpg-connect-agent` (part of GnuPG)

## Limitations

This plugin is a **workaround**, not a fix. The underlying issue is that Claude Code's terminal renderer does not release keyboard control for interactive subprocesses. A proper fix requires changes to Claude Code's Bash tool to temporarily pause the Ink renderer when a subprocess needs terminal access.
167 changes: 167 additions & 0 deletions plugins/gpg-pinentry-guard/hooks/gpg_signing_guard.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
#!/usr/bin/env bash
# gpg_signing_guard.sh — PreToolUse hook for Claude Code
#
# Detects git commands that would trigger GPG signing with a terminal-based
# pinentry program. Since Claude Code's Ink renderer holds exclusive control
# of the terminal, pinentry-curses/pinentry-tty cannot read user input,
# causing "No passphrase given" failures.
#
# This hook blocks such commands early and provides actionable guidance.
#
# Exit codes:
# 0 — Allow the command (not a signing command, passphrase cached, GUI pinentry, etc.)
# 2 — Block the command (terminal pinentry would fail)
#
# Known limitations:
# - Git aliases (e.g., `git ci` -> `commit --no-gpg-sign`) are not resolved
# - Inline config (`git -c commit.gpgsign=true commit`) bypasses config check

set -euo pipefail

# Read PreToolUse JSON from stdin
input=$(cat)

# Only process Bash tool calls
tool_name=$(printf '%s' "$input" | jq -r '.tool_name // empty')
if [[ "$tool_name" != "Bash" ]]; then
exit 0
fi

# Extract the command
command=$(printf '%s' "$input" | jq -r '.tool_input.command // empty')
if [[ -z "$command" ]]; then
exit 0
fi

# Step 1: Is this a git command that triggers signing?
# Uses \b word boundaries and .* to handle compound commands (&&, ||, ;),
# env prefixes, git flags (--no-pager, -C, -c), full paths (/usr/bin/git), etc.
signing_type=""
if printf '%s\n' "$command" | grep -qE '\bgit\b.*\bcommit\b'; then
signing_type="commit"
elif printf '%s\n' "$command" | grep -qE '\bgit\b.*\btag\b'; then
signing_type="tag"
elif printf '%s\n' "$command" | grep -qE '\bgit\b.*\bmerge\b'; then
signing_type="merge"
fi

if [[ -z "$signing_type" ]]; then
exit 0
fi

# Step 2: Does the command already have --no-gpg-sign?
if printf '%s\n' "$command" | grep -qE -- '--no-gpg-sign'; then
exit 0
fi

# Step 3: Is GPG signing enabled for this command type?
cwd=$(printf '%s' "$input" | jq -r '.cwd // "."')

# Check for explicit signing flags in the command
has_explicit_sign=false
if printf '%s\n' "$command" | grep -qE -- '(\s|^)(-S|--gpg-sign)\b'; then
has_explicit_sign=true
fi

# Check the relevant git config for the signing type
gpgsign_config="false"
case "$signing_type" in
commit)
gpgsign_config=$(git -C "$cwd" config --get commit.gpgsign 2>/dev/null || echo "false")
;;
tag)
# git tag signs with -s/--sign explicitly, or tag.gpgsign for annotated tags
if printf '%s\n' "$command" | grep -qE -- '(\s|^)(-s|--sign)\b'; then
has_explicit_sign=true
else
gpgsign_config=$(git -C "$cwd" config --get tag.gpgsign 2>/dev/null || echo "false")
fi
;;
merge)
gpgsign_config=$(git -C "$cwd" config --get merge.gpgsign 2>/dev/null || echo "false")
;;
esac

if [[ "$gpgsign_config" != "true" && "$has_explicit_sign" != "true" ]]; then
exit 0
fi

# Step 4: Determine the pinentry program
pinentry_program=""

# Try gpg-agent.conf first (last matching line wins, matching GnuPG behavior)
gpg_agent_conf="${GNUPGHOME:-$HOME/.gnupg}/gpg-agent.conf"
if [[ -f "$gpg_agent_conf" ]]; then
pinentry_program=$(grep -E '^\s*pinentry-program\s+' "$gpg_agent_conf" 2>/dev/null \
| tail -1 | awk '{print $2}' || true)
fi

# Fall back to system default
if [[ -z "$pinentry_program" ]]; then
pinentry_program=$(command -v pinentry 2>/dev/null || echo "pinentry")
# Resolve symlinks to find the actual binary
if [[ -L "$pinentry_program" ]]; then
pinentry_program=$(readlink -f "$pinentry_program" 2>/dev/null || echo "$pinentry_program")
fi
fi

# Step 5: Is it a GUI pinentry? (GUI pinentry opens its own window — no conflict)
pinentry_basename=$(basename "$pinentry_program" 2>/dev/null || echo "")
case "$pinentry_basename" in
pinentry-gnome3|pinentry-gtk*|pinentry-qt*|pinentry-mac|pinentry-wsl|pinentry-x11)
exit 0
;;
esac

# Step 6: Is the passphrase already cached in gpg-agent?
# gpg-connect-agent KEYINFO requires a keygrip (40-hex-char), not a key ID.
signing_key=$(git -C "$cwd" config --get user.signingkey 2>/dev/null || echo "")
if [[ -n "$signing_key" ]]; then
# Prefer signing subkey keygrip ([S] capability), fall back to primary key
keygrip=$(gpg --with-keygrip --list-secret-keys "$signing_key" 2>/dev/null \
| grep -B1 'Keygrip' | grep -A1 '\[S\]' | grep 'Keygrip' | head -1 \
| awk -F= '{print $2}' | tr -d ' ' || true)

if [[ -z "$keygrip" ]]; then
keygrip=$(gpg --with-keygrip --list-secret-keys "$signing_key" 2>/dev/null \
| grep -A2 '^\s*sec' | grep 'Keygrip' | head -1 \
| awk -F= '{print $2}' | tr -d ' ' || true)
fi

if [[ -n "$keygrip" ]]; then
cache_status=$(gpg-connect-agent "KEYINFO --no-ask $keygrip ERR" /bye 2>/dev/null || echo "")
if printf '%s\n' "$cache_status" | grep -qE '^S KEYINFO\s+\S+\s+\S+\s+1'; then
exit 0
fi
fi
fi

# All checks failed — this command will trigger a broken terminal pinentry.
# Block with exit 2 and provide guidance.

cat >&2 <<'BLOCK_MESSAGE'
GPG signing blocked: terminal pinentry conflict detected.

Claude Code's terminal renderer holds exclusive control of keyboard input.
When git triggers GPG signing, pinentry-curses/pinentry-tty cannot read
your passphrase, causing a "No passphrase given" failure.

To fix this, do ONE of the following:

1. Cache your passphrase first (run in a separate terminal):
echo "test" | gpg --clearsign > /dev/null
Then retry the commit in Claude Code.

2. Use --no-gpg-sign to skip signing for this commit:
git commit --no-gpg-sign -m "your message"

3. Switch to a GUI pinentry (permanent fix):
echo "pinentry-program /usr/bin/pinentry-gnome3" >> ~/.gnupg/gpg-agent.conf
gpgconf --reload gpg-agent

4. Increase gpg-agent cache timeout in ~/.gnupg/gpg-agent.conf:
default-cache-ttl 86400
max-cache-ttl 86400
BLOCK_MESSAGE

exit 2
17 changes: 17 additions & 0 deletions plugins/gpg-pinentry-guard/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"description": "Intercepts git commands that would trigger GPG signing with a terminal-based pinentry, blocking them before they cause a broken interactive prompt",
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/gpg_signing_guard.sh",
"timeout": 15
}
]
}
]
}
}