- Go 93.1%
- Just 3.7%
- Shell 3.2%
| .woodpecker | ||
| scripts | ||
| .gitignore | ||
| agent.go | ||
| agent_darwin.go | ||
| agent_linux.go | ||
| agent_test.go | ||
| args.go | ||
| args_test.go | ||
| CHANGELOG.md | ||
| config.go | ||
| config_test.go | ||
| go.mod | ||
| go.sum | ||
| justfile | ||
| main.go | ||
| preload.go | ||
| preload_test.go | ||
| README.md | ||
| renew.go | ||
| renew_test.go | ||
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 viassh-add.gpg_keys— anythinggpgaccepts 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 forSSH_AUTH_SOCK<identity>.pid— the PID of thessh-agentprocess
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/fdfor FD cleanup) - macOS (
/dev/fdfor FD cleanup)