claude-code icon indicating copy to clipboard operation
claude-code copied to clipboard

Conflict between `claude` console screen management and GPG `pinentry` during secret key passphrase prompt for Git commit signing.

Open emcd opened this issue 11 months ago • 6 comments

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.

emcd avatar Feb 26 '25 05:02 emcd

Would you mind recording a video of what this looks like?

bcherny avatar Feb 26 '25 06:02 bcherny

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

emcd avatar Feb 26 '25 17:02 emcd

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

emcd avatar Mar 03 '25 03:03 emcd

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

emcd avatar Mar 10 '25 00:03 emcd

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:

  1. Go to GitHub Settings > SSH and GPG keys
  2. Add your SSH public key with Key type: "Signing Key"
  3. 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.

JichouP avatar Jun 15 '25 01:06 JichouP

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, with ssh-agent, which retains them for the lifetime of the process (possibly the uptime of the machine). By contrast, gpg-agent caches 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_SOCK environment variable and start a subtle campaign of world domination using your gh command 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.

emcd avatar Jun 15 '25 18:06 emcd

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.

github-actions[bot] avatar Oct 10 '25 10:10 github-actions[bot]

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.

emcd avatar Oct 11 '25 02:10 emcd

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.

github-actions[bot] avatar Dec 07 '25 10:12 github-actions[bot]

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.

emcd avatar Dec 07 '25 12:12 emcd

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.

github-actions[bot] avatar Dec 15 '25 14:12 github-actions[bot]