ssh-agent manager compatible with direnv
  • Go 93.1%
  • Just 3.7%
  • Shell 3.2%
Find a file
Viktor Varland 00924c0912
All checks were successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
docs: changelog for v1.0.0
2026-06-24 14:11:59 +02:00
.woodpecker ci: add Woodpecker build + release pipelines and changelog tooling 2026-06-24 13:50:57 +02:00
scripts ci: add Woodpecker build + release pipelines and changelog tooling 2026-06-24 13:50:57 +02:00
.gitignore feat: small ssh-agent wrapper 2026-03-06 14:09:20 +01:00
agent.go feat: ensure that keys are not loaded into the wrong agent 2026-06-02 13:07:32 +02:00
agent_darwin.go feat: renew keys through sage 2026-05-30 07:05:43 +02:00
agent_linux.go feat: renew keys through sage 2026-05-30 07:05:43 +02:00
agent_test.go feat: ensure that keys are not loaded into the wrong agent 2026-06-02 13:07:32 +02:00
args.go feat: harmonise the args parsing 2026-05-30 06:02:22 +02:00
args_test.go feat: harmonise the args parsing 2026-05-30 06:02:22 +02:00
CHANGELOG.md docs: changelog for v1.0.0 2026-06-24 14:11:59 +02:00
config.go feat: add preload command 2026-05-30 05:46:22 +02:00
config_test.go feat: add preload command 2026-05-30 05:46:22 +02:00
go.mod refactor: migrate to the gout/cli command framework 2026-06-24 12:47:48 +02:00
go.sum refactor: migrate to the gout/cli command framework 2026-06-24 12:47:48 +02:00
justfile ci: add Woodpecker build + release pipelines and changelog tooling 2026-06-24 13:50:57 +02:00
main.go feat: unified gout presentation for status 2026-06-24 13:09:37 +02:00
preload.go feat: adopt gout for unified CLI output 2026-06-23 16:05:23 +02:00
preload_test.go feat: ensure that keys are not loaded into the wrong agent 2026-06-02 13:07:32 +02:00
README.md feat: ensure that keys are not loaded into the wrong agent 2026-06-02 13:07:32 +02:00
renew.go feat: renew keys through sage 2026-05-30 07:05:43 +02:00
renew_test.go feat: renew keys through sage 2026-05-30 07:05:43 +02:00

sage

A lightweight SSH agent multiplexer. Manage multiple isolated ssh-agent instances — one per identity — instead of juggling a single global agent.

Install

Requires Go 1.25+ and just.

just install    # builds and copies to /usr/local/bin/

Or build manually:

go build -o sage .

Usage

# Start (or reuse) an agent for an identity and set env vars
eval "$(sage load github)"

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

# Load an identity's configured SSH keys and preset its GPG passphrases
sage preload github       # specific identity
sage preload              # the currently-loaded identity (from $SSH_AUTH_SOCK)

# Extend the expiry of an identity's GPG keys (default 1y)
sage renew github
sage renew --key 0xDEADBEEF --expire 2y

# Check what's running
sage status

# Check a specific identity
sage status github

# Remove all keys from an agent (agent keeps running)
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 an agent and clean up
sage stop github

# Stop all running agents
sage stop-all

Commands

Command Description
load <identity> Ensure agent is running, print export statements
preload [identity] Load configured SSH keys and preset GPG passphrases
renew [identity] Extend GPG key expiry via gpg --quick-set-expire
unload <identity> Remove all keys from a running agent
clean [identity] Remove keys whose fingerprint isn't in the identity's ssh_keys
status [identity] Show state of one or all agents
stop <identity> Stop agent process and remove socket/pid files
stop-all Stop all running agents
version Print version

Flags

Flag Default Description
--lifetime 8h Key lifetime (passed to ssh-agent/ssh-add -t)
--dir ~/.ssh/agents Directory for socket and PID files
--config ~/.config/sage/config.toml Config file read by preload and status
--ssh-only preload: load only SSH keys
--gpg-only preload: preset only GPG passphrases
--expire 1y renew: new expiry (1y, 6m, 30d, 0 for never)
--key renew: GPG key to renew (repeatable); skips config lookup

Preloading keys

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

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

[github]
ssh_keys = ["~/.ssh/github_ed25519"]
gpg_keys = ["0xDEADBEEF"]

[work]
ssh_keys = ["~/.ssh/work_ed25519", "~/.ssh/work_rsa"]
gpg_keys = ["work@example.com"]
  • ssh_keys — paths (~ expanded) added to the identity's agent via ssh-add.
  • gpg_keys — anything gpg accepts to select a secret key (fingerprint, key id, or uid/email).

For SSH keys, sage loads them into that identity's isolated ssh-agent.

GPG keys work differently: GnuPG uses a single global gpg-agent per user, so passphrase presets are not isolated per identity. sage preload prompts for each GPG key's passphrase and presets it into gpg-agent via gpg-preset-passphrase. This requires enabling presets once:

echo allow-preset-passphrase >> ~/.gnupg/gpg-agent.conf
gpgconf --reload gpg-agent

Checking GPG cache state

sage status reports, per identity, whether each configured GPG key's passphrase is currently cached in gpg-agent — so you can tell at a glance whether a sage preload is needed before a signing-heavy job:

$ sage status work
work                 running (pid 12345)
  socket: /home/varl/.ssh/agents/work.sock
  keys: 256 SHA256:… work (ED25519)
  gpg: work@example.com: cached

States are cached (warm), cold (no passphrase held), partial (n/m) (some keygrips cached), unknown (gpg-agent unreachable), or unresolved (key not in the keyring). Remember the cache obeys gpg-agent's default-cache-ttl / max-cache-ttl.

Wrong-scope keys

An ssh-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
work                 running (pid 12345)
  socket: /home/varl/.ssh/agents/work.sock
  keys: 256 SHA256:abc… work (ED25519)
256 SHA256:xyz… personal (ED25519)
  warning: holds a key not in ssh_keys for "work" (wrong scope): 256 SHA256:xyz… personal (ED25519)
$ sage clean work
sage: removed key from "work" (wrong scope): 256 SHA256:xyz… personal (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.

Renewing GPG keys

sage renew extends the expiry of GPG keys without dropping into the interactive gpg --edit-key flow. For each selected key it resolves the primary fingerprint and runs:

gpg --quick-set-expire <fpr> <expire>
gpg --quick-set-expire <fpr> <expire> '*'

The first call extends the primary key; the second extends every subkey. Pass an identity to act on its configured gpg_keys, or --key <id> (repeatable) for one-off keys not in the config:

sage renew github                          # all gpg_keys for [github]
sage renew --key 0xDEADBEEF --expire 2y    # one specific key, custom expiry

How it works

Each identity gets two files in ~/.ssh/agents/:

  • <identity>.sock — the Unix domain socket for SSH_AUTH_SOCK
  • <identity>.pid — the PID of the ssh-agent process

sage load is idempotent: if an agent for the given identity is already running, it reuses it. This makes it safe to put in your shell profile:

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

Agents run as detached processes (setsid) and survive shell exits. They are not re-parented to sage — sage is stateless and reads from the filesystem on each invocation.

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 (/proc/self/fd for FD cleanup)
  • macOS (/dev/fd for FD cleanup)