Sign requests & approvals
A sign request is the moment ShellWatch needs a human to authorise something — a WebAuthn passkey signature for a new SSH connection or an agent-proxy operation. The whole approval flow is unified by the PendingAction system.
What triggers a sign request
Section titled “What triggers a sign request”| Trigger | When | Approval is for |
|---|---|---|
endpoint-auth | A user (Web UI) or agent (MCP) opens an SSH session to an endpoint that uses a passkey. | One SSH handshake to one endpoint. |
agent-forwarding | A running SSH session forwards an auth-agent@openssh.com channel — i.e. you ran ssh -A and the remote shell is now asking the broker to sign for its outbound SSH. | One downstream SSH handshake. |
agent-proxy | A shellwatch-agent Go client running on someone’s workstation requests a sign. | One SSH handshake from that workstation. Every sign — even ones a self-hosted instance might back with a file key — surfaces a PendingAction here, because that’s the whole point of the agent proxy: a human gate on every signature. |
Each trigger carries different metadata, surfaced to the approver as a discriminated union (SignRequestContext).
Anatomy of an action
Section titled “Anatomy of an action”When a sign is requested:
- The
WebAuthnSshAgentcallsonSignRequestwith aresolve(signature)/reject(error)pair. SigningBridgepackages this into aPendingAction— a record with an ID, a context (one of the three kinds above), and a 60-second TTL.- The
NotificationDispatcherfans the action out to every registeredNotificationChannel:WebSocketChannelsendssign:requestto every browser tab open for the target account.PushChannelsends a Web Push notification to OS-level notifications, if VAPID is configured and the account has subscribed.
- The user opens
/sign/:id(from the toast, the push notification, or directly). - The page loads context via
GET /api/actions/:id. Foragent-forwarding, it also loadsGET /api/sessions/:id/tailso you can see what the parent session was doing right before it asked for the sign — useful when an interactive command is what triggered the request. - The user reviews and approves:
POST /api/actions/:id/resolvewith the WebAuthn assertion payload. Store.resolve()calls the storedresolvecallback →ssh2gets the signature → the SSH handshake completes.- The browser navigates to
redirectTo(e.g./session/<new-session-id>) so the approver lands on the running session.
If the user denies (POST /api/actions/:id/deny) or the 60-second TTL expires, the reject callback fires — ssh2 sees a sign failure, the SSH connection is aborted.
sign:resolved broadcasts on the WebSocket clear toasts on every other tab open for the account.
Every outcome — approved, denied, expired, or cancelled (the requester gave up before approval) — is persisted to audit_signing_requests with the full SignRequestContext snapshotted at the time of the decision, viewable later at /audit/signings. See Concepts → Audit log.
What the approver sees
Section titled “What the approver sees”The /sign/:id page tries to give the approver enough context to make a real decision rather than waving things through:
For endpoint-auth:
- Endpoint label and address (
ubuntu@dev.example.com:62222) - Trigger source (
uiormcp) - For
mcp: the MCP client’s self-reported name and version (Claude Code 0.4.21)
For agent-forwarding:
- Endpoint context as above
- The parent session’s recent output as a read-only snapshot
For agent-proxy:
- Source IP of the agent client
- Self-reported hostname / OS / version of the workstation (from
X-ShellWatch-Hostname,-OS,-Versionhandshake headers)
Self-reported fields are rendered in a separate “self-reported” block, not next to the trustworthy fields, so an approver can’t mistake a spoofable string for authoritative identity.
Notification channels
Section titled “Notification channels”| Channel | Delivery | When it fires |
|---|---|---|
| WebSocket toast | Live, in any open browser tab for the account | Whenever the tab is open |
| Web Push | OS-level notification (PWA-style) | When the tab is closed or backgrounded; user must subscribe in Settings → Notifications |
Approval is decoupled from the requester
Section titled “Approval is decoupled from the requester”A subtle but important property: the browser that approves does not need to be the same browser that initiated the request. Examples:
- An MCP agent triggers a sign while you’re not at your laptop. Your phone gets a Web Push, you tap it, the iOS browser opens
/sign/:id, you complete the WebAuthn ceremony with your phone’s platform authenticator (assuming you registered a passkey on it), and the agent’s session opens. - An on-call team shares one operator account, with each member’s personal authenticator registered as a passkey on it. Whoever is on-call gets the prompts and approves with their own key. (Pending actions are scoped to the account, so this is per-account by design — not cross-account.)
This is why PendingAction lives in a separate store rather than being attached to the requester’s WebSocket — the approver and the requester are different sessions, often on different devices.
Limits
Section titled “Limits”- 60-second TTL — generous enough to fish out a YubiKey, short enough that an old prompt can’t be approved minutes later.
- In-memory store — pending actions don’t survive a broker restart. The requester sees a sign failure and can retry.
- One approver per action — once resolved or denied, the action is final.