/docs/gpg-agent

Coding agents and your GPG key

A coding agent that signs your commits needs to talk to gpg-agent, the background process that holds your private key. Whatever the agent runs does so as you, so unless something stands in between, any command it runs can talk to that agent too. We measured what that gives a command: it can guess your passphrase with no dialog, export your secret key, and decrypt anything encrypted to you. This page shows the measurements, why the obvious fixes make things worse, and how nd7 lets an agent sign without any of that.

How gpg signs#

Since GnuPG 2.1, gpg is split in two:

  • gpg, the command you run, reads the public keyring and builds what is to be signed. It never holds a private key.
  • gpg-agent, a process that keeps running in the background, holds the private keys, asks for your passphrase through a dialog called pinentry, remembers it for a while (the cache), and does the signing and decrypting.

They talk over a Unix socket: a special file, ~/.gnupg/S.gpg-agent, that one process listens on and another connects to, exchanging short text commands such as PKSIGN (sign this) or EXPORT_KEY.

Any process running as your user can connect to that socket. That includes an agent, every command the agent runs, and every script a prompt injection talks the agent into running. A prompt injection is text planted in a file, web page or issue that the agent reads and then follows as if it were an instruction.

What a command can do through the main socket#

We connected to the main socket from inside a sandbox, using a throwaway gpg setup with throwaway keys, and left the agent's defaults unchanged, including allow-loopback-pinentry, which is on by default. Each of these worked:

  • Guess your passphrase with no dialog. --pinentry-mode loopback tells the agent to take the passphrase from the caller instead of showing pinentry. Wrong guesses come back as Bad passphrase; the right one signs. Nothing appears on screen.
  • Export your secret key. gpg --export-secret-keys printed a full -----BEGIN PGP PRIVATE KEY BLOCK-----. Together with the guessing above, both the key and its passphrase leave the machine.
  • Decrypt anything encrypted to you. A file encrypted to the throwaway key came back as plaintext. For a real key that means pass password stores, sops and git-crypt secrets, encrypted backups and mail.
  • Sign as you. Silently while the passphrase is cached; GnuPG's default cache is ten minutes after each use, up to two hours.
  • Control the agent. Kill it, reload it, and choose which terminal pinentry appears on.

Worst first, that means:

  1. The key is gone for good. A signing key lives for years; the session that leaked it lasted minutes. Once it is revoked as compromised, nothing it ever signed can be trusted.
  2. Your secrets are readable. Anything encrypted to the key, while the passphrase is cached or once it has been guessed.
  3. Forged work in your name. Commits and tags that GitHub marks Verified, and signed release artifacts if the key signs releases.

The fixes that make it worse#

When an agent in a sandbox cannot sign, the first error is misleading:

gpg: failed to create temporary file '/Users/you/.gnupg/.#lk0x…': Operation not permitted
gpg: can't connect to the gpg-agent: Operation not permitted

It reads as though gpg needs to write to ~/.gnupg. It does not. The sandbox refused the connection to the agent's socket; gpg then assumed no agent was running and tried to start one, and starting one takes a lock file in ~/.gnupg, which the sandbox also refused. That lock is the first line.

Each obvious fix gives away more than it should:

  • Allow the main socket. Everything in the previous section.
  • Make ~/.gnupg writable. Worse still. gpg-agent.conf names the programs the agent runs, such as pinentry-program, and the agent runs them outside any sandbox the next time it starts. A writable config is a way out of the sandbox. A writable trust database also lets a session mark any key as fully trusted, so a forged signature would check out as good.
  • Allow every socket in ~/.gnupg. That includes S.gpg-agent.ssh when SSH support is on, which is SSH as you to every host your keys open.

The restricted socket#

gpg-agent listens on more than one socket. Next to the main one it keeps S.gpg-agent.extra, which GnuPG built for forwarding your agent to a machine you do not fully trust. The agent marks a connection that arrives there restricted, and refuses the dangerous commands on it. Through it, with the same throwaway setup:

  • Signing: works.
  • Decrypting: works.
  • Exporting the secret key: refused, gpg: error getting the KEK: Forbidden.
  • Loopback pinentry, or any pinentry mode: refused, Forbidden.
  • Choosing the pinentry terminal, killing or reloading the agent, storing values in it: refused, Forbidden.

What is left: a session can sign and decrypt while the passphrase is cached, or after a pinentry dialog you approve. It cannot take the key, cannot learn the passphrase and cannot guess it. That is the right amount for a coding agent: it can use your key while you are there, and never walks away with it.

How nd7 points gpg at it#

