Skip to content

Security: mikecann/threadbear

SECURITY.md

Security

Reporting a problem

Please use GitHub's private vulnerability reporting: the Report a vulnerability button on this repo's Security tab. Don't open a public issue, and keep tokens, keys and private thread content out of anything you send.

Threadbear is one person's project, so there's no formal timeline. I'll aim to reply within a few days and to fix anything real on main as soon as I can. Only the latest main is supported.

What Threadbear is, security-wise

Threadbear runs Claude Code and Codex agents on your own Mac, with your own logins. Everything is built for one owner per deployment: you, your Macs, your phone and your browser, and a Convex backend you deploy yourself. There are no other users or tenants inside a deployment.

The agents themselves have whatever access you give them. New threads start with full access by default, the same as running the CLIs with permissions skipped. Threadbear doesn't sandbox an agent. Its job is to not hand anything more to code or text you haven't chosen to trust.

What it protects

  • Your Mac from a repo you open. A project's patches (.threadbear/patches), the scripts they run and its own Claude Code hooks, commands (its agents' and skills' too) and environment settings don't run until you trust them, and in a thread that asks before tools run, nor do its permission rules that would let tools run without asking. Trust covers the exact bytes, including any packages the repo commits in its own node_modules and any workspace package it links there (but not ones you installed), so a later change asks again, and anything it can't check fails closed (protocol: Trusting a folder's patches). A patch's action must be one trust can follow all the way, and the patch is hashed again as the action starts, so a chip never runs bytes that changed since you trusted them. Commands that run by themselves (a patch's live state and list panels, a repo's Claude Code hooks) are held to a short list of shell, and anything outside it doesn't run, rather than being searched for known tricks: a live command may run only a named list of programs in named forms, with literal words, write no file, and use git only with a repo's own config unable to add what git runs (protocol: The short list of shell). What a repo's patch runs on another machine over ssh is held to a list too. Moving a thread to another Mac reads, packs and checks out its folder with git told to run nothing the repo's own config names: no filters (yours either, as git-lfs runs what a repo names), hooks, fsmonitor, signing or diff programs, no look inside a submodule (whose own config it hasn't read), only the git commands it has been checked for, and for a fetch only your own credential helpers and ssh. The done card's check of what a thread left uncommitted or unpushed runs git the same way. What the list can't reach: a program that makes its own session outlives the command's process group, and a script outside the repo is checked for who could have written it, not for what it imports.

  • A schedule's check after you approved it. A check runs by itself on every run, so it's bound to the hash of the command and every file it reaches, kept on the Mac it runs on. If that changes, or it reached the Mac only through the backend, it doesn't run: the thread is told once, and passing the same check again approves it as it is then. Installed packages and programs on PATH aren't covered, and an owner's or Full thread's check that can't be followed all the way (python3 x.py) is bound only as far as it can be; from a thread that asks first, one like that is refused.

  • Ask and Auto threads from raising their own access. An agent in a thread set to Ask or Auto can't switch its own or any other thread or schedule to more access (a schedule that takes the runner's default mode counts as that mode, and one whose mode can't be known here as Ask), start a thread with more access than its own, send a message to a thread that has more (or move it, rename it, or put words in a project brief it reads, or have its closing reply passed to one waiting on it that it didn't ask for, or that has been raised since, or have its title and words named in a new thread's first instructions as possibly the same work), or add a schedule check (a shell command), unless you allow that one call on a card. A reply button presses only from its own thread's transcript or message box, so one copied into another thread's reply or ask presses nothing. The runner's own question about full sync is answered only by your pick on its card, which the runner acts on once, itself: an agent's message never carries a pick, so another thread's words to the asking thread change nothing. That card has no "Always allow", and no rule or Auto's classifier answers it. It shows all of what the call does (the whole message or brief, and the folder as the disk has it, links followed), and the call does exactly that or nothing. A thread with full access can already run anything as you, so it isn't asked. The same limits hold for tb in a thread: each thread's CLI gets a key of its own instead of the runner's token, and anything it asks the runner for above its mode (a thread or schedule above it, a message to a thread above it or renaming one, raising a mode, answering a card, pressing a button, running an action, trusting a patch, setting instructions, a restart) is refused rather than carded. The runner also checks who's really calling: a connection that comes from under a thread's CLI gets that thread's limits whatever it authenticated with, so reading the token file or wiping the thread's key from the environment gains nothing. A thread that isn't on Full access has Claude's file and shell tools denied the runner's secrets folder as well. What this can't stop is a shell command that deliberately starts a new session outside its thread's process tree and reads the token (Codex's sandbox can read anywhere, too): that's the same-user boundary below, and in Auto it rests on the classifier. A thread started before this release keeps its old tb until its CLI next starts. Claude's "Always allow" on a tb command covers exactly that command, never a prefix of it.

  • A sandboxed thread from leaving its sandbox (an experiment, protocol: Sandboxed threads). Its agent counts as restricted even on Full access: anything that would run outside the sandbox asks the same way, and a thread it starts in a sandbox folder stays shut in one. Its login's config folder is shared with that login's other threads, so give sandboxed threads a login of their own.

  • Plan first, Ask and Auto on another Mac. A new thread started on another runner keeps the mode you picked through every hop, or isn't started. One an extra Mac's agent asks the main Mac for comes as Ask rather than Plan first.

  • "Always allow" on a Codex, OpenCode or Cursor command covers that exact command in the folder it really runs in, never a prefix of it or the same command somewhere else, and is only offered for a program whose words say what it does (ls, grep, rm and the like), never for a shell, an interpreter or a package or build launcher (npm test, make, cargo run), whose code the repo can change. Codex asking for more filesystem or network access is allowed for one turn at a time.

  • Approval cards show the whole command. Allow never runs more than the card showed. A file edit's card shows every line it writes and every other argument (a line break the diff can't draw is said in words), and a card for one of Threadbear's own tools says what it would do in the runner's words ("Switch this thread to Full access") above all of its arguments. A command, a tool's arguments or an edit too long to show whole are refused, and a card the phone can't show in full (any file edit among them) can only be allowed on the Mac.

  • Ask and Auto from becoming code that runs. In those modes an agent's edit to a patch, a file a patch uses or Claude Code's settings always asks, with no Always allow, and what it writes waits for you to trust in Patches. Only a Full access thread's own edits are trusted as it leaves them, and an edit to a file you hadn't trusted doesn't trust the rest of it.

  • One Mac from another. Each extra Mac has its own key, which can only act as that Mac. It can't read or change another Mac's threads, commands or secrets, or pair phones. What it sends the Mac, iPhone and Android apps (times, sizes, counts, its machine's figures, the text of its threads, its replies' markdown) is checked before they use it, so a bad value is left out or held in range rather than stopping or freezing the app. It can't get one of its schedules run on another Mac, even under the id of one just deleted there. Its usage readings reach no other Mac, so it can't stop one using a login, and which logins it gets the main Mac's readings of is the main Mac's word (tb runner logins), never what it says it's signed in to. What it can store is capped in size and number and runs out, and it can't stop the backend's cleanup; a move's uploads count too, and go when the move ends. A thread moved from it brings its design reviews cut to what show_options takes, and no schedule check, "Always allow" answer or anything else that runs or approves something on the Mac it goes to, and arrives on the main Mac with at most Ask, with any patch or Claude Code setting its git work changed waiting for you to trust it (kept so through upgrades: a moved worktree whose state can't be known waits too), and a malformed record from it can't stop the runner starting, a move that asked for it goes to the runner the card named by id, and what a move tells it about a thread's repo has no password or token in it. Its request for a thread on another Mac shows on the card as that Mac's runner read it from the backend, with nothing it sends stacked over it, and Start it starts only what the card showed. A command in its thread's reply is never put in a Terminal on the main Mac (only Copy is offered), and nothing it names its threads or archives stops a shell there: its thread ids are only ever a thread id's letters, digits, - and _ (the backend takes no other from it, and the runner and both Mac apps leave out any other they see), so one can't put a command into Copy debug info's line or markdown into a reply's thread chip. A thread moving off it stays shown as its, with the message box locked, until where it's going lists it: taking its own row out of the sidebar and putting it back can't make the thread look like it's already on the main Mac, and the GPUI app names the runner it shows a thread on with every message and edit, which the main Mac's runner refuses to pass to any other. Fresh thread ids it churns through the sidebar don't pile up in the GPUI app's memory. The phone and a shared thread's page keep only so much of a thread that's still being written to, however long it's open. The main Mac's key can do everything (protocol: Runner keys).

  • Secrets you type on your phone. They're sealed to the Mac named in your pairing code, so the backend and other Macs can't read them.

  • Your phone and browser logins. Tokens are stored only as hashes, expire, and can be revoked, which also stops that phone's notifications at once, however many it registered. A token's photo uploads are its own: capped, deleted a day after if never sent, and no token can delete a file it didn't upload.

  • Your keys on the wire. The runner sends its key only to an https sync URL or Connectors gateway (plain http only to the same machine), and never follows a redirect with it.

  • Shared threads. A share link is a public, read-only bearer link. It shows only that thread, and only pictures from the thread's own media folder. Stopping sharing or deleting the thread takes it down: the runner checks the backend's shared threads against its own on every start and every hour, so a stop or a delete the backend missed doesn't leave the link readable, and none of a thread's new messages go up while the backend's copy is still shared by a link the thread no longer has. The website and share pages look up a reply's links into its own steps with a fixed amount of work per page, so text an agent was steered into writing can't freeze them.

  • Your copy from a hijacked upstream. On a fork, only release/* tags with an SSH signature by the release key setup pinned count as Mike's releases; a PGP or X.509 signature never does. A tag counts only if its commit is on upstream's main, so a key used to sign a side branch gets nowhere. The key can be replaced through a signed release (a new key counts a week after a release signed with a pinned key names it) and revoked (only by itself or a newer key; a revoked key is refused from then on, by the runner and by setup). A release keeps the tag it verified with, and bringing it in merges the commit that was checked. The thread that brings one in works the merge out without checking it out, and runs nothing from the release (no checks, installs or builds) until you say go. Upstream's tags are fetched a bounded few at a time, with a deadline, into a folder outside your checkout, and only one that verifies comes in (protocol: Mike's releases).

  • Approvals you chose, through a review. A review by another lab (Settings > General > Code review) runs with the reviewed thread's own permissions, never more (a sandboxed thread's review runs in the sandbox too, or not at all), and is told the change and replies it's handed are data, not instructions.

  • Another Mac's address. The Mac app only sends a runner's token to the host you pinned for it, never one the backend suggests.

  • Your Mac and phone from links and files. A link in a reply, a page, a patch or anything another Mac's runner sends opens without asking only if it's a web page (http or https); any other kind (slack:, x-apple.systempreferences:) starts its app only after you say yes to that link, by name. A file opens in its app only after its symlinks are followed, as the real file, and without asking only if it's a known kind of document (a picture, text or code, a PDF, a film, an office file) going to an app that shows it: anything else (a .command, a .terminal, an app, a script set to open in Terminal, a kind it can't place) asks first, and another Mac's copy of one never opens. A thread from another Mac never names a file on this one: dragging, Quick Look, a link's hover card and pictures in its replies and its agents' messages use only this Mac's copy of that Mac's file, and a picture in this Mac's own threads is drawn only from a picture file of a sane size (never a device like /dev/zero). Convex's sign-in opens only Convex's own sign-in page, a review's Open live only a web page, a pin an agent shows only ever opens in the pane (or a web page where you've set links to go), and another Mac's thread never offers a sign-in that runs here. A skill's description in the slash palette keeps its formatting but none of its links, and its Open file shows the skill in the pane rather than handing its path to the system. The same rules hold in the new desktop app (desktop/), which opens every link and file through one gate, and a pasted picture waits for the runner in a folder only you can open. What's typed but not sent (both apps' drafts, the GPUI app's review drafts), and every other file the GPUI app keeps in the data folder, is readable only by your account from the moment it's made, never written first and locked after. A link clicked in the Terminal panel (a program's hyperlink, or a path in its output) goes through the same checks. A paste into the Terminal panel, in either app, loses every control character but Tab and line breaks, so text copied from a page with a hidden one in it (a ^\ or ^Z, which the shell's terminal takes as a signal) can't make the paste run by itself. A page's alert, confirm or prompt in the Browser panel is drawn over that page under its site's name, never as one of Threadbear's own dialogs. Open in Chrome for one of Chrome's profiles starts only the Chrome that's already running, and only once its signature says it's Google's (the picture panel's Simulator, only Apple's), so an app in a project folder that borrows their id never runs in their place, and a patch's app icon is read from the icon file inside the app, never by asking macOS about the app. A local page in the pane, and HTML dragged in from a browser, are read in one pass with fixed limits on what's kept, so a crafted page or drag can't make the app run out of memory or stall.

  • The desktop app's web runtime. Chromium isn't part of the new desktop app (desktop/): it's downloaded from CEF's official builds only when you choose to install it, and unpacked only if its size, the SHA-1 Spotify publishes for it and a SHA-256 pinned in the app all match. Only its framework's files and folders come out of the archive, unpacked from the same open file that was hashed; a link of any kind, or an entry that would land outside the framework, stops the install (CEF's framework has no links). An agent's html block and a mermaid diagram are shown there under the Mac app's rules: the page's own scripts never run (JavaScript is off in it; a diagram's renderer runs apart from the page, which lets none run), every request but the page's own data is cancelled in a request context that keeps nothing, with a dead proxy and no prefetching, and it can't navigate, open windows or download. A web panel's page (a patch's site, the Convex dashboard) runs as a website does, in Chromium's own profile, which keeps its sign-ins: a link, form or new window to another site that you clicked goes to your browser through the app's open gate rather than into the panel, one page for each click or key press in the page and one a second at most, and nothing a page's own script asks for opens anywhere (the Convex dashboard's own navigation off its site is cancelled; a patch's page may still go through another site by itself, as a sign-in does). A file or any other kind of address never loads as a panel's page, dropped on it or asked for in a new window (its iframes are Chromium's to rule on, and it keeps files out of them), nothing downloads, and what a panel's page logs to its console isn't kept. The Mac app's panels follow the same rules: only a click WebKit itself records as a person's sends a page out, one page for each gesture it records and one a second at most, and only a web page loads as a panel's page. The Convex dashboard's deploy key goes only to Convex's embedded dashboard, from a script that answers only on its origin and only the page's own request, in a request context kept in memory; a scripted test build can point it at a stand-in page on 127.0.0.1, a release build can't. Chromium's own process sandbox is off for now, as on a Mac it only covers a runtime inside the app's bundle (gpui-port.md, "How A is built").

What it assumes

  • Your Mac account is yours. Other processes running as you are trusted.
  • Commits on the main your runner follows are yours. The runner builds, deploys and restarts from it.
  • A check host (checkHosts) is trusted to run tests, not to build what your Mac runs. The new desktop app's update is built on another Mac only if you name it in updateBuildHosts, as what it sends back runs here unchecked; with check hosts but no such host it isn't built at all. A staged update goes in only if this Mac or a host on that list built it, and an app installed before 12 Nov 2026 can't take its next update from a check host: install it again.
  • Your Convex deploy keys, CLI login and the main runner key stay on your main Mac.
  • Other Macs' runners are never connected to directly: their threads reach your main Mac through your Convex backend, and each extra Mac holds a runner key that reaches only its own threads.
  • Anyone with a share link can read that thread while sharing is on.

Shared services

  • The push relay is run by me so the App Store app can get notifications. It sees device tokens and encrypted notification text, and only registers Convex deployments that prove who they are. Forks can run their own (relay/).
  • Sentry gets crash reports from release builds only, with no thread text.

Out of scope

  • What an agent with full access chooses to do on your Mac, including after a prompt injection. That's the agent's permission mode, which you choose.
  • Problems that need your Mac account, your Convex admin access or your main branch already in someone else's hands.
  • The marketing site in site/, beyond the usual.

There aren't any published security advisories