Scope: collied on the Mac or on a Linux machine, Collie.app on the iPhone, the tailnet between them, herdr as collied's local backend, and APNs. On Linux collied runs as a systemd user unit and advertises tag:collie-linux. Tailnet configuration is in tailnet.md, design in architecture.md.
herdr socket (0600, no auth: same UID means full control); reachable from the phone only through collied's allowlist
Mac node keys
~/Library/Application Support/collie/tsnet (0700)
Linux node keys
<data dir>/tsnet (0700), data dir $XDG_DATA_HOME/collie, else ~/.local/share/collie. Separate from a system tailscaled's state (/var/lib/tailscale): the embedded node is its own device with its own keys
Phone node keys
app container Application Support/collie/tsnet (0700, completeUntilFirstUserAuthentication, excluded from backup)
Machine TLS key (proves the machine to the phone)
tls-key.json in collied's data dir (P-256 PKCS#8, 0600, made on first start, read only as a regular file, not a symlink, owned by the user with no group or world access); its pin in the pairing QR and the phone's machines.json
Phone TLS key (proves the phone to collied)
the phone's Secure Enclave: a P-256 key, non-exportable, .privateKeyUsage, AfterFirstUnlockThisDeviceOnly and no user presence, so lock-screen decisions can connect; collie-core gets a signing callback, never the key; its pin in peers.json (tls_key), recorded when the pairing is confirmed
Pairings (StableID list) and the owner user ID
peers.json in collied's data dir (0600); optional owner_user_id in collied.toml
Pairing code
the QR that collied pair draws as text in the terminal, and the invite URI it also prints with --show-uri; 16 random bytes, one window at a time, 120 s, burned by the first attempt, redacted in Debug
Approval nonces
collied memory and the phone session (collie-core only, never handed to Swift); 32 random bytes, single use
APNs .p8 key (macOS)
login Keychain item dev.rbstp.collied.apns/<key ID>, ACL trusting only the Developer ID signed collied (legacy: 0600 file named by key_path); a backup in the user's password manager
APNs .p8 key (Linux)
systemd user credential <data dir>/apns/<key ID>.cred (0600), encrypted by systemd-creds with the host secret (/var/lib/systemd/credential.secret, root, 0400) and the TPM2 when one is usable, bound to the user and to the name collied-apns-<key ID>; fallback when systemd-creds cannot encrypt: 0600 file <data dir>/apns/AuthKey_<key ID>.p8 named by key_path; a backup in the user's password manager. Each machine has its own key and key ID
Live Activity update tokens
collied's push.json (0600) next to the device token, bound to the device's StableID; collie-core memory. Never logged, audited or written to disk on the phone; redacted in Debug
Notification keys (32 bytes, one per paired Mac)
iOS Keychain (dev.rbstp.collie.notify/<Mac node ID>, AfterFirstUnlockThisDeviceOnly, shared with ColliePush); a mirror file per Mac in the App Group container (notify-keys/<node ID>.key: 0600, completeUntilFirstUserAuthentication, excluded from backup), because a Live Activity renders in a process with no Keychain (errSecNotAvailable) and needs the key to show the command; collied's push.json (0600) next to the device token; collie-core memory. Never logged, audited or written elsewhere; redacted in Debug. The mirror is weaker than the ThisDeviceOnly Keychain item against a jailbroken or forensically imaged phone (user decision, 2026-10-03)
Claude Code's ~/.claude/projects/*/<session>.jsonl (or under CLAUDE_CONFIG_DIR) and Codex's ~/.codex/sessions/YYYY/MM/DD/rollout-*-<session>.jsonl (or under CODEX_HOME), 0600, written by the agents; collied reads them and keeps only values derived from them, in memory
Plan usage and context window sizes
collied's usage.json (0600) in the data dir, written from Claude Code's status line input and the local Codex app-server: Claude's 5-hour and weekly limits, Codex's monthly used and allowed credits and reset date, and up to 64 Claude session ids with their window size; collied memory. Codex handles its OAuth token; collied keeps none of it.
Terminal key (signs terminal grants)
the phone's Secure Enclave: a P-256 key apart from the TLS key, non-exportable, .privateKeyUsage and .userPresence, WhenPasscodeSetThisDeviceOnly, so every signature needs Face ID or the passcode and removing the passcode destroys it; its public key in peers.json (terminal_key), recorded when the pairing is confirmed on the machine, and in the phone's machines.json
Terminal challenges and grants
collied memory only, per session: one challenge (32 random bytes, 60 s, single use), grants per terminal for 5 min. Never logged or written to disk
Attachments (photos and files sent from the phone)
the phone; collied's attachments dir (~/Library/Caches/dev.rbstp.collied/attachments on macOS, $XDG_CACHE_HOME/collied/attachments, else ~/.cache/collied/attachments, on Linux; 0700 dirs, 0600 files) for up to 24 h; the tailnet session
Tailnet to collied. The only network entry point is the tsnet listener on TCP 8457. Every connection passes the whois gate (untagged, not shared in, owned by the owner once one is known, paired StableID with its paired user and a pinned TLS key, or an open pairing window), then mutual TLS against the key the gate pinned for that peer (any P-256 key in a pairing-only session), then the same gate again under the peers, pairing and sessions locks, which must reach the same decision (a pairing or revoke during the handshake refuses it), all before the WebSocket upgrade; then a fail-closed method allowlist (parse_client_frame: unknown methods rejected before params are decoded, unknown fields denied, herdr privileged methods unreachable). Terminal methods are their own allowlist class, refused with terminals_disabled unless [terminals] enabled = true, and need a grant signed by the phone's terminal key.
collied to herdr. Same UID. Nothing on the computer running as the user is a boundary. On Linux the systemd unit's sandboxing is defense in depth, not a boundary against the same UID.
Tailscale control plane. Trusted for node identity: whois and the phone's netmap check both come from it.
Apple. APNs sees what collied pushes: labels and ids in clear, the pending action only as ciphertext, and the Live Activity content of followed agents.
Paired session (read and control of agents); phone node keys
Attack
Thief opens the unlocked app, or unlocks the phone, and drives agents or answers approvals.
Mitigations
iOS passcode and data protection on the node state (built). Revocation: collied peers revoke plus removing the node in the admin console (built, tailnet.md). Phone node key expiry (Tailscale default 180 days) bounds a forgotten revocation. Closing a pane or a workspace needs confirm: true, else confirm_required before herdr is called (built); the phone sets the flag itself, so it guards against an accidental close, not against a thief. Every frame is rate limited per paired StableID (20/s, burst 40; attachment.chunk frames have their own bucket, 128/s) and every method other than hello, flock.snapshot, approval.list, attachment.chunk, attachment.abort and an issued terminal.challenge is written to the audit log (server.rsfinish; a refused challenge is logged); of a run of frames over the limit only the first is logged, as rate_limited (built). Approvals: in-app decisions need LocalAuthentication (deviceOwnerAuthentication, fresh context per decision), lock-screen actions need an iOS unlock (authenticationRequired), approval.decide is limited to 1/s, burst 5, per StableID, and every attempt that passes the frame limit is audited (built). Terminals (off by default) need a grant that collied verifies, signed by the Secure Enclave terminal key, which signs only after Face ID or the passcode (built).
Residual
Until revoked, an unlocked phone drives agents (prompts, keys, new tasks, closes) without any per-action check, and an agent can run any command: only approval decisions and terminal grants ask for Face ID or the passcode, so the terminal grant does not make the phone as a whole safer against a thief holding it unlocked. A thief who knows the passcode passes both approval gates and the terminal grant.
Phase
Revocation built. Approval authentication built. App-level gate for the other drive methods: not planned.
Pairing code, Mac tailnet node name, Mac StableID, the pin of its TLS key (public)
Attack
Someone photographs the QR, or copies it or the invite URI (printed next to it by collied pair --show-uri) from terminal scrollback, a screen share or recording, or a herdr pane that other same-UID code can read. They then pair their own device. The menu bar app's pairing window shows the same QR.
Mitigations
The code alone grants nothing: the pairing session is only offered to an untagged, not-shared-in node (owned by the owner, once one is known) and only while a locally opened window is active. One window at a time, 120 s; the first pair.complete from any connection burns it, right or wrong code. With the right code, collied shows the candidate's device label, node name, StableID, login name and user ID and asks y/N on the Mac, 60 s timeout. Code compared in constant time; carried in the URI fragment and redacted in Debug (built). The policy grants port 8457 to the owner's devices only.
Residual
When owner_user_id is not configured and nothing is paired yet, the owner is set by the first confirmed pairing (trust on first use), so any tailnet member the policy lets reach the port can race the window with a leaked code; the y/N prompt, which shows the login name, is then the only check. An attacker controlling a node logged in as the owner (account compromise) can race the window at any time. Any node admitted to a pairing-only session can burn an open window (denial of pairing only). The QR in the menu bar app's window is on screen for the same 120 s, and screen recordings capture it like the terminal's. The QR drawn in the terminal, and the URI with --show-uri, stay in scrollback and screen recordings after the window closes; they are useless once burned or expired, but a capture read live during the 120 s is as good as a scan. Host name and node ID in the QR are not secret.
Everything on the computer (Mac or Linux); the phone's view of agents and approvals
Attack
herdr plugins are unsandboxed code running as the user with the full herdr CLI. A plugin can drive every pane directly, read collied's data dir (node keys, tls-key.json, peers.json, .p8; on Linux it also decrypts the APNs credential), drive collied's control socket, and write pane text that collied turns into approval snippets to mislead the phone.
Mitigations
None against the computer side: same UID is out of scope for file modes. Snippets are capped at 200 chars with controls, bidi and format characters stripped (built), so a plugin cannot inject escapes into the phone UI through them. Approval decisions only send up, down, enter or esc, never text from the pane (built).
Residual
Full compromise of the computer. A plugin can draw a fake but well-formed Claude Code menu in a pane and so show the phone a fake approval. It also controls the gate's inputs: it can rewrite collied.toml and peers.json (owner and paired StableIDs), answer y/N over the control socket, and run collied's node and TLS keys elsewhere. Other users' devices are then kept out only by the tailnet policy, whose src names the owner alone and which only the admin account can change.
Phase
Built as far as it goes (see same-UID malware for the Keychain item's limits). Otherwise not planned (install only trusted plugins).
Node identity, which every collie authorization decision rests on
Attack
The control server (or a stolen admin account) adds a node with its own key and gives it the phone's StableID and the owner's user ID, or changes the tag owners and grants, or points the phone's netmap at a fake Mac.
Mitigations
Admin account protection (IdP, MFA) is the user's. collied pins StableID + user ID + untagged, and the phone pins the machine's StableID + the tag seen at pairing, tag:collie-mac or tag:collie-linux (built; the owners of either tag are trusted for the tag half of the pin), which stops casual misconfiguration but not a control plane that lies. The y/N prompt at pairing catches a new device only during pairing. Mutual TLS inside the tunnel, independent of Tailscale keys (built): the phone pins the machine's TLS key from the pairing QR, which is shown on the machine and so out of band, and collied pins the phone's Secure Enclave key, recorded when the pairing is confirmed. A node the control plane injects with the phone's StableID, or a fake machine in the phone's netmap, fails the TLS handshake.
Residual
Low: a lying control plane can still cut or delay traffic, and can learn which nodes talk. It cannot get a full session without the phone's Secure Enclave key, or pass as a machine without its TLS key.
An old push (or a captured approval decision) is replayed to approve something again or something else.
Mitigations
The push payload never carries the nonce, only approval_id and node_id as lookup keys (built). A lock-screen action fetches the nonce over the tailnet (approval.list) from the pinned, paired Mac and decides there; the app validates both keys and trusts nothing else in the payload (built). Nonce: 32 random bytes, constant-time compare, burned by the first attempt past the rate limit, whatever its result, approval_ttl 10 minutes; a burned id answers approval_already_resolved (built). Every attempt that passes the per-peer frame limit, including replays and expired ones, is audited; a run of frames over that limit logs only its first, as rate_limited (built). Decisions are bound to a fingerprint and fail as superseded if the prompt changed (built). APNs apns-expiration is the approval's expiry, and the terminal_id collapse id makes a reissued alert replace the dead one (built). The Nonce type rejects malformed values and is redacted in Debug (built).
Residual
A replayed or stale push shows a notification whose approval_id no longer resolves: acting on it answers "no longer pending, nothing was sent". A fresh alert for the same terminal can follow each reissue while the agent stays blocked.
With the key and a device token, an attacker sends arbitrary pushes to Collie (or, the key being team wide, to any app of the team) to phish the user.
Mitigations
Dedicated, revocable key, never in GitHub (release.md). On macOS it is stored in the login Keychain by collied apns import, which deletes the file after confirmation; the item's ACL lets only collied signed with the team's Developer ID (just collied-install) read it without a prompt; collied doctor warns about a key_path config (built). On Linux collied apns import stores it as a systemd user credential instead (or a 0600 file where systemd-creds cannot encrypt) and offers to delete the original; collied doctor warns about a key_path config when systemd-creds works; limits in APNs key storage on Linux (built). On both, collied doctor warns about a .p8 left in collied's apns dir other than the configured key_path (built). Each machine uses its own key with its own key ID, so a key leaked from one machine is revoked in the portal without touching the others. Pushes carry no nonce and cannot create or decide an approval: approval_id and node_id are validated lookup keys, and a lock-screen action decides only what the pinned, paired Mac returns over the tailnet (built).
Residual
A leaked key sends fake notifications to Collie (and to every app of the team) until revoked in the portal: a phishing alert with Approve/Deny buttons whose approval_id matches nothing ends as "no longer pending, nothing was sent", but its text is the attacker's (as a plaintext body only: without the notification key it cannot produce an enc the NSE opens). A forged background push naming a genuine approval_id (16 random bytes, seen only by Apple, the paired devices and the Mac) can remove that alert from the phone; the approval stays pending in the app. Copies outside the Keychain or the systemd credential (the downloaded file before import, the password manager backup) are protected only by where they sit.
Phone node keys, which pass the whois gate as the paired phone
Attack
Keys read from the device (forensic tools, jailbreak, exploit) or from a backup, then used from another machine.
Mitigations
App sandbox; state dir 0700 with data protection completeUntilFirstUserAuthentication, both set by StateDirectory.swift; collie-core refuses a state dir with group or world access or another owner (built). State dir excluded from backups, also set by StateDirectory.swift (built). Revocation and key expiry as for a stolen phone.
Residual
Node keys are files, readable after first unlock by code inside the app's sandbox. They no longer suffice: collied also requires the phone's TLS key, a Secure Enclave P-256 key that cannot leave the device (ThisDeviceOnly, usable after first unlock). Code running on the unlocked phone, inside the app's sandbox, can still use that key there.
Phase
Secure Enclave TLS key: built (Phase 6). Backup exclusion for the node state: built.
herdr, collied's data dir, the APNs key, the control socket
Attack
Any process running as the user controls herdr directly, reads node keys, tls-key.json and peers.json, uses the 0600 control socket.
Mitigations
None by design for herdr and collied's files: same UID already owns every shell. Data dir 0700, files and control socket 0600, control socket peer UID checked: these stop other local users only (built). On macOS the APNs key is the exception: it is a Keychain item whose ACL trusts only collied's designated requirement (Developer ID Application, team RM3UT3MMSR, identifier dev.rbstp.collied), and the binary is signed with the hardened runtime, so other programs cannot read the key bytes without a Keychain prompt (built). On Linux there is no such exception: any same-UID process decrypts the systemd credential with no prompt (APNs key storage on Linux). The systemd unit's sandboxing (NoNewPrivileges, ProtectSystem=strict, a system call filter and more) limits what a compromised collied can reach, but it does not constrain other processes of the same UID, and a user manager skips it where unprivileged user namespaces are unavailable (built).
Residual
Full compromise of the computer side. Stolen node keys and tls-key.json let the malware impersonate the computer to the phone and show fake state or fake approvals. It can report a tool call through the control socket's hook request that differs from the dialog, so the alert names another call; the decision still binds to the screen's fingerprint, and the in-app approval also shows the screen's snippet. It controls the gate's configuration (collied.toml, peers.json) and can answer the y/N prompt, so the gate no longer protects anything; other users' devices are kept out only by the tailnet policy, which needs the admin account to change. Keychain ACL limits (macOS): malware can raise a prompt that the user may accept; anyone with the login password can change the ACL or export the item; it can run the signed collied itself (its own --config or HOME) to make it send pushes with the key, so it can push through collied without holding the key; a key_path config or an un-deleted .p8 gets none of this protection. On Linux the malware reads the key itself and can push from anywhere until the key is revoked.
Phase
Built (Keychain item, systemd credential). Otherwise out of scope.
The invite (pairing code) while its QR is shown; the y/N decision on a candidate phone; the state of collied
Attack
Another app or a stray click approves a phone in the app's pairing window; the invite leaks from the app; phone-provided strings disguise the candidate; the app becomes a way in from the network.
Mitigations
The app is a local client of the 0600, same-UID control socket only, with the CLI's requests plus a read-only watch; it opens no network listener. Developer ID with the hardened runtime and no entitlements. The invite is never shown as text, copied, logged or persisted, and window restoration is off. Pair is not the default button and is live only 1 s after the candidate appears; one answer per candidate, never automatic; collied refuses after 60 s. The candidate's strings are escaped like collied::printable (controls, format and bidi characters, combining marks, default-ignorable characters and non-ASCII spaces). Closing the window cancels the pairing (built).
Residual
Same-UID code can drive the control socket or the app itself, as under Same-UID malware on the Mac: it can already answer the y/N prompt. The QR is on screen for up to 120 s, like the terminal's.
The invite while its QR is shown; the y/N decision on a candidate phone; the state of collied
Attack
A stray click or key approves a phone; the invite leaks into argv, a file or the shell's log; phone strings render as rich text or disguise the candidate; the audit log is edited through the viewer; the widget becomes a way in from the network.
Mitigations
A plugin of the Omarchy shell, a local client of the 0600, same-UID control socket only, with CollieBar's three requests; no listener, no network access, no change to collied. The invite reaches qrencode on stdin only and is never logged or shown as text; the QR is dropped with the pairing, but qrencode's output, which encodes the one-time invite, stays in the shell's memory until the next pairing. Pair is bound to no key, is live only 1 s after the candidate appears, and sends one answer; collied refuses after 60 s; closing the panel cancels. Phone strings are escaped like collied::printable and drawn as plain text. Commands run by argv, never a shell string. The audit log opens in less with LESSSECURE=1 (built).
Residual
Same-UID code can drive the control socket, the shell or the plugin files under ~/.config/omarchy/plugins, as under Same-UID malware on the Mac. Any other shell plugin runs in the same Quickshell process. The QR is on screen for up to 120 s.
The y/N decision on a candidate phone; the launchd agent or the systemd user unit; collied.toml and the APNs key
Attack
Setup runs with piped input (yes into it) so a pairing is confirmed without a human; it installs an unsigned agent, or a service running a build output; it leaves the key or config readable; on Linux it logs in a new node over the paired one.
Mitigations
Refuses unless stdin is a terminal and the binary is ~/.cargo/bin/collied, on macOS also Developer ID signed as dev.rbstp.collied, before any step: the service then never points at a build output that the next cargo build replaces unsigned. Never confirms a pairing: it runs the collied pair flow, whose y follows a tcflush. Reuses service install and apns import (Keychain, read back, offer to delete the .p8); a new collied.toml is created 0600, an existing one is rewritten atomically keeping its mode ([apns] holds no secret, the key is in the Keychain). It changes no bind, gate or pairing rule, refuses to drop the --config the service runs with, and stops on any doctor fail line, listen included (built). On both, ~/.cargo/bin/collied must be a regular file, so a symlink cannot point the service at a build output. On Linux there is no signing: it requires ~/.cargo/bin/collied only, writes the unit through service install (0644, no secret, UMask=0077), and imports the key through apns import (systemd credential, or the 0600 fallback, read back, offer to delete the .p8; the key is never printed). It refuses a unit loaded from another file, with a collied.service.d drop-in it cannot check, or pinning another XDG_DATA_HOME, so it never logs in a fresh node beside the paired one (built).
The machine's APNs .p8 key; the systemd host secret /var/lib/systemd/credential.secret
Attack
Read the key from <data dir>/apns/<key ID>.cred or the fallback file: a process running as the user, another local user, root or a polkit admin, or someone holding an offline copy of the file or of the disk.
Mitigations
collied apns import applies the same file checks as on macOS ([apns] ids, refuses a file named for another key ID, 0600 regular file owned by the user, opened with O_NOFOLLOW, ES256 key), encrypts it with systemd-creds --user under the name collied-apns-<key ID>, writes the credential itself (0600, O_NOFOLLOW, atomic), decrypts it back and compares before and after storing, then asks y/N to delete the original (only if it is still the same inode). An existing credential is kept only if it decrypts to the same key. systemd seals it with AES-256-GCM under a key derived from the host secret (root, 0400), the TPM2 when one is usable, the uid, the user name and the machine-id. An offline copy of the .cred alone decrypts nothing; with a TPM2 seal, decryption also needs that TPM chip. Other unprivileged users cannot decrypt it: the key is bound to the uid and user name, and systemd asks polkit for admin authentication (auth_admin_keep) before decrypting another user's credential. collied passes --no-ask-password, so it never raises a polkit prompt itself. The name is bound into the credential and every decrypt passes it, so a credential made under another name does not decrypt as this key. The daemon, collied apns test and doctor decrypt through stdin and stdout, never to a file. Doctor reads the seal from the credential header and reports it ("host key only (no usable TPM2)", "host key and TPM2" or "TPM2"); it fails on a credential that does not decrypt or has a null key, warns on an unrecognized seal, and warns when a host-only credential exists but a TPM2 is now usable (re-import to bind it). When systemd-creds fails to encrypt (for example no systemd, no systemd-creds.socket, systemd older than 256, a container), import falls back to a 0600 copy at <data dir>/apns/AuthKey_<key ID>.p8 named by key_path and says so; doctor reports that file as protected by its mode only while systemd-creds stays unusable, and warns to import again once it works (built).
Residual
No per-application binding: any process running as the user decrypts the credential with no prompt, so same-UID malware reads the key itself, where on macOS the Keychain ACL leaves it the signed collied or a Keychain prompt. Root, and anyone who authenticates as an admin through polkit, decrypts it too. An unprivileged caller cannot choose the seal: systemd picks host key and TPM2 when a TPM2 is usable, else host key only, and falls back to host key only if TPM sealing fails; doctor shows which one applies. Without a usable TPM2 the seal is host key only (the dev machine: T2 Mac hardware, where systemd-analyze has-tpm2 reports partial), so whoever reads the unlocked disk (host secret, machine-id and credential) can decrypt it off the machine. The credential stops decrypting after an OS reinstall, a machine-id change, a uid or user name change, or a restore to another machine: import again from the backup. The 0600 fallback file is protected by its mode only, like key_path on macOS.
Followed agents' status and labels; the update tokens
Attack
Someone looks at the locked phone, or a party holding the APNs key and a token pushes fake content.
Mitigations
Nothing is shown unless the user follows the agent, which is off by default. The activity shows status, labels, elapsed time and the approvals count. While an approval routed to it is pending it also shows the command, decrypted on the phone by the widget with the per-Mac notification key and marked privacySensitive, so iOS redacts it while the phone is locked or the display dimmed, and Approve and Deny, only when enc opens with the approvalId as AAD (an id pushed without a valid enc shows "Approval needed" and no buttons). The buttons are a LiveActivityIntent with authenticationPolicy = .requiresAuthentication, so iOS asks for Face ID or the passcode before the decision runs, then the same background path as the notification actions, one decision at a time per approval (nonce fetched from the pinned, paired Mac over the tailnet, fingerprint checked, approval.decide rate limited and audited). Tapping elsewhere only opens the app on that agent, which validates both ids and decides nothing. Tokens live only in collied's push.json (0600) and collie-core memory, are never logged or audited, are bound to the registering device's StableID (another paired device can neither replace nor end them) and are dropped on revoke, on Unregistered/BadDeviceToken, on end, when the terminal is gone and 8 h after the activity first registered (ActivityKit's activity lifetime), so a dead activity stops receiving plaintext; at most 8 per device, one per terminal (built). The alert that expands the Dynamic Island is the approval alert itself, under the same rules (a new prompt alerts, the same prompt reissued within 30 s does not), and replaces the approval notification on that device, so a flapping agent cannot turn it into an alert stream (built in collied).
Residual
Anyone who sees the locked phone sees the followed agents' names, workspaces, whether they wait for approval and the Approve and Deny buttons, but not the command. With the APNs key and a token, an attacker can make an activity show any status or text until it ends, including a fake "Approval needed"; buttons need an enc sealed under the notification key, and a decision still needs an unlock and the Mac's nonce. enc is bound to the approval id but not to the terminal, so such an attacker can replay a real approval's approvalId and enc onto another followed agent's activity: the real command shows, under the other agent's name.
Someone holding the phone approves or denies from a notification or from the app, or a fake alert tricks the owner into an action.
Mitigations
Lock-screen Approve and Deny are authenticationRequired on the notification and .requiresAuthentication on the Live Activity's intent: iOS asks for Face ID or the passcode before the app runs; collie adds no check of its own there (built for notifications). "Approve always" is not offered on the lock screen. In the app, each decision first passes LocalAuthentication deviceOwnerAuthentication with a fresh LAContext, so one unlock never covers a later approval (built). The background decision runs a 20 s capped one-shot session: never a Tailscale login (NeedsLogin fails at once as Unauthorized), pinned Mac StableID, paired hello, then the nonce from the Mac; the follow-up notification claims success only on applied (built).
Residual
The lock-screen gate is iOS's unlock and nothing more: a device unlocked recently enough for iOS to skip the prompt, or a known passcode, passes it. The alert title and body come through Apple and are shown before any check.
The decision on a blocked agent; the commands, agent lines and plan usage shown on the watch
Attack
Someone wearing or holding the owner's watch approves or denies, a watch message claims to be allowed, or a watch request names an approval or an answer the phone never showed.
Mitigations
Watch decisions are off by default; turning them on in the iPhone's Settings needs LocalAuthentication deviceOwnerAuthentication, and turning them off needs nothing. On every request the phone re-reads the setting from prefs.json (missing, unreadable or before first unlock: off) and trusts no flag from the watch. It decides only an unexpired approval, and an answer it offered, that is in the last application context it sent the watch; anything else is refused with nothing sent. On the watch, each decision first passes deviceOwnerAuthenticationWithWristDetection with a fresh LAContext. The phone then takes the lock-screen path: the same one-shot background decide (pinned Mac StableID, paired hello, the nonce from the Mac, fingerprint checked, approval.decide rate limited and audited). No Approve always, notes or typed text. When the watch app opens, or opens from an alert, it asks the phone for the current state; out of the foreground the phone lists each paired Mac's approvals over the same one-shot sessions (approval.list, no decision) and sends them as the state the watch may decide on, so the shown-state check applies to them unchanged. The watch holds no keys, never links CollieCore and never talks to collied.
Residual
The phone cannot verify the wrist check, so the setting is the phone-side boundary. Decisions work while the iPhone is locked (after its first unlock). A decision in flight when the setting turns off completes. Alerts mirrored from the iPhone: on device, tapping the iPhone's Approve or Deny on the watch opened the watch app and decided nothing. The watch app is the notification delegate on the watch and turns any response to an alert naming an approval (either button or the alert) into opening that approval in the watch app, so a decision still needs the watch path. The watchOS SDK documents no rule for which app's category a mirrored alert takes; the watch app registers APPROVAL without actions. If watchOS ever sent such a response to the iPhone instead, it would be the lock-screen path, governed by iOS authenticationRequired and not by this setting. A refresh happens whatever the setting, so a locked phone hands the watch the pending commands, as the published state already did; each refresh wakes the phone and dials every paired Mac, at most once a minute while the watch app is in front (each wrist raise makes it active again), about every 15 minutes in the background (a watchOS background app refresh, at most 4 an hour), on every approval alert opened, and on every approval alert received while it is in front, and a Mac it cannot reach keeps the approvals last shown for it. Switching the iPhone to another watch turns the setting off. Commands, agent lines and plan usage reach the watch over WatchConnectivity, the system's encrypted channel to the paired watch, and stay in the watch app's last received context.
Scan and connect to ports on the computer or the phone from the local network.
Mitigations
No kernel TCP listener (built): collied listens only through tsnet; tailscale_loopback (127.0.0.1 SOCKS5 and LocalAPI) is never called; the peerapi kernel listener, which upstream binds on every interface in netstack mode, is patched out. crates/tailnet/tests/end_to_end.rs asserts the process has no TCP LISTEN socket, and collied doctor checks the running daemon the same way. The check (tailnet::kernel_tcp_listeners) runs /usr/sbin/lsof on macOS (lsof's warning for an unstat-able mount point is the one stderr accepted); on Linux it matches the process's socket inodes in /proc/<pid>/fd against LISTEN rows of /proc/<pid>/net/tcp and tcp6. A read or parse error is an error, never an empty list, so a failed check never reads as "no listener". The only real-interface socket is magicsock UDP, which accepts only WireGuard and disco packets authenticated against netmap keys. Control socket is a Unix socket (built).
Residual
magicsock UDP is reachable and parses unauthenticated packets before rejecting them. On the computer, its NAT port mapping (UPnP, NAT-PMP, PCP) can expose that UDP port beyond the LAN; the phone's library is built without a port mapper. Traffic metadata (timing, DERP use) is visible. On the tailnet side, the phone node is user-owned, so any policy rule reaching the owner's devices reaches it: it opens no listener of its own, but netstack serves Tailscale's peerapi to those peers in-process. On Linux a system tailscaled on the same machine is a separate node with its own state and its own listeners, outside this check.
A prompt or key sequence sent from the phone, or a compromised phone session, escapes the agent and runs commands in the shell.
Mitigations
PromptText rejects ESC and every C0/C1 control except newline and tab, preventing an embedded paste terminator. Key is a closed list of nineteen keys (no ctrl+d, ctrl+z, ctrl+r, ctrl+j, ctrl+m, ctrl+o or ctrl+t). Privileged herdr methods are not exposed directly to the phone. For manual agent.type_text, collied validates the live agent identity and authorization, then sends bracketed-paste text and Enter through pane.send_input in one ordered write. This deliberately accepts approval answers without parsing their prompt (Keys and text on a blocked prompt). Shell terminal.run and terminal.send_keys still require enabled terminals and a live grant, with no agent or pending approval in the target pane. Structured approval notes remain one line and are sent only into a verified amend field. agent.scroll_bottom sends only fixed Ctrl+End bytes to a Claude Code scrolled-up screen. agent.answer_notice sends one listed digit without Enter, only above an empty input box under the displayed label. agent.slash_draft sends a bounded slash token as a bracketed paste without Enter, only to an unblocked, unscrolled Claude Code input box, then verifies its contents. Draft replacement clears only the exact expected text in a readable, ordinary input box and rechecks that it is empty before prompting. These writes share a lock and recheck authorization. Enter and Ctrl+Enter remain refused for mirrored slash commands when Claude Code is not blocked. Agent startup and new folders remain scoped to configured task roots and agent kinds, with occupied panes refused and authorization checked before every write.
Residual
herdr checks the foreground agent before queuing the text and the delayed Enter: if the agent exits in between, the shell receives the prompt. Closing it needs conditional input in herdr upstream. Text an agent reads from a repository can still steer the agent itself; that is outside collie. A dialog Claude Code draws between agent.slash_draft's last read and its write receives the command token as a paste, the same gap as agent.answer_notice but narrower: the token always starts with /, is never a bare digit and carries no Enter. Dictation through the iOS keyboard's own mic, and the keyboard's paste, reach the prompt field like typing, so a dictated or pasted /word can show in the machine's input box; it is never submitted without a send.
The question under a still-blocked agent changes (new tool call, different command) between display and decision, so "approve" answers a different question.
Mitigations
Approval bound to a SHA-256 fingerprint of agent kind, matched rule id, terminal_id, agent session id and the dialog region with its cursor. Claude Code's deny countdown line is left out of the region, the cursor fingerprints, the snippet and the context, so its ticking does not supersede the approval; only its exact wording (a fixed sentence whose only variable part is the time, so it carries nothing the approver needs), only for agent kind claude, and only where Claude Code draws it, below the command block's closing ╌ rule when the dialog has one, else anywhere under the dialog's top rule, at the dialog's margin (indent of 1 or less), and above the question over option 1 of the parsed menu, is dropped; a copy in output or in the command, in a Codex or Copilot dialog, or a line with anything else on it, stays, and nothing inside the command block is ever dropped. The countdown's time is never read, so it changes neither expiry nor alerts. Re-read before any key (same state_change_seq and fingerprint, else superseded). Arrows and Enter are separate calls: after the arrows collied re-reads and sends Enter only if the screen matches the target option's cursor fingerprint. Deny uses esc where the menu labels it (esc) and on the trust prompt, so it does not depend on the cursor. The only unnumbered menu read is Claude Code's folder trust prompt, under the same checks, and only when each option is one aligned line mapping to its own decision under exactly one cursor on a whole dialog; anything else unnumbered is not answered. A note goes through the same path, nonce, fingerprint, cursor check and LocalAuthentication included, only with Approve or Deny on a prompt showing "Tab to amend", and at most 200 characters (longer, it would wrap past what the parser reads back, and could never be confirmed): after the cursor check, tab, then a re-read until the target option reads as its amend field with the rest of the dialog unchanged, the note typed, a re-read until the option reads as Yes, <note> or No, <note> with the rest unchanged, then Enter; the agent must stay blocked with the same state_change_seq and the peer authorized before each write. Any mismatch stops without Enter and without esc (which would cancel the prompt), as superseded. Typed into anything but the amend field, a note could act as digit shortcuts; the field is proven open by a label that changed to the amend wording on the target option. The note is never logged. No key after the 6 s budget. Outcome is applied only if the agent leaves blocked within 3 s, otherwise unconfirmed; neither the app nor the lock-screen follow-up claims success otherwise. 10 minute expiry (all built).
Residual
The gap between the last re-read and the final key remains until herdr offers conditional input: a question that replaces the dialog inside it, with the same option layout, gets that key.
A paired phone (or a stolen, unlocked one) uses agent.send_keys or agent.type_text to answer a permission prompt with Enter or y, skipping the approval's nonce, expiry, fingerprint and the app's LocalAuthentication.
Mitigations
Agent identity is checked through agent.list and agent.get before manual input, and authorization is re-checked before the herdr write. Keys use the closed allowlist. Typed answers are bounded to 32 KiB, reject unsafe control, bidi and invisible formatting characters, and are bracketed-pasted with Enter in one ordered write. Newlines and tabs are allowed inside the paste. Every send is audited without logging its text. Structured approval.decide actions and notes still use their nonce, fingerprint, expiry and LocalAuthentication checks. Shell panes still require terminal authentication. Ordinary Claude Code keys and prompts still respect scrolled transcripts and mirrored slash commands while the agent is not blocked. agent.answer_notice and agent.slash_draft remain refused while blocked or scrolled up.
Residual
Manual input can approve any permission or plan with the paired session alone as the gate. It is not tied to the prompt shown on the phone, so a changed prompt can receive unintended input. The gap between checking the live agent and writing to its pane also remains until herdr offers conditional input; an agent that exits in that gap can leave a shell receiving the write. Notice buttons still have their separate read/write gap: a newly drawn dialog can receive the digit. Rating follow-ups or plugin bands can capture subsequent input, and installed plugin actions run under the displayed label without separate LocalAuthentication. Notice answer processing waits up to one second for the input box to change, but a slower fill can still replace a concurrently submitted prompt. Tip-feedback buttons send only their feedback signal; disabling tips fills a slash command and still requires an explicit send while the ordinary slash-command safeguards apply.
Phase
Built
Manual keys and text intentionally have no approval-specific restrictions for Claude Code, Codex or Copilot. They can grant permissions, trust folders, or accept plans without an approval nonce, expiry, fingerprint or LocalAuthentication. All displayed buttons remain available, including Codex's Shift+Left, and key batches are allowed. There is no prompt-rule check, automatic field selection, or answer readback. The user must select the intended control and verify the live terminal. A changed prompt receives the input, and an unlocked paired phone can approve any displayed operation. This replaces the earlier Claude/Codex input restrictions and the Copilot-only key exception.
The computer's disk; files an agent may read; the contents of attached files
Attack
A paired phone (or a stolen, unlocked one) can now write files on the computer. It tries to fill the disk, to write outside the attachments dir or over an existing file (a crafted name, .., a symlink), to plant an executable or a hidden dotfile, to tamper with or hijack another session's upload, or to get file content into the logs.
Mitigations
Same gate as agent.prompt: paired full session, re-checked on every frame and before begin, each chunk and the commit (built). Caps: 20 MiB per file (phone, decoder and collied), 32 KiB chunks in order and never past the declared size, 2 uploads in flight per session, idle uploads dropped after 60 s, 200 MiB stored (each file counted at least at its allocated blocks) including what is reserved by uploads in flight, at most 1000 entries under the root so a flood of one-byte uploads cannot use more disk than the byte cap suggests nor make the per-begin scan unbounded, chunk frames rate limited per StableID (128/s) and the other frames by the shared 20/s bucket (built). The phone's name is a hint only: AttachmentName rejects separators, ., .., controls, bidi and format characters, then collied keeps [A-Za-z0-9._-], drops leading dots and dashes and caps the length. Each upload writes into a fresh random 0700 directory created with create_dir under a root that must be a 0700 directory owned by the user, not a symlink; the partial file is opened create_new with O_NOFOLLOW, mode 0600, and renamed inside that directory only, so no existing file is ever overwritten and nothing is executable. The SHA-256 and size are checked before the rename; a mismatch deletes the partial file. An upload id is 16 random bytes bound to the session and StableID that began it. Files expire after 24 h; partials of a previous run are removed at start. The audit log gets the sanitized name, size and a SHA-256 prefix, never content or base64; ChunkData is redacted in Debug (built).
Residual
Up to 200 MiB of phone-chosen bytes sit on the computer for 24 h, and the path is put into a prompt: a file is as trusted as the phone that sent it, and an agent told to read it may follow instructions inside it (prompt injection through the file, as with any repository content). Contents are readable by anything running as the user and are not encrypted at rest beyond FileVault on macOS, or the disk encryption the Linux machine uses, if any. The name, while sanitized, is still the phone's choice and shows up in the prompt and in the audit log.
The names of folders under the task roots; the disk under the roots
Attack
A paired phone (or a stolen, unlocked one) lists or creates folders outside the roots through .., a symlink, or a folder swapped for a symlink between the check and the use; reads file names, hidden entries or what is inside the folders; sends a name that renders as something else (bidi, invisible or control characters) or that is a path; floods the disk with folders or collied with listings.
Mitigations
Same gate as task.new: paired full session, the per-peer token bucket, the method allowlist with params that deny unknown fields, authorization re-checked before the listing and again right before the folder is created, and an audit line for each request (built). The path is canonicalized and must lie under a root, then opened one component at a time from the root's descriptor with O_NOFOLLOW, so a swapped component fails rather than leading outside, and a folder below the root that starts with a dot or carries macOS UF_HIDDEN is refused, so nothing is listed or created inside ~/.ssh, a .git folder or ~/Library; the listing reads that descriptor, and the folder is created with mkdirat on it, which makes exactly one entry and never follows a symlink at the name. The name is a FolderName: one component of 1 to 255 bytes, no / or \, no control, format or other invisible character (Hangul fillers, braille blank, variation selectors), no leading dot (so no ., .. or hidden folder), no surrounding whitespace, checked on the phone and in collied. Creation needs a name that does not exist yet and a path under 1024 bytes, makes an empty 0755 folder owned by the user (umask applies) and nothing else, checks that its path resolves under the roots to the folder it made, and is one op_id, so a retry replays rather than creating again. A listing returns only names that pass FolderName, of directories and of symlinks that resolve to a directory under the roots, skips macOS UF_HIDDEN entries, scans at most 10,000 entries and returns at most 500; contents and file names are never read or sent. Listed folder names never reach the audit log or the server log; for task.new the created path (<path> (created)) or the requested name (<cwd> new_folder="<name>") is the audit target (built, tested).
Residual
Empty folders can pile up under the base, as fast as the shared rate limit lets new tasks start (one at a time per session). A folder stays if the agent then fails to start; the error names it. herdr opens the path by name after collied's check, as for any task.new, so a process running as the user could swap it in between (Same-UID malware on the Mac). The names of folders directly inside any visible folder under the roots (the home folder by default) are visible to the paired phone, which could already have an agent list them. The base folder is a phone setting: collied re-checks every path against the roots, whatever the phone stored.
The user's account on the computer; the disk under the task roots; git and SSH credentials
Attack
A paired phone (or a stolen, unlocked one) sends a clone source that git reads as an option (-u, --upload-pack), a local path or another transport (file://, ext::, fd::), credentials in a URL, or a target that escapes the roots or overwrites an existing folder; relies on an insteadOf rewrite or a protocol.<name>.allow in the user's config to reach a dangerous transport; or hangs collied on a credential prompt.
Mitigations
Same gate as task.new with new_folder: paired full session, one task at a time, an op_id, authorization re-checked before the folder is created and again before the clone runs, and an audit line naming the source (built). CloneSource admits only owner/repo, https://host[:port]/path and git@host:path with a narrow ASCII alphabet, no leading - in any part, no userinfo, query or ..; owner/repo never reaches git as typed. The source and target follow -- for both git clone and gh repo clone. The target is a new empty folder made with mkdirat under the roots, so an existing one is refused before anything runs. The child has null stdin, GIT_TERMINAL_PROMPT=0, GH_PROMPT_DISABLED=1, no inherited GIT_* variables, GH_TOKEN or GITHUB_TOKEN, and command-line config that allows only https and ssh, which outranks the user's config files and applies after URL rewriting. It runs in its own process group with a 75 s timeout and kill_on_drop. Submodules are not cloned.
Residual
The user's own git config still applies otherwise: url.<base>.insteadOf may send a source to another HTTPS or SSH host the user configured, and credential helpers, core.sshCommand and hooks from init.templateDir or core.hooksPath run as configured. A GUI credential helper or SSH askpass could show a prompt on the Mac until the timeout. A clone that times out leaves its partial folder in place; the error names it. The cloned repository is untrusted content the agent then works in (prompt injection, as with any repository).
The user's account on the computer; the repository's branches; git and SSH credentials
Attack
A paired phone (or a stolen, unlocked one) sends a pull request number that reaches gh as something else; a pull request whose head branch name git reads as an option or a revision expression, or that turns into a checkout path outside .worktree/; a fork's branch, whose project settings an agent would load; an origin that is not the pull request's repository, so another commit is checked out; a fetch that hangs or prompts.
Mitigations
Same gate as a worktree task.new: paired full session, one task at a time, an op_id, authorization re-checked before gh, before the fetch and before herdr's worktree.create, and an audit line with the number (built). The number is an integer from 1 to 2^32 - 1, checked when the frame is parsed, and reaches gh pr view as decimal digits. gh runs as for linked pull requests. A cross-repository pull request is refused. The head branch name passes the typed branch rules (ASCII letters, digits, /._-, no .., no leading -, no trailing /) and git check-ref-format --branch; the checkout folder is the name with / as -, must be a FolderName and must not exist under .worktree/, and herdr's answer must name that path. The fetch takes only +refs/heads/<branch>:refs/remotes/origin/<branch> after --, without tags, submodules or FETCH_HEAD, in the clone's environment (HTTPS and SSH only, no prompts, no inherited GIT_*, own process group); the gh read, the checks and the fetch share one 75 s budget. origin/<branch> must then be exactly the head commit gh named. A local branch of that name is never moved and must be at that commit, or the task is refused; a missing one is created tracking origin/<branch>.
Residual
gh picks the repository from the checkout's remotes, and the fetch always uses origin; a checkout whose origin is a fork of the pull request's repository fails the commit check. The branch is the repository's own code, which the agent then works in (prompt injection, as with any repository). If herdr then fails, the new local branch stays; the error says so.
Agent transcripts; the first prompt line of each session; the agent session the user resumes
Attack
A paired phone (or a stolen, unlocked one) lists sessions of a folder outside the roots, sends a session id that reaches a file path or the agent's command line (.., a separator, --fork-session, a leading -), resumes a session of another folder, or resumes a session already running in a pane so two agents write the same transcript; the listed prompt lines end up in logs, the audit log or a push.
Mitigations
Same gate as task.new: paired full session, one task at a time, an op_id, authorization re-checked before the herdr writes. task.sessions and task.new resolve cwd under the roots. SessionId admits only 36 characters of 0-9a-f- not starting with -, checked when the frame is parsed and again with collied's own is_session_id; the agent arguments are built by collied, never taken from the phone. A resume needs the session's transcript in that folder; a session any herdr agent reports is not listed and is refused. Transcript files are read as for summaries (O_NOFOLLOW, O_NONBLOCK, regular files only), bounded in count and bytes. Prompt lines are capped at 160 characters with controls, bidi and format characters dropped, sent only to the paired phone, shown as plain text, and never logged, audited or pushed; the audit line has the folder and the count, or the session id for a resume (built).
Residual
The first prompt lines can carry secrets the user typed, like the summary's last_prompt. A session open outside herdr (a plain terminal, the Codex app) is not detected, so two processes may append to the same session. Claude Code's project folder name is lossy (/a/b-c and /a/b/c share one), so such folders list each other's Claude sessions. Codex sessions started through a symlinked path that differs from the canonical folder are not listed.
A task's checkout and uncommitted files; merged branches and worktrees in its repository
Attack
A paired phone archives another task or removes files outside the configured task roots. A replay repeats cleanup.
Mitigations
task.archive requires confirmation, a paired full session and an op_id for one cached outcome. collied locates the terminal and its workspace in a fresh herdr snapshot, checks the linked checkout and repository against the task roots, verifies that herdr associates that checkout with the workspace, and re-checks authorization before worktree.remove. For a regular folder it closes only the pane. It runs gh poi from a repository inside the roots, with GH_TOKEN, GITHUB_TOKEN and the enterprise token variables removed, and reports cleanup failures separately from the close or removal.
Residual
Archive deliberately forces removal of a linked worktree, including uncommitted changes. gh poi may remove other merged branches and worktrees in the repository, even when archiving from a regular folder. Repository paths can change between checks and subprocess execution under the same user account.
The user's gh login; the repository and branch of each agent's checkout
Attack
A crafted checkout or GitHub response makes collied run gh somewhere it should not, with a token it should not use, or makes the phone open a hostile link.
Mitigations
collied runs gh pr view only while a phone is connected or a paired phone has "Archive when the PR merges" on (then only for agents it could archive), only in checkouts that resolve inside the task roots, with no argument taken from the checkout, a fixed binary path, GH_PROMPT_DISABLED=1, GH_TOKEN, GITHUB_TOKEN and the enterprise token variables removed, null stdin, a 30 s timeout and kill_on_drop. Only the number, state, check outcome, an https:// URL without whitespace or control characters, up to 10 failing check names (no control, format characters or quotes, 100 characters each), the count of reviews and comments (counted inside gh by --jq, so no text reaches collied) and whether it conflicts reach the phone, which opens only https links. The prompts the badge menu fills hold only the URL and those check names, and are sent only when the user taps Send. Nothing from gh is logged.
Residual
gh reads the checkout's git config and remotes, as gh poi does. A repository whose remote points at another GitHub repository shows that repository's pull request.
Someone who can comment on, review or run checks on a watched pull request uses the wake to steer the agent, or a crafted answer makes collied type something else into the agent.
Mitigations
Starting a watch is a paired-phone request, audited, only for a Claude Code agent's linked open pull request with a GitHub-shaped URL, at an interval from a fixed list, with gh signed in. The GraphQL query asks for no comment or review body. The prompt holds only the pull request number and URL, check names stripped of controls, format characters and quotes and cut at 100 characters, and https:// comment and review URLs. It is a PromptText, so no control character reaches the paste. It goes through Driver::prompt with no expected_draft, only to an idle or done agent still on the watched branch, never over text in the input box, which is never cleared. Right before the write, collied checks that the watch still exists and that the phone that started it is still paired. Revoking that phone ends its watches. Each wake is audited. Comment-only wakes stop after 10 in a row, and failed reads after 8.
Residual
The woken agent may open a review or comment URL and read its text, which is untrusted, as it would if the user asked it to.
A worktree task's checkout and its uncommitted or unpushed work; the agents and panes in its workspace; other worktrees and branches of the repository
Attack
A merge, a crafted gh answer or a race makes collied remove a checkout that still holds work, a regular folder, or a workspace where an agent, its subagents or the user are still busy, or reach other tasks' worktrees, or a revoked or forgotten phone keeps the automation on.
Mitigations
Off by default; on only while a paired phone that turned it on (task.archive_on_merge, audited, kept by StableID and dropped on revoke or unpair) is still paired, checked again right before the removal; any phone turning it off clears it for all, and a phone where the user never picked it sends nothing. Only a fresh read finds the merge, and gh pr view is read once more before archiving. Only a linked worktree whose every pane is an idle or done agent with no running subagent (a subagent scan that left some out counts as running), in a fresh herdr snapshot, is archived; a regular folder is refused, never just closed. The checkout must have no uncommitted or untracked file, no dirty submodule or submodule off its recorded commit (--ignore-submodules=none overrides .gitmodules), no assume-unchanged or skip-worktree index entry in the checkout or a submodule (whose edits git status hides), and its HEAD must be an ancestor of the merged pull request's head commit (a hex id only, so nothing from gh reaches git as an option). The checkout herdr is about to remove must be the one whose pull request was read, and these checks run on it again right before worktree.remove. gh poi is never run automatically, so no other branch or worktree is touched. Each archive is audited and sends one alert, which mute_done silences.
Residual
worktree.remove still forces, so a file written between the last check and the removal (milliseconds) is lost, as is anything git ignores and commits a submodule holds on another branch. An agent that starts working in that window is closed. A sparse checkout (skip-worktree entries) is never archived automatically. A pull request answered by gh for a repository other than the checkout's (a remote pointing elsewhere) can only archive a checkout whose HEAD is already in that pull request's history.
Every shell herdr runs, which is the user's account on the computer
Attack
1. A paired session without the user present (an app bug, an instrumented app, a stolen unlocked phone) types commands into a shell. 2. A grant is replayed to another session, terminal or machine. 3. The feature is turned on from the phone. 4. Commands leak through logs. 5. Input lands in a pane where an agent now runs, answering its permission prompt outside approvals. 6. A compromised app registers a key that signs without Face ID.
Mitigations
Off by default; only collied.toml, read at start, turns it on, and no method changes it. When off, the snapshot lists no shells, and every terminal.* request (but terminal.lock) is refused with terminals_disabled before herdr is called, audited; pane.read reads agent panes only, whatever the setting. A separate allowlist class, params that deny unknown fields, one line of PromptText (no newline, tab, ESC or other control), the closed Key list. Grant: a 32-byte challenge bound to the session and the terminal, 60 s, burned by any attempt; the phone signs the domain-separated machine node ID (from its pinned machine record), terminal ID and challenge with the Secure Enclave terminal key after Face ID or the passcode, with a fresh LAContext each time; collied verifies it against the key recorded at the confirmed pairing. The grant lasts 5 min for that terminal in that session, is bound to the key it was verified with, and is dropped on terminal.lock (sent when the app goes to the background; any other answer closes the session), when the session ends, on revoke or unpair, when the phone pairs again, and as soon as collied sees an agent on that terminal. A running terminal.watch reads nothing while its session holds no grant for the terminal under the current key, so output stops with the grant. Before every write collied re-reads the snapshot (no agent on the pane, launching ones included), re-checks the peer, the grant of the session that sent the operation (a retry from the next session replays it and finds no grant) and that no approval is pending there (hook-driven approvals know of an agent before herdr detects it). Pairing refuses a terminal key that is not P-256 or is the session's TLS key, and the machine's y/N prompt says whether the pairing brings a new key or replaces one, and whether it replaces an existing pairing. Grants are audited, keys by name, and run with the terminal and result only, never the text; a herdr error from these writes keeps its code only. No push, alert or Live Activity carries terminal text; notification actions and the Live Activity intent never send terminal.*, and the terminal key cannot sign while the phone is locked. Shell titles a program sets never leave the machine (built, tested).
Residual
The passcode plus the unlocked phone gives 5 min of shell input per grant. Within a grant the session is the only gate, as for prompts. The gate assumes the app was genuine when it paired: an app instrumented at pairing time can register a key that signs without Face ID (App Attest is out of scope), and the only way to swap the key later is a pairing confirmed on the machine. Against a compromised app the grant adds nothing overall: approvals ask for Face ID on the phone only, and an agent the phone prompts can run any command, edit collied.toml and restart collied, so the opt-in is no boundary against a phone that drives agents; it guards the plain-shell path and keeps it off by default. Agents herdr does not detect (inside ssh or tmux, or an unknown name) count as shells. An agent starting between collied's check and the write gets the line as a prompt (until herdr has conditional input), and herdr reuses pane ids, so a pane closed and recreated in that window of milliseconds gets the write. Text left on the shell's command line on the machine is joined with the line sent from the phone; the phone shows the screen first. The audit log shows that a command ran, not which one; the shell's history may show it. Granted output can carry secrets to the phone, as the agent screen already does. A shell's scrollback is not confidential once an agent starts in that pane: agent.read and agent.watch (any paired session, no grant) read the pane's recent lines, which include the shell commands and output from before the agent, as they always have. Phones paired before this must pair again to use terminals.
Agent transcripts; the context left, latest reply line and latest prompt derived from them
Attack
collied now reads files that hold whole conversations. A crafted session id from herdr (or a same-UID process reporting one) points it at another file through .. or a separator; a symlink or FIFO put in a transcript's place makes it read another file or hang; the derived text ends up in logs, the audit log, push.json, a push payload or an approval; a hostile line in a transcript (prompt injection that the agent echoes, or a same-UID writer) is taken as an instruction.
Mitigations
Only for a live herdr agent of kind claude or codex, whose agent_session id is exactly a lowercase UUID (36 characters of 0-9a-f-): the id is the only part of the path that comes from herdr, next to the working directory mapped to [A-Za-z0-9-]. The file is opened with O_NOFOLLOW and O_NONBLOCK and must be a regular file; summary reads cover its last 1 MiB and only when its size or mtime changed (cached per session id, dropped when herdr no longer reports the session). It is read for flock.snapshot and by the 1 s agent reconcile, the latter only while a paired phone has a session open. Summary values leave the Mac as optional Agent fields (context_left, last_line, last_prompt, last_activity_ms): a percentage, two single lines with controls, bidi and format characters dropped and capped at 160 characters, and the file's mtime, sent only to paired phones in the whois-gated, mutual-TLS session, the same class of data as the terminal content the phone already reads. They are never logged, audited, written to disk by collied, put in a push (alerts and Live Activities are built from herdr's agent record, not from Agent), or used by approvals. collied status does not read transcripts. The phone shows them as plain text (no Markdown, no links) and acts on none of them (built, tested).
Residual
The derived lines can carry secrets an agent printed or the user typed (a token pasted into a prompt): they reach the phone's memory and screen, as the terminal view already does. A same-UID process can write a transcript that shows false text, a false context level or a fresh activity time; the inbox and ring are display only. Claude Code does not record the context window in the transcript, so unless the status line tap recorded the session's window, collied infers it from the model name (1M for Opus 4.7 and later, Sonnet 5 and later and Fable, else 200K, and 1M once usage passes 200K): a session held to 200K (CLAUDE_CODE_DISABLE_1M_CONTEXT, a plan without 1M) shows more context left than it has, and an Opus 4.6 or Sonnet 4 session with the 1M window shows less until it passes 200K.
Phase
Built
Running subagents (Agent.subagents) come from the session's own subagents/agent-<id>.jsonl, subagents/workflows/<workflow>/agent-<id>.jsonl and their .meta.json, reached only through real directories under the session's directory, for ids of letters and digits and workflow names of letters, digits, _ and -, with the same O_NOFOLLOW, O_NONBLOCK regular-file open. Liveness comes from Claude Code's sessions/<pid>.json, opened the same way and capped at 64 KiB, whose pid must match its file name and answer kill(pid, 0); only subagent files written since that process started count. A pid reused by another process after Claude Code was killed without removing its file keeps that session's later-written subagents listed until they finish. Only files whose size or mtime moved are opened, at most 8 per refresh, each read from where its last read ended (at most 512 KiB) or else only its last 64 KiB, lines over 64 KiB left out unheld, and only while a phone is connected. The type, title and activity line are cleaned like the summary lines and capped at 64, 120 and 120 characters; an activity line can carry a file name, a search pattern, a URL or a Bash command's first line when the call has no description, the same class of text Chat already shows. They are never logged, audited, pushed or used by approvals.
herdr's session id is cross-checked against the pane's own processes: their pids come from herdr's pane.process_info (whose argv and cmdline are dropped unread, since they can hold secrets), and each one's sessions/<pid>.json is trusted only when its pid matches its name, kill(pid, 0) succeeds, the process's start time (from the kernel) is no later than the file's startedAt plus 2 s, so a file left by a process whose pid was later reused is refused, and its cwd is the pane's. Disagreeing files keep herdr's id. Hand-offs are followed only from the session's own transcript, to a session whose transcript is in the same project directory, at most 4 hops, never around a loop; a /resume into a background session needs exactly one live, checked kind: bg process of that job and directory. A same-UID process could still write a matching file for a live process it does not own; it can already write the transcripts themselves. Ids stay lowercase UUIDs before they become paths, and the mismatch log line carries ids only.
Chat additionally sends bounded native conversation pages over that same authenticated session. It reads at most 2 MiB per request on a blocking worker, checks the live agent identity again before replying, and binds cursors to the session and file. Missing transcripts are unavailable, not empty. Chat content and search queries are never added to logs, push storage, notifications or approvals. The phone renders Markdown and tool text as display content, with no automatic tool execution or remote image fetching; a user can follow a rendered HTTP or HTTPS link with a host. File URLs and custom schemes are discarded. Tool content remains plain text behind an explicit disclosure. Long text is marked when shortened, oversized or malformed records can be omitted with a notice, and search operates before display truncation. The same transcript confidentiality and same-UID tampering residuals apply to full conversation pages.
The plan's usage and reset times; the usage.json collied reads
Attack
The status line input holds the working directory, the transcript path, the session name, costs and PR links, which could end up on disk, in logs or on the phone. A same-UID process feeds the tap or writes usage.json to show false usage, make collied read another file (symlink, FIFO, a huge file), or plant a session id that becomes a path. Two sessions writing at once corrupt the file.
Mitigations
collied statusline keeps only the two windows' percentages and reset times and, per lowercase-UUID session id, the window size within 1K to 100M; nothing else from the input is written, and it prints and logs nothing. The data dir must be 0700 and owned by the user; usage.json is written 0600 through a temporary file created with O_EXCL and rename, under an exclusive lock, and read only through peers::load_json (0600, owned, regular file). The daemon opens it with O_NOFOLLOW and O_NONBLOCK, requires a regular file of at most 64 KiB, and reads it only when its size or mtime changed; the session ids in it are used as map keys, never as paths. The phone receives Flock.plan_usage, the plan.usage event and the approval.list reply's plan_usage (two percentages, two reset times and when they were recorded) only in the whois-gated, mutual-TLS session. It is never logged, audited, put in a push or a Live Activity, or used by approvals (built, tested). It reaches the paired Apple Watch over WatchConnectivity, where only the five-hour percentage and its reset time are written, for the complication.
Residual
A same-UID process can write false usage or a false window size: both are display only (the ring and the usage line), and the phone compares a recorded_ms from the future without overflowing. Claude Code's input names no account, so two subscriptions used on one Mac (a second CLAUDE_CONFIG_DIR) would merge into one figure. The values are as fresh as the last status line refresh on this machine; usage from another machine or claude.ai shows up only after the next refresh here, which the age shown on the phone hints at. The user adds the tap by hand to the status line script; collied never edits ~/.claude.
Apple (or anyone with APNs access) reads payloads and metadata.
Mitigations
Alert title is the herdr agent name or the agent kind, never the terminal title or a title the pane's program sets; body is Blocked in <workspace label>; plus approval_id, node_id and terminal_id (thread and collapse id). The background push that clears an alert once its agent moves on carries only approval_id and node_id. The done alert, when an agent finishes a turn of 30 s or more, carries the same title, Done in <workspace label>, node_id and terminal_id, and no enc: nothing about the turn, no terminal or transcript text, never stored by APNs; the phone's "Notify when an agent finishes" setting (mute_done on push.register, inside the authenticated session) turns it off for that device. The pending action (up to 600 chars of the command or question) travels only in enc: ChaCha20-Poly1305 under the phone's per-Mac notification key, fresh random nonce per send, bound to the approval_id as AAD, opened by ColliePush on the phone. The key is generated on the phone and reaches collied only inside the authenticated tailnet session. Never the snippet or nonce; details are fetched over the tailnet (built). Live Activity pushes go only to agents the user follows (off by default) and carry the status, when it started, the same title and workspace label as the alert, the agent kind when it is claude, codex or copilot, and the pending approvals count, in clear: ActivityKit cannot decrypt a content state. While an approval routed to the activity is pending they also carry its approvalId in clear (as the alert does) and the context only as enc, sealed exactly like the alert's; the widget opens it on the phone, so Apple sees only ciphertext. No terminal text, terminal title or nonce (built).
Residual
Agent and workspace names, opaque ids, the ciphertext's length (so roughly the command's length), timing, frequency and the device token are visible to Apple. For a followed agent Apple also sees every status change (idle, working, blocked, done) with its time, and the activity's update token. From the clear push, Apple also sees when each alerted approval's agent stopped being blocked, for every agent, followed or not. From the done alert, unless the phone turned it off, Apple sees when any agent finished a turn of 30 s or more. A leaked notification key (from push.json or a Mac compromise, which already exposes far more; from the phone's Keychain) lets whoever also has the APNs payloads read past and future alert contexts for that Mac until the phone pairs again; it grants no decision, since the nonce never travels in a push. With a leaked APNs key as well, it also lets an attacker forge an alert whose body the NSE shows as genuine context.