Skip to content
Specification 1.0 — normative. One section (§17, standing approvals) is explicitly provisional and so marked.

Part V — Linked-device connections (the sidecar surface)

Some providers authenticate a device session, not a request: there is no API key to paste and no OAuth redirect to run — a long-lived, stateful client holds session key material and speaks a proprietary transport (WhatsApp’s linked-device protocol is the reference case). Snug’s answer keeps every existing wall intact:

  • A helper — a local, user-installed companion process — owns the device session: its key material, its transport, its sync state. The helper is LLM-free: transport and custody, never a second brain. Every analysis or compose turn runs in the governed host.
  • The app reaches the helper only through the connected-fetch executor of §14, as an ordinary linked_device connection — same grant, same confirm gate, same governance.

20.1 A helper is a capability, never a host

Section titled “20.1 A helper is a capability, never a host”

The frozen ceiling is host-granular and has no port dimension, so 127.0.0.1 in a ceiling would grant every loopback port on the machine — and a loopback host:port pair is not even storable under the host grammar. A conforming host therefore MUST NOT admit a loopback or localhost address into any ceiling for a helper. Instead:

  • The helper listens on a unix-domain socket with 0600 permissions (or an equivalent OS-scoped IPC endpoint). With no TCP endpoint there is no port to squat and no network path to filter: the filesystem decides who may connect.
  • The connection declares a symbolic host — a name under the RFC 6761-reserved .localhost TLD (pattern <provider>.sidecar.localhost) that can never resolve publicly and is never dialled. The name exists so the connection has a stable identity the ceiling can hold: hosts are the ceiling’s unit, and a capability needs a host-shaped identity to be containable. The executor matches on it to route to the helper transport, and it stands down gate 5 (SSRF) only — every other gate applies, including gate 6’s confirm on every send (§14, gate 6a).
  • The symbolic host is single-homed in the protocol contract — a second spelling once sent an app’s reads to a real DNS resolver, which is the failure this rule exists to prevent.

Two secrets exist and have opposite exposure rules:

  • Provider session key material (the keys to the user’s account) lives ONLY in the helper’s own store and is serialized on no route — not scrubbed, not redacted: never in a response in the first place. Compromising everything Snug stores yields a key to a helper, not to the account.
  • The helper access token (the key to this helper) is minted exactly once at pairing — ≥256 bits CSPRNG; a second mint is refused — crosses the wire exactly once on the releasing pairing poll, and lands in snug_secrets as the connection’s declared credential field, injected thereafter by the executor like any credential.

Pairing routes are guarded by a spawn nonce: a per-launch secret the host passes to the helper at spawn, required on every pairing/verify route. The pairing status route releases the access token, so it must not be reachable by an arbitrary local caller — “local” is not “trusted”.

  • A closed, enumerated route table, method-pinned, is the entire reachable surface. The app-reachable subset is derived by filter from the one table (wizard-only prefixes: the pairing routes and the session routes — status and the destructive forget/unlink), never a second hand-written list — two lists drift invisibly until an app reaches a route nobody intended.
  • Every route requires a credential — the access token for app routes, the spawn nonce for wizard routes. No open route exists.
  • Path admission checks the decoded form: percent-decode first (malformed encoding refuses), then refuse any ./.. segment, then match method + pattern anchored. Placeholder segments match exactly one non-empty segment.
  • Caps refuse, never truncate: request and response bytes are bounded (reference: 1 MiB, enforced while reading), and an over-cap resource is refused with a structured answer — a truncated payload is a corrupt payload.
  • Where a native shell mediates the socket (desktop hosts), the shell’s route admission MUST be held equivalent to the protocol table by test.
  • One writer per session store. Exactly one helper instance may hold a device session: the host reaps a stale helper before spawning (order is load-bearing — reaping after leaves two live helpers fighting over one session), and the helper watches its own parent and exits through its clean shutdown path when orphaned (signal: a changed parent pid, not ppid == 1 — subreapers exist).
  • Termination is graceful-first (SIGTERM, bounded wait, then SIGKILL): the helper’s clean shutdown flushes durable state a hard kill would drop.
  • A stale-process verdict must identify the process (command line naming this host’s helper entry), never trust a recorded pid alone — pids are recycled, and a bare number would kill a stranger.
  • Durable helper state is quarantined on corruption, never treated as fresh truth; and deleting or forgetting the connection MUST sweep the helper’s session store and derived caches with it.

