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.
Where you run it
Section titled “Where you run it”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 pushto a self-hosted forge,sshinto prod,scpconfigs 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_SOCKfrom its parent process. The agent cansshandgitfreely — 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
sshhops 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
sshto 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.
1. Install the agent client
Section titled “1. Install the agent client”Homebrew (macOS, Linux) — recommended
Section titled “Homebrew (macOS, Linux) — recommended”brew install rado0x54/tap/shellwatch-agentBrew 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).
Manual download
Section titled “Manual download”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):
# Substitute the platform you need and the latest agent version from the releases page.VERSION=v0.1.0PLATFORM=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-agentsudo mv shellwatch-agent /usr/local/bin/From source
Section titled “From source”git clone https://github.com/rado0x54/ShellWatch.gitcd ShellWatch/agent-clientmake build # version pulled from `git describe`# or: make build VERSION=0.1.0make 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.
2. Authorize the device with login
Section titled “2. Authorize the device with login”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.
shellwatch-agent login # default: app.shellwatch.aishellwatch-agent login --server https://shellwatch.example.comYou can hold credentials for multiple servers simultaneously (login keys them by URL). To remove stored credentials:
shellwatch-agent logoutshellwatch-agent logout --server https://shellwatch.example.comlogout 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:
| Platform | Primary store | Fallback (mode 0600) |
|---|---|---|
| macOS | Keychain | ~/Library/Application Support/shellwatch/credentials |
| Linux | libsecret D-Bus (gnome-keyring / KWallet) | ${XDG_CONFIG_HOME:-~/.config}/shellwatch/credentials |
| Windows | DPAPI / 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.
3. Run it
Section titled “3. Run it”shellwatch-agent # picks up the credentials from the credstoreeval "$(shellwatch-agent --print-env)" # tells your shell where the socket isVerify:
ssh-add -l # lists your registered passkeysssh user@host # uses ShellWatch to sign| Flag | Env var | Description |
|---|---|---|
--server | SHELLWATCH_SERVER | Broker URL (default: https://app.shellwatch.ai) |
--token | SHELLWATCH_TOKEN | Static agent-scoped bearer token. Skips the credstore lookup. |
--socket | SHELLWATCH_AGENT_SOCK | Listener path (default: platform-appropriate; see below) |
--insecure | — | Allow ws:// (daemon) or http:// (login) — local dev only |
--print-env | — | Print 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.sockif set, else${TMPDIR:-/tmp}/shellwatch-agent-<uid>.sock. - macOS:
${TMPDIR}/shellwatch-agent-<uid>.sock— note$TMPDIRis 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, sossh.exefinds it withoutSSH_AUTH_SOCK).
Static bearer token (CI / headless)
Section titled “Static bearer token (CI / headless)”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:
shellwatch-agent --token "$TOKEN"SHELLWATCH_TOKEN="$TOKEN" shellwatch-agentA 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.
Run as a daemon
Section titled “Run as a daemon”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.
Homebrew (brew services)
Section titled “Homebrew (brew services)”If you installed via the tap:
shellwatch-agent login # one-timebrew services start shellwatch-agentBrew 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.
brew services listbrew services restart shellwatch-agentbrew services stop shellwatch-agentLogs 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.
Windows
Section titled “Windows”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:
Stop-Service ssh-agentSet-Service ssh-agent -StartupType Disabled
.\shellwatch-agent.exe login.\shellwatch-agent.exe # foreground daemonInvoke-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.
How it works end-to-end
Section titled “How it works end-to-end”ssh client ──► Unix socket ──► shellwatch-agent ──WSS──► ShellWatch /agent-proxy │ └─ passkey sign (PendingAction → /sign/:id)sshopens a connection toSSH_AUTH_SOCKand sends an SSH agent protocol frame.shellwatch-agentopens a per-connection WebSocket to/agent-proxyand relays the frame as a binary message. It advertisesX-ShellWatch-Hostname,-OS,-Versionhandshake headers so the approver knows which host is asking.- ShellWatch’s
AgentProtocolinstance 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.
- The signed response goes back through the WebSocket to the local Unix socket, and
sshcompletes 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 typesign_and_send_pubkey: signing failed for ECDSA-SK "" from agent: signature algorithm not supportedFix:
brew install openssh # macOS — /usr/bin/ssh ships an older versionssh -V # should report ≥ 10.3A browser session must be open
Section titled “A browser session must be open”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.
Enforcing user verification on the server
Section titled “Enforcing user verification on the server”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:
PubkeyAuthOptions verify-required…or per-key in ~/.ssh/authorized_keys:
verify-required sk-ecdsa-sha2-nistp256@openssh.com AAAA... user@hostsshd 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.
Newer OpenSSH session-binding extension
Section titled “Newer OpenSSH session-binding extension”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.
Troubleshooting
Section titled “Troubleshooting”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.