Phone approvals

Approve a held action from your phone with a passkey. The hosted relay is not available yet; terminal approvals work now.

Edit on GitHub

Approve or reject a held action from your phone when nobody is at the terminal. The approval is signed by a passkey on the phone (WebAuthn, user verification required) and verified by the firewall itself against keys pinned in the hook command. LaunchSafe's relay only carries sealed items and signed decisions: it holds no key, so neither the relay nor the agent can approve anything.

The relay and the installable approval web app (approve.launchsafe.com) are LaunchSafe's hosted service, not part of the firewall's repository.

  • It is planned to need a LaunchSafe account once the relay is available; pricing is not decided.
  • Only approval items go to the phone. Proposals (instruction, memory and persistence changes) and scope widenings stay in the terminal, which shows the diff; the phone only sees a count of them.
  • The passphrase approver stays. A phone is an additional signer; pairing one needs the passphrase. With no phone paired nothing changes.

Commands

launchsafe-firewall phone pair [--label NAME] [--no-login-item]   # person-run: terminal + approval passphrase
launchsafe-firewall phone list [--json]                            # read-only
launchsafe-firewall phone remove <label|credential id>             # person-run (alias: phone unpair)
launchsafe-firewall relay status [--json]                          # read-only
launchsafe-firewall relay run [--once]                             # the daemon (a login item after phone pair)

phone pair, phone remove and relay run are state-changing: from an agent's tool call they are denied (FW-TAMPER). So is stopping the daemon.

Pairing

  1. phone pair checks for a person at the keyboard and the approval passphrase, creates the device's ECDSA P-256 signing key (mode 600) and starts a pairing on the relay (signed request).
  2. The terminal shows a QR code for an approve.launchsafe.com pairing address whose fragment holds a pairing id, 32 random bytes and the first 16 hex characters of the device key's fingerprint. The fragment never reaches a server. It also prints a comparison code.
  3. The phone creates a new passkey used only for approvals (user verification required), an ECDH P-256 key, and MACs its answer with the secret from the QR code.
  4. The firewall polls the relay, checks the MAC (a relay that swaps in its own passkey cannot make it: it never saw the secret), checks the registration (client data, challenge, pinned origin, user presence and verification, a supported algorithm: ES256, RS256 or EdDSA), shows the phone's label and the code, and waits for "yes".
  5. The relay confirms. The phone is added to keys/phones.json and every installed hook command is re-pinned with LAUNCHSAFE_FIREWALL_PHONES='sha256:<hex>', the SHA-256 of the file's canonical JSON, next to the approver pin. Then the daemon is installed as a login item (launchd agent on macOS, systemd user unit on Linux).

Nothing is written before step 5. A key appended to phones.json by anything else changes its hash: every phone approval is refused and doctor fails until a person re-pins (which needs the passphrase).

What the phone sees