Rule R7 (§5) was written for this surface: the host MAY run a pump that long-polls the helper’s event route through the governed executor on the app’s behalf and forwards hints ({jid, kind, ts}-shaped references, every field rebuilt by the host) over snug:host-event. The app answers a hint with its own governed reads. Media bytes stop at the app frame: memory-only, never written to the app’s database, never into an LLM-bound payload.

The two-population rule. Read-on-behalf eligibility (the pump) keys on an approved grant whose ceiling holds the symbolic host. The pseudonymisation population (§20.6) keys on the connection fact in any status — approved, declared-by-import, or revoked with data left behind. These are different predicates by design and MUST NOT be conflated.

A linked-device connection is the one place Snug routinely handles other people’s data: thread participants never consented, are not Snug users, and cannot opt out. Under BYOK their message content reaches the user’s configured model provider — that is the feature — but their identities need not. A conforming host that mediates linked-device data to a model MUST enforce an egress backstop, host-side, that does not rely on app-layer redaction:

  • Harvest at the governed seat. Third-party identities (display names, provider ids) are extracted from directory-shaped helper responses at the one seat every governed read crosses. Extraction is the scrub: only the identity fields enter the directory; message text and unknown fields never do — a directory that ingested content would itself become the leak.
  • Redact on every LLM-bound surface of any app holding a sidecar connection fact, in any status: the app-message envelope (all string values and object keys; response-schema values case-sensitively with keys and required untouched; envelope ids verbatim — a disclosed residual channel) inside every leaf transport, and provider-lane results classified by the canonical connection-URL grammar with the executor’s own normalization (hand-rolled classifiers have already been beaten by case and whitespace). Provider-id patterns and dialable digit runs (≥7 digits) are redacted as primitives.
  • Fail closed. An unreadable directory refuses the send; a scrub failure on the provider lane surfaces as a tool error, never the raw body; a malformed envelope is scrubbed as a raw string with an unescape-normalized shadow pass.
  • Lifecycle. The directory is third-party PII in its own right: it is wiped — in the same transaction — when the last approved sidecar connection is revoked or its app deleted; it deliberately survives import (the demoted-to-declared rows travel with the replayable app data the scrub exists for); and the in-memory session copy resets on import/restore/revoke/delete, so one user file’s contacts are never written into another’s.

Honest statement of class (normative to repeat wherever this is claimed): the backstop is anti-default and anti-naive, not anti-adversarial. Disclosed residuals: an app that obfuscates (homoglyphs, base64, numbers smuggled as JSON numerics) defeats substring redaction; identities never surfaced through the helper seam are invisible to the directory; rows an app persisted in its own tables can reach a model through the data lane; and message content itself reaches the provider by design. Third-party consent remains a real residual, disclosed to the user before linking.

Before a linked-device connection is created, a conforming host discloses: any provider-ToS risk (unofficial automation can violate terms and cost the account — pacing is harm reduction, never a guarantee and never detection evasion), the third-party-consent residual of §20.6, and — wherever the connection is disclosed — any standing approval (§17) currently armed on it.

The reference implementation binds this surface to WhatsApp: a Node helper owning a linked-device session; symbolic host whatsapp.sidecar.localhost; a 12-route table (pair/start, pair/qr, pair/status, session/status, session/forget wizard-only; chats, per-chat history/messages GET+POST, events, media, picture app-reachable — POST /session/forget is the deep-delete unlink: nonce-guarded provider logout plus auth-store erasure behind a persist tombstone, added 2026-08-21 ahead of the next consolidated push); a device-link pairing family entry in the registry; and the macOS desktop shell mediating the socket with a Rust-side admission table held equivalent by a source-parsing test. None of those specifics are normative; §20.1–§20.7 are.