ssh-agent-proxy is a filtering proxy that sits in front of an existing SSH agent —
especially the agent built into a password manager such as Bitwarden, KeePassXC,
1Password, or Secretive.
The problem. Password-manager SSH agents expose every key they hold to every
client, all at once and in no particular order. You can't scope which keys a given
host, repo, or container sees, and you can't control their order. Beyond being untidy,
it breaks logins: ssh offers agent keys one at a time, so once you have more than a
handful the server rejects you with "Too many authentication failures" (it hits
MaxAuthTries) before ever reaching the key you actually needed.
What this does. ssh-agent-proxy sits in front of that agent and exposes one or
more filtered views ("groups") of its keys, each on its own socket. A client that
points SSH_AUTH_SOCK at a group's socket sees — and can sign with — only the keys
you assigned to that group, in the order you listed them. Every other key is
invisible and unusable through that socket. The private keys never leave the upstream
agent, and any confirmation/biometric prompt it enforces still applies.
- The proxy connects to your real ("upstream") agent.
- For each configured group it listens on a Unix socket.
- List identities returns only the group's keys, in the order you configured.
- Upstream identities are shared across all groups and cached for three seconds by default, limiting bursts to one upstream list refresh per cache interval.
- Sign requests are honored only for keys in the group; any other key is refused.
- The proxy is read-only: requests that would modify the agent (add/remove a key, lock/unlock, extensions) are always refused. Your upstream agent is never modified.
Select a group from a client by pointing SSH_AUTH_SOCK at its socket:
export SSH_AUTH_SOCK=~/.ssh/agent-work.sock
ssh-add -l # shows only the work group's keys
ssh git@github.com # can only authenticate with those keysBinaries are built for Linux and macOS (amd64 and arm64).
macOS note: downloaded binaries are not code-signed/notarized. Installing via Homebrew avoids Gatekeeper warnings (Homebrew strips the quarantine attribute). If you download a binary manually, clear it once with
xattr -d com.apple.quarantine ./ssh-agent-proxy.
brew install krisiasty/tap/ssh-agent-proxyDownload the archive for your OS/arch from the
releases page, extract it, and
put the binary on your PATH:
tar -xzf ssh-agent-proxy_*_"$(uname -s)"_"$(uname -m)".tar.gz
sudo install ssh-agent-proxy /usr/local/bin/ssh-agent-proxy manages its own service installation. This creates the config
directory (with a sample config if none exists) and registers a per-user service that
starts at login and restarts if it crashes:
ssh-agent-proxy -install- Linux: a systemd user service (
~/.config/systemd/user/ssh-agent-proxy.service); logs go to the systemd journal (journalctl --user -u ssh-agent-proxy). - macOS: a LaunchAgent (
~/Library/LaunchAgents/io.github.krisiasty.ssh-agent-proxy.plist); logs go to~/Library/Logs/ssh-agent-proxy.log.
Runtime logs use JSON Lines; see Logging for the default and debug output.
Then edit the config (see below) and reload:
ssh-agent-proxy -reloadssh-agent-proxy -uninstall # stop and remove the serviceThis removes the service definition. Your config file is left in place; delete it
manually if you want it gone. For Homebrew, also run brew uninstall ssh-agent-proxy.
| Flag | Description |
|---|---|
-install |
Install and start the service, then exit. Fails if already installed. |
-reinstall |
Reinstall (uninstall then install), then exit. Use to update the installed binary path. |
-uninstall |
Stop and remove the service, then exit. |
-start |
Start the service, then exit. |
-stop |
Stop the service, then exit. |
-restart |
Restart the service, then exit. |
-reload |
Ask the running managed service to reload its config, then exit. |
-status |
Print service status (installed / running), then exit. |
-diag |
Check configuration, service, upstream, and group health, then exit. |
-list |
List upstream keys as ready-to-paste config entries, then exit. |
-foreground |
Run in the foreground, logging to stdout (for testing / debugging). |
-config PATH |
Use an alternate config file path. |
--cache SECONDS |
Cache upstream identities for 0–60 seconds (default 3; 0 disables caching). |
-version |
Print version and exit. |
The lifecycle flags are mutually exclusive. With no flag, the tool runs the proxy in
the foreground of the current process — this is how the service manager runs it.
When installing or reinstalling a managed service, the selected --cache value is
saved in its service definition.
ssh-agent-proxy -reloadsends SIGHUP to the running managed service. The process reads and validates its
configured file before disturbing the active proxy. If validation fails, it logs the
error and keeps serving the current configuration. After successful validation it
replaces the upstream connection and group listeners; existing client connections are
closed so clients reconnect against the new authorization state.
If replacement startup fails—for example, because a new socket cannot be bound—the
proxy restores the previous working configuration. A managed service that is idling
after an initial config or upstream error can also recover when reloaded. A foreground
process accepts SIGHUP directly; -reload specifically targets the installed managed
service. The command-line --cache value is not reloaded because it is stored in the
service definition.
ssh-agent-proxy -diagperforms read-only checks of the selected configuration and reports each result as
[PASS], [WARN], or [FAIL]. It verifies that:
- the configuration loads and validates;
- an installed service is running with the selected config and current executable;
- the upstream agent accepts an identity request;
- every selector's match count can be resolved; and
- each enabled group socket returns the exact expected keys in configured order.
Disabled groups are reported but not probed. An unmatched selector is a warning. An invalid config, unreachable upstream, stopped installed service, different installed config, unavailable enabled socket, or unexpected group result is a failure. If no managed service is installed, responding group sockets are recognized as a foreground or otherwise unmanaged proxy.
Diagnostic output contains counts but never prints selector values, key comments, fingerprints, or public-key blobs. The command continues independent checks after a failure and exits non-zero if any check fails.
ssh-agent-proxy -listprints every key in the upstream agent with all three ways to match it, as YAML entries
you can paste under a group's keys:. Each key is preceded by a header line with its
index, algorithm, and size in bits:
[1] ssh-ed25519 256
- comment: "laptop@work"
- sha256: "SHA256:9N6igGbuSz87xjbn/QUg/C5yfT1nLBMw+MkKnZoOrLI"
- md5: "MD5:d7:4a:ab:42:5a:c8:a6:fc:c3:a2:c2:9d:86:bc:4b:a9"Runtime logs use JSON Lines (JSONL): every line is a complete JSON object with time,
level, msg, and event-specific structured fields. This makes the output suitable
for both command-line processing and ingestion by log-management systems.
With the default debug: false configuration, the proxy logs:
- version, configuration path, upstream socket, cache and telemetry intervals, and group startup with the number of distinct matched keys;
- one warning for each configured selector that matches no upstream key, identifying its group, config index, and selector type without logging the selector value;
- client connections and disconnections, including UID, PID, and process name when the operating system provides them;
- successful client identity-list requests, including the group and number of returned identities;
- successful client sign requests, including the group and public-key fingerprint;
- shutdown, refused operations, reconnects, and any warnings or errors.
For example:
{"time":"2026-08-09T22:55:55.850+02:00","level":"INFO","msg":"serving group","group":"work","socket":"/Users/example/.ssh/agent-work.sock","keys":2}
{"time":"2026-08-09T22:55:55.851+02:00","level":"WARN","msg":"configured key selector matched no upstream key","group":"work","config_index":3,"selector_type":"comment"}
{"time":"2026-08-09T22:55:55.870+02:00","level":"INFO","msg":"client connected","conn":"67d54585","group":"work","uid":501,"pid":60181,"process":"ssh"}
{"time":"2026-08-09T22:55:55.873+02:00","level":"INFO","msg":"list identities","conn":"67d54585","group":"work","uid":501,"pid":60181,"process":"ssh","count":2}
{"time":"2026-08-09T22:55:55.893+02:00","level":"INFO","msg":"sign","conn":"67d54585","group":"work","uid":501,"pid":60181,"process":"ssh","fingerprint":"SHA256:5D4g5Lj3m9tn9r/lOD4vP42WHHyII1A6Y9+Myi1OqVM"}
{"time":"2026-08-09T22:55:55.921+02:00","level":"INFO","msg":"client disconnected","conn":"67d54585","group":"work","uid":501,"pid":60181,"process":"ssh"}With debug logging enabled, the proxy samples its Go runtime every second and reports
once group startup completes, then periodically every ten minutes. telemetry current
contains a fresh sample, telemetry max contains the highest sampled values during the
interval, and telemetry interval contains activity since the previous report. All
three records and all their defined fields are emitted every time, including unchanged
and zero values, providing a stable schema for log processing. After reporting, the
interval maximum and counter baselines reset.
The current and maximum records contain:
| Field | Description |
|---|---|
uptime_seconds |
Process telemetry uptime in seconds |
goroutines |
Live goroutines |
os_threads |
OS threads created by the Go runtime |
heap_alloc_bytes |
Bytes allocated to heap objects |
heap_inuse_bytes |
Bytes in in-use heap spans |
heap_live_bytes |
Reachable heap bytes marked by the most recent garbage collection; zero until the first GC completes |
heap_goal_bytes |
Target heap size for the end of the current garbage-collection cycle |
stack_inuse_bytes |
Bytes in stack spans |
runtime_reserved_bytes |
Bytes reserved by the Go runtime |
heap_objects |
Live heap objects |
The interval record contains:
| Field | Description |
|---|---|
heap_allocated_bytes |
Heap bytes allocated during the interval |
heap_allocated_objects |
Heap objects allocated during the interval |
gc_cycles |
Completed garbage-collection cycles during the interval |
{"time":"2026-08-09T23:10:34.642Z","level":"DEBUG","msg":"telemetry current","uptime_seconds":117000.000737511,"goroutines":16,"os_threads":7,"heap_alloc_bytes":1713304,"heap_inuse_bytes":3194880,"heap_live_bytes":1605632,"heap_goal_bytes":4194304,"stack_inuse_bytes":491520,"runtime_reserved_bytes":13728008,"heap_objects":10041}
{"time":"2026-08-09T23:10:34.642Z","level":"DEBUG","msg":"telemetry max","uptime_seconds":117000.000737511,"goroutines":17,"os_threads":7,"heap_alloc_bytes":2493920,"heap_inuse_bytes":3858432,"heap_live_bytes":1605632,"heap_goal_bytes":4194304,"stack_inuse_bytes":524288,"runtime_reserved_bytes":13728008,"heap_objects":19349}
{"time":"2026-08-09T23:10:34.642Z","level":"DEBUG","msg":"telemetry interval","heap_allocated_bytes":171064,"heap_allocated_objects":594,"gc_cycles":1}Debug mode also emits three application-level interval records alongside runtime telemetry:
| Record | Fields |
|---|---|
telemetry clients |
Active and maximum concurrent clients, new connections and connection errors, plus list/sign requests and errors |
telemetry upstream |
Upstream calls, errors, and successful reconnects |
telemetry cache |
Identity-cache hits, misses, actual refreshes, and requests that waited for an in-flight refresh |
The active-client values are gauges. All other values count activity since the previous
report and reset after being logged. A cache miss counts each request that first finds an
expired cache; concurrent misses share one refresh and increment waits while blocked.
{"time":"2026-08-09T23:10:34.643Z","level":"DEBUG","msg":"telemetry clients","active_clients":0,"max_active_clients":30,"connections":60,"connection_errors":0,"list_requests":30,"list_errors":0,"sign_requests":30,"sign_errors":0}
{"time":"2026-08-09T23:10:34.643Z","level":"DEBUG","msg":"telemetry upstream","calls":31,"errors":0,"reconnects":0}
{"time":"2026-08-09T23:10:34.643Z","level":"DEBUG","msg":"telemetry cache","hits":29,"misses":1,"refreshes":1,"waits":0}Set debug: true in the configuration and reload the proxy to include debug records.
Debug mode adds:
- runtime and proxy telemetry immediately after group startup and every ten minutes;
- every upstream operation, with its attempt number, duration, result count, error, or other operation-specific metadata;
- every identity returned by an upstream list, numbered as
identity n/m, with its fingerprint, comment, algorithm, and key size; - configuration-key resolution summaries;
- every identity returned to a client, numbered as
group identity n/m, with the same public-key metadata and only the relevant connection and group context; - low-level client and upstream connection diagnostics.
For example, these records are added to the normal output:
{"time":"2026-08-09T22:37:37.445+02:00","level":"DEBUG","msg":"upstream call","operation":"list","attempt":1,"keys":10,"duration":"9.836284ms"}
{"time":"2026-08-09T22:37:37.445+02:00","level":"DEBUG","msg":"identity 1/10","fingerprint":"SHA256:0KAYsd1LwHitQ6zCUWoRw2FPSLWjsfLMRU6Fn/CyFBw","comment":"laptop@work","algorithm":"ssh-ed25519","key_size":256}
{"time":"2026-08-09T22:37:37.445+02:00","level":"DEBUG","msg":"config keys resolved","group":"work","trigger":"client-list","configured_keys":2,"upstream_keys":10,"resolved_keys":2}
{"time":"2026-08-09T22:37:42.821+02:00","level":"DEBUG","msg":"group identity 1/2","conn":"bb411e20","group":"work","fingerprint":"SHA256:5D4g5Lj3m9tn9r/lOD4vP42WHHyII1A6Y9+Myi1OqVM","comment":"laptop@work","algorithm":"ssh-ed25519","key_size":256}Only public-key metadata is logged. Private keys, signing payloads, and passphrases are never included.
The excellent hl log processor understands JSONL
and renders these structured records in a compact, colored, human-friendly format. For
example:
2026-08-09 22:55:55.873 [INF] list identities :: conn=67d54585 group=work uid=501 pid=60181 process=ssh count=2
2026-08-09 22:55:55.893 [INF] sign :: conn=67d54585 group=work uid=501 pid=60181 process=ssh fingerprint=SHA256:5D4g5Lj3m9tn9r/lOD4vP42WHHyII1A6Y9+Myi1OqVM
2026-08-09 22:37:37.445 [DBG] identity 1/10 :: fingerprint=SHA256:0KAYsd1LwHitQ6zCUWoRw2FPSLWjsfLMRU6Fn/CyFBw comment=laptop@work algorithm=ssh-ed25519 key-size=256
2026-08-09 22:37:42.821 [DBG] group identity 1/2 :: conn=bb411e20 group=work fingerprint=SHA256:5D4g5Lj3m9tn9r/lOD4vP42WHHyII1A6Y9+Myi1OqVM comment=laptop@work algorithm=ssh-ed25519 key-size=256
View the macOS log file, follow it live, or process live systemd journal output with:
hl ~/Library/Logs/ssh-agent-proxy.log
tail -f ~/Library/Logs/ssh-agent-proxy.log | hl -P
journalctl --user -u ssh-agent-proxy -f -o cat | hl -PThe config file is YAML, stored at os.UserConfigDir()/ssh-agent-proxy/config.yaml:
- Linux:
~/.config/ssh-agent-proxy/config.yaml - macOS:
~/Library/Application Support/ssh-agent-proxy/config.yaml - Windows:
%APPDATA%\ssh-agent-proxy\config.yaml(planned)
| Key | Required | Description |
|---|---|---|
upstream |
yes | Absolute path to the upstream SSH agent socket. Environment variables and ~ are not expanded. |
debug |
no | true for verbose logging (default false). |
groups |
no | List of groups. With none defined, no keys are exposed. |
groups[].name |
yes* | Unique name for the group (used in logs). *Required if the group exists. |
groups[].enabled |
no | true (default) to expose the group, false to skip it. |
groups[].socket |
yes* | Absolute socket path this group is exposed on; no environment-variable or ~ expansion (*required if the group exists). |
groups[].keys |
no | Ordered list of key entries assigned to the group. |
groups[].keys[].comment |
no* | Exact, case-sensitive key comment. *Exactly one match field is required per entry. |
groups[].keys[].md5 |
no* | MD5 fingerprint, with an optional MD5: prefix. |
groups[].keys[].sha256 |
no* | SHA256 hash, with an optional SHA256: prefix. |
Notes:
- Each group needs a unique
name; it appears in log messages. upstreamand group socket paths must be written as absolute paths. Values such as${SSH_AUTH_SOCK}and~/.ssh/agent.sockare not expanded.- Enabled groups must use distinct socket paths, including paths that become equivalent after cleaning or resolving existing symlinked directories. An enabled group socket also cannot resolve to the upstream agent socket.
- A group with
enabled: falseis not exposed (its socket is not created). Omittingenabledmeans the group is enabled. commentmatches the key comment exactly (case-sensitive).md5matches the MD5 fingerprint; theMD5:prefix is optional.sha256matches the SHA256 hash; theSHA256:prefix is optional.- Each key entry must contain exactly one of
comment,md5, orsha256. - See Logging for the records enabled by
debug: trueand the public-key metadata they contain. - Keys appear in a group in the order their entries are listed.
- A config entry that matches several upstream keys includes them all; one that matches no upstream key is skipped.
- On macOS, configure the LaunchAgent with the upstream agent's explicit absolute
socket path; do not use
${SSH_AUTH_SOCK}in the configuration.
# Path to the upstream SSH agent socket (required).
upstream: /absolute/path/to/upstream-agent.sock
# Verbose logging.
debug: false
# Filtered views of the upstream agent. Point a client at a group with:
# export SSH_AUTH_SOCK=<socket>
groups:
- name: work
enabled: true
socket: /absolute/path/to/agent-work.sock
keys:
- comment: "laptop@work"
- sha256: "SHA256:9N6igGbuSz87xjbn/QUg/C5yfT1nLBMw+MkKnZoOrLI"
- name: personal
enabled: false
socket: /absolute/path/to/agent-personal.sock
keys:
- md5: "MD5:d7:4a:ab:42:5a:c8:a6:fc:c3:a2:c2:9d:86:bc:4b:a9"Configuration is read at startup and on SIGHUP; run ssh-agent-proxy -reload to apply
changes to a managed service.
If the config cannot be read or validated, or the initial upstream connection cannot be
opened, the service logs the error and idles rather than exiting into a restart loop. Fix
the config or upstream agent and reload the service. In -foreground mode it returns
the error and exits instead. With no enabled groups, it idles without connecting to the
upstream agent because it has no sockets to serve.
One shared upstream identity list resolves every enabled group during startup. If the
connected upstream agent is locked or listing otherwise fails, the proxy still starts
serving and defers resolution until a client request. It picks up matching keys after the
agent is unlocked or populated. Successful upstream key lists are cached process-wide
for the configured --cache interval. Concurrent refreshes across every group share one
request; if a refresh fails, the last successful list is served until the next interval.
At startup, each enabled group socket is protected by a nonblocking file lock. If
another proxy instance owns a lock or an existing socket accepts connections, startup
fails without removing that socket. A socket is replaced only when a connection probe
proves that the endpoint is stale. The adjacent .lock files are intentionally retained
between runs so every instance locks the same file inode.
Temporary listener accept failures are logged and retried with exponential backoff capped at one second. A terminal listener failure closes every group listener and exits non-zero so launchd or systemd can restart the complete proxy instead of leaving one group silently unavailable.
| Platform | Service | Logging | Status |
|---|---|---|---|
| Linux | systemd (user) | systemd journal | ✅ |
| macOS | LaunchAgent | log file | ✅ |
| Windows | Task Scheduler | Event Log | planned |
go build -o ssh-agent-proxy .Requires Go 1.26+. Dependencies: golang.org/x/crypto (SSH agent protocol) and
go.yaml.in/yaml/v3 (config).
Pull requests and pushes to main run formatting, vet, race-enabled tests, a
vulnerability scan, workflow validation, a transitive dependency license gate, and
cgo-free cross-builds for Linux and macOS on amd64 and arm64. The same checks run
weekly to detect newly disclosed dependency vulnerabilities.
Contributions are welcome. See CONTRIBUTING.md for the development workflow and pull request expectations. Please report security vulnerabilities through the private process in SECURITY.md, never through a public issue.
Releases are cut by pushing a v* tag. The release workflow repeats the quality gates,
then uses GoReleaser to publish platform archives and checksums to GitHub. It also
updates the ssh-agent-proxy cask in
krisiasty/homebrew-tap. The repository
must define HOMEBREW_TAP_GITHUB_TOKEN as a fine-grained token with Contents write
access to that tap.
To validate release packaging locally without publishing:
goreleaser release --snapshot --clean --skip=publishApache 2.0 — see LICENSE. Licenses and notices for statically linked dependencies are collected in THIRD_PARTY_NOTICES.
Participation in this project is governed by the Code of Conduct.