Server security
Tokens, TLS and proxies, browser origins, limits and the audit log of zidane-server, and what a client token lets its holder do.
A client token can run commands on the server, as the server's user, and read whatever that user can read. An admin token runs any command directly: terminals, ! commands and sessions.workspaceExec. A chat token runs any command through the agent, because it approves the agent's calls and sets a session's rules. Treat a token like an SSH key. Run the server under its own user, with only the folders it needs in its workspace roots.
Threat model#
| Threat | What stops it |
|---|---|
| Someone on the network reads a token or a transcript | TLS off loopback. The server refuses to listen in the clear, and clients refuse ws:// to anything but loopback |
| A stolen or lost device | One token per device. Revoking it drops its connections within 2 seconds |
| A web page uses a token it got hold of | Browser upgrades need their Origin on the allowlist, empty by default |
A peer forges X-Forwarded-Proto or X-Forwarded-For | --trusted-proxies. The server believes only those peers and answers others with a 403 |
| A reconnect storm or a flood | Upgrade rate per address, connection caps per token and per address, request rate per connection |
| A token read from disk | The server stores only SHA-256 hashes and compares them in constant time. Logs name tokens by id and name |
| A client token holder | What its scope reaches. Both admin and chat tokens can run any command: admin directly, chat through the agent. Workspace confinement guards against mistakes, not against a client that wants to read a file |
Tokens#
zidane-server token create --name laptop # a zds_ token, printed once on stdout (admin)
zidane-server token create --name web --scope chat
zidane-server token create --name ci --scope machine
zidane-server token list # id, name, scope, created, last used, revoked, created by
zidane-server token revoke laptop # by id (tok_…) or name
zidane-server token generate # a fresh token, stored nowhereA token is zds_ and 43 base64url characters (32 random bytes). The server keeps its SHA-256 in server-tokens.json (mode 0600) and its last use in server-tokens.usage.json.
A token works until you revoke it. To give it an end, add --expires: zidane-server token create --name laptop --expires 90d. The server refuses an expired token like a revoked one. It drops the token's connections, then stops the runs and terminals that the token started, as on revoke. The audit log gets token.expire, and token.expire.stopped with counts when the server stopped something. A token that another token creates (a machine token, the token of a browser from a pairing code) expires no later than that token.
The commands edit those files directly, so they work while the server runs. The server re-reads the file on change and every 2 seconds. When a token is revoked, the server drops its connections. Then it interrupts the runs of the sessions the token drove, cancels their background tasks and closes the terminals the token opened (audited as token.revoke.stopped).
| Scope | Opens | Created by |
|---|---|---|
admin (default; client is the same) | A window: every method and signal | token create, the bootstrap variable, or an admin's tokens.create |
chat | A window that reads and drives sessions, answers the agent's questions, plans and tool approvals, sets a session's rules, and clones repositories and creates folders in the workspace roots. It doesn't configure the server | token create --scope chat, or an admin's tokens.create |
machine | A machine lending its folders and shell: only ipc.hello, $/ping and machine.register | token create --scope machine, or an admin's tokens.createMachineToken |
Scopes#
Each protocol method declares the lowest scope that may call it (Protocol). admin covers configuring the server and a direct shell: credentials, settings, installs, MCP servers, machines, sync, tokens, updates, terminals, and running or writing files in a workspace outside the agent. chat covers chatting: sessions, runs, tool approvals, removing "Always allow" entries, a session's rules (looser ones included), integrations of any kind (channel: ones too), and orchestration workers with the interactions tool. It can also clone a repository into the workspace roots and create a folder there, unless the server turns that off. A call above the token's scope is refused (-32015, with the scope it needs) and audited (forbidden). The hello lists the methods the connection may call, so a client shows only those.
A scope limits what the client calls directly, not what the agent does. A chat token still runs the agent, and the agent's tools run commands as the approval mode allows. With Full access, a chat token is close to a shell. With approvals on, the agent's risky calls wait for a client to answer, and a chat client can answer them.
The token file keeps an admin token's scope under its old name, client, so a server from before scopes still reads a file of admin and machine tokens. A chat token is a scope such a server doesn't know: after a downgrade it refuses the whole file (and keeps the tokens it had loaded, or none at a cold start). Revoke those tokens before you downgrade. A token or pairing code stored with approve, the scope between chat and admin in older releases, reads as chat.
A browser can't read why the server refused a WebSocket upgrade. After a failed connect, the browser client (zidane/ipc/browser) asks GET /zidane/auth with the same token: it answers {"ok":true,"scope":…}, or the refusal an upgrade would get (401, or 403 with X-Zidane-Refusal). It goes through the same origin check, rate limits and audit as an upgrade, and sends CORS headers only to an allowed origin.
An admin client manages tokens with tokens.list, tokens.create (any scope; the new token is bound to the one that created it, so revoking that one revokes it too) and tokens.revoke. Any client revokes its own token with tokens.revokeSelf: the web app's sign out does.
A connected client mints machine tokens for its "share this computer" step (tokens.createMachineToken). One client token can hold at most 16 live machine tokens. The audit log records which token asked, and each refusal. token list shows that token under created by. Revoking a client token revokes the live machine tokens it minted in the same write, and token revoke names them.
To rotate, create a token for the device, switch the device to it and revoke the old one. If the server cannot parse the token file, it keeps the last tokens it read and says so on stderr. It never treats a bad file as "no tokens". create and revoke refuse to write over the file.
Bootstrap token#
For a platform with no shell before the first deploy, put a token from token generate in ZIDANE_SERVER_BOOTSTRAP_TOKEN (or its path in ZIDANE_SERVER_BOOTSTRAP_TOKEN_FILE). At start the server stores its hash as the client token bootstrap. A new value replaces it. A revoked value is ignored. The server never prints the token. It removes both variables from its environment, so the commands of the agents do not inherit them.
The inline value stays visible in docker inspect, docker exec shells and /proc/1/environ. The file variable avoids that. Revoke bootstrap once your devices have their own tokens. If you only delete the variable, the server keeps the stored token.
If a token leaks#
A client token is shell access as the server's user. Treat a leak like a leaked SSH key:
- Revoke it. Its connections, runs and terminals stop, and the machine tokens it minted are revoked with it.
- Restart the server. Processes its commands left in the background (
nohup, a daemon) end only with the server. - Look for what it changed.
grep <token id> server-audit.jsonllists its connections and installs: remove the skills, extensions and MCP servers you don't recognize. Check the server user's dotfiles (~/.bashrc,~/.profile,~/.ssh/authorized_keys,~/.gitconfig) and your workspaces' git hooks. In the image,HOMEis/data, so they persist on the volume. - Rotate what it could read: the provider keys and any deploy key in
~/.ssh. The other tokens are stored hashed, and a hash of a random 256-bit token can't be turned back into it.
TLS and proxies#
| Mode | Set | Clients use |
|---|---|---|
| Loopback only | Nothing (the default --host 127.0.0.1) | ws://127.0.0.1:4280, or an SSH tunnel |
| TLS in the server | --tls-cert and --tls-key | wss://host:4280 |
| A TLS-terminating proxy | --behind-proxy, --trusted-proxies | wss:// through the proxy |
On any other interface, the server refuses to start without one of those. TLS and --behind-proxy together is a boot error, and so is --trusted-proxies without --behind-proxy.
Behind a proxy, every upgrade must carry X-Forwarded-Proto with https for every hop. Anything else gets a 403, also from loopback. Local tools use the unix socket instead.
With --trusted-proxies (addresses, CIDRs or private), the server believes only those peers and refuses others with a 403. private stands for 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, ::1/128, fc00::/7 and fe80::/10.
The server takes the client address for the audit log and the per-address limits from X-Forwarded-For. It reads the header from the right. It takes the entry that your proxy added and skips the trusted proxies of a chain.
private trusts every private address. So publish the port on 127.0.0.1 only, or not at all. Rootless Docker and Docker Desktop publish ports through a userland proxy, so every client arrives from a private address. In those setups, set the exact address of the proxy. Plain Docker does the same over IPv6: a port published on every interface (-p 4280:4280) also listens on the host's IPv6 address. Docker's default bridge has no IPv6, so docker-proxy relays those clients from the bridge gateway (172.17.0.1), which private trusts.
A peer that you trust by mistake can claim https and any client address. It can then avoid the per-address limits and fake the address in the audit log. It still needs a valid token.
The clients trust the public certificate authorities and the operating system's own store (the macOS keychain, the Linux CA bundle). For a private CA, an mkcert certificate or a self-signed one, trust it on each client's system, or list it in NODE_EXTRA_CA_CERTS when you start zidane. The clients never turn certificate checks off.
Exposure#
exposure says whether the internet can reach the port of the clients. It is private by default. With public (--exposure public, ZIDANE_SERVER_EXPOSURE=public), the server:
- Turns off remote installs and lending itself as a machine. If you turn either one on, the server does not start and names the setting.
- Refuses to start with a bootstrap token.
- Refuses other browser origins next to the web app.
- Ends pairing codes and machine join codes after 10 minutes at most.
At start, the server warns when the settings do not match the exposure.
Browser origins#
Browsers send Origin on a WebSocket upgrade. The TUI, the desktop app and the Node client send none. An origin outside ZIDANE_SERVER_ALLOWED_ORIGINS (comma-separated, like https://app.example.com) gets a 403. The list is empty by default, so no browser can connect.
With the web app on (--web), the server's own origin is admitted too: the scheme it is reached by (https with TLS or behind a proxy), and the Host the browser sent (X-Forwarded-Host behind a proxy). On a loopback listener that host must be localhost, 127.x.x.x or [::1], so a page on another domain that resolves to 127.0.0.1 (DNS rebinding) is still refused.
Pairing#
A browser logs in with a one-time code instead of a pasted token. On the server's machine:
zidane-server pair --scope chat --ttl 10m --name laptopIt prints a code like 7KQM-3XFD on stdout. Type it in the web app's pairing screen. The browser posts it to POST /zidane/pair and gets a normal device token of that scope, named browser: laptop. It keeps the token in its local storage and drops it when you sign out. The token shows in zidane-server token list and is revoked like any other.
| Rule | Value |
|---|---|
| Alphabet | 32 symbols: digits 2 to 9 and A to Z without I and O, shown as XXXX-XXXX |
| Lifetime | --ttl, 30s to 1h, default 10 minutes |
| Use | Once. A second try with the same code gets used |
| Wrong guesses | The first half names a code, the second is its secret. Five wrong secrets for one code burn it |
| Attempts | 10 per minute per client address, then 429 |
| Scope | --scope chat (default) or admin. A machine join code has the scope machine |
| Storage | server-pairing.json in the data dir, mode 0600, holding each code's SHA-256, never the code |
An admin client can issue a code too, with tokens.pair ({ scope, ttlMs?, name? } → { code, expiresAt }). The token it mints is bound to the calling token: revoking that one revokes the browser's, and a code whose issuer was revoked before it was used is refused.
POST /zidane/pair takes JSON {"code": "7KQM-3XFD", "name": "Firefox"} (name is used when the operator gave none). It follows the upgrade policy and the origin rules above: a foreign browser origin, a plain-HTTP hop behind a proxy or an untrusted proxy gets a 403. An allowlisted origin gets CORS headers; the server's own origin needs none.
| Status | Body | Cause |
|---|---|---|
| 200 | {"token": "zds_…", "id": "…", "scope": "chat", "serverName": "box"} | Paired. id is the token's id, the one tokens.list and tokens.revoke use. serverName when the server has a --name |
| 400 | {"error": "invalid"} | Not JSON, or not a code's shape |
| 401 | {"error": "invalid"} | No such code, or a wrong secret |
| 410 | {"error": "expired"} | The right code, past its lifetime |
| 410 | {"error": "used"} | The right code, already used or burned |
| 429 | {"error": "rate-limited"} | Over 10 attempts a minute from this address |
| 403 | {"error": "forbidden"} | An origin, proxy or transport the policy refuses |
A browser app of your own pairs with pairBrowser(baseUrl, code, name?) from zidane/ipc/browser. It posts the code, returns { token, id, scope, serverName? }, and throws a ServerPairError whose reason is the server's error above, unavailable (no pairing route: an older server, or one without auth) or network (no answer it understands). Pass '' as baseUrl for the page's own origin; a ws(s):// URL is read as http(s)://.
import { createServerClient, pairBrowser, ServerPairError } from 'zidane/ipc/browser'
try {
const { token } = await pairBrowser('https://box.example', code, 'Firefox on the laptop')
localStorage.setItem('token', token)
const client = createServerClient({ url: 'wss://box.example', token })
}
catch (error) {
if (!(error instanceof ServerPairError))
throw error
showError(error.reason) // 'invalid' | 'expired' | 'used' | 'rate-limited' | 'forbidden' | 'unavailable' | 'network'
}Issuing a code is audited as pair.create, a redemption as paired (token, scope, the code's first half, address), and a failed attempt as auth.failure.pair in the failures file.
Machine join codes#
A join code is a one-time code that adds a computer as a machine (servers and machines). On the server, zidane-server machine invite [--name <name>] [--ttl 10m] prints one. An admin client gets one with tokens.machineInvite ({ name?, ttlMs? } → { code, expiresAt }). The code follows the rules of the table above, with the scope machine.
zidane machine setup --join <url>#<code> posts the code to POST /zidane/pair with the name of the machine. The server mints a machine token and answers {"token": "zds_…", "id": "…", "scope": "machine", "serverName": "box", "hostId": "…"}. The machine pins hostId before it connects. The token takes the name from --name, else the name the machine sent. If a live token has that name, the server adds -2, -3 and so on. If the name is not valid, the server answers 400 {"error": "name"} and the code stays unused.
Join codes count against the 16 live machine tokens of their creator. Codes from the command line have no creator, so they share one group with token create --scope machine. When that group is full, the server answers 503 and the code stays unused.
Refused upgrades#
The server answers before any JSON-RPC, with a status. A 403 also carries a reason code in X-Zidane-Refusal. Clients show a fixed text per code, never the body.
| Status | Body | X-Zidane-Refusal | Cause |
|---|---|---|---|
| 401 | Unauthorized | No token, an unknown one or a revoked one (indistinguishable) | |
| 403 | Forbidden: origin not allowed | origin | A browser origin outside the allowlist |
| 403 | Forbidden: HTTPS required | insecure-transport | Behind a proxy, a hop that was not https |
| 403 | Forbidden: not a trusted proxy | untrusted-proxy | A peer outside --trusted-proxies |
| 429 | Too many requests | Over the upgrade rate for this client address | |
| 429 | Too many connections | Over the connection cap of the token or the address | |
| 503 | Shutting down | The server is stopping |
Limits#
| Limit | Default | Past it |
|---|---|---|
Message size (ZIDANE_SERVER_MAX_MESSAGE_BYTES) | 32 MiB | A bigger incoming frame closes the connection (1009). A bigger reply becomes a ResponseTooLarge error, and the connection stays open |
Requests per connection (_RATE_LIMIT, _RATE_BURST) | 50 per second, burst 200 | A RateLimited error (-32012). The connection stays open |
Upgrades per client address (_UPGRADE_RATE_LIMIT, _UPGRADE_RATE_BURST) | 5 per second, burst 30 | 429 |
Connections per token (_MAX_CONNECTIONS_PER_TOKEN) | 16 | 429 |
Connections per client address (_MAX_CONNECTIONS_PER_ADDRESS) | 32 | 429 |
| Pushed signals waiting for a slow client | 8 MiB | The server drops the client. The client reconnects and resumes |
Heartbeat (_HEARTBEAT_MS) | A ping every 20 s | Two missed in a row drop the connection |
Streamed ! commands | 4 at once per connection, 1 MiB of output each | The command is stopped, its exit says truncated |
Audit log#
The server writes two JSON Lines files in the data dir, with mode 0600. It appends lines in order and moves a file to <file>.1 past 5 MiB. Entries name tokens by id and name, never by value.
| File | Holds |
|---|---|
server-audit.jsonl | Server start and stop, connects and disconnects (token, address, client version, reason), token create and revoke (minted tokens revoked along are marked via: "cascade"), what a revocation or an expiry stopped (token.revoke.stopped, token.expire.stopped), calls refused for their scope (forbidden: method, scope needed, scope granted), pairing codes issued (pair.create) and redeemed (paired), settings sync, and every install, update or removal from a client |
server-audit.failures.jsonl | Refused upgrades. The first one per address and minute is written in full. The rest of that minute becomes one auth.failure.summary. Failed pairing attempts (auth.failure.pair) |
A session from the protocol capture:
{"ts":"2026-10-14T09:09:33.813Z","event":"server.start","url":"ws://127.0.0.1:64234"}
{"ts":"2026-10-14T09:09:33.840Z","event":"connect","transport":"ws","tokenId":"tok_8adfcb0e9b854773","tokenName":"docs","remoteAddress":"127.0.0.1","appVersion":"docs"}
{"ts":"2026-10-14T09:09:34.188Z","event":"disconnect","transport":"ws","tokenId":"tok_8adfcb0e9b854773","tokenName":"docs","remoteAddress":"127.0.0.1","reason":"closed by the peer (1000)"}
{"ts":"2026-10-14T09:09:34.390Z","event":"server.stop","reason":"shutdown"}{"ts":"2026-10-14T09:09:33.815Z","event":"auth.failure","remoteAddress":"127.0.0.1","reason":"missing"}
{"ts":"2026-10-14T09:09:34.390Z","event":"auth.failure.summary","remoteAddress":"127.0.0.1","count":2,"reasons":{"invalid":1,"origin":1},"windowMs":60000}The server writes these failure reasons: missing, invalid (unknown or revoked), origin, insecure-transport, untrusted-proxy, rate-limited and too-many-connections.
What runs where#
| What | Where |
|---|---|
The agents' tools, ! commands, terminals | On the server, as the server's user, in the session's folder |
| Session folders | Inside a workspace root (--workspace-root, default <data dir>/workspaces) |
File methods (sessions.workspaceReadFile, sessions.workspaceWriteFile, sessions.workspaceListFiles) and the folder of sessions.workspaceExec | Confined to the workspace roots, with symlinks resolved. Never the data dir |
| Provider keys | The server's environment and credentials.json. A window can set them, but never read them back |
| OAuth sign-ins | Run on the server. The browser opens on the client. Tokens stay on the server |
| Tool approvals | Asked on the server, answered by a window that can show them (TUI, desktop app) |
The host checks the workspace roots wherever a request names a folder: new sessions, folder changes, orchestration workers, environment and command listings. The check guards against mistakes only. It does not confine the commands that you or the agent run.
Tool approvals is a server setting. Unless it is Full access, a tool call that needs you waits as an approval:request until the TUI or the desktop app answers it. A browser page cannot answer it (why). With Full access, the server's agents run tools without asking.
| Option | Default | Widens |
|---|---|---|
allowRemoteInstalls (--no-remote-installs turns it off) | On | Clients install, update and remove skills, extensions and MCP servers in the server user's scope, audited |
allowClone (--no-clone turns it off) | On | Any client token clones a repository into the workspace roots, with the server's git credentials. A file:// URL must point inside the workspace roots |
allowCreateFolders (--no-create-folders turns it off) | On | Any client token creates a folder in the workspace roots |
allowAsMachine (--allow-as-machine) | Off | A client can use the server as a machine of its own computer, through the same confined executor. At most 4 per token |
The unix socket (<data dir>/server.sock, mode 0600) takes no token, like the socket of the local daemon. File permissions guard it. --socket off removes it. GET /healthz answers ok to anyone and says nothing else.