The daemon builds an item from fields the firewall controls (the rule catalogue's fixed sentence, the one-line summary, up to 3 reasons, the last 2 trust sources, the destination, rule ids, vault references with kind, the machine label, the first 6 characters of the action hash), never from raw tool input. Every free-text field is sanitized: secrets redacted, cards, IBANs and national ids masked ([card ••••4242]), control, bidi and zero-width characters escaped, workspace paths relative and home paths ~/..., URLs cut to scheme, host and first path segment, at most 300 characters per field and 6 KB per item.

Each item is sealed per paired phone: an ephemeral ECDH P-256 key with the phone's key, HKDF-SHA256 to an AES-256-GCM key, then the device's ECDSA signature over the sealed blob. The phone checks the signature against the device key it pinned at pairing before decrypting.

Decisions and verification

The phone signs a challenge derived from a canonical record naming the device, the item, the session, the action hash, the decision (approve or reject), a hash of what was displayed, a nonce and the time. The daemon long-polls the relay and checks, in order:

  1. phones.json hashes to the pin in every installed hook command, and the credential is in it.
  2. The client data is a WebAuthn get for the expected challenge, from the pinned origin, not cross-origin.
  3. The authenticator data matches the pinned relying party, with user presence and verification set (phone.requireUserVerification cannot be turned off in 0.3).
  4. The signature verifies with the pinned key.
  5. The signature counter grows (0 and 0 is a synced passkey, accepted).
  6. The record names this device and a pending queue item with that action hash and session, its display hash and its unused nonce, and the relay delivered it under that item's reference.
  7. The firewall's clock is before the item's expiry (created plus phone.itemTtlMinutes), and the issue time is within phone.clockSkewMinutes.

Before check 6 the item must still be one a phone may decide under the policy in effect now (its project's): phone.enabled, phone.approvable and phone.excludeRules. Tightening the policy after an item reached the phone therefore reaches that item: its decision is refused, the item stays pending for the terminal, and the phones are told it was refused. The daemon's withdrawal pass, every few seconds, also pulls a pending item already on the phone once it has expired or once the current policy no longer lets a phone decide it. A policy file that does not parse fails closed the same way.

Checks 6 and 7 run again under the queue lock, where the decision is applied (first decision wins). An approval is recorded in the session with a window of phone.useWithinMinutes, spends the nonce, marks the item approved by phone: <label> and is logged with via: "phone". A signed rejection rejects the item. Either way the daemon withdraws the item so the phones stop showing it. A refused decision is logged with the check number and leaves the item pending.

The hook re-verifies every stored phone approval from the assertion it carries, for this session's tag and a device id this machine holds. A failing event is corrupt (the session turns untrusted, exactly as for a forged passphrase approval). An approval past its window is ignored, not corrupt, and logged once so you can see why it did not count. The retry of the exact call is then allowed once (FW-APPROVED).

An approval never turns a proposal hold into an allow. A phone approval, or a passphrase approval of an approval item, lets through only an action that still needs approval. If the session has since read outside content and the same change is now held as a proposal, the retry is held as a proposal in the terminal, with its diff.

Gateway-only clients

A client with no firewall hook (Claude Desktop, VS Code) decides through the MCP gateway's stdio wrapper, so the pin travels there too: mcp wrap-config writes --phones sha256:<hex> into the wrapper's arguments. phone pair and phone remove re-pin the wrappers in the same pass as the hook commands; restart the client for the new arguments to take effect. An entry changed since it was wrapped is reported and left alone, so phone approvals are not honoured for it (the terminal decides) and doctor warns. mcp serve pins the list as it is when you start it, so a phone paired or removed later needs a restart.

Replay and stale approvals

ThreatProtection
The relay replays a decision for the same itemCounter (check 5), single-use nonce, the item is no longer pending
The relay delivers item A's decision as item B'sItem id, action hash, display hash and nonce are signed; the reference must belong to the same item
Another machine of the same personThe device id is signed
The decision arrives after the item expiredRefused (check 7); the item stays queued for the terminal
The policy was tightened after the item was sentThe item is withdrawn from the phones; a decision for it is refused; it stays queued for the terminal
The approval is used long after it was givenThe hook ignores it after its window (60 minutes by default)
A phone approval line written by hand or copied between sessionsCorrupt: does not verify, or names another session
A key appended to phones.jsonThe pin no longer matches: every phone approval refused; nothing is sealed to the list
Terminal and phone decide the same itemFirst under the queue lock wins; the other is refused and the item withdrawn

The honest residual: the approval web app computes the challenge, so compromised web-app code could show item A while signing B. The firewall accepts that only if B is a real pending item on this machine with its unused nonce inside its TTL. The action code (first 6 characters of the action hash) on the phone, which queue list prints beside each held item (code 9f3ac1) so you can match phone to terminal, the separate origin with a strict CSP, and phone.excludeRules limit it.

Offline and failures (fail closed)

SituationBehaviour
Relay downThe daemon backs off (up to 5 minutes); status shows "phone relay unreachable since ..."; doctor warns; terminal approvals unaffected
Machine offline or asleepItems stay in the queue; uploaded on wake with their real expiry (shown expired, then withdrawn)
Daemon not runningNo phone approvals; doctor fails "phone relay daemon not running"
Device unpaired in the LaunchSafe appUploads refused (401); the registration is reset and the daemon stops; phone pair registers again. Phone approvals already stored in sessions stay valid
Fair-use refusalThe item stays terminal-only; recorded and shown by doctor
phones.json unreadable or not matching the pinEvery phone approval refused; doctor fails
Clock moved backwards more than 5 minutesstatus and doctor warn (windows were longer by that much)

No network is ever used in a hook decision: the hook only queues. The daemon is the only process that talks to the relay.

Hold while the phone answers (phone.holdSeconds)

Off by default. When set and a phone is pinned, a queued approval item makes the hook wait for a verified phone approval; if it arrives in time the same call is decided again and allowed once. The wait is capped at the agent's hook timeout minus 10 seconds (20 s for every supported agent today), because Codex and Gemini CLI let a call run when a hook times out. doctor warns when the policy asks for more than the cap.

Policy

KeyDefaultA project file mayUser or managed only
phone.enabledtrueSet falseSet true
phone.itemTtlMinutes30Lower itRaise it (up to 720)
phone.useWithinMinutes60Lower itRaise it (up to 1440)
phone.clockSkewMinutes10Lower itRaise it (up to 30)
phone.requireUserVerificationtrueNothingNothing (cannot be false in 0.3)
phone.approvable["approval"]Remove kindsNothing
phone.excludeRules[]Add rulesAdd or remove
phone.holdSeconds0Lower itRaise it (capped as above)
phone.relayOriginhttps://approve.launchsafe.comNothingChange it (https origin, or http://127.0.0.1:<port> for a local relay)

These keys belong to schema version 3 and are still read from a version 2 file. See Policy.

Doctor

Nothing is checked until a phone is paired. Then: the phone pin (the list matches every hook command's pin), the relay (registered, not revoked, reachable, sealable, within fair use), the relay daemon (heartbeat within 2 minutes from a live process), the clock, and the hold when one is set. These are not part of the protection score.

Files

In the protected data directory (an agent writing any of them is denied): keys/phones.json, keys/relay-device.json, relay/outbox/<itemId>.json, relay/nonces.json, relay/counters.json and relay/state.json.

On this page