* feat(auth): authenticate daemon clients through hello Give local CLI and desktop clients a per-run credential while remote clients send the host password inside the protocol. Keep relay passwordless admission for this mobile release so existing saved hosts keep working until the follow-up enforcement release. Move password recovery for saved hosts into host settings and keep pairing credentials in the add flow. Include isolated web QA evidence for both flows. * fix(auth): preserve Hub bootstrap and local credential expectations Hub-owned sockets already carry admission; keep their hello handling synchronous through session bootstrap while resolving credentials asynchronously for ordinary sockets. Keep the resolver out of lightweight configuration imports. Update CLI expectations for same-home credentials, let a rejected connection yield to another usable connection, and clear pairing secrets when add-flow targets change. * fix(pairing): retain passwords across edits to the same target Track the last valid pairing host while a URI is edited. This preserves an entered password for whitespace and partial edits but clears it when a different host becomes the target. * test(auth): align local credential checks across platforms Expect the CLI to authenticate through the credential in its selected home, and verify POSIX file modes only where Node exposes them. * feat(app): move host password into a settings row with a modal The host page rendered an always-visible password form above Name on every host. Replace it with a Password row under a Security section below Appearance; tapping it opens a modal with Save and, when a password is saved, Clear. The connection-error callout keeps its guidance and gains a Set password button that opens the same modal. Drop the unused pairing.hostPassword body/reopen/saveError strings. * chore: remove committed QA screenshots QA evidence stays out of the repository; the PR body describes it in words. * feat(app): keep the host password out of the host page The saved password is connection data, entered only when a host is added. A Password setting on the host page reads as the daemon's own password, so the row, modal, and callout button are gone. A rejected host shows the daemon's reason with guidance to remove the host and add it again with the password. useHostMutations no longer exposes setHostPassword; relay imports still set it inside the runtime. * feat(app): show host connection errors in an error alert HostConnectionError renders the existing Alert with the connection error as its title. Auth rejections add the re-add guidance as the description; other errors show the title only. The Overview and Connections pages share it. * style(app): soften alert borders to half-opacity accent * feat(app): size alerts on the button scale Alert takes a size prop (xs/sm/md/lg, default md) whose geometry lives next to the button's in control-geometry: one font size for title and description, the button icon size centered on the first text line, button padding and radius, and a one-line alert as tall as the button of the same size. Consumers pick sm among settings rows, cards, panels, and modals, and md when the alert is the whole page or sheet state. * style(app): roomier, rounder alerts with a tight title block Alert sm padding returns to the original 12/16 and every size is at least borderRadius.xl. Title and description sit a small gap apart with no line height override. The icon slot carries a zero-width text strut in the alert's font so the icon centers on the first line for titled, title-only, and description-only alerts. * refactor(app): align the alert icon with a centered lead row Drop the zero-width strut. The icon and the alert's first text (title, else description) share one row centered on each other; the description and actions sit below, indented by the icon slot width plus gap so they start at the lead text's left edge. * refactor(app): drop the unused Alert icon override
11 KiB
Security
Paseo follows a client-server architecture, similar to Docker. The daemon runs on your machine and manages your coding agents. Clients (the mobile app, CLI, or web interface) connect to the daemon to monitor and control those agents.
Your code never leaves your machine. Paseo is a local-first tool that connects directly to your development environment.
Architecture
The Paseo daemon can run anywhere you want to execute agents: your laptop, a Mac Mini, a VPS, or a Docker container. The daemon listens for connections and manages agent lifecycles.
Clients connect to the daemon over WebSocket. There are two ways to establish this connection:
- Relay connection — The daemon connects outbound to our relay server, and clients meet it there. No open ports required.
- Direct connection — The daemon listens on a network address and clients connect directly.
Relay threat model
The relay is designed to be untrusted. All traffic between your phone and daemon is end-to-end encrypted. The relay server cannot read your messages, see your code, or modify traffic without detection. Even if the relay is compromised, your data remains protected.
How it works
- The daemon generates a persistent Curve25519 keypair on first run and stores it at
$PASEO_HOME/daemon-keypair.jsonwith mode0600 - The pairing URL (rendered as a QR code or opened directly) carries the daemon's public key in its URL fragment (
https://app.paseo.sh/#offer=...). Fragments are not sent to the web server, soapp.paseo.shnever sees the key. - When the phone connects via the relay, it generates a fresh ephemeral Curve25519 keypair and sends an
e2ee_hellomessage containing its public key. The daemon will not process any application messages until this handshake completes. - Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl
box). The encrypted bundle is[24-byte nonce][ciphertext]. Peers optionally negotiatebinaryCiphertextine2ee_hello/e2ee_ready: negotiated application text is carried as a base64 WebSocket text frame, while application binary is carried as a raw WebSocket binary frame. A peer that does not negotiate the capability uses base64 text frames for both kinds.
The WebSocket opcode is preserved end to end after negotiation; the receiver never guesses whether authenticated plaintext is text or binary from its byte contents. The plaintext handshake remains WebSocket text and contains only public keys and capability declarations.
The relay sees only: IP addresses, timing, message sizes, session IDs, and the plaintext e2ee_hello / e2ee_ready handshake frames (which contain only public keys). It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
Why the relay can't attack you
The daemon requires a valid cryptographic handshake before processing any commands. A compromised relay cannot:
- Impersonate the daemon to your phone — Without the daemon's secret key, it cannot derive the shared key, so any traffic it injects fails authenticated decryption on the phone
- Send commands as you — The daemon only accepts traffic that decrypts and authenticates under a shared key derived with its own secret key. The phone's keypair is ephemeral per connection, so there is no persistent phone-side secret to steal; protection comes from the daemon's secret key never leaving the daemon.
- Read your traffic — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake
- Forge messages — NaCl box provides authenticated encryption; tampered messages are rejected
- Replay old messages across sessions — Each session derives fresh encryption keys, so ciphertext from one session cannot be replayed into another session. Within a live session, replay protection is not yet implemented; the protocol uses random nonces and does not track nonce reuse or message counters.
Trust model
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
When a daemon password is configured, new relay clients send it in the encrypted hello message. This release still admits relay clients that send no credential so existing mobile builds continue to connect. A wrong password is rejected. The next release will require the password for relay connections after updated mobile builds are available.
Local daemon trust boundary
By default, the daemon binds to 127.0.0.1. With no password configured, anything that can reach the daemon socket can control the daemon. Loopback is reachable by other users on the machine and by some forwarding tools.
The daemon supports an optional shared-secret password (set via auth.password in config.json or the PASEO_PASSWORD env var; stored bcrypt-hashed). WebSocket clients send the password in hello; the daemon sends no session data before admission. Direct connections still accept bearer headers and WebSocket bearer subprotocols for older clients. HTTP stays bearer-header based. Health (GET /api/health) and CORS preflight (OPTIONS) are exempt; /api/files/download and /mcp/agents use their own capability tokens.
The daemon writes a new $PASEO_HOME/local-credential on every run with mode 0600 and removes it on shutdown. The CLI and desktop main process read it only for the daemon whose PID lock listen matches their connection target. A same-user process can read this credential, so the password protects against network clients and other OS users, not processes running as the daemon user. Protect $PASEO_HOME accordingly. Relay traffic remains end-to-end encrypted independently of password admission.
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
When Paseo checks out a change request from a different repository, it does not run that workspace's paseo.json setup, automatic terminals, named scripts, or teardown until you explicitly run setup for that workspace. The decision lasts for the workspace and does not re-prompt after new commits. Same-repository changes, ordinary branches, local workspaces, agent launches, terminals, explicit shell commands, and metadata-generation instructions are outside this gate.
If you expose the daemon beyond loopback, such as by binding to 0.0.0.0, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
In Docker, the official image runs the daemon and agents as the non-root
paseo user by default. Mounted workspaces and credentials are still fully
available to anything the agents run inside the container.
For remote access, use the relay connection. It is the supported path for reaching the daemon off-machine, and it adds end-to-end encryption plus a pairing handshake before commands are accepted.
Host header validation and CORS origin checks are defense-in-depth controls for localhost exposure. They help block DNS rebinding and browser-based attacks, but they do not replace network isolation.
DNS rebinding protection
CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding).
Paseo validates the Host header on every HTTP request and every WebSocket upgrade against an allowlist (Vite-style semantics). By default, only localhost, *.localhost, and any literal IP address (IPv4 or IPv6) are accepted. Additional hostnames can be configured via hostnames in config.json or the PASEO_HOSTNAMES env var (comma-separated; entries beginning with . match a domain and its subdomains; the value true disables the allowlist entirely). Requests with unrecognized hosts are rejected with 403 Host not allowed.
HTML file preview
Previewing an .html file in the file pane renders it as a page, so markup an agent wrote — or markup that arrived with a repo you cloned — executes when you open it. The preview is built to contain that, not to trust it.
The document loads with an opaque origin and a policy that permits inline script and style and refuses everything else: no remote script, font, image, or media; no fetch, XHR, WebSocket, or beacon; no form posts; no plugins; no nested frames. It has no access to Paseo's DOM, and storage and cookie APIs throw inside it rather than returning anything. It cannot navigate the top window, and it cannot open popups. It cannot read any file but itself.
One gap remains on web and desktop: a sandboxed document may navigate itself, and no CSP directive in current browsers prevents that. navigate-to was dropped from CSP Level 3 and is not enforced, and <meta http-equiv="refresh"> needs no script at all. A hostile page can therefore reach a server by navigating away, carrying data available inside the preview, such as its own contents, browser and device properties, user input inside the page, and your IP address. It cannot read Paseo, another file, storage, or cookies.
Native builds narrow this gap rather than closing it outright. The WebView refuses every navigation after the initial document, but that decision is made in the app's JavaScript, and on Android the WebView falls back to allowing a navigation when the decision doesn't come back in time. Treat it as a strong mitigation, not a guarantee: if the JS thread is stalled at the moment a page navigates, the same leak is possible there too.
If you don't trust a page, read it in Source, which executes nothing. Source is available as an editable view on supported web hosts and a read-only view everywhere else.
Agent authentication
Paseo wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their authentication. Each agent provider handles its own credentials. Paseo never stores or transmits provider API keys. Agents run in your user context with your existing credentials.
Forge host trust
Paseo only talks to a forge host that is either a known cloud host or one the forge CLI is already authenticated to. It never probes or routes credentials to an unauthenticated, remote-derived host.
Reporting vulnerabilities
If you discover a security vulnerability, please report it privately by emailing hello@moboudra.com. Do not open a public issue.