ssh-agent manager compatible with direnv
  • Go 99.1%
  • Just 0.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Claudia 22737480b1
All checks were successful
ci/woodpecker/push/build Pipeline was successful
chore: sync the files wrangle manages
.woodpecker/release.yml
2026-10-20 21:23:18 +02:00
.just feat: install to the XDG bin dir 2026-08-01 23:42:51 +02:00
.woodpecker chore: sync the files wrangle manages 2026-10-20 21:23:18 +02:00
.gitignore chore: ignore local agent settings 2026-08-01 23:55:36 +02:00
agent.go feat!: serve every identity's agent from one sage process 2026-10-16 11:56:45 +02:00
agent_darwin.go feat: unlock every key with one keyring passphrase 2026-10-16 11:01:19 +02:00
agent_linux.go feat: unlock every key with one keyring passphrase 2026-10-16 11:01:19 +02:00
agent_test.go feat!: serve every identity's agent from one sage process 2026-10-16 11:56:45 +02:00
args.go feat: harmonise the args parsing 2026-05-30 06:02:22 +02:00
args_test.go feat: rename preload --ssh-only/--gpg-only to --ssh/--gpg 2026-07-10 09:37:43 +02:00
CHANGELOG.md docs: changelog for v2.5.0 2026-10-20 21:00:28 +02:00
client.go fix: name the control socket sage.ctl 2026-10-16 14:57:26 +02:00
config.go feat!: drop gpg support 2026-10-16 11:47:58 +02:00
config_test.go feat!: drop gpg support 2026-10-16 11:47:58 +02:00
daemon.go feat: hand locker's identity to the installed locker asking directly 2026-10-20 20:55:39 +02:00
daemon_test.go fix: expire keys added with a lifetime over a suspend 2026-10-18 07:53:58 +02:00
fingerprint.go feat!: serve every identity's agent from one sage process 2026-10-16 11:56:45 +02:00
fingerprint_test.go perf: read openssh key fingerprints without ssh-keygen 2026-10-16 09:34:56 +02:00
go.mod feat: add an encrypted keyring for key passphrases 2026-10-16 10:47:18 +02:00
go.sum feat: add an encrypted keyring for key passphrases 2026-10-16 10:47:18 +02:00
identity_agent.go fix: expire keys added with a lifetime over a suspend 2026-10-18 07:53:58 +02:00
justfile feat: install to the XDG bin dir 2026-08-01 23:42:51 +02:00
keyring.go feat!: drop gpg support 2026-10-16 11:47:58 +02:00
keyring_cmd.go feat: keep locker's identity in the keyring 2026-10-16 15:00:21 +02:00
keyring_test.go feat: name keys by identity in prompts and messages 2026-10-16 12:24:48 +02:00
locker.go fix: name where locker was looked for when none is installed 2026-10-20 20:58:39 +02:00
locker_test.go feat: hand locker's identity to the installed locker asking directly 2026-10-20 20:55:39 +02:00
main.go feat: keep locker's identity in the keyring 2026-10-16 15:00:21 +02:00
peer.go feat!: serve every identity's agent from one sage process 2026-10-16 11:56:45 +02:00
peer_darwin.go feat: keep locker's identity in the keyring 2026-10-16 15:00:21 +02:00
peer_linux.go feat: keep locker's identity in the keyring 2026-10-16 15:00:21 +02:00
preload.go fix: move the sage agent's control socket beside the agent sockets 2026-10-16 13:37:11 +02:00
preload_test.go feat!: serve every identity's agent from one sage process 2026-10-16 11:56:45 +02:00
prompt.go feat!: drop gpg support 2026-10-16 11:47:58 +02:00
README.md feat: hand locker's identity to the installed locker asking directly 2026-10-20 20:55:39 +02:00
sshkeys.go feat: name keys by identity in prompts and messages 2026-10-16 12:24:48 +02:00
sshkeys_test.go feat: name keys by identity in prompts and messages 2026-10-16 12:24:48 +02:00
xdg_test.go fix: ignore a relative XDG_CONFIG_HOME 2026-08-28 16:36:42 +02:00

sage

A per-identity SSH agent. Each identity gets an isolated agent of its own, instead of one global agent holding every key — all served by one sage process, which also keeps your key passphrases in a keyring unlocked with one passphrase.

Install

Requires Go 1.25+ and just.

just install    # builds and copies to ~/.local/bin ($XDG_BIN_HOME)

Or build manually:

go build -o sage .

Usage

# Serve an identity's agent and point the shell at it
eval "$(sage load github)"

# Add a key to the running agent (standard ssh-add)
ssh-add ~/.ssh/github_ed25519

# Make an identity's configured SSH keys usable
sage preload github       # specific identity
sage preload              # the currently-loaded identity (from $SSH_AUTH_SOCK)

# Check what's running (also the bare default: `sage` on its own)
sage status
sage

# Check a specific identity
sage status github

# Remove all keys from an agent (it keeps being served)
sage unload github

