Case holds logins. These are promises, with code you can read.
- The password is typed into the page, never handed to the agent. Login
injects via CDP
Input.insertText(not keystrokes, not clipboard); the secret stays out of API responses, logs, and exec output. Seeimage/deskd.py. - deskd returns 423 while a credential is being injected. Not just
screenshots:
/exec,/action,/file(read and write),/eval,/auth/observeand the capture reads all refuse until the injection finishes, and network capture drops anything in flight. The password field is cleared (CLEAR_PASS) before the gate reopens, so the first screenshot after a login sees an empty box. - That gate is "we do not hand it over", not "cannot obtain".
computer_execruns bash in the same container as Chromium, and Chromium's CDP port is on that container's loopback. An agent that goes looking can reach what the browser holds. The 423 closes the paths Case itself offers; it is not a sandbox boundary. - Login checks the live page before inserting credentials. The host must
match the credential's
domains, and the scheme must be HTTPS. HTTP is allowed only onlocalhost,127.0.0.1, and::1. Password and verification-code entry repeat that check after redirects and between login steps. - MCP has no credential-write tool. Secrets enter via the Drive UI,
/fill, orbin/case cred add. - File API uploads are capped at 8 MiB. cased and deskd count bytes as the body arrives, including requests without Content-Length. File reads enforce the same limit.
computer_uploadonly assigns files already on the computer. The path must be under/home/agent/, at most 5MB, and the snapshot ref must beinput[type=file]. Password/OTP-like inputs are refused. Bytes travel through deskdGET /file, never command stdout.- cased and Drive check
Host, andOriginwhen one is present. Anything else gets a 403. Allowed by default:127.0.0.1,localhost,[::1], the compose service name, plusCASE_PUBLIC_HOSTand anything inCASE_ALLOWED_HOSTS. This is what stops a DNS-rebinding page or a cross-site WebSocket open from driving a loopback install. Drive checks every request. cased checks the untokened ones — the token-in-URL doors always, everything whenCASE_TOKENis unset; withCASE_TOKENset the rest of the API is bearer-only. - cased binds loopback by default. Compose publishes
127.0.0.1:8787and127.0.0.1:4174. SetCASE_TOKENbefore exposing those ports. - MCP HTTP has no built-in bearer check. Compose publishes port 8788 on
loopback. Its
CASE_TOKENauthenticates calls to cased, not requests from MCP clients. Keep that port local or put an authenticating reverse proxy in front. - Desktops sit on their own Docker network (
case-desks). Compose joins only cased to both networks, so Drive and the MCP server have no route to a desktop. Desktops sharecase-deskswith each other, so a desktop can still address its neighbours; what stops it there is the desk token, not the network — deskd and websockify both answer a neighbour with 401, and x11vnc listens on loopback only. Desktop containers publish no host ports under compose. - The live desk is behind the desk token. With
CASE_DOCKER_NETWORKset, websockify runs under basic authagent:$DESK_TOKEN(image/start.sh). Drive's/live/<id>/…is a proxy to cased/v1/computers/<id>/live/…, and cased adds that header; the WebSocket upgrade checksCASE_TOKENitself, because Starlette's HTTP middleware never sees a websocket scope. WithoutCASE_TOKEN, that upgrade checks Host and Origin instead. Host mode (bin/case up) sets no network, so websockify is open there on the desktop's loopback-published port. /fill,/assistand/answerare doors opened by a token in the URL. A human on a phone has no bearer header, so these three skipCASE_TOKENand carry their own key:/filland/assistlinks expire and are single-use,/answeris an HMAC over the handoff id. Anyone holding the URL is the human. Treat the links like one-time passwords.- Audit log (
~/.case/audit/<date>.jsonl): one line per API call with the caller's address, the query string, method, path, status and duration; request bodies that can carry secrets are redacted; response bodies are never logged. The three token doors log as/fill/[token]and friends, so the log records that a door was used without storing the key. - ntfy handoff notifications carry a full-desktop PNG. When the computer is
awake, the screenshot rides along as the message attachment. Everyone
subscribed to the topic sees whatever was on screen, which is the argument for
a random topic name. Signed approval buttons require
CASE_PUBLIC_HOSTand an HTTPS reverse proxy; they are omitted without it. - DeathByCaptcha gets scheme, host and path only. The page URL is stripped
of query and fragment before the solve request leaves
(
control-plane/login_flow.py), so the session tokens and continuation URLs a login page carries in its query string stay on the box. Sitekey, that URL and a configured proxy are all that go out. - Drive screenshots and chat attachments persist on disk under
~/.case/drive/shotsand~/.case/drive/inbox(Compose:ui-dataviaCASE_HOME=/data). They are content-addressed and kept until you delete the files or the volume. Deleting a thread does not erase them. Treat~/.case/drive/ui-dataas sensitive chat material. Attachments never copy onto the computer; the model reads them from Drive. Max 4 files per turn, 5MB each; allowed types are PNG/JPEG/GIF/WebP, PDF, and text (including JSON, JS, XML).
When you run Case yourself, these assumptions matter:
(a) No CASE_TOKEN means an open API on the bind address. Anything that can
reach cased's or Drive's bind address can drive them. The Host/Origin check only
rejects callers that address the box under a name it does not know; it is not
authentication. Set CASE_TOKEN if the host is shared or ports are not
loopback-only.
(b) A desktop can still reach cased. Docker bridges are bidirectional: cased
joins case-desks to dial the desktops, so a desktop can dial cased back on
:8787. The Host check does not help, because a container writes its own
Host: header. This does not require a compromise: anything running inside a
desktop by design has that reach, including whatever the agent starts through
/exec and any gateway installed into the box. The separate network keeps Drive
and MCP out of a desktop's reach; it does not put cased out of reach, and it
does not separate desktops from each other. CASE_TOKEN is the thing that stops
a compromised desktop from driving the API, and each desktop's DESK_TOKEN is
what stops it from driving its neighbours. This is a known limit, not a closed
hole.
(c) ~/.case/ is secret-equivalent. The Fernet encryption key lives beside the
SQLite database. Treat the whole directory like a password manager export: back it
up accordingly, restrict filesystem permissions, and do not copy it to untrusted
storage.
(d) Desktops keep passwordless sudo by design. The container is the agent's
sandbox; no-new-privileges is deliberately not set because passwordless sudo
requires setuid. Chromium also starts with --no-sandbox; the container is its
isolation boundary. Do not run untrusted code inside a desktop you also use for
personal browsing.
(e) ntfy topics and Telegram bot tokens are bearer secrets. Anyone who knows a topic name can post or subscribe; anyone who holds the bot token can read and send as the bot. Treat both like passwords; use random topic names, and revoke the token in @BotFather if it leaks.
Report vulnerabilities privately via GitHub Security Advisories: https://github.com/case-computers/case/security/advisories/new Do not file public issues that include tokens, ntfy topics, bot tokens, or vault contents.