Skip to content

SSH agent proxy

The SSH agent proxy lets your local SSH clients authenticate against passkeys held in your ShellWatch account. Every signature is gated on a human-in-the-loop browser approval, and the SSH session itself is established directly — the broker is on the path for the signing ceremony only, not for any session bytes.

This is the trustless half of ShellWatch:

  • The broker only signs. It never sees your shell input, your shell output, environment variables, file transfers, port forwards — none of it.
  • Even a fully compromised broker cannot read or alter the session it just signed for. Compromise lets an attacker block or delay future signatures, not steal what you’re doing today.
  • You give up the live-observe / take-over capability of broker-mediated sessions. In exchange you get a transport ShellWatch is, by construction, not on.

The agent proxy is not “a laptop feature”. It works anywhere your ssh does — anything that talks SSH_AUTH_SOCK: ssh, scp, git, rsync, ansible, terraform, your AI coding agent’s shell tool. Common deployments:

  • Workstation / dev box — replace your ~/.ssh/id_* with passkey-backed signing. git push to a self-hosted forge, ssh into prod, scp configs out. Every connection is gated on a passkey tap.
  • A box where an AI coding agent runs — an LLM that has shell access (Claude Code, an in-IDE agent, an autonomous build agent) inherits SSH_AUTH_SOCK from its parent process. The agent can ssh and git freely — but it cannot complete a single connection without you tapping a passkey. This is a third human-in-the-loop model: the agent’s environment never holds key material, ShellWatch never sees the session, and every outbound SSH from that box gets a per-connection human gate.
  • Jump host / bastion — forwarded agent socket from your client; downstream ssh hops on the bastion still require a human signature back at the original signer.
  • CI runner doing a one-off privileged deploy — a build job can ssh to a target only when a human approves the sign on their phone. No long-lived deploy keys on the runner.

You run a small Go binary, shellwatch-agent, on whichever host above. It listens on a local socket — Unix domain socket on macOS / Linux, named pipe on Windows — and relays SSH agent protocol frames over WebSocket to ShellWatch’s /agent-proxy endpoint. Every sign request becomes a PendingAction that needs your approval — typically a touch on the same authenticator you registered as a passkey.

Terminal window
brew install rado0x54/tap/shellwatch-agent

Brew auto-derives the tap URL from the conventional homebrew- prefix, so no separate brew tap step is needed. The formula declares a service do block, so brew services start works out of the box (see Run as a daemon below).

Pre-built binaries are attached to each agent/v* release for linux/{amd64,arm64}, darwin/{amd64,arm64}, and windows/amd64. Pick the latest agent tag from the releases page (the agent and the server release on independent cadences, so releases/latest/download/... is not safe — it points at the most recent server release, which has no agent assets):

Terminal window
# Substitute the platform you need and the latest agent version from the releases page.
VERSION=v0.1.0
PLATFORM=darwin-arm64 # or linux-amd64, linux-arm64, darwin-amd64, windows-amd64.exe
curl -fsSL -o shellwatch-agent \
"https://github.com/rado0x54/ShellWatch/releases/download/agent/${VERSION}/shellwatch-agent-${PLATFORM}"
chmod +x shellwatch-agent
sudo mv shellwatch-agent /usr/local/bin/
Terminal window
git clone https://github.com/rado0x54/ShellWatch.git
cd ShellWatch/agent-client
make build # version pulled from `git describe`
# or: make build VERSION=0.1.0

make build injects the version via -ldflags "-X main.Version=..." so the value shows up in the X-ShellWatch-Version handshake header — the approver sees it on /sign/:id.

shellwatch-agent login runs an OAuth 2.1 loopback authorization_code + PKCE flow — the same flow an MCP client uses: it registers itself via mediated Dynamic Client Registration, opens your browser for a passkey login + consent, and persists the resulting refresh token in your platform credstore. The daemon then mints short-lived agent-scoped access tokens from it automatically (silent renewal — no plaintext long-lived secret anywhere). Default server is https://app.shellwatch.ai — pass --server only for self-hosted instances.

Terminal window
shellwatch-agent login # default: app.shellwatch.ai
shellwatch-agent login --server https://shellwatch.example.com

