Collie provides remote shell access to your machine by design. A single Collie API call sends arbitrary keystrokes directly to a live terminal pane. Any device you paired can read every pane (source code, secrets, environment variables, agent output) and execute commands as your user. Since 1.18.0 every API request needs a paired device's token, reads included, so the door is pairing itself: anyone who can reach the URL and holds a valid pairing code gets the same access. Before 1.18.0, anyone who could reach the URL could read every pane.
There is no sandbox and no command allow-list, as filtering commands would defeat the purpose of
the tool. Treat every paired device as a root login, and a pairing code as a password. Revoke a
lost device at once with collie devices revoke <label>. Pairing lowers the risk but does not
remove it, so keep the URL on a private network such as a tailnet, never on the public internet.
Since 1.18.0 this is the credential for every request, reads included, not for writes alone.
# on the host — prints an 8-character code and a QR code, good for 10 minutes
bin/collie pairPairing is always on, and reads need the pairing token as well as writes. Until a device is
paired, Collie answers every phone and browser with 403 device not paired. Run collie pair
first, on the host, before you open Collie on the phone.
Open Collie on the phone, go to Settings → Paired devices, and enter the code with a label for the device, or scan the QR code printed by the command to open directly to that screen with the code already filled in. The phone stores the returned token. Collie keeps only the hash, and the token is displayed once. You do not need to restart the process; the running daemon applies pairings and revocations on the next request.
The two device gates answer different questions, and you can run either, both, or neither:
| asks | trusts | revoke by | |
|---|---|---|---|
COLLIE_DEVICE_HEADER |
is this device on the operator's list? | your proxy, to inject a name it sanitised | editing COLLIE_DEVICE_ALLOWLIST, then restarting |
| pairing | does this device hold a credential I issued? | nothing on the network | collie devices revoke <label> — live |
Pairing requires no extra infrastructure. It fits a direct tailscale serve setup where no proxy
exists to inject headers.
Pairing gates every /api route, reads included. Only two routes stay open: /api/health, which
a monitor or a load balancer asks, and /api/pair, which a device needs before it holds a token.
The device header gates writes and one read, the Files view, which can show
every file under a workspace's folder
(ADR 0086).
bin/collie devices list # what holds a credential, and when each was last seen
bin/collie devices revoke old-phone # effective immediately, no restartA fresh install has no paired device, so Collie answers no phone until you run collie pair.
Revoking the final device does not open Collie again: every device then gets
device not paired. To recover, run collie pair on the host and pair the phone again.
collie doctor warns while no device is paired.
Note. The crew link and the standby door keep their own credentials. A lead forwards a read to a member over pinned mutual TLS and the crew secret, so a member needs no paired device of its own (crew).
Five failed code attempts invalidate the code, which requires running collie pair again. On top of
that, the bridge refuses more than ten pairing attempts per source address per minute with 429 and
Retry-After: 60. Behind a front door on the same machine, the source address is the last
X-Forwarded-For entry, the one the front door adds. From any other peer, that header is ignored. The
counters live in memory and reset when the bridge restarts.
At each start the bridge writes a new random secret to local-secret in its state folder.
collie doctor and collie crew update send it to read their own bridge on the same machine.
The file is owner-only, so only your account can read it, and a clean stop deletes it. The
bridge takes it for reads only, never for a write or the Files view, and never from another machine.
It accepts the secret from loopback only, and never on a request that carries a proxy's header such
as X-Forwarded-For. A crew member that binds COLLIE_HOST to its tailnet address has nothing on
loopback, so there the bridge also accepts it from this machine's own addresses. A plain TCP relay
on the same machine, such as socat or ssh -L, can still pass the secret on. The file is what
keeps the secret yours, so keep the state folder owner-only.
A paired device keeps its token until you revoke it. To limit that, pass a lifetime when you pair:
bin/collie pair --expires 30d # also 12h, 2w; at most 3650d
bin/collie devices set-expiry pixel 90d # a new lifetime, counted from now
bin/collie devices clear-expiry pixel # no expiry againThe lifetime counts from the moment the phone claims the code. Without --expires, a token never
expires, exactly as before, and no existing token changes. After the expiry, the bridge refuses the
token with device expired instead of device not paired. The phone then
clears what the pairing left and shows Pair again in
Settings, so run collie pair for a new code. collie devices list and the Settings screen show
each device's expiry. An expired device stays in the list until you revoke it or give it a new
expiry with set-expiry. To pair it again, you can use the same name: the new pairing replaces
the expired entry and revokes its old token, and the audit log records the revoke. A name that a
device still uses, with a token that has not expired, is refused. A crew deputy's standby door
refuses an expired token too.
On a host running multiple instances, prefix commands with COLLIE_INSTANCE=<name> and open that
specific instance URL on the phone
(Multiple Collie instances on one host).
When a pairing ends, the phone deletes what Collie stored under it. This happens when you unpair the
phone from Settings, and when the bridge refuses its token with device not paired or device expired.
The phone deletes the token, every unsent draft, the saved pane text and herd, the push subscription, and Collie's caches except the app shell. Your settings stay: theme, language, pins and other preferences hold no session text. Settings asks you to confirm before it revokes any device.
The phone's own wipe is the only one. The bridge sends no Clear-Site-Data header, because that
header would also clear your settings and the offline app, and on a shared host name it would
reach the other apps there too.
If the bridge cannot read its list of paired devices, for example a half-written file, it answers
503 pairing unavailable instead. The phone then keeps its token and everything it stored, and
tries again.
The phone keeps a small, bounded copy of what it last saw, so a cold open can show it while the bridge is out of reach. Every item below has a size bound and a lifetime.
| What | Where | How long | At unpair |
|---|---|---|---|
| Pairing token | localStorage | until unpair, revoke or expiry | deleted |
| Settings: theme, language, pins | localStorage | until you change them | kept |
| Unsent drafts | localStorage | 48 hours | deleted |
| Push endpoint | localStorage | until it changes | deleted |
| App shell | Cache Storage | until the next build | kept |
| Fonts and push titles | Cache Storage | until replaced | deleted |
| Herd and pane text, as last seen | IndexedDB collie-store |
24 hours | deleted |
| Newest Chat turns of each pane | IndexedDB collie-store |
1 day, or the "Keep chat on this phone" setting | deleted |
The last two rows are the on-device store. It holds only text the bridge already masked, at most 256 KiB per pane and 10 MiB in all, and it drops the oldest entries first. The pane text and the Chat turns each get half of a pane's 256 KiB, so one never pushes the other out. The Chat turns are kept whole, as Chat drew them, never as the raw terminal screen. "Keep chat on this phone" set to Off keeps none and deletes the ones kept. When a pane asks for a password, the phone drops that pane's entries. Nothing in the store can send a key or a reply. The deletions at unpair are the ones in What unpair clears on the phone.
The phone's app switcher can also show a pane. When you leave Collie, the phone keeps a picture of the screen for its list of recent apps, and that picture can show pane text. A web app cannot stop this: Android took its picture before the page heard that it went to the background (tested on a Pixel, 2026-10-08), and iOS gives no promise either. If that matters to you, lock the phone or close Collie before you hand it to someone.
Key security boundaries and risks:
- It runs with your user permissions. Collie inherits your full access rights, including
~/.ssh,git push --force,rm -rf, andsudo. - Authentication identifies devices, not humans. Tailscale verifies the hardware endpoint rather than the user holding it. There are no passwords or user sessions; an unlocked or stolen phone provides an open shell. Pairing ties access to a credential on the device (above), so revoke a lost phone at once. The built-in idle lock merely blanks an unattended screen and provides no actual security boundary (ADR 0007).
- All local system users can reach the port. Standard terminal multiplexer sockets (
tmux,zellij,herdr) use filesystem permissions to restrict access to other local users. Collie listens on a local TCP port, which exposes it to every local UID. Reads need the pairing token, so another local user who holds no token reads nothing but/api/health. The token is the boundary: a local user who can read your browser profile can read the token too (ARCHITECTURE.md §6). - The Files view reads files off your disk. It shows any file under a workspace's folder, except
a
.gitfolder, Collie's own state and config folders, and files named like a Collie state secret. So it asks for an authorised device, like a write (ADR 0083). - Every paired device can read files under the workspace's folder. That includes
.envfiles. Pair only the devices you trust with the shell itself. - Credential files under a workspace's folder are readable. A workspace opened in
~/.claude,~/.codex,~/.config/ghor~/.sshshows what is there. So does the.envof a second Collie whose config folder sits under the workspace. A hard link inside the folder to a file outside it is not caught either. - A single instance exposes all sessions. By default, one Collie process fronts every multiplexer session discovered under Herdr's configuration root, including sandbox sessions (Multi-session).
- Writes are recorded to
<state-dir>/audit.log, which is~/.local/state/collie/audit.logunlessCOLLIE_STATE_DIRmoves it. The server logs all incoming keystrokes, replies, file uploads, and pane/tab lifecycle events. Note that an audit log provides visibility after the fact rather than access control. Characters sent in Type mode are never written to the log:COLLIE_AUDIT_CONTENT=noneredacts each one, and the default preview records only a count of•marks. Named keys such as Enter and Ctrl+C stay readable. (ARCHITECTURE.md §6). - Response headers. Every response carries
X-Content-Type-Options: nosniff,Referrer-Policy: no-referrerandPermissions-Policy: camera=(), geolocation=(), microphone=(self), payment=(), usb=(). The microphone stays allowed for the hands-free speech setting. The Content-Security-Policy on the app shell includesobject-src 'none',form-action 'self'andframe-ancestors 'none'.Strict-Transport-Securityis sent only when a request arrived over HTTPS, either on a TLS listener or withX-Forwarded-Proto: httpsfrom your proxy. It covers the host name that answered and not its subdomains, because Collie often shares a parent domain with other services. The bridge itself speaks plain HTTP behindtailscale serve, and a browser ignores the header there. Images served from/api/blobs/are session content, so they carryCache-Control: private, no-storeand no cache keeps them, not even the phone's own. - Default defensive controls. Collie binds strictly to loopback interfaces, routes traffic
solely through
tailscale serveor an equivalent reverse proxy, and applies strict CSP rules, same-origin checks, and host-header validation. Pane output renders as React text nodes instead ofinnerHTML. Never usetailscale funnelor expose a raw port. To authorize specific hardware, use pairing directly, or, if your proxy injects device IDs, the twoCOLLIE_DEVICE_*variables below.
| variable | what it does |
|---|---|
COLLIE_ALLOW_NON_LOOPBACK_BIND=1 |
Opts out of the loopback-only bind; unset, the bridge refuses to bind to 0.0.0.0. |
COLLIE_ALLOW_ANY_HOST=1 |
Disables host-header validation, which is otherwise on by default and fails closed. |
COLLIE_TRUSTED_USER |
Rejects a request whose Tailscale-User-Login header is missing or does not match. |
COLLIE_TRUSTED_USER_OPTIONAL=1 |
Permits a missing Tailscale-User-Login header (tagged nodes never send one). |
COLLIE_ACCESS_TEAM + COLLIE_ACCESS_AUD |
Cloudflare Tunnel only. Rejects every remote request unless its Cf-Access-Jwt-Assertion verifies for this Access app. Only a local process on loopback, /api/health and the crew links skip it, and pairing still applies. Other front doors, such as tailscale serve, are refused too (Cloudflare Tunnel). |
COLLIE_DEVICE_HEADER |
Name of the header your proxy injects with a device id. |
COLLIE_DEVICE_ALLOWLIST |
Comma-separated device ids allowed to write; every other device stays read-only (docs/deployment.md). |
COLLIE_REDACT=off |
Turns off the secret mask on pane text, diffs and file text (below). On by default. |
🚫 Never use
tailscale funnelwith Collie. Funnel routes traffic to the public internet, whereastailscale serverestricts access to your private tailnet. There is no supported use case for running Collie over Funnel.
Restrict access further with Tailscale ACLs and COLLIE_TRUSTED_USER. Provided as-is, without
warranty.
Collie masks known secret shapes in pane text and file content before it reaches a phone.
# in your .env, only to turn the mask off; it is on by default
COLLIE_REDACT=offThe mask runs on the bridge, on five paths: the terminal mirror, the Chat and History views, every
push notification, the diffs in the Changes view, and the file text in the Files view. What it hides
becomes • marks of the same width, so columns and line counts hold. A vendor prefix stays
readable, so sk-o•••• still tells you what was hidden.
It matches high-confidence shapes only:
| shape | example of what is masked |
|---|---|
| prefixed API keys | sk-…, sk-ant-…, sk-or-v1-…, ghp_…, github_pat_…, xoxb-…, AKIA…, AIza…, glpat-…, npm_… |
| JWTs | three base64url parts, the first starting eyJ |
| PEM private keys | every line between BEGIN … PRIVATE KEY and its END line |
| bearer tokens | the token after Bearer , 20 characters or more |
| named values | the value after password=, secret:, token=, api_key: and similar, 8 characters or more |
Caution. This is a mitigation, not a guarantee. A plain password on its own, a bare hex or base64 value, and a key with a prefix not in the list are missed on purpose. A pattern for them would mask ordinary text and code on every screen.
Some ordinary text is masked too, for example a line of prose or YAML that reads token: something.
What you type and send is never masked, and the audit trail keeps its own rules
(COLLIE_AUDIT_CONTENT).
The switch lives on the bridge only, as COLLIE_REDACT. Settings → System shows it read-only, as
"Secret masking". The phone has no switch on purpose: a switch on the phone would let any paired
phone, or a stolen one, turn the mask off. To read a masked value, read it on the machine. The first
time a pane shows masked text, a short line on the phone says so, once per device. If you paste
masked text into a reply, a caution under the box says the pane gets the dots, not the secret.
In a crew, each member masks its own text, and the lead masks it again before your phone gets it.
The lead masks a member's mirror, Chat, History, diffs, file text and pane titles with the same mask
it uses for its own, so a member that still runs 1.17.x cannot send a key to your phone in clear.
Masked text stays as it is when it is masked again. Text reaches the phone unmasked only when
COLLIE_REDACT=off is set on both the lead and the member. One edge: a member on 1.17.x masks
nothing itself, so when a secret sits in the lines a reply is checked against, the phone may refuse
that reply with "The screen changed before that could be sent". The reply then waits until the
member runs 1.18.0.
If the lead cannot read a member's answer to mask it, it does not pass the answer on. The phone then shows that read as failed, as it does for a member it cannot reach.
A push notification also names a pane only by the name you gave it: the pane's label, Claude's
/rename name, or a one-pane tab's name. It never uses the title a program in the pane set, because
any program can set that title, and a title can hold a path, a command or a secret. A pane with no
such name is called by its harness, for example claude.
On Windows (experimental), Collie keeps its secret files private to your account, SYSTEM and Administrators. "Your account" is the Windows account that runs Collie.
NTFS has no 0600 mode, so Collie uses the access control list (ACL) of its state folder and its
config folder. Each file that Collie writes there gets the same list. The default folders in your
user profile are already closed to other standard users. The check matters most when you move a
folder with COLLIE_STATE_DIR or another setting.
At start, the bridge checks both folders and their secret files. If other accounts can read one,
the bridge repairs it, but only in Collie's own folders. It saves the old list first, in
acl-backups in the state folder, and prints the icacls /restore command that puts it back.
Run that command in a terminal run as administrator. A
folder that also holds other files is checked, not changed: Collie prints the icacls command
for you to run. Other commands, such as collie version, only check and warn.
collie doctor shows the result as secrets-private. A folder that other accounts can read is an
error, with the fix. A folder that Collie cannot check is a warning: cannot confirm.
| Case | What happens |
|---|---|
You copy, restore or sync the folder (zip, robocopy without /SEC, OneDrive, File History, a USB drive) |
The copy can lose the list. Collie checks again at the next start. |
| Antivirus or Controlled folder access blocks the change | Collie says that it could not make the folder private, and gives the fix. |
| The folder is on FAT, exFAT or a network share | There is no list that Collie can use. Collie says cannot confirm. Move the state folder to an NTFS drive on this PC. |
COLLIE_NO_ACL_REPAIR=1 |
Collie changes no list. It still checks and warns, and doctor still reports. |
Note. This is not protection from an administrator. Administrators can still read the files. It also does not cover other programs that run as your account, hard links inside the folder, agent backups in
~/.claude, or a state folder inside OneDrive or Documents.
Nothing, by default and by policy. Collie sends no install events, no usage statistics, no crash reports and no analytics. There is no flag that enables them.
The one unprompted outbound call is the update check: an anonymous HTTPS GET to GitHub's public
tags API (bridge/update.ts) that compares your version to the newest tag. It carries no data about
you or your machine, only the static user-agent collie-update-check.
If you set COLLIE_ACCESS_TEAM, Collie also fetches that Cloudflare Access team's public keys, at
start and once an hour. That call is yours to turn on, and it sends nothing about you either.
If collection is ever added, explicit opt-in is the ceiling — off by default, asked as a visible question, never carried by a flag or a default. Removing that promise would be a breaking change (ADR 0034).