# Remove only keys that don't belong to the identity (wrong-scope leaks)
sage clean github         # specific identity
sage clean                # sweep every running agent

# Stop serving an agent, dropping its keys
sage stop github

# Stop the sage agent: every agent, and lock the keyring
sage stop-all

Commands

Command Description
load <identity> Serve the identity's agent, print export statements
preload [identity] Make the identity's configured SSH keys usable
keyring init Create the keyring, choosing its passphrase
keyring add [identity] Store the passphrases of an identity's keys in the keyring
keyring rm [identity] Remove an identity's key passphrases from the keyring
keyring unlock Unlock the keyring for --hold (preload does this when it needs to)
keyring lock Lock the keyring now, dropping the keys decrypted from it
keyring Show whether the keyring is unlocked and which keys it holds
keyring locker add [file] Store locker's identity in the keyring (its file by default, or stdin)
keyring locker rm Remove locker's identity from the keyring
unload <identity> Remove all keys from an agent
clean [identity] Remove keys whose fingerprint isn't in the identity's ssh_keys
status [identity] Show the agents and the keyring (the bare sage default)
stop <identity> Stop serving the identity's agent and remove its socket
stop-all Stop the sage agent
version Print version

Flags

Flag Default Description
--lifetime 8h Lifetime of a key loaded with its own passphrase, in ssh's time format (8h, 1h30m, 2d); for load, of keys added without one
--dir ~/.ssh/agents Directory for the agent sockets
--config ~/.config/sage/config.toml Config file read by preload and status
--hold 12h keyring unlock: how long the keyring stays unlocked

Preloading keys

sage preload reads a config file that maps each identity to its SSH keys. With no argument it targets the currently-loaded identity (derived from $SSH_AUTH_SOCK); with an argument it targets that identity, serving its agent first.

The config lives at ~/.config/sage/config.toml (honoring $XDG_CONFIG_HOME):

[github]
ssh_keys = ["~/.ssh/github_ed25519"]

[work]
ssh_keys = ["~/.ssh/work_ed25519", "~/.ssh/work_rsa"]
  • ssh_keys — paths (~ expanded) to private keys loaded into the identity's agent.

sage manages SSH keys only. It used to preset GPG passphrases too; that is gone since git can sign commits with an SSH key (gpg.format = ssh), and a config that still has gpg_keys is refused with that advice.

