Clisbot 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. Clisbot is a local-first tool that connects directly to your development environment.
The Clisbot 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.
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.
- The daemon generates a persistent Curve25519 keypair on first run and stores it at
$CLISBOT_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.clisbot.com/#offer=...). Fragments are not sent to the web server, soapp.clisbot.comnever sees the key. - When the phone connects via the relay, it generates fresh ephemeral keys and sends an
e2ee_hellomessage. The daemon will not process any application messages until this handshake completes. - Updated peers run a Noise
NKhandshake (Noise_NK_25519_ChaChaPoly_SHA256) keyed to the daemon's static key: thee2ee_hellocarries the first Noise message and the daemon'se2ee_readycarries the second. Every later frame is a counter-nonced ChaCha20-Poly1305 record. When either side predates this, the session falls back to the legacy construction: a Curve25519 ECDH shared key with XSalsa20-Poly1305 (NaClbox) and a random[24-byte nonce][ciphertext]bundle. Both modes negotiatebinaryCiphertext: 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. docs/relay-endpoint-security.md has the exact mapping.
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.
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 a paired device — With device pairing enabled, an encrypted handshake alone grants no authority. Admission also requires a signed device proof or a redeemable invitation; managed
externalsessions require a Hub ticket. Legacy admission has different rules, described below. - Read your traffic — After the handshake every message is encrypted: ChaCha20-Poly1305 in protected mode, XSalsa20-Poly1305 (NaCl box) in legacy mode
- Forge messages — Both constructions are authenticated encryption; a tampered message is rejected in either mode
- Replay old messages — In protected mode the daemon contributes a fresh ephemeral key to every session and every record carries a strictly increasing counter, so a recorded session cannot be replayed into a new one and a duplicated, reordered, or reflected record closes the session. Legacy mode has neither property: it uses random nonces, and a daemon that still admits legacy clients accepts a recorded legacy transcript (hello, credentials, commands) as a new session. Set
daemon.relay.requireProtectedSecurity: trueto refuse legacy clients once every client you use has updated.
Clisbot legacy receive replay checks require an updated receiving endpoint; older receivers still lack them. Updated legacy receivers accept random nonces with a bounded ledger of 4,096 prefixes per channel. A legacy sender reaching that limit must reconnect or update; the receiver closes instead of evicting evidence and permitting an old replay. Current senders use one prefix, so long sessions do not fill this ledger. These checks assume the existing ordered WebSocket transport and preserve the NaCl bundle, handshake and application schemas.
Android may select an optional native receive codec before the encrypted handshake, after a known-answer capability check. For negotiated legacy NaCl frames, a dedicated serial worker authenticates with pinned libsodium and decodes strict UTF-8/binary; the existing channel still owns replay admission and ordered delivery. A native failure never retries the same frame synchronously or admits plaintext. Input failures close the channel; infrastructure failures may disable the native implementation for subsequent fresh channels. Legacy receive and platforms without this capability retain the existing implementation. Queue overload closes rather than dropping updates. Source provenance, bounds, copying and lifecycle contracts are maintained in the native codec owner.
The encrypted handshake buffer is bounded to 64 frames and 1 MiB while the ready send is pending. Hub ingress requires a verified persistent device proof within 15 seconds; public discovery and ephemeral account-entry proofs cannot keep an anonymous slot indefinitely. The deadline terminates the socket without waiting for a peer's close acknowledgement. Client requests reconnect after a long sign-in interaction. These bounds limit retained resources; they do not provide protection against a distributed flood.
The QR code pins the backend's public encryption key. A legacy V2 offer contains public metadata; knowledge of that key alone is not a client authentication mechanism. Restrict legacy admission independently.
New V3/V4 pairing links also contain an invitation secret. Keep these links private: an invitation grants one device the approved backend scope and expires after five minutes by default. Its verifier is stored at the backend; redemption binds it to an Ed25519 device key. A fresh signed retry from that key can recover the same credential within the invitation lifetime. Device proofs bind the backend identity, credential, operation, body, timestamp and nonce; reused proof nonces are rejected. Hub and daemon have separate credentials and durable revocation records.
Existing clisbot onboarding provisions protected daemon access in a fresh personal home. Hub startup is explicit through the app or clisbot hub start --personal; there is no additional public serve command. Managed Access off requires the daemon's own device credential; a Hub owner account cannot replace it. external uses the Hub's existing grants, tickets and leases. A Hub can operate without account login, but enabling its persistent login policy requires every paired device to sign in before management or ticket issuance. Hub revocation invalidates its device leases and notifies connected daemons; a disconnected daemon is fenced at lease expiry.
For an initialized account-required Hub, a URL opens a bounded, device-key-bound login exchange without granting management access. Only admitted account authentication issues persistent login credentials. An uninitialized owner still needs operator-approved pairing and a separate one-use setup grant; merely reaching the URL cannot claim the Hub. Sign-in sessions bind their refresh authority and managed leases to the authenticated device. Signing out a session removes that session's authority; an allowed account can authenticate again.
Hub traffic uses its own pinned encrypted channel, directly or through a separate relay identity. The web/gateway only forwards fixed service namespaces and does not issue authority. Tailscale Serve exposes that gateway over HTTPS; tailnet membership does not replace device authentication. Native credentials use SecureStore, desktop credentials use OS encryption, and web credentials use non-extractable IndexedDB keys. Browser script compromise can still use credentials available to that origin; keep the app origin trusted.
When a daemon password is configured, relay clients send it in the encrypted hello message, so the relay never sees it. The daemon rejects a relay client that sends a wrong password or none, so a leaked pairing link alone does not grant access to a password-protected daemon.
With device pairing enabled, /mcp/agents requires the per-run MCP capability token injected into local agent configurations. Neither an absent daemon password nor a legacy password bearer opens this endpoint. Keep that token private; its tools control agents and terminals.
The daemon normally binds to 127.0.0.1. In legacy mode, with no password configured, anything that can reach the socket can control it. Protected device admission also applies on loopback. Loopback is reachable by other users on the machine and by forwarding tools.
Legacy daemon admission supports an optional shared-secret password (set via auth.password in config.json or the CLISBOT_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. Legacy HTTP stays bearer-header based. Protected HTTP requires a request-bound signed device proof or the OS local operator credential. 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 $CLISBOT_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 $CLISBOT_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 Clisbot checks out a change request from a different repository, it does not run that workspace's clisbot.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
clisbot user by default. Mounted workspaces and credentials are still fully
available to anything the agents run inside the container.
Personal serving prefers Tailscale HTTPS and supports encrypted relay or a user-managed HTTPS proxy. All protected routes require admission; encryption alone does not authenticate a client.
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.
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).
Clisbot 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 CLISBOT_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.
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 Clisbot'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 Clisbot, 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.
Clisbot wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their authentication. Each agent provider handles its own credentials. Clisbot never stores or transmits provider API keys. Agents run in your user context with your existing credentials.
Clisbot 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.
If you discover a security vulnerability, please report it privately by emailing clisbot@gmail.com. Do not open a public issue.