Conflict between `claude` console screen management and GPG `pinentry` during secret key passphrase prompt for Git commit signing.
After Claude runs a shell tool call with git commit -m <some message> and GPG pinentry tries to take over the console screen for secure passphrase entry, the claude program keeps refreshing the screen causing an endless flicker on the console as the two programs fight.
Not sure what the best solution here is. Might have to warn people about this edge case. Could also check the Git clone's .git/config to see if signing is enabled, but that is probably overgeneralized, since some people will be using keychain managers which might not exhibit this problem. Unfortunately, keychain manager integration is neither default nor particularly mature on some systems.
Another approach would be for Claude to add --no-gpg-sign to the command. The user could always amend the commit with a signature after the fact.
Would you mind recording a video of what this looks like?
@bcherny : Please see https://asciinema.org/a/nUbgVegTDwbAu36b3RaiDj1EV starting at the 2:34 mark. Of course, Asciinema captures each refresh as a separate frame, but, if I made an actual video, this would appear as the console screen continuously flickering. After I supplied a keyboard interrupt (CTRL+C), you can see how pinentry still has the terminal in noecho mode and is echoing a * for each of my keystrokes.
Below is a simple curses-based Rogue-like which Claude and I put together and tested in Claude Code. You can use it as a partial reproducer for some of the various raw terminal IO-related issues which have been filed (this one and possibly #268). If you run the code directly outside of Claude Code, you will see that it has no problem (screen renders and character can move around in response to key strokes). But, if you try to get to Claude to run it or if you execute it as a shell command within Claude Code, you will see a stack trace and the terminal will be somewhat horked until you reset it.
Not a perfect reproducer for the GPG pinentry issue or the Pacman issue, but you may find it useful for debugging them. (The ultimate verdict may be that there is not a good way to run other programs which need to perform raw terminal I/O from inside Claude Code.)
#!/usr/bin/env python3
import curses
import random
from typing import Tuple, List
class Entity:
def __init__(self, x: int, y: int, char: str, color=None):
self.x = x
self.y = y
self.char = char
self.color = color
class Game:
def __init__(self, width: int = 80, height: int = 24):
self.width = width
self.height = height
self.map = self._generate_map()
self.player = Entity(width // 2, height // 2, '@')
self.message = "Welcome to PyRogue!"
self.running = True
def _generate_map(self) -> List[List[str]]:
"""Generate a simple dungeon map."""
# Initialize with walls
dungeon = [['#' for _ in range(self.width)] for _ in range(self.height)]
# Create some random rooms and corridors
num_rooms = random.randint(5, 10)
rooms = []
for _ in range(num_rooms):
room_width = random.randint(5, 10)
room_height = random.randint(5, 8)
x = random.randint(1, self.width - room_width - 1)
y = random.randint(1, self.height - room_height - 1)
# Don't allow overlapping rooms
overlaps = False
for r in rooms:
rx, ry, rw, rh = r
if (x < rx + rw and x + room_width > rx and
y < ry + rh and y + room_height > ry):
overlaps = True
break
if not overlaps:
rooms.append((x, y, room_width, room_height))
# Fill room with floor tiles
for dy in range(room_height):
for dx in range(room_width):
dungeon[y + dy][x + dx] = '.'
# Connect rooms with corridors
for i in range(len(rooms) - 1):
start_x, start_y = rooms[i][0] + rooms[i][2] // 2, rooms[i][1] + rooms[i][3] // 2
end_x, end_y = rooms[i + 1][0] + rooms[i + 1][2] // 2, rooms[i + 1][1] + rooms[i + 1][3] // 2
# Horizontal then vertical
if random.random() < 0.5:
self._create_h_tunnel(dungeon, start_x, end_x, start_y)
self._create_v_tunnel(dungeon, start_y, end_y, end_x)
else:
self._create_v_tunnel(dungeon, start_y, end_y, start_x)
self._create_h_tunnel(dungeon, start_x, end_x, end_y)
return dungeon
def _create_h_tunnel(self, dungeon: List[List[str]], x1: int, x2: int, y: int) -> None:
"""Create a horizontal tunnel between x1 and x2 at height y."""
for x in range(min(x1, x2), max(x1, x2) + 1):
dungeon[y][x] = '.'
def _create_v_tunnel(self, dungeon: List[List[str]], y1: int, y2: int, x: int) -> None:
"""Create a vertical tunnel between y1 and y2 at width x."""
for y in range(min(y1, y2), max(y1, y2) + 1):
dungeon[y][x] = '.'
def is_wall(self, x: int, y: int) -> bool:
"""Check if the given position is a wall."""
# Check bounds
if x < 0 or y < 0 or x >= self.width or y >= self.height:
return True
return self.map[y][x] == '#'
def move_player(self, dx: int, dy: int) -> None:
"""Move the player by the given amount if not blocked."""
new_x, new_y = self.player.x + dx, self.player.y + dy
if not self.is_wall(new_x, new_y):
self.player.x, self.player.y = new_x, new_y
self.message = f"Moving to ({new_x}, {new_y})"
else:
self.message = "You bump into a wall."
def handle_input(self, key: int) -> None:
"""Handle player input."""
# Vi-like / Roguelike keys
if key == ord('h'): # Left
self.move_player(-1, 0)
elif key == ord('j'): # Down
self.move_player(0, 1)
elif key == ord('k'): # Up
self.move_player(0, -1)
elif key == ord('l'): # Right
self.move_player(1, 0)
elif key == ord('y'): # Up-left
self.move_player(-1, -1)
elif key == ord('u'): # Up-right
self.move_player(1, -1)
elif key == ord('b'): # Down-left
self.move_player(-1, 1)
elif key == ord('n'): # Down-right
self.move_player(1, 1)
elif key == ord('q'): # Quit
self.running = False
self.message = "Quitting the game..."
def render(stdscr, game: Game) -> None:
"""Render the game state to the screen."""
stdscr.clear()
# Draw the map
for y in range(game.height):
for x in range(game.width):
try:
stdscr.addch(y, x, game.map[y][x])
except curses.error:
pass # Ignore errors from writing to the bottom-right corner
# Draw the player
try:
stdscr.addch(game.player.y, game.player.x, game.player.char)
except curses.error:
pass
# Draw the message
stdscr.addstr(game.height, 0, game.message)
# Draw help text
help_text = "Movement: h(left) j(down) k(up) l(right) y(up-left) u(up-right) b(down-left) n(down-right) | q(quit)"
stdscr.addstr(game.height + 1, 0, help_text)
stdscr.refresh()
def main(stdscr) -> None:
# Setup
curses.curs_set(0) # Hide cursor
stdscr.clear()
game = Game()
# Place player in a valid position
while game.is_wall(game.player.x, game.player.y):
game.player.x = random.randint(1, game.width - 2)
game.player.y = random.randint(1, game.height - 2)
# Game loop
while game.running:
render(stdscr, game)
key = stdscr.getch()
game.handle_input(key)
if __name__ == "__main__":
try:
curses.wrapper(main)
except KeyboardInterrupt:
print("Game terminated by user.")
Fwiw, I do not encounter this problem when using Aider; it correctly relinquishes the console to pinentry. I suspect that Claude Code is not saving the console screen state and then relinquishing it before attempting to run shell commands. If you are using a Curses-based package for managing the screen in Claude Code, the following references might be useful:
- https://pubs.opengroup.org/onlinepubs/7908799/xcurses/def_prog_mode.html
- https://docs.oracle.com/cd/E88353_01/html/E37849/endwin-3xcurses.html
Workaround: SSH Signing as Alternative
I encountered this issue and found that switching to SSH signing works as a practical workaround for those who can use it.
Alternative Approach: SSH Signing
For Git 2.34+, SSH signing can serve as an alternative that avoids the pinentry conflict:
# Switch to SSH signing
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true
Trade-offs
- ✅ Avoids the screen conflict with Claude Code
- ✅ Uses existing SSH infrastructure
- ✅ Works well in devcontainers
- ⚠️ Different from GPG signing (may not be suitable for all use cases)
- ⚠️ Requires Git 2.34+ and GitHub/GitLab support
GitHub Integration
To get "Verified" badges on GitHub:
- Go to GitHub Settings > SSH and GPG keys
- Add your SSH public key with Key type: "Signing Key"
- Note: You may need to add the same key twice - once for Authentication and once for Signing
Verification
Test that it's working:
# Check configuration
git config --list | grep -E "(gpg|sign)"
# Make a test commit
git commit -m "Test SSH signing" --allow-empty
git log --show-signature -1
This approach provides a workaround for the specific Claude Code + pinentry conflict, though it doesn't address the underlying screen management issue. It might be helpful for users who can switch to SSH signing in their workflow.
Thanks for mentioning this, @JichouP. Last time I looked at the Github docs on signing (years ago), only GPG and S/MIME were available. One thing to note about SSH signing is that trust verification works differently on the local machine; unlike GPG, you do not automatically get verification via a private key from which the public key was derived. Unfortunately, the Github documentation does not mention how to set this up and the Github maintainers closed an issue about it. However, the Gitlab documentation does address local signature verification.
For those reading this and are wondering what the difference is:
- SSH private keys are registered, via
ssh-add, withssh-agent, which retains them for the lifetime of the process (possibly the uptime of the machine). By contrast,gpg-agentcaches GPG private keys for 10 minutes, by default. So, if you add your password-protected SSH private key to the agent before Claude attempts to make a commit, it should be smooth sailing. - As long as you trust agentic Claude to not snoop on the private keys via the socket referenced in the
SSH_AUTH_SOCKenvironment variable and start a subtle campaign of world domination using yourghcommand to fork repos and create PRs and your SSH key to provide "authentic" underhanded commits, then all should be good.
I'm going to leave this issue open until the screen management problems are resolved. But, thank you for mentioning this workaround for the specific GPG pinentry issue.
This issue has been inactive for 30 days. If the issue is still occurring, please comment to let us know. Otherwise, this issue will be automatically closed in 30 days for housekeeping purposes.
Issue is still occurring with Claude Code 2.0.14.
If you do not have Git configured to sign commits and tags with GPG keys, you can also simply have Claude run gpg --clearsign <filename>. If you have a private key with a passphrase on it and do not have a keychain manager integrated with GPG, then you should see that the pinentry prompt fights with Claude Code.
This issue has been automatically closed due to 60 days of inactivity. If you're still experiencing this issue, please open a new issue with updated information.
This issue has been automatically closed due to 60 days of inactivity. If you're still experiencing this issue, please open a new issue with updated information.
I added a comment 30 days ago to keep this issue open. Automatically closing this issue is a violation of the policy that you stated.
This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.