You can hold credentials for multiple servers simultaneously (login keys them by URL). To remove stored credentials:

Terminal window
shellwatch-agent logout
shellwatch-agent logout --server https://shellwatch.example.com

logout only deletes the local copy; it does not revoke the grant server-side. To revoke remotely, sign out / revoke the device’s sessions in ShellWatch.

The credentials land in:

PlatformPrimary storeFallback (mode 0600)
macOSKeychain~/Library/Application Support/shellwatch/credentials
Linuxlibsecret D-Bus (gnome-keyring / KWallet)${XDG_CONFIG_HOME:-~/.config}/shellwatch/credentials
WindowsDPAPI / Credential Manager%AppData%\shellwatch\credentials

The fallback is used when no keyring is reachable (a Linux VPS without D-Bus, a CI runner, an SSH session into a desktop without a logged-in GUI user). The agent prints a one-line warning when it falls back to file storage.

Terminal window
shellwatch-agent # picks up the credentials from the credstore
eval "$(shellwatch-agent --print-env)" # tells your shell where the socket is

Verify:

Terminal window
ssh-add -l # lists your registered passkeys
ssh user@host # uses ShellWatch to sign
FlagEnv varDescription
--serverSHELLWATCH_SERVERBroker URL (default: https://app.shellwatch.ai)
--tokenSHELLWATCH_TOKENStatic agent-scoped bearer token. Skips the credstore lookup.
--socketSHELLWATCH_AGENT_SOCKListener path (default: platform-appropriate; see below)
--insecureAllow ws:// (daemon) or http:// (login) — local dev only
--print-envPrint export SSH_AUTH_SOCK=... (or PowerShell-friendly form on Windows) and exit

Precedence: CLI flags > env vars > credstore > defaults.

Default listener path:

  • Linux: $XDG_RUNTIME_DIR/shellwatch-agent.sock if set, else ${TMPDIR:-/tmp}/shellwatch-agent-<uid>.sock.
  • macOS: ${TMPDIR}/shellwatch-agent-<uid>.sock — note $TMPDIR is set per-user to a /var/folders/.../T/ path by launchd, not /tmp.
  • Windows: \\.\pipe\openssh-ssh-agent (the path stock OpenSSH for Windows looks for, so ssh.exe finds it without SSH_AUTH_SOCK).

There is currently no non-interactive login — a Device Authorization Grant (RFC 8628) is a planned follow-up. For a short-lived headless run you can obtain an agent-scoped access token out-of-band and pass it directly:

Terminal window
shellwatch-agent --token "$TOKEN"
SHELLWATCH_TOKEN="$TOKEN" shellwatch-agent

A static token takes precedence over the credstore, so this works even if you’ve also run login. But note: static tokens are short-lived (~30 minutes) and not refreshed — for a long-running daemon, use login.

The agent is designed to run long-lived. WebSocket-level keepalive detects dead connections within ~1 minute, and the dialer reconnects with exponential backoff on the next SSH operation — laptop sleep, network changes (WiFi ↔ cellular), and brief server outages are handled transparently.

If you installed via the tap:

Terminal window
shellwatch-agent login # one-time
brew services start shellwatch-agent

Brew translates the formula’s service block to a launchd plist on macOS or a systemd-user unit on Linux. The daemon picks up the credentials from the keyring at startup — there’s no plaintext secret in the generated unit.

Terminal window
brew services list
brew services restart shellwatch-agent
brew services stop shellwatch-agent

Logs land in ${HOMEBREW_PREFIX}/var/log/shellwatch-agent.{log,err.log}.

The brew service uses the default server URL. Self-hosted instances with a custom --server flag need the manual launchd / systemd --user setup — see the agent-client README for full plist / unit examples.

Run the binary directly or wrap it with nssm / sc.exe for service-style auto-start. Stop the built-in ssh-agent Windows service first, otherwise it’ll own the same named pipe path:

Terminal window
Stop-Service ssh-agent
Set-Service ssh-agent -StartupType Disabled
.\shellwatch-agent.exe login
.\shellwatch-agent.exe # foreground daemon
Invoke-Expression (& .\shellwatch-agent.exe --print-env)

The default pipe matches what ssh.exe looks for, so SSH_AUTH_SOCK is optional. For service-style auto-start, nssm install is the path of least resistance.

ssh client ──► Unix socket ──► shellwatch-agent ──WSS──► ShellWatch /agent-proxy
└─ passkey sign
(PendingAction → /sign/:id)
  1. ssh opens a connection to SSH_AUTH_SOCK and sends an SSH agent protocol frame.
  2. shellwatch-agent opens a per-connection WebSocket to /agent-proxy and relays the frame as a binary message. It advertises X-ShellWatch-Hostname, -OS, -Version handshake headers so the approver knows which host is asking.
  3. ShellWatch’s AgentProtocol instance handles the request:
    • List identities → returns the passkeys registered for the account.
    • Sign request → fires a PendingAction. The approver opens /sign/:id, taps their authenticator, and the assertion flows back.
  4. The signed response goes back through the WebSocket to the local Unix socket, and ssh completes the SSH handshake against the target host.

Each Unix-socket connection gets its own WebSocket, so concurrent SSH clients don’t share state.

Requires OpenSSH 10.3+ on the local client

Section titled “Requires OpenSSH 10.3+ on the local client”

ShellWatch returns WebAuthn-format signatures (webauthn-sk-ecdsa-sha2-nistp256@openssh.com). Pre-10.3 OpenSSH internally canonicalises this to sk-ecdsa-sha2-nistp256@openssh.com and rejects the response as a signature-type mismatch. OpenSSH 10.3 (released 2026-04-02) relaxes the check.

Symptoms on older clients:

agent key ECDSA-SK SHA256:... returned incorrect signature type
sign_and_send_pubkey: signing failed for ECDSA-SK "" from agent: signature algorithm not supported

Fix:

Terminal window
brew install openssh # macOS — /usr/bin/ssh ships an older version
ssh -V # should report ≥ 10.3

A passkey sign is delegated to a browser via the SigningBridge + PendingAction flow. If no browser tab is open and connected to ShellWatch for your account, sign requests will fail. Web Push (if you’ve subscribed in Settings → Notifications) closes this gap somewhat — a tap on the push notification opens /sign/:id and re-establishes the WebSocket — but you have to complete the action within the 60-second TTL.

By default, ShellWatch performs the WebAuthn ceremony with userVerification: "required", so every signature carries the UV flag. You can relax this per endpoint in Settings → Endpoints if a specific authenticator can’t provide UV — but this is a client-side setting and is only as good as the server’s enforcement.

To make UV load-bearing, configure sshd on the target host to reject signatures whose UV bit is unset:

/etc/ssh/sshd_config
PubkeyAuthOptions verify-required

…or per-key in ~/.ssh/authorized_keys:

verify-required sk-ecdsa-sha2-nistp256@openssh.com AAAA... user@host

sshd ORs the global option with the per-key option — either source enables enforcement. With UV required, sshd parses sk_flags from the signature and rejects when SSH_SK_USER_VERIFICATION_REQD (0x04) isn’t set, logging user verification requirement not met.

For a hardened deployment, prefer the global form so the policy can’t be bypassed by a stale authorized_keys entry.

OpenSSH ≥ 9.x sends SSH_AGENTC_EXTENSION (type 27) for session binding before requesting identities. The Go proxy handles these locally (responds with SSH_AGENT_FAILURE) without forwarding to the server, working around a parse bug in ssh2’s AgentProtocol for unknown message types with payloads. Invisible in normal use.

SSH_AUTH_SOCK not set — make sure you exported it (export SSH_AUTH_SOCK=...) in the shell that’s running ssh. For sudo, see the PAM module guide.

No browser session available for WebAuthn signing — open the ShellWatch UI in a tab and retry. Or set up Web Push so a closed tab still gets nudged.

agent key returned incorrect signature type — your ssh is older than 10.3. See above.

Sign request expires before you can approve — the TTL is 60 seconds. Use Web Push to get the prompt onto your phone faster, or pick a passkey that lives on the device you’re already on.