Skip to content

Security: mbeato/blimp

Security

docs/SECURITY.md

blimp threat model

What blimp protects, from whom, and how. Anything in "Accepted" is a deliberate trade-off, not an oversight.

What's at stake

blimpd can start Claude sessions, type into them, and read and write every file in your home folder. Whoever can talk to blimpd can do anything you can do on the server. So the whole design is about making sure only you can talk to it.

Who might attack, and what stops them

Attacker What they try What stops them
A web page open on your Mac Call the local tunnel port (fetch, WebSocket, DNS rebinding) Every request needs the token. The token is never in a cookie, so the browser never sends it on its own. A 401 has no CORS header, so a page can't read it to learn that the port is blimp.
Another program or user on your Mac Same as above Same token. (A program running as you can already read your SSH keys, so it's out of scope.)
Another user on the server Connect to blimpd, or read its token blimpd listens only on a unix socket in ~/.blimp (folder 700, socket 600, token and phone token 600). No TCP port.
Another user on the server Make zmx's socket folder first (/tmp/zmx-<uid> when there's no XDG_RUNTIME_DIR), then swap a session's socket for their own to get what you type blimpd makes that folder itself (700) if it's missing, and refuses to start, or to run zmx, if it isn't yours or others can write it.
Someone on the network Read or change traffic Everything goes through your SSH connection. The app runs ssh with BatchMode=yes, so an unknown or changed host key fails instead of being accepted (unless your own ~/.ssh/config says StrictHostKeyChecking accept-new or no for that host; blimp follows your config).
A malicious file in a repo you open (Markdown, SVG, PDF, HTML) Run script inside the app, then call the app's native commands (for example, open SSH connections) Markdown goes through DOMPurify. SVG is shown only as an image. pdf.js runs with scripting off. HTML shows in a srcdoc frame with an empty sandbox (no scripts, an opaque origin, so no reach into the app or its token) under the app's policy; its own <script> elements are taken out first. Markdown is parsed in a worker with time and size limits, so a hostile file can't freeze the app. The app's content security policy allows no remote scripts and no inline script. The native commands only take a host, user and port, or (for windows and notifications) strings that Rust stores, compares, or hands to a new window JSON-encoded, never as script.
Text printed in a terminal (a hostile program, a repo's output) Get the app to open something by making it look like a link Web links open only on ⌘-click, http and https only, in the system browser. File paths become links only for files that exist, and open only on ⌘-click, in a file tab, through the same home-folder check as every other file read. Nothing navigates the app.
A file dropped from Finder, or a name on the server Overwrite something, or escape a folder, on either side Uploads go to a hidden temp file, then a hard link (fails if the name is taken) or, after you pick Replace, a rename over a file; never over a folder; 2 GB at most. The Mac app reads only what you dropped or picked. Downloads land in ~/Downloads under a name made safe there (no /, no leading dot, never over an existing file). A folder downloads as a tar that stores symlinks as links, so nothing outside home is read.
A web page in a browser tab (any site, or a dev server on a server port) Reach blimp's page, its token or its native commands The page runs in the server's Chrome, not in the app: the app gets only pictures of it (JPEG frames drawn in a canvas) and its address and title as text, and sends it input. Nothing of the page runs on your Mac. On the server it's an ordinary Chrome page, with no way into blimpd (the gate and the view socket are blimpd's, behind the uid check and the token).
A web page you send to a session Get Claude to do something by what the page says (prompt injection) Nothing goes to a session unless you pick Send to session and a session. What's sent is a file of the page's text, with its address, under ~/.blimp/uploads, and its path is pasted for you to see before pressing Enter. Treat it like any text you paste in.
Another user on the server Drive the server browser (its logins, its pages) through the gate's 127.0.0.1 port The gate admits a connection only if the kernel's connection table (/proc/net/tcp) says its socket is blimpd's own uid. Chrome has no debugging port at all: only blimpd holds its pipe. The profile folder is 700.
A web page in the server's Chrome (it runs as you) Open the gate's WebSocket, then drive the browser: file:// pages would read your files, the token included The gate also needs a key from ~/.blimp/browser/key (600), which a page can't read, and refuses any request with an Origin header (a page's WebSocket always sends one) or another Host (DNS rebinding).
A web page in the server browser, read by Claude Get Claude to do something by what the page says (prompt injection) Claude reads pages only through its MCP tools, as it reads any web content; treat a page like any text it reads. A session sees only its folder's tabs, and the gate refuses Browser.close and browser-wide sessions.
A crafted session name, folder or path Escape the home folder, or the status folder Every file path is resolved (symlinks too) and must stay inside home. Hook status files use the zmx session name only if it's a plain name. zmx, claude and git get argument lists, never shell strings.
Text sent from the phone Send keys to a session (Esc, arrows, a fake end of paste, ctrl+C, zmx's detach key ctrl+\) input is pasted as a bracketed paste. Before that, \r\n and a lone \r become \n, and every other control character except tab is removed (U+0000–U+0008, U+000B–U+001F, U+007F–U+009F), so the text can't end the paste, press keys, interrupt or detach. Then Enter is pressed. It is refused while the session is waiting, so that Enter can't confirm a permission's highlighted "Yes". Apart from that Enter, only answer and interrupt send keys: answer types one digit 1–9 or Esc and nothing else, checked in code whatever the status file says (a digit for an option shown on screen, or Esc for No when the options couldn't be read); interrupt types Esc. On a fresh chat client, an Esc (answer No, or interrupt) is preceded by an empty paste (ESC[200~ ESC[201~), because zmx drops a new client's first ESC; Claude ignores the empty paste.
Someone with your iPhone Open blimp and use your sessions blimp locks with Face ID (or the passcode) when it opens and when it comes back after more than five minutes away. On a phone with no passcode at all it can't lock. The server's address and the phone's token are in the iOS Keychain, readable only while the phone is unlocked, on this device only, never synced to iCloud or put in a backup that restores to another phone. The phone has its own token (~/.blimp/phone-token, made by blimpd pair): blimpd pair --revoke on the server deletes it, which refuses the phone's next request and closes its open sockets within a second. The Mac's token is separate and keeps working.
A holder of one token (the phone's, say) Read or plant another through the file api — fetch ~/.blimp/token and stay in after a revoke The file api refuses blimp's own key files (the two tokens, the gate key, the push key, the Reddit key ~/.blimp/reddit.json, the model key, the secrets folder ~/.blimp/secrets and everything in it) and any folder that holds one: they can't be read, replaced, created at a missing path, renamed, moved, deleted, or downloaded as part of a folder. This guard covers the file routes only. It is not a wall against a token holder: a token can run commands (a terminal session, a run, POST /runs and its Send result to Claude) and ask Claude in the chat, and any of these can print ~/.blimp/token or a secret's value. So blimpd pair --revoke is final only for a phone that hasn't done that. If you think a revoked phone read the Mac's token, delete ~/.blimp/token and restart blimpd: it makes a new one.
A hostile transcript or tool output (shown on the iPhone) Run script in the phone app, or get it to open something The chat renders no HTML. Claude's Markdown is read by blimp's own small parser into native text; tool input and output are plain monospace text. Only http and https links stay links (others become plain text), and they open only when you tap them, in the default browser (Safari). A page Claude showed opens only from its card, in the visuals' web view (next row). Nothing navigates the app.
A page Claude showed with the show tool (Claude wrote it, perhaps from something hostile it read) Reach blimpd, the app, its token or your files; send what's on the page somewhere; take the app to a website The page can run its own scripts, so it is walled off. blimpd sends it with a policy that allows only its own scripts and styles, blimp's kit from blimpd and data:/blob: images and fonts: no fetch, XHR, WebSocket or beacon, no images or frames from anywhere, no forms (connect-src 'none', frame-src 'none', …). sandbox allow-scripts gives it an origin of its own: no cookies or storage, no windows, and it can't move the page around it. On the Mac it is a frame with the same sandbox, and the app's policy lets frames come only from 127.0.0.1, so it can't be sent to a website (it can be sent to any address on the Mac's 127.0.0.1, any local service and not only blimp's forwards: that is a GET with no click, and the frame then shows that service's page under that page's own rules, but with no blimp token). On the iPhone the web view loads the page's own address and cancels every other navigation. The page's address holds the visual key, which reads visuals and blimp's kit and nothing else; the page can't send a request with it anyway. Copies are 600 in ~/.blimp/visuals and go after 30 days. Checked in WebKitGTK and Chromium (the spec's spike) and by the apps' e2e tests.
Anyone who can read the app's storage, logs or process list, on the Mac or the server Learn a model key (Z.ai) Keys live only on the server, where sessions run. The app sends a key once (PUT /keys/zai, over the tunnel, with the token) and keeps no copy; blimpd writes it to ~/.config/zai/key (folder 700, file 600) through a temp file and a rename. No route returns a key (GET /keys says only whether there is one). A GLM session gets the key in its environment, read from the file at start: never on a command line, so not in ps, zmx list, /sessions or blimpd's log. Z.ai's address and the models are set after the login profile runs, so a profile that sets its own ANTHROPIC_BASE_URL can't send the key to another server.
Anyone who can read the app's storage, logs or process list, or the session list Learn a secret given to a session (an API key, a password) A secret lives only in ~/.blimp/secrets/<NAME> on the server (folder 700, file 600). You and any process running as you can read it, Claude included: that's the point, so Claude can use it without it ever being in the chat. The app sends the value once, in the body of PUT /secrets/<NAME>, and keeps no copy. The value never goes in argv, a log, the session list or the text typed into Claude: Claude is told only the file's path. No route returns a value (GET /secrets lists names and times). Every /fs route refuses the secrets folder and everything in it (guardFile), and the folder routes never list what is in it.
A hostile reply, or a hostile file or page Claude read (prompt injection) Put a command on the Run bar, for you to run without reading it Nothing runs until you click Run. The full command shows before you do: the Mac shows it in the Run button's tooltip, and the phone shows it in a confirm sheet, with the folder it runs in. blimpd gives the command to the shell as an argument ("$1", run with eval), never pasted into a script, so its text can't change what the wrapper does. Read a command before you run it, as you would one you paste.
A host or user name starting with - Inject ssh options The app rejects them and passes -- before the destination.
A tampered app update Get the app to install a different build Updates must be signed with the updater key, which only the release workflow has (a repository secret); the app checks the signature against the public key built into it before installing. The update comes from this repository's GitHub releases over HTTPS. Update server runs the latest release's install.sh over your SSH connection, the same as installing by hand.
A tampered download Install a different blimpd or zmx zmx is pinned to a version and SHA-256 in install.sh. Releases publish SHA-256 sums. Dependencies are locked (bun.lock, Cargo.lock) and CI builds with --frozen-lockfile / --locked. Release builds run read-only, with every action pinned to a commit; only a separate publish step can write the release.
Anyone but you Send your phone fake notifications Only the holder of the push key can. It stays on the server in ~/.blimp/apns.p8 (600), and no file route serves, replaces or downloads it.
Someone who sees your locked phone Read commands, questions or replies A notification's text shows on the lock screen. iOS Settings > Notifications > Show Previews > When Unlocked hides it until Face ID. The README says so.
Someone who gets the widget key (it's in blimp's App Group on the phone) Read session names, titles, states, times and full folder paths, and counts It reads GET /widget and nothing else. The App Group's files (the server's address and the widget key) can be in the phone's backups, and so reach a phone restored from one; the Keychain's address and token never do. To change the key: blimpd pair --revoke, then blimpd pair and pair the phone again.
Someone who gets the relay key (in relay.json, mode 600, in the relay's Mac user; the server keeps it at ~/.blimp/relay-key, mode 600) Text the assistant as you, read what it texts back, and answer its permission prompts It reaches the /messages/ routes and nothing else, and only while texting is on (blimpd messages on). Every other route refuses it. blimpd messages revoke deletes it. The copy handed over in /Users/Shared/blimp-relay is readable only by you and the relay's Mac user (mode 600 and an ACL), and install.sh deletes it.
Someone who texts the assistant's Apple ID Give the assistant instructions The relay passes only texts from the handles in its allow list, in one-to-one chats. Everything else stays in that Mac user's Messages.
Someone who sees your locked phone, with blimp's rectangular widget on the lock screen Read session titles It shows the top two titles, as notifications show their text. The round and inline widgets show counts only.
A hostile page, file or session output the assistant reads (prompt injection) Get the assistant to act on your other sessions: type into one, start one, answer its question The assistant's folder settings make Claude ask you before blimpd start, send and answer and before connectors' send tools, and blimpd starts it in default permission mode whatever your settings say. ask beats any allow. Read the command in the prompt before you allow it. This catches mistakes and tricks that use those commands; it isn't a wall (see Accepted). Job updates blimpd types into it carry other sessions' words (their notes, what they ask); its instructions say they're information, never instructions, and acting on one still asks you.

Accepted

  • Keystroke timing. Plain ssh (OpenSSH 9.5+) hides typing rhythm by sending keystrokes in fixed 20 ms ticks. blimp's tunnel is a port forward, which doesn't do that, so a network observer could see when you type. This is the same as VS Code Remote or Tailscale. It's also part of why blimp is about 8–12 ms faster per keystroke than ssh at the median.
  • Whole home folder. The file tree can open anything in your home folder, including dotfiles, and make new files and folders there. That's no more than your SSH access already allows. (blimp's own token and key files are the exception: no route serves, replaces, or downloads them — nor a folder that holds one, so a whole-home download is refused too.)
  • Deleting and moving files. Anyone with the token can delete a file or a folder with everything in it (DELETE /fs/file), or rename and move one (POST /fs/rename), anywhere in your home folder. The server has no trash, so a delete can't be undone. This doesn't widen what a token can do: it can already run rm -rf in a terminal session. The routes refuse home itself, blimp's own key files, blimpd's socket, the secrets folder and anything in it, the folders that hold them (~/.blimp, ~/.config) and links to any of those, so blimp's files can't be moved out of reach or replaced through them. Without its socket, blimpd lets no new connection in until it restarts, so one wrong click would lock you out. They act on a link itself, never on what it points to. The app asks before every delete and every move by drag.
  • Ending sessions. Anyone with the token can end a session (DELETE /sessions/<name>). They could already type exit into it. Claude's conversation stays on disk either way.
  • Logins in browser tabs. Sites you sign in to in a browser tab stay signed in (that's the point), in the server browser's profile (~/.blimp/browser/profile). Sessions' browser tools use the same logins. Anyone who can read your files on the server can use them.
  • Ports on the server. blimp lists ports that programs in your sessions listen on, and a browser tab opens them on the server directly. Open in Browser on such a tab forwards the port to your Mac while the tab is open. A forward has no token: meanwhile, anything on your Mac (a web page in your browser included) can reach that dev server, as with VS Code's port forwarding or any ssh -L. All forwards are on 127.0.0.1, and cookies don't tell ports apart, so two dev servers open at once can see each other's cookies. Close the tab to close the forward.
  • A key in a session's environment. A Claude Code on GLM session has the Z.ai key in its environment, as a claude-glm script would. Programs in that session, and anything running as you on the server, can read it. Anyone with the token can replace or remove the key (not read it).
  • Codex hooks. blimp's Codex hooks run blimpd hook codex outside Codex's sandbox, once you trust them in Codex. They only write a small status file in ~/.blimp/status.
  • The phone token can do everything the Mac's can. Every route takes either token. A phone that is paired can start, type into and end sessions, turn the assistant on or off (PUT /assistant), start runs, save and remove secrets, and read and write files in your home folder, as the Mac app can. Revoke it with blimpd pair --revoke. A revoke stops the phone's token, but it can't undo what the phone already did: a command it ran could have read the Mac's token or a secret's value (see above).
  • One browser profile for every folder. The server browser's folders keep sessions' tabs apart, but every tab shares one set of logins and cookies, and a session could open a page of another folder's site. Folders aren't a security boundary.
  • Server browser cookies. With --password-store=basic, Chrome encrypts cookies with a fixed key instead of a desktop keyring (a server has none). Anyone who can read ~/.blimp/browser/profile as you can use them.
  • The MCP comes from npm. npx downloads chrome-devtools-mcp, pinned to 1.11.0, the first time a session uses it, and runs it as you.
  • Token lifetime. The token stays the same until you delete ~/.blimp/token and restart blimpd. Anyone who has it can already reach you over SSH.
  • Notifications go through Apple. Once you add a push key, blimpd makes outbound HTTPS connections to Apple's push service, and a notification's text (a session's title, a command's summary, a question, the start of a reply, with secrets taken out) passes through Apple's servers to reach the phone.
  • The feed reads the internet. With the feed on, blimpd fetches Hacker News, Reddit, GitHub, the blogs you list and the pages they link to, every hour. Their text goes to the small Claude model as data to summarize. The model has no tools, so a post can't make it run anything; the most a post can do is change what the summaries in its batch of 10 say, which the phone shows as plain text. The phone loads a card's picture straight from its address, so that site sees the phone's address, the same as opening the link. blimpd also fetches the pages posts link to, wherever they point, including addresses on the server's own network; it only reads them (a GET), and what it reads goes no further than the model and the summary on your phone.
  • The Reddit key is typed on the command line. blimpd reddit-key <client id> <secret> leaves the secret in your shell's history. It's a Reddit app's key that only reads Reddit as that app. Delete the line from your history if that matters to you.
  • The assistant can get around its own prompts. It runs as you, like every session, so a command that does what blimpd send does another way (curl to blimpd's socket with the token, or zmx) wouldn't match the ask rules. Its instructions forbid that, and its permission prompts catch the commands it's told to use. It reads the rest of your home folder without asking (blimp denies only the known secret files listed in DESIGN.md), so a browser tool you've allowed without a prompt is a way out for what it reads, as in any session; cat in Bash isn't covered by the read rules either. A separate guard that checks everything it sends out is possible later. Buying, posting or signing up in its browser tabs is a rule in its instructions, not a prompt: a click on Buy looks like any other click.
  • The relay's Mac user sees every text to the assistant. Messages keeps them in its database, and blimp-relay has Full Disk Access in that user. A rebuilt relay may need its Full Disk Access and Messages permissions given again: macOS ties both to the exact program.

Latency budget

Typing has to feel like ssh. Measured on a simulated 40 ms link (bench/):

Path Median 95th percentile
ssh -tt + zmx attach (today) 49 ms 59 ms
ssh -N -L forward alone 40 ms 55–74 ms (rises with typing speed)
full blimp path (tunnel → WebSocket → blimpd → zmx) 40–41 ms 44–58 ms

Target: the full blimp path stays within 5 ms of today's path at both the median and the 95th percentile. Measured: it's about 8–12 ms faster at the median (across runs) and no worse at the 95th. blimpd adds about 0.1 ms (median) over a direct zmx attach.

There aren't any published security advisories