Signing commits from inside Claude Code kept failing on my Ubuntu-on-WSL setup:

error: gpg failed to sign the data
error: unable to sign the tag
gpg: signing failed: No pinentry

Running the same git commit -S by hand in my terminal worked. That gap is the whole story.

Why it happens

Claude Code executes shell commands in a non-interactive shell with no controlling TTY — the terminal is owned by the session UI, not by the command. GPG_TTY evaluates to not a tty there.

A terminal-based pinentry (pinentry-curses, pinentry-tty) has to draw its passphrase prompt onto a TTY. With none available, gpg either hangs forever or gives up with No pinentry. This isn’t specific to gpg: sudo prompts, ssh key passphrases, and git rebase -i fail the same way for the same reason.

It’s intermittent, which makes it confusing. gpg-agent caches your passphrase for default-cache-ttl seconds (600 by default), so signing succeeds while the cache is warm and fails the moment a prompt becomes necessary. I had a commit sign fine at 00:25 and a tag refuse at 00:37.

The fix: prompt outside the terminal

Install Gpg4win on the Windows side and point WSL’s gpg-agent at its pinentry. A Windows GUI dialog renders on the desktop — out-of-band, needing no TTY at all — so signing works from a non-interactive shell.

~/.gnupg/gpg-agent.conf:

pinentry-program /mnt/c/Program Files/Gpg4win/bin/pinentry.exe

Then restart the agent:

gpgconf --kill gpg-agent

Use --kill, not --reload — a changed pinentry-program isn’t picked up on reload. The agent restarts on the next gpg call.

Verify:

echo test | gpg --clearsign -u <YOUR_KEY_ID> > /dev/null

A Windows passphrase dialog should pop up. After that, git commit -S and git tag -s work from inside Claude Code.

Gotchas

  • Spaces in the path need no quoting or escaping. gpg-agent takes the entire rest of the line as the filename.
  • Check the path still exists after a Gpg4win upgrade. Mine broke when Gpg4win moved into the 64-bit tree: my config still pointed at C:\Program Files (x86)\GnuPG\bin\pinentry.exe, which had been emptied out, while the working binary was at C:\Program Files\Gpg4win\bin\pinentry.exe.
  • pinentry-w32.exe sits in the same directory as a fallback if the Qt build misbehaves across the WSL boundary.
  • Diagnose without hanging:

    echo test | gpg --batch --pinentry-mode error --clearsign -u <KEY>   # fails fast
    gpg-connect-agent 'keyinfo --list' /bye                              # is the key cached?
    
  • Raise the cache if a commit-then-tag sequence re-prompts:

    default-cache-ttl 3600
    max-cache-ttl 7200
    
  • export GPG_TTY=$(tty) in your shell rc matters only for terminal pinentry. Harmless to keep, irrelevant to this route.

Alternatives I passed on

Unlock in a real terminal, then let the agent work within the cache window. Fragile — it expires mid-session, which is exactly what bit me.

SSH-format signing (git config gpg.format ssh with an ssh-agent-held key) sidesteps pinentry entirely, but changes how verification appears on GitHub.

The GUI route is the only one that’s unconditionally safe from a non-interactive shell.


discussion