preload is idempotent: a key the agent already holds, or can unlock from the keyring, is left alone. So it prompts only for what is actually cold and is a quiet, cheap no-op when everything is warm — which is what lets a tool run it on every context entry (e.g. moor's per-domain preload) without re-asking for keys it already holds.

A key the keyring does not hold is asked for and decrypted by preload and handed to that identity's isolated agent, so every passphrase prompt is sage's and reads the same way:

⚠ unlock ssh github:
⚠ unlock ssh work/id_rsa:

A key is named by its identity, and by its file as well when the identity has several keys — key files are often all called id_ed25519, which would not say whose passphrase is wanted.

A wrong passphrase is asked for again, up to three tries. Prompts are read from the terminal with echo off, and anything typed before the prompt appeared is thrown away rather than taken as the passphrase. A key in a format sage cannot decode itself is handed to ssh-add, which asks with its own prompt.

Keyring

Instead of a prompt per key, sage can keep the passphrases of your SSH keys in a keyring, encrypted under one keyring passphrase. Set it up once:

sage keyring init          # choose the keyring passphrase
sage keyring add github    # store github's key passphrases, each checked against its key
sage keyring add work

From then on, when preload finds keys cold, it asks one thing:

⚠ unlock keyring:

The keyring then stays unlocked for 12 hours (sage keyring unlock --hold 1d for another length, sage keyring lock to end it early). While it is, every key it holds is in its identity's agent — listed straight away, and decrypted the first time ssh or git asks it to sign — so nothing needs loading. preload asks for the keyring only when the keyring is locked and holds one of the keys that are cold; a key it does not hold is asked for on its own, as without a keyring. Pressing Enter at the keyring prompt skips the keyring for that run.

The keyring lives in $XDG_DATA_HOME/sage/keyring: the passphrases are sealed with AES-256-GCM under a key derived from the keyring passphrase with Argon2id (about a second to unlock, on purpose). The file lists in the clear which keys it holds, by fingerprint, so preload can tell without the passphrase whether unlocking it would help; that list is bound to the encrypted part, so an edited one fails to open. After changing a key's passphrase, sage keyring rm and sage keyring add it again; until then preload reports that the stored one no longer works and asks for the key.

Lifetimes. A key decrypted from the keyring lasts as long as the keyring stays unlocked, and locking the keyring — or its hold running out — drops it at once, along with the passphrases. Keys added by hand (ssh-add, AddKeysToAgent, or preload asking for a key the keyring lacks) keep their own lifetimes.

Where the keys live. The keyring's passphrases and the keys decrypted from it stay inside the sage agent: no key from the keyring is handed to another process. It keeps them in memory, locked out of swap where the system allows, and marks itself non-dumpable so other processes of yours cannot attach to it or read its memory. The keyring passphrase reaches it over a control socket beside the agent sockets, ~/.ssh/agents/sage.ctl, after sage checks the process on the other end is your own sage; nothing it holds is handed back. Keeping it there means whatever can reach your agents can reach what controls them, and nothing else: a sandbox that keeps ~/.ssh out, as claudia's does, keeps both out.

What this does not stop is a process running as you while the keyring is unlocked: it can ask your agents to sign, as it could ask any agent, and it could replace the sage binary or wait for you to type a passphrase. The keyring's protection is for the passphrases at rest and in memory; the time it stays unlocked is the window in which anything running as you can use the keys.

locker's identity

locker seals its token stashes to an age identity that otherwise sits unencrypted on disk. The keyring can hold it instead, so the one keyring passphrase guards it too:

sage keyring locker add            # from ~/.config/locker/identity.txt
locker status                      # identity: sage's keyring (once the file is gone)
rm ~/.config/locker/identity.txt

With its identity file absent, locker asks for it, and it is handed only to the installed locker binary ($XDG_BIN_HOME/locker, or ~/.local/bin/locker). At a terminal locker asks through the sage program, which checks that locker ran it and can ask for the keyring passphrase when the keyring is locked. locker env, which runs in a shell hook and never asks, goes to the sage agent directly, which checks that the caller is the installed locker: no sage process per switch, and no dependence on which sage is first on PATH. Your shell, or any other program — another build of sage included — asking is refused, and while the keyring is locked locker env gets nothing.

As with keys, this guards the identity at rest and from other programs, not from a process running as you that sets out to get it: it can run locker itself.

Wrong-scope keys

An agent accepts any key handed to its socket, so a key can land in the wrong identity's agent without sage's involvement — most commonly via OpenSSH's AddKeysToAgent yes depositing a key into whatever agent $SSH_AUTH_SOCK points at when you connect. To keep this from happening, pin each host to its identity's socket in ~/.ssh/config:

Host github.com
    IdentityFile ~/.ssh/github_ed25519
    IdentityAgent ~/.ssh/agents/github.sock
    IdentitiesOnly yes

sage status flags any loaded key whose fingerprint isn't in that identity's ssh_keys, and sage clean removes them (leaving the configured keys in place):

$ sage status work

identities (1)
  ● work  ~/.ssh/agents/work.sock
      work@laptop      ssh-ed25519  SHA256:abc…  keyring
      personal@laptop  ssh-ed25519  SHA256:xyz…
⚠ holds a key not in ssh_keys for "work" (wrong scope): SHA256:xyz… personal@laptop
$ sage clean work
removed key from "work" (wrong scope): SHA256:xyz… personal (ssh-ed25519)

An identity with no config section is left untouched, and if a configured key's fingerprint can't be resolved sage stays silent rather than remove on a guess.

How it works

One sage process — the sage agent — serves every identity's agent, each on its own socket, ~/.ssh/agents/<identity>.sock, for SSH_AUTH_SOCK or an IdentityAgent line. It speaks the standard agent protocol, so ssh, git, ssh-add and agent forwarding work with it as with ssh-agent.

sage load starts the sage agent when it is not running — detached, so it outlives the shell — and it runs until sage stop-all or the session ends. On start it serves every identity in the config, so a host pinned to its identity's socket finds an agent there before any shell has loaded that identity. sage load is idempotent, which makes it safe in a shell profile:

# ~/.bashrc or ~/.zshrc
eval "$(sage load default)"

sage status (or just sage) shows what the identities share — the sage agent's pid and control socket, and whether the keyring is unlocked — and then each identity's agent with its keys:

● sage
agent:   pid 3044212 · ~/.ssh/agents/sage.ctl
keyring: unlocked · 11h42m left · 3 key(s)

identities (2)
  ● amedia  ~/.ssh/agents/amedia.sock
      varl@nikki  ssh-ed25519  SHA256:bZmd…  keyring
  ● vlv     ~/.ssh/agents/vlv.sock
      varl@nikki  ssh-ed25519  SHA256:0Jlw…  keyring, decrypted on first use

sage is controlled over a socket of its own in the same directory, ~/.ssh/agents/sage.ctl, which sage keeps owner-only. Run sage agent in a terminal to see why it fails to start, if it does.

Coming from sage 1.x, which ran one ssh-agent per identity: the first sage load stops each of those agents and serves its socket in its place, so shells already pointing at it keep working. Their keys go with them; preload or the keyring brings them back. Keys of the sk- (hardware security key) kind, PKCS#11 tokens, ssh-add -c confirmation and ssh-add -h destination constraints are not supported by the sage agent.

Shell integration examples

Multiple identities:

# Load a specific identity before a git operation
eval "$(sage load work)"
git push

# Switch to another
eval "$(sage load personal)"
git push

SSH config integration:

# ~/.ssh/config
Host github.com
    IdentityAgent ~/.ssh/agents/github.sock

Platform support

  • Linux
  • macOS (built, not tested on a Mac)