gpg has no option for "use the restricted socket". It always connects to $GNUPGHOME/S.gpg-agent, where GNUPGHOME is its home directory, ~/.gnupg unless set. So for each session, nd7 run creates a small stand-in home directory and sets GNUPGHOME to it for the agent and everything the agent runs:

/var/folders/…/T/nd7-gpg-<pid>/     readable only by you
  S.gpg-agent  -> ~/.gnupg/S.gpg-agent.extra
  pubring.kbx  -> ~/.gnupg/pubring.kbx
  gpg.conf     -> ~/.gnupg/gpg.conf
  trustdb.gpg     a private copy (see the next section)

The sandbox profile, the list of rules the macOS kernel enforces for the session, gains one line:

(allow network-outbound (literal "/Users/you/.gnupg/S.gpg-agent.extra"))

When gpg connects to $GNUPGHOME/S.gpg-agent, the kernel follows the symlink and checks the real path, which is the one the rule allows. gpg believes it is talking to the usual agent; the agent sees a connection on its restricted socket.

Details that matter if you build this yourself:

  • The kernel checks the path after symlinks. So the symlink redirects gpg, and no symlink can get past the rule: pointing the stand-in's S.gpg-agent at the main socket is still refused.
  • Socket paths are limited to 104 bytes on macOS. A longer path fails as though no agent were running.
  • The stand-in must be writable. gpg takes lock files in its home directory, which is why it lives in the temp directory and not in ~/.gnupg.
  • Start the agent before the sandbox. A sandboxed gpg cannot start one, so nd7 run runs gpgconf --launch gpg-agent first.
  • It fails closed. If a command unsets GNUPGHOME, gpg goes back to the main socket, and the sandbox refuses it.

A second trap: "Operation not permitted" is not "Permission denied"#

With the socket working, git commit signed. Yet gpg --list-secret-keys, and gpg --clearsign without naming a key, still died:

gpg: Fatal: can't open '/var/folders/…/nd7-gpg-16641/trustdb.gpg': Operation not permitted

trustdb.gpg is gpg's trust database: how much you trust each key's owner, plus a cache of how valid each key is. gpg consults it whenever it needs a key's validity: to list keys, to pick your default key when you do not pass -u, and to verify signatures. git passes your configured key with -u, which is why commits signed while these commands failed.

gpg opens the trust database for reading and writing even when it only needs to read, in case the cache needs an update. When the open fails it looks at the reason:

  • "Permission denied" (EACCES), which is what ordinary file permissions return: gpg prints gpg: Note: trustdb not writable, opens the file read-only and carries on. We checked this with a copy set to read-only.
  • Anything else: fatal.

The macOS sandbox refuses with a different error, "Operation not permitted" (EPERM). Both mean "you may not write this", but gpg only checks for the first, so under a sandbox it never tries read-only. It does not need to write; it does not recognise the sandbox's way of saying no.

A symlink cannot help here, for the same reason it works for the socket: the kernel checks the real path, and the real trustdb.gpg sits in ~/.gnupg, where writing is refused. So the stand-in gets a private copy instead. gpg's read-write open succeeds, and listing keys, signing with the default key and verifying all work. Your real trust database stays unwritable. A session can change only its own copy, which is deleted when the session ends, so trusting a key there convinces nothing outside that session.

What to take from this#

  • To the sandbox, a Unix socket is a network connection. Any tool that talks to a local background service needs a rule for that socket's path, and the rule decides what the tool can do. For gpg the choice of socket is the whole difference between "can sign" and "can steal the key".
  • The sandbox judges the real path after symlinks. That makes symlinks a safe way to redirect a tool to something you allowed, and useless for getting past something you refused.
  • Sandbox refusals come back as EPERM, not EACCES. Tools written to cope with read-only files often only check for EACCES, so they fail harder under a sandbox than on a read-only disk. When a tool dies with "Operation not permitted" on a file it only needed to read, suspect this.
  • The first error is often a symptom. Here it pointed at writing to ~/.gnupg; the cause was a socket.

How we tested#

On 23 September 2026, with GnuPG 2.4.7 from Homebrew and pinentry-mac on macOS (Darwin 25.6). We ran each claim under the macOS sandbox with a copy of nd7's profile. Passphrase guessing, key export and decryption used a separate gpg home with its own agent and throwaway keys; we used a real key only to show that signing works through the restricted socket. Still open: smartcard commands (SCD) pass through the restricted socket and were not tested with a hardware key, and the newer use-keyboxd keyring setup was not tested. The commands and their outputs are in the nd7 repository, in docs/spikes/2026-09-23-F-gpg-agent.md.

Updated . This page as Markdown · Source on GitHub.