Phone approvals
Approve a held action from your phone with a passkey. The hosted relay is not available yet; terminal approvals work now.
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
phone pairchecks 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).- The terminal shows a QR code for an
approve.launchsafe.compairing 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. - 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.
- 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".
- The relay confirms. The phone is added to
keys/phones.jsonand every installed hook command is re-pinned withLAUNCHSAFE_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:
phones.jsonhashes to the pin in every installed hook command, and the credential is in it.- The client data is a WebAuthn
getfor the expected challenge, from the pinned origin, not cross-origin. - The authenticator data matches the pinned relying party, with user presence and verification set (
phone.requireUserVerificationcannot be turned off in 0.3). - The signature verifies with the pinned key.
- The signature counter grows (0 and 0 is a synced passkey, accepted).
- 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.
- The firewall's clock is before the item's expiry (created plus
phone.itemTtlMinutes), and the issue time is withinphone.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
| Threat | Protection |
|---|---|
| The relay replays a decision for the same item | Counter (check 5), single-use nonce, the item is no longer pending |
| The relay delivers item A's decision as item B's | Item id, action hash, display hash and nonce are signed; the reference must belong to the same item |
| Another machine of the same person | The device id is signed |
| The decision arrives after the item expired | Refused (check 7); the item stays queued for the terminal |
| The policy was tightened after the item was sent | The 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 given | The hook ignores it after its window (60 minutes by default) |
| A phone approval line written by hand or copied between sessions | Corrupt: does not verify, or names another session |
A key appended to phones.json | The pin no longer matches: every phone approval refused; nothing is sealed to the list |
| Terminal and phone decide the same item | First 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)
| Situation | Behaviour |
|---|---|
| Relay down | The daemon backs off (up to 5 minutes); status shows "phone relay unreachable since ..."; doctor warns; terminal approvals unaffected |
| Machine offline or asleep | Items stay in the queue; uploaded on wake with their real expiry (shown expired, then withdrawn) |
| Daemon not running | No phone approvals; doctor fails "phone relay daemon not running" |
| Device unpaired in the LaunchSafe app | Uploads refused (401); the registration is reset and the daemon stops; phone pair registers again. Phone approvals already stored in sessions stay valid |
| Fair-use refusal | The item stays terminal-only; recorded and shown by doctor |
phones.json unreadable or not matching the pin | Every phone approval refused; doctor fails |
| Clock moved backwards more than 5 minutes | status 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
| Key | Default | A project file may | User or managed only |
|---|---|---|---|
phone.enabled | true | Set false | Set true |
phone.itemTtlMinutes | 30 | Lower it | Raise it (up to 720) |
phone.useWithinMinutes | 60 | Lower it | Raise it (up to 1440) |
phone.clockSkewMinutes | 10 | Lower it | Raise it (up to 30) |
phone.requireUserVerification | true | Nothing | Nothing (cannot be false in 0.3) |
phone.approvable | ["approval"] | Remove kinds | Nothing |
phone.excludeRules | [] | Add rules | Add or remove |
phone.holdSeconds | 0 | Lower it | Raise it (capped as above) |
phone.relayOrigin | https://approve.launchsafe.com | Nothing | Change 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.