Skip to content

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.

TriggerWhenApproval is for
endpoint-authA user (Web UI) or agent (MCP) opens an SSH session to an endpoint that uses a passkey.One SSH handshake to one endpoint.
agent-forwardingA 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-proxyA 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).

When a sign is requested:

  1. The WebAuthnSshAgent calls onSignRequest with a resolve(signature) / reject(error) pair.
  2. SigningBridge packages this into a PendingAction — a record with an ID, a context (one of the three kinds above), and a 60-second TTL.
  3. The NotificationDispatcher fans the action out to every registered NotificationChannel:
    • WebSocketChannel sends sign:request to every browser tab open for the target account.
    • PushChannel sends a Web Push notification to OS-level notifications, if VAPID is configured and the account has subscribed.
  4. The user opens /sign/:id (from the toast, the push notification, or directly).
  5. The page loads context via GET /api/actions/:id. For agent-forwarding, it also loads GET /api/sessions/:id/tail so 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.
  6. The user reviews and approves: POST /api/actions/:id/resolve with the WebAuthn assertion payload.
  7. Store.resolve() calls the stored resolve callback → ssh2 gets the signature → the SSH handshake completes.
  8. 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.

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 (ui or mcp)
  • 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, -Version handshake 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.

ChannelDeliveryWhen it fires
WebSocket toastLive, in any open browser tab for the accountWhenever the tab is open
Web PushOS-level notification (PWA-style)When the tab is closed or backgrounded; user must subscribe in Settings → Notifications

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.

  • 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.