One section per surface: the invariant, the enforcement point (file:symbol - the line that actually holds it), and what a maintainer must not break.
Stale source comments are flagged, not trusted.
How mail moves, and where each rule sits
§1
HTML sanitization at ingest
Every incoming email is scrubbed once, the moment it arrives, so nothing that could run code is ever stored.
Invariant
messages.body_html never contains active content: no <script>, no
on* handlers, no javascript:/data: URLs, no forms, no nested browsing contexts,
no <meta>/<link>/<base>.
Enforcement
squelch-core/src/sync/html.rs:sanitize_email_html - a closed
ammonia::Builder allow-list, run once at ingest before storage; pure, no I/O,
fixture-tested in the same file. Four deliberate deviations from ammonia's
defaults: url_schemes narrowed to {http, https, mailto, cid}; <style> kept with
CSS verbatim (rm_clean_content_tags then add_tags - that order, or ammonia
panics); style/class/id added to the generic attribute set; link_rel
pinned to noopener noreferrer.
Do not break
- Never widen
url_schemes;data:coming back re-admits inlinedata:text/htmlpayloads and is the only reasondata:image URIs drop today.cid:is the one addition ever made, and only because it is a pointer with nothing behind it: it names a part of the same message (RFC 2392), reaches no host, and no renderer we ship resolves it. It is kept so the client can pair the reference with the attachment row carrying that Content-ID; dropping thesrcinstead leaves an anonymous<img>nothing can resolve. The client rewrites the resolvable ones topassband-cid:and deletes the rest before any web view sees the body (§3), andimg-srcnever namescid:. - Never add a tag that creates a browsing context, submits, or loads a document
(
iframe,object,embed,form,link,meta,base). <style>is the ONLY raw-text tag allowed. Ammonia escapes<in text and in attribute values, so every surviving<imgis a real tag - the client's regex image passes (§3) depend on that for correctness and for linear runtime.- HTML never crosses the agent door; MCP serves flattened text only.
body_htmlis baked at ingest - a sanitizer change does not retroactively fix stored mail. Delete thesync_statehistoryrow and restartsquelchdto re-ingest (triage is preserved). Thesync_cursorrow needs no edit: for a Gmail account thehistoryrow is the cursor (store/sqlite/sync_cursor.rs).
§2
Rendering sandbox
Even scrubbed mail opens inside a locked-down viewer. There are five separate locks, and any one of them would stop scripts, network calls and navigation on its own.
Invariant
A body renders with no script execution, no network except the image proxy, no navigation, no cookies, nothing on disk.
Five locks around every email body, each enough on its own
5 Throwaway storage No cookies, nothing on disk, an opaque origin
4 Navigation refused Only loads we started; links open outside; no popups
3 Content Security Policy No script source, no http or https; images only via the proxy
2 JavaScript off Page script cannot run at all
1 Scrubbed at ingest §1: no scripts, handlers, forms or frames stored
Enforcement
passband/Sources/Passband/Views/EmailWebView.swift -
five independent layers, each sufficient alone. Layer 1 is §1 above; the rest,
numbered as the source comments number them:
allowsContentJavaScript = false: page script cannot run at all. Our own injectedWKUserScriptis governed separately and still runs.- CSP injected as the FIRST child of
<head>byCoordinator.document(html:allowRemote:), built byLib/MailCSP.swift:default-src 'none'; style-src 'unsafe-inline'; img-src passband-img: passband-cid: data:(img-src passband-cid: data:when remote images are off), plus<meta name="referrer" content="no-referrer">. Noscript-src, and nohttp:/https:/cid:anywhere in the policy.passband-cid:is unconditional because those bytes are a part of the same message fetched from the user's own daemon, so the remote-image opt-in has nothing to gate (§3). - Navigation refused.
Coordinator.decideNavigationallows exactly the loads we started (pendingOwnLoadsis a counter, not a flag:loadHTMLStringis async, so two loads would otherwise race one permission) and cancels everything else; alinkActivatedURL goes toOpener.open, which re-guards to http(s), andFrameRelay.webView(_:createWebViewWith:…)returns nil - no popups. - A shared non-persistent
WKWebsiteDataStore;baseURL: nilfor an opaque origin.
Do not break
- The delegate signature
webView(_:decidePolicyFor:decisionHandler: @escaping @MainActor @Sendable …)must match exactly. A near-miss leaves the requirement unimplemented and WebKit silently defaults to allowing every navigation. FrameRelaywith no owner keeps returning.cancel: a pooled frame is unowned, and nobody is entitled to navigate it.WebFramePool.Keyis(message, allowRemote, document)- all three, which is what makes a pool hit a byte-identical document under an identical CSP. DroppingallowRemotereuses a permissive frame for a strict render; droppingmessageleaves "one sender's mail in another's frame" to a hash collision.- A body with no message id (the sealed reveal) is never pooled -
Coordinator.releaserefuses it becauseloadedPoolKeyis nil. KeepWebFramePool.discardunwiring the relay too: the content controller retains it, so merely dropping the frame leaves a live callback target.
§3
Image loading and trackers
Images can load, but only through Passband's own fetcher: no cookies, no referrer. Tracking pixels are removed unless the sender is someone you write to and Gmail vouches for them.
Invariant
Remote images may load, but only through our own audited fetch path, never with cookies or a referrer, and never for a reference we did not mint. Tracking pixels are stripped from mail whose sender the user has never written to.
When tracking pixels survive: both halves, or nothing
Some(true)Some(false)NULLWhat happens to an image, in this exact order
- Strip trackersTiny, hidden or known pixels
- Dedupe repeatsSame image, once
- Read linksBefore anything is rewritten
- Resolve cid:Only to this message's own parts
- Rewrite to proxyAlways last; signed per launch
Outbound read tracking is the mirror image of this section and is documented separately in TRACKING.md.
Current behavior - VERIFIED, and not what old comments claimed. Remote images
load by default: Lib/Prefs.swift registers squelch.pref.loadRemoteImages = true, SettingsView.swift:MailSection exposes it as Mail → images → Always /
On demand, and EmailWebView.allowRemote == prefs.loadRemoteImages || optedIn.
There is no per-message i keystroke gate - that was the retired desktop client.
On On demand img-src collapses to data:, an opt-in bar appears, and no
request is made (the proxy scheme is absent from the policy, so the document
refuses it before the handler is reached).
Enforcement
Lib/Trackers.swift:stripdrops<img>elements on strong signals only: both declared dimensions ≤ 2px,display:none/visibility:hidden, or a short path-scoped known-endpoint list. The bias is deliberately asymmetric - a missed tracker is one opaque referrer-less GET, a false strip visibly breaks the mail. No reserialization: every non-<img>byte survives verbatim, because a DOM round-trip would rewrite markup ammonia already vetted.- The strip is CONDITIONAL as of 2026-08-04.
GET /client/thread/{id}carries a per-messagesender_known, true iff BOTH halves hold: the sender is a Sent-derived contact (Store::is_known_contact- acontactsrow withsent_count > 0, the same predicate Stage-1 uses) AND the message's stored email-authentication verdict is a pass. When it is trueEmailWebView.Prepared.makerenders the unstripped body: someone the user writes to is allowed to learn they opened the mail. The bypass is scoped toTrackers.stripALONE -ImageProxy.rewrite, thepassband-img:CSP, the ingest sanitizer, and the remote-images default all still apply, so an allowed pixel still needs remote images on before it fetches.allowTrackersis part ofPrepared.cacheKey, andImageWarmeralways prefetches the STRIPPED body so warming can never report an open the reader never made. - The auth half of the gate. The
Fromheader is free text, so contact membership alone cannot authorize a tracker bypass.squelch-core/src/sync/ingest.rs:extract_auth_passcomputes the verdict once at ingest intomessages.auth_pass;squelch-api/src/handlers.rs:get_threadANDs it intosender_known. Gated there and not insideis_known_contact, because that call also feeds triage's known-contact importance floor, which must not move. The verdict is read from the TOPMOSTAuthentication-Resultsheader: Gmail PREPENDS its own and does not strip copies the sender wrote, so every occurrence below the first is attacker-controlled text. It counts only when the authserv-id ismx.google.com, a Google-written header (X-Google-Smtp-Source, or aReceivedhanded offby mx.google.com) sits ABOVE it, and the POP markerX-Gmail-Fetch-Infois absent. Everypassmust bind to the RFC 5322 From domain -dmarc=passvia an alignedheader.from=,dkim=passvia an aligned signing domain. TwoFromheaders yield no verdict at all. NULL DENIES THE BYPASS. OnlySome(true)is a pass.Some(false)is "Gmail evaluated it and neither method held";NULLis "never evaluated" - no Gmail header, an undecodable one, twoFromheaders, or a row ingested before the column existed. Absence of proof withholds a permissive feature. Nothing is backfilled.store/sqlite/migrate.rsadds the column to an existingmessagestable with every historical row left NULL; a re-sync refills it through the message upsert.Residual risks, accepted
- The evidence is header SHAPE, not a signature - nothing in the bytes is
cryptographically bound to Google. On an ingestion path where Gmail prepends
nothing (POP with the marker somehow absent, or
users.messages.import/insertperformed with the user's own credentials) a sender who writes both a fakeReceived: … by mx.google.comand a fake verdict still gets through. Closing it needs the Gmail API's own delivery metadata, which this parser cannot see. - No public-suffix list. Alignment is equality or a dot-suffixed child of
the signing domain, so a signature by a registry suffix such as
co.ukwould align withvictim.co.uk. Not attacker-reachable - it needs a DKIM key for the suffix itself. header.i=alone is accepted whenheader.d=is absent (the form Gmail usually emits), which DELEGATES the binding to Gmail enforcing RFC 6376 §3.5: ani=domain must bed=or a subdomain of it. When both are present that relationship is checked here instead of assumed.
- The evidence is header SHAPE, not a signature - nothing in the bytes is
cryptographically bound to Google. On an ingestion path where Gmail prepends
nothing (POP with the marker somehow absent, or
Lib/ImageProxy.swift:rewriterewrites every http(s) image reference -<img src>,style="…"url(),<style>block CSS - topassband-img://local/<hmac>?u=<encoded>. Default-porthttp://targets are upgraded by splicing only the scheme to satisfy App Transport Security without normalizing or otherwise changing the source URL.@import/@font-faceare skipped on purpose: they load no image, are dead underdefault-src 'none', and rewriting them would hand the launch warmer requests the reader never made.Model/ImageSchemeHandler.swiftis the only responder andImageProxy.original(from:)its only parser: it requires this launch's HMAC and an http(s) target. The key is 256 random bits per process, never written down, so a signature cannot be replayed across launches. Provenance, not secrecy - mail can spellpassband-img:itself in a kept<style>block, whichrewriteneuters topassband-img-blocked:before minting anything.Model/ImageStore.swiftowns the fetch: ephemeral session, no cookies, empty referrer, images only (animage/*header, or failing that a raster magic number perLib/ImageSniff.swift, never svg), redirects re-guarded per hop, files namedsha256(url)and a manifest holding no URLs - a directory listing the reader's mail URLs would be a readable index of their correspondence.Model/HeroCache.swift:HeroDiskkeeps the Reading cards' rendered 108px thumbnails across launches and account switches, by the same rule: files namedsha256(hero src), and an index fromsha256(lowercased sender address)to the last one that sender sent, holding no URLs and no addresses. Fetching it still goes throughHeroBytes(ephemeral, no cookies, empty referrer) behind the remote-images pref, and painting from it is gated on the same pref. Residual risks, accepted: the hashes are unsalted, so someone holding the file can CONFIRM a guessed sender or url, though not list them;ImageStoreaccepts the same exposure. The sender index trusts free-textFrom: a forged mail that carries art can file that art under a real sender, and that sender's card paints it on its first frame (and whenever its own fetch fails) until their next real mail replaces it. It cannot ERASE a sender's art, because a verdict of "no art" never unlinks; and it cannot outlast the real sender's next resolve. Closing it would need the daemon to expose each message'sauth_passto the client.cid:references are the local mirror of all this.Lib/CidImages.swiftmatches each<img src="cid:…">against this message's own attachment rows by normalized Content-ID (§1 keeps the scheme so there is something to match) and rewrites the ones that resolve topassband-cid://local/<hmac>?a=<id>&n=<name>. A reference that matches nothing, or whose part fails the same inline gate the attachment strip renders under (AttachmentKinds.isInline- stored bytes, a non-svg image mime, withininlineMaxBytes), has its whole<img>tag dropped: pre-migration mail resolves nothing at all, and a body with a gap reads as a message while one full of broken glyphs reads as a bug. The HMAC is over both values the parser returns and is what scopes a reference to its own message - without it a body could hand-write?a=91and read another message's attachment. Bytes come from the authenticated human door (GET /client/attachments/{id}), served by the sameImageSchemeHandlerunder the same live-set discipline, refused again overinlineMaxBytes.
Do not break
- The order in
EmailWebView.Prepared.make:Trackers.strip→ImageRepeatsdedupe → thehasNetworkImages/extractLinksreads →CidImages.rewrite→ImageProxy.rewritelast. Trackers first so a pixel can never be the "first occurrence" that suppresses a real image; rewrite last because after it nothing recognises a reference as remote. Prepared.cacheKeyfolds in every attachment field the cid rewrite reads (id, content-id, downloadable, mime, size). Drop them and two bodies that share html but not parts collide inPreparedBodies- which is one message's photo pasted into another's.- Never put
http:/https:inimg-src. A missed rewrite must fail closed instead of becoming an un-proxied request. - Keep the signature check: without it a hand-written
url(passband-img://…)in a kept<style>fetches a tracker while the UI says the mail has no remote content. auth_passis three-valued and the comparison must stay== Some(true). Any rewrite that treats NULL as a pass -!= Some(false),unwrap_or(true), aNOT NULL DEFAULT 1column, a backfill "so old mail isn't penalised" - silently grants the bypass to every row that predates the column and to every message on an ingestion path Gmail never stamped.- Read the verdict from the FIRST
Authentication-Resultsheader, never the last.Message::header_rawresolves to the LAST occurrence - the forgeable one - andheaders_raw()DROPS non-UTF-8 headers, which would let an attacker hide the genuine verdict and promote one they wrote.
§4
Human and agent access after the triage rewrite
You can read all of your mail. AI agents, the built-in assistant included, cannot read login codes or reset links, and cannot read anything that has not been checked yet.
Invariant
Humans and internal triage models can read all their mail, including pending assessments and actionable authentication. External user agents, including the embedded assistant, cannot read actionable authentication secrets or their derivatives. Informational login/security alerts are normally allowed.
Who can read what
| Everyday mail | Login and security alerts | Codes and reset links | Not checked yet | |
|---|---|---|---|---|
| You, in the app | Yes | Yes | Yes | Yes |
| Internal triage models | Yes | Yes | Yes | Yes |
| The assistant and MCP agents | Yes | Normally | Never | No, until checked |
Assessment. The triage model decides whether actual access-granting material is present. There is no deterministic sealing detector in ingest. A missing or stale assessment denies external access until a current allowed decision exists. Model summaries, notification text, reasons, and evidence locations must not quote credentials. This is a model-output requirement; it is not a claim that arbitrary model output can be proven secret-free by a string filter.
Enforcement
store/sqlite/messages.rs:external_messages_allowed_conn checks the
current assessment and source provenance, of one message or of a set at once, under
the store lock. External whole-thread
reads require the thread's sources to be allowed. MCP and dedicated
/client/agent/ routes use these checks for messages and derived results. Search
rehydrates results under the same guard instead of releasing stale cached snippets.
Account ownership is checked independently of access assessment.
Archive mail (issue #422). Mail older than the backfill window is stored
with messages.is_archive = 1 and an agent_message_state row left at
pending: no model call is spent on it at ingest. To an external reader such a
row does not exist yet, which is a different thing from "pending" on live mail:
- The thread guard (
thread_guard_and_subject) OMITS an archive sibling whose access is NULL orpendinginstead of closing the thread on it. The row is left out of the messages, the count and the subject (the thread is titled by the oldest message the reader may see). A thread of nothing but such rows is not found. Arestrictedsibling, archive or not, an archive row the owner sealed by hand, and any NON-archive pending sibling close the thread as before. - The check is bought when an external caller surfaces the row: an agent search
whose KEYWORD leg matches it (
search_mail,GET /client/agent/search) queues oneaccessjob with triggerarchive_access, best-ranked first, and an explicit thread open at the door (get_thread,GET /client/agent/thread/{id}) queues the SAME job for the thread's newest unassessed archive siblings, at most eight an open. A row the vector leg alone returned is never queued: nearest neighbours are not matches. A row that already has a job at its revision (finished, or parked on the daily budget) is not queued again; the one exception is a row whose latest check FAILED, which may be asked for again once that failure is a day old, and comes back asarchive_accesswhatever queued it first. - Three ceilings bound what a caller can make the daemon spend on archive mail,
and every agent-door path that queues work on an archive row goes through the
one function that keeps them (
request_archive_access_conn): ten rows per call, twentyarchive_accessjobs outstanding per account, andtriage.agent.archive_access_daily_cap(default 50) a day, charged on top of the total and access-lane budgets. Nothing is queued once today's access budget, or the total, is spent. A check that has been deferred even once loses its place ahead of ordinary background work. The thread READ itself (thread_view, which is also how create_draft and send_email check a reply target) queues nothing for an archive row, and neither does the source repair the triage agent's evidence tools run;source_accessis for live mail only. - The door waits for the checks it queued only while the refine lane says it
can run (a poll has succeeded, a model is configured, the provider circuit is
closed), gives up after two seconds if no worker has picked one up, and stops
at eight seconds (MCP) or six (HTTP).
get_threadwaits the same way for the sibling checks its own open queued, BEFORE it applies the guard, so a thread is not served with a sibling missing and then closed when the verdict lands. - UNTIL THE ROW IS
allowedNOTHING OF IT IS RETURNED. No id, subject, sender or snippet, and no count or flag either. WHICH rows a search returns is the same set whether or not mail the caller cannot read matched: a search that matched only unassessed archive mail returns the same empty list as one that matched nothing. That is a statement about membership and it is the whole of the guarantee. The ORDER of the readable rows, and theirrelevancenumbers, are not covered by it at the keyword leg (residual 3), and neither is how long the search took (residuals 1 and 2). An earlier draft reported a count of pending matches; that is an oracle (ask it about any word, phrase, sender or date and read the answer off mail nobody has cleared) and it must not come back in any form. What the response does carry is the same for every query:indexed_since, a DATE that is the walk's cursor (or, with no walk, where agent-readable mail first becomes dense, never one row's timestamp), andarchive={target, walk_complete, older_mail}whereolder_mailis the constant"checked_on_first_match". - HIDDEN MAIL TAKES NO PLACE IN AN EXTERNAL SEARCH. Both agent doors run one
store method (
external_search_filtered). Its recall legs carry a PREFILTER in their own SQL (external_candidate_sql!: the row isallowed, the owner has not sealed it, and no sibling the thread guard counts is un-allowed or sealed), so a row the caller cannot be shown is never ranked, windowed or hydrated. The keyword leg carries it in its FTS query. The vector leg does not use the vec0 nearest-neighbour index at all for these callers, because that index picks its neighbours over the whole account and can only be filtered afterwards: it selects the rows that pass the prefilter first, reads each one's stored vector, and orders them byvec_distance_l2(the metric the vec0 table ranks by), keeping the nearest 4,096 (nearest_readable). A hidden row is never measured, so no number of hidden rows, however near the query, moves a readable row down that list or off it. The human door keeps the vec0 index unchanged. The prefilter is a necessary condition and authorizes nothing: each leg's list is then cut to what the AUTHORITY allows (external_messages_allowed_conn, with its recursive check of every source a decision consumed, plus the thread guardthread_allowed_with_conn) BEFORE the legs are fused, and every hit is rehydrated through the same authority on the way out. Which readable rows come back is therefore decided over readable mail alone. Their order is too at the vector leg and in the fusion; at the keyword leg it is not quite (residual 3). Do not replace the authority with the prefilter, and do not loosen one without the other:the_external_prefilter_is_implied_by_the_authorityholds them together. - The rows a search asks an access check for are chosen by a separate query
bounded at ten (
archive_surface_candidates), before the search runs, so the search itself runs once. A query with no text the index can look up (operators alone: a sender, a date range) is not a keyword match and buys nothing, at either door. - Accepted residuals. None of these returns a field of a hidden row; each is
something a caller can infer from a response it was entitled to.
The four residuals
- SURFACING TIME. The time a search takes says that some unassessed archive row keyword-matched and a check was queued and waited for. Bounded by the ceilings above, and carries no content.
- KEYWORD LATENCY. A query whose words match hidden mail is slower than one whose words match nothing. FTS5 walks and scores every row a word matches before the prefilter can discard it, so the time grows with how many hidden rows say the word. Measured in a debug build over 4,200 hidden rows that all match: 18 ms when one readable row matches too, 17 ms when only hidden rows match, 0.3 ms for a word nobody says (it was 1.3 s before the prefilter). A caller who can time its searches can therefore tell "some mail I cannot read says this word, and roughly how much" from "none does". Not bounded by the surfacing caps: no check is queued and none is needed for the effect.
- KEYWORD RANKING. Full-text relevance (bm25) is computed from word
statistics over the whole full-text index: how many stored messages
contain each word, and the average length of a stored message. The index
holds all mail, readable or not. So the relative order of two READABLE
hits, and their
relevancenumbers, can change with whether, and how often, a word appears in mail the caller cannot read. That includes unassessed archive mail, and it equally includesrestrictedand owner-sealed LIVE mail. In plain terms: a caller with two readable messages that each contain a different word can search for both words, see which message ranks first, and learn something about whether one of those words also appears in mail it cannot see. One mail carrying the word is enough to flip the order of two otherwise evenly matched hits. It is about one bit per query, a word-existence signal, never a field of the hidden mail. Single-word queries are affected too, through the average document length: enough long hidden messages reorder two readable hits of different lengths. It applies to keyword mode and to hybrid mode (the keyword leg's order feeds the fusion), not to semantic mode. It PREDATES the archive tier: sealed and restricted live mail sits in the same index and moves the same statistics; the archive adds more hidden mail to the index, not a new channel. It is NOT bounded by the surfacing caps, which limit model calls and not searches. Removing it means scoring against statistics of readable mail only, which FTS5 cannot do; that is a ranking change and has not been made. - VECTOR LEG COST. The exact scan in
nearest_readablereads and measures every readable vector of the account on each semantic or hybrid agent search, where the vec0 index would not. Its time depends on how much mail the account holds, not on the query, so it says nothing about any word. It also holds the store lock for that long, so live ingest and the human door wait behind it. Measured in a debug build over 10,000 readable rows, one message per thread: about 245 ms for the leg, and more on mailboxes with long threads; a release build has not been timed. A readable row beyond the 4,096 nearest READABLE rows is still found by the keyword leg or not at all.
- PROMOTION is the one way
is_archivechanges, 1 to 0 and never back: the owner, at the human door, re-triages one archive message or corrects how it is filed (promote_archive_conn, called fromretriage_reset's single-message arm and fromcorrect_agent_triage_connfor every field butexternal_access). Nothing at the agent door, in the walk, in a live re-sighting or in a heal reaches it. A promoted row is live mail in every respect: while its access is pending it CLOSES its thread like any live pending row, and it is RE-JUDGED like any live row. The triage that promotion queues writes access from its own decision, so arestrictedan earlier access check put on the row holds only until that triage commits and may becomeallowed. The one verdict that is permanent is the owner's own seal (external_accesscorrected to true), which survives promotion and every later triage. Sealing or unsealing an archive row by hand does not promote it and queues nothing. The owner's OWN archive mail (sent, or from their address) is never promoted and never queued, whichever path names it. cache_allowedon the human routes is stricter than the agent guard (human_cache_allowed): the human reader is served the archive sibling, so a thread carrying an unassessed one is not cacheable.- A reminder the owner sets on an archive message lists it at the human door
like any reminded row. It does not surface at the agent door until the row
is
allowed. - LITE ROWS (archive mail more than a year back,
messages.html_lite = 1, docs/SEARCH.md §8) are archive rows in every respect above; they only lackbody_html. The HTML is fetched back from the provider byPOST /client/thread/{id}/hydrate, which is HUMAN DOOR ONLY (the agent doors serve text and have no HTML to hydrate), runs the fetched mail through the same sanitizer ingest does, and writesbody_htmland the mark and nothing else:body, the full-text index and the content snapshot are untouched, so hydration advances no revision, queues no job and changes nothing an agent can read. Reading a thread (the GET the client prefetches through) never hydrates.
Do not widen the omission: it is exactly is_archive = 1 AND access NULL or
pending AND no owner seal (archive_unassessed_sql! in store/sqlite/mod.rs).
The human /client/v2/messages/{id} endpoint can open the exact message immediately.
It returns Cache-Control: no-store; cache_allowed controls client prefetch
eligibility. Human read, attachment, draft, and reply paths do not treat a legacy
sensitivity=sealed value as a denial. The legacy sealed list/reveal API remains a
compatibility surface, not the new access authority.
Do not break
- Do not let the embedded assistant call unrestricted human read endpoints.
- Do not treat a missing assessment as allowed, or trust a client-only filter.
- Keep source provenance with derived summaries, attention, records, and drafts.
- Internal evidence readers stay account-scoped and never execute email instructions.
- Keep user message access independent from the speed or availability of triage.
See TRIAGE-OPERATIONS.md for the current execution contract.
Staged outbound attachments. outbound_attachments holds composer files until
a send consumes them or their owning draft is deleted. Unclaimed uploads are
swept after a day by the next upload. These human-only routes never expose bytes
on MCP, never audit file contents, and use no-store. Byte responses use the same
content-type whitelist, nosniff, and attachment disposition as inbound files.
Restricting external-agent access does not delete human drafts or
their attachments. However many drafts an account holds (one per reply target,
one per forwarded message, any number of new-message drafts), they are served
only on /client/drafts and the owner's own are never audited. The agent door
cannot list, read, edit or delete a draft; the one thing it can do, when the
owner allows it, is ADD one (§4c).
A staged file has a SECOND claim, scheduled_send_id, beside draft_id: the
outbox row (scheduled_sends, docs/SCHEDULED-SEND.md) that will send it. The
outbox is human-only, served only on /client/scheduled, with no MCP surface
at all (an agent's send is immediate and cannot be scheduled); unlike drafts it IS audited (ids and reasons only, never the
frozen request). A scheduled row holds its files for as long as it waits, up
to 366 days, and a failed row for 30 days more; only a cancel releases them
(back to unclaimed, the one-day clock restarted), and the compose door's
DELETE answers 404 for a file an outbox row holds. A fired row reads, sends and
consumes ONLY the files it holds, never an unclaimed one: file ids are reused
rowids, and the unclaimed arm would let somebody's fresh, unscanned upload go
out in place of a file the row lost.
Recipients and the contact card are human-door data. To and Cc are stored
for every message and Bcc only for the account's own sends. The store enforces
the Bcc rule in SQL, because on received mail that header is a leak or a
forgery. They ride on ClientMessage beside html; the agent door's
SanitizedMessage has no recipient fields at all. GET /client/person (the
reader's contact card) answers for one address at a time, excludes sealed mail
from every count and list, and has no MCP counterpart. The contacts seed stays
sent-only, so being copied in beside a stranger never earns them the
known-contact floor.
The relationship ledger is human-door data too. The relationships table
(one row per correspondent: how much mail each way, how often and how fast the
owner replies, what they open, the fatigue score) is served by
GET /client/person and GET /client/relationships, and read by triage, which
is the human door's own backend. It holds counts and timestamps only, never
email text. No /mcp tool serves any of it, and
squelch-mcp/tests/agent_door_wire.rs (no_read_tool_serves_the_relationship_ledger)
fails if a read tool's response carries any ledger field.
§4b
Provider spam
Spam is kept for you to look at, and away from everything else: no model reads it, no agent sees it, and it never notifies you.
Invariant
Mail the provider filed as spam is stored and readable on one human-door page, and is otherwise absent: it never reaches an LLM, never crosses the agent door, is never embedded, and never fires a notification.
Where provider spam can go
- The spam pageFetched only when you open it
- Triage modelsNever scored
- EmbeddingsNever embedded
- Agent doorNot found, by design
- NotificationsNever fires
- Lists, counts, searchFiltered out in SQL
Why it is a security rule and not only a cost one. Spam is attacker-authored text selected for its ability to talk a reader into things, and a Stage-1 prompt is a reader. It is also the bulk of what arrives, so "score it like anything else" would mean spending the most model calls on the least trustworthy corpus. The same reasoning is why the agent door gets no spam at all rather than spam it is told to distrust: everything the agent reads is text it may act on.
Enforcement, in order
- Fetched only when asked for. The poll loop does not walk the
SPAMlabel at all - not on backfill, not on a history tick, not on a catch-up.SyncEngine::sync_spam_windowruns when the page that shows the folder is opened, capped atsync.spam_maxnewest-first. That is a cost decision rather than a security one, but it has a security effect worth stating: the untrusted corpus is not continuously ingested, so on an install where nobody ever opens the page it is never fetched at all. The store's upsert keepsis_spam = MIN(stored, incoming), so a message ever seen under a visible label cannot be hidden by a later spam sighting. - Never triaged.
sync/ingest.rsreturns a neutraltier=noiserow before Stage-1 - after the seal check, so a misfiled OTP is still sealed and sealed still outranks spam.ingest_messagestamps the'n/a'stage markers, which is what keeps the row out of both LLM queues. - Never embedded.
SyncEngine::ingest_onereturns no embed text for a spam row, andmessages_missing_vectorsselectsis_spam = 0. An embedding is a similarity claim, and spam is written to imitate the mail it impersonates. - SQL absence. Every band, queue, count and search leg pairs
is_spam = 0with itsis_sent = 0. The one caller asking for the other side isGET /client/updates?spam=only; an unrecognized value is a 400, never a silent full listing. - The page's rows carry the mail's own words, and only there. Nothing
triaged a spam row, so its
one_lineis the empty string and the page would be a column of senders against a blank. The human door's listing (human_agent_updates) fillssubjectandpreview(the stored 200-character snippet) on the rows it serves, spam or not. Both areOption, skipped whenNone, so the agent door'sUpdatenever grows the keys at all - the same disciplinefrom_nameholds, for the sharper version of its reason: this is text out of the corpus written to talk readers into things. A person reading their own spam folder expects that and can weigh it. It is rendered as words on a row, never handed to a model, and never summarized. - Agent door.
store::thread_view(the/mcpshape) selectsis_spam = 0, so a thread of nothing but spam isNotFound- the same shape sealed mail gets.hybrid_searchis gated on all three of its queries. - Never notifies.
triage::events::worthy_kindreturnsNonefor a spam row as an explicit arm, not as a side effect of its neutral tier. - The one write.
POST /client/actions/not_spamremoves the label in Gmail FIRST and only then clears the flag locally, so a refused write leaves both sides agreeing. There is deliberately no route the other way: squelch cannot show anyone the effects of training a filter it does not read.
Guard tests. squelch-core/src/store/sqlite/tests/spam.rs seeds one spam row
beside one ordinary row with overlapping search terms and walks every listing at
once, so a predicate dropped in a refactor fails there rather than in production.
The sync suite asserts the "not fetched routinely" half against the mock's CALL
LOG rather than against what landed: "no spam rows appeared" would also pass if
the walk ran and the folder happened to be empty, and the thing being avoided is
the request.
§4c
Agent writes: drafts and sending
Agents can leave drafts by default and can send only if you turn that on. Everything an agent writes goes through the same pipeline and checks as your own mail, with fewer options.
Invariant
An agent writes mail only as far as the mailbox owner has said it may, only through the human door's own send pipeline, and never with more latitude than the owner has there. With nothing chosen it may leave drafts and cannot send: no mail leaves a mailbox on an agent's say-so unless the owner chose that level. Nothing here changes what an agent can READ.
What an agent may write, set per mailbox by you
- noneRead onlyNo drafts, no sending
- drafts · the defaultLeave drafts for youMarked “from your agent”; mail leaves only when you press send
- send · opt-inSend with your approvalSame pipeline as yours, plus a secret check with no override
attachmentsforwardssend groupsread trackingschedulingThe setting. app_settings key agent.write_access, per mailbox:
none, drafts, send; each level includes the ones before it. A mailbox
that has never set it is at drafts (AgentWriteAccess::UNSET), so an agent
is useful out of the box without being able to send. That default is safe to hold for the reason
drafts is the hard gate at all: a draft is a local row, marked as the
agent's, that becomes mail only when the owner presses send. What it does
cost is stated under the residual risks below.
squelch-core/src/agent_write.rs:AgentWriteAccess::load reads it, and a
STORED value this build does not recognize FAILS CLOSED to none, not to the
unset default: "the owner chose something we cannot read" is not "the owner
never chose". The type has no Default impl so that a failed read can never
be spelled unwrap_or_default(). It is set
only on the human door (GET/PUT /client/settings/agent-access, bearer-authed,
audited as agent.write_access with before->after; the ledger row is written
FIRST and a failed row leaves the setting unchanged). The agent door has no
tool or route that changes it. It is read from the store on every call, so
turning it off stops the next tool call, not the next restart.
Enforcement
The agent door (squelch-mcp) holds a dyn AgentMailer and
nothing else: no write credential, no message builder, no draft or outbox store
call (squelch-mcp/src/main.rs:the_mcp_crate_never_reaches_the_draft_store_itself
scans its source for those identifiers). The one implementation is
squelch-api/src/agent_write.rs:ApiAgentMailer, which squelchd serve hands
over via ApiState::agent_mailer. The standalone squelch-mcp binary passes
none and offers neither tool. For each call the mailer:
- re-reads the setting and refuses below the needed level (the MCP server also hides tools the level does not allow, but listing is a courtesy and this check is the gate);
- resolves a reply target by PERFORMING THE AGENT DOOR'S OWN READ
(
readable_parent: the message must appear in thethread_viewthatget_threadserves), so an agent can only reply to mail it can read, and a restricted, pending or spam id answers the samenot foundas an unknown one. It asks the read rather than re-listing the read's conditions: a first version re-listed them and left out spam (§4b); - DECIDES THE RECIPIENTS ITSELF: the
tothe agent stated, else the parent's stored From, and theccthe agent stated or none. The owner's own reply honors the parent'sReply-Toand derives copies from its headers; an agent's does not, because the agent is shown only a message's From and its human approves only what the agent shows them. AReply-Tonobody in that conversation saw must not decide where approved mail goes. The resolvedto/ccare returned in the tool result; - for
send_email, runsvalidate_send, the outbound secret guard (§5) andperform_send. The guard has no override here. It reads recipients and subject as well as the body, and the body a second time with markdown emphasis marks removed (guard_text), since for an agent all of it is model output; - for
create_draft, INSERTS a local draft markeddrafts.from_agent = 1and never edits one: a reply whose message already has a draft is refused, because that row may be the owner's own unfinished writing and an agent cannot see it to know. At most 25 unsent agent drafts per mailbox. The client marks such a draft "from your agent" in the drafts list AND inside the composer it is restored into (AgentDraftNotice), with its recipient fields laid open: a reply draft is restored straight into the reply box when the owner pressesr, so a mark only on the list would never be seen; - refuses, for both tools, a body with no visible character, a header field carrying any control character or Unicode line separator, and more than 50 recipients;
- audits every call that reaches it, refusals included, as actor
agent(ids, counts and fixed reason strings only, never content). A draft and its ledger row are one transaction, likeset_sender_rule. NOT audited: asend_emailcall the MCP server turns away forhuman_approvednot beingtrue, which never reaches the mailer; and, as on the human door, a send's success row is best-effort once the mail has left.
An agent's composition is deliberately narrower than the owner's: no attachments, no forward, no send group, no read tracking, no scheduling.
The draft card (MCP Apps). create_draft names a ui:// resource
(squelch-mcp/src/draft_card.html) that app-capable clients such as Claude
render in a sandboxed frame in place of the raw result. The HTML is static and
holds no mail: the client hands it the tool's own arguments and result
(subject, recipients, the body the agent wrote, the draft id), which the agent
already had. It loads nothing from the network (empty CSP allow-list), writes
everything with textContent, and its one action asks the client to call
open_draft for that draft, over the same authenticated agent session; it
never names send_email. resources/read serves it to any authenticated
session whatever the write setting, because it is the same file for everyone;
it is listed only while drafts are allowed.
Asking for a draft on screen (open_draft, and open: true on
create_draft). At drafts and above an agent may ask the owner's Mac to
bring one of its drafts up in the composer. This is the first thing the agent
door can do that reaches the owner's SCREEN AND KEYBOARD FOCUS, so it is held
to these rules:
- It is a request to an app, not a reach into a machine. The daemon publishes
the draft id on an in-memory channel (
ApiState::draft_opens); a Mac app that asked for it on its own authenticated event stream (GET /client/events?opens=1) receives an unpersisted, datedopen_draftframe. Nothing is stored or replayed, and the app drops a frame more than a minute old, so a Mac that wakes from sleep has no window thrown at it. - The phone is told by push, ONCE PER DRAFT. The same request hands a
DraftPing(draft id + creation second) to the APNs pusher over a second in-memory channel (ApiState::draft_pings); it is not an event, is never persisted, and is tried once. The relay sees"<tag>:draft:<id>:<created>"andkind: "draft", which picks a fixed fallback line and can carry no text of its own; no subject, recipient or body crosses it. The phone's service extension resolves the draft from its own daemon and opens it only if the daemon says an agent wrote it at that second. A tap opens the composer; nothing on the phone sends without the owner pressing send. Asking again for the same draft never buzzes the phone again (ApiState::draft_pinged). - Only a draft an agent wrote can be named. The mailer refuses any other id
with the same
not foundas an unknown one, and the app checksfrom_agentAGAIN against the daemon's own draft list before opening anything. - It can open a draft; it must never be able to send one. Bringing an
app to the front under somebody who is typing delivers their next
keystrokes to it, and in an agent's chat Cmd+Enter is "submit". So:
a composer opened by a request has
ComposeState.keyboardSendLockedset, and Cmd+Enter does not send it; only the send button does, and a click cannot be typed ahead. Keyboard focus is taken off the composer after it opens. And when the owner has anything of their own being written in Passband, the app does not come forward at all (their own half-written mail would be the thing a typed-ahead Cmd+Enter sent): a toast waits instead, and nothing on screen is replaced. It never switches accounts. - It is rationed: one request per five seconds per mailbox, across all
agents (
ApiState::draft_open_clock). The clock is checked BEFORE the draft is looked up, and a request inside the interval is refused alike whatever it named and writes no ledger row. In the other order the refusal would tell an agent whether the owner had sent or discarded a draft, and every attempt would be a row. - The agent is told the truth. The result says whether any Mac was listening (the channel's receiver count), and the instructions tell it not to claim a draft is on screen when none was, and that an open draft or a link is not approval to send.
- Audited as
draft.open(ok:mac,ok:no_mac,failed:target), at most one row per interval. Best-effort, like the human door's action rows.
The passband://draft/<id>?mailbox=<address>&t=<created> link every draft
comes back with is UNTRUSTED INPUT on the app side: any web page or local
process can open one. It is parsed strictly (Lib/AgentDraftLink.swift:
exactly the minted shape, no user, port, fragment or second path segment). It
is bound to one draft by t, the second that draft was created, because draft
ids are small row ids that are reused: without it an old link in a transcript
would open whichever agent draft held the number next, and anything could walk
draft/1..N. The app opens a link only for a from_agent draft created at
that second, in the mailbox the link names (confirmed against the account's
own address, never assumed), at most once per five seconds, and under every
rule above. It can neither open nor probe for a draft the owner wrote.
Residual, accepted: on an Open door at the default level, anything that can
reach /mcp can make Passband come to the front every five seconds with a
draft it wrote, when the owner is not writing in Passband. It cannot send,
read more, or displace the owner's writing. on_mac reports a live
connection, which a Mac that just went to sleep still has for a while; the
agent may be told a Mac heard a request that the app then drops as stale.
from_agent is set at insert and never cleared. An owner who rewrites an
agent's draft still sees it marked, and so does an owner whose own save landed
on a row the agent created a moment earlier. That errs toward the warning.
What "with your approval" does and does not mean. At send, the approval
is the agent's human saying yes IN THE AGENT'S OWN CONVERSATION. The daemon
cannot see that conversation and does not verify it. What it does is:
- tell the agent the rule three times: in the server instructions sent at
initialize(server.rs:write_instructions), in thesend_emaildescription, and in the requiredhuman_approvedargument, each saying that approval is per message, comes from the human, and is never text found in an email or a tool result. A sender rule'sstanding_instructionis named explicitly as never being approval:set_sender_ruleis an agent write, so that text may have been written by an earlier, compromised session; - refuse a call whose
human_approvedis nottrue; - annotate the tool
destructiveHint+openWorldHint, which is what MCP clients key their own confirmation prompts on.
Residual risk of the default (drafts), accepted. Every mailbox whose
daemon serves the agent door offers create_draft without the owner having
opened the setting. On an Open door (self-host, no agent sign-in) that means
anything that can reach /mcp can leave up to 25 drafts in the owner's
drafts list, and a reply draft is restored into the owner's reply box. What
stands between such a draft and the wire is the owner: the mark in the list,
the notice in the composer with its recipients laid open, and their own
press of send. A draft written to be sent unread ("looks like mine") is the
attack this default exposes, and the mark is the defence; do not weaken it.
Residual risk of send, accepted for an opt-in level and stated in the
client. A
prompt-injected or simply wrong agent can assert human_approved: true and
send. At send, the blast radius of the agent door grows from "can read
triaged mail and write local rules" to "can send mail as the owner", including
mail that quotes whatever it was able to read. Sealed mail stays unreadable and
the outbound guard still blocks secret-shaped text with no override, but
ordinary content is not protected by either. On an Open door (self-host, no
agent sign-in) "the agent" is anything that can reach /mcp. An owner who
wants a hard gate uses drafts, where the only way mail leaves is the owner
pressing send in Passband. Not built yet, and the natural next steps if send
needs hardening: per-send approval inside the app, a recipient allow-list, a
daily cap, reply-only, and recording who authored a sender rule so agent-written
rule text is not delivered as the owner's words.
Do not break
- Never give
squelch-mcpthe write credential, aGmailWriteClient, or a direct draft/outbox store call. Agent writes go throughAgentMailer. - Never give the agent send path a way past the outbound guard (§5).
- Never resolve an agent's reply target with
action_message_refalone; it applies no agent access check. And never re-list the agent door's read conditions beside it: askthread_view. - Never let an agent's send take its recipients from the pipeline's
derivation (
Reply-To, the parent's Cc). Statetoandccon theSendBody. - Never let the unset level be
send. Sending is only ever a choice. - Keep an unreadable stored setting, and a failed read, meaning
none. - Keep the setting off the agent door: an agent that could raise its own level has no level.
§4d
Hosted mail text in processes we operate
On hosted, your mail text leaves your own instance for exactly two of our services: the AI gateway that triages it, and the search embedder, which never sees spam or login codes.
Invariant
On hosted, a tenant's mail text leaves its own pod for exactly two processes we run: the LLM gateway and the shared embedding service. Provider spam reaches neither. Sealed mail (login codes, reset links, sign-in alerts, anything the model or a person restricted) never reaches the embedding service, nor the in-process embedder on self-host. Self-host reaches neither process. Nothing else of ours is handed mail text.
What each one sees. They are gated differently, on purpose.
- The LLM gateway (Bifrost,
SQUELCH_ANTHROPIC_BASE_URL,docs/HOSTED.md) receives what triage, translation and the hosted assistant send it, and it IS NOT filtered by sealed status. It cannot be: there is no local login-code detector, and since the triage rewrite (4b7099de) the triage model is what decides whether a message is authentication or restricted. Triage therefore reads every non-spam message, login codes included, because judging them is its job; translation reads foreign mail before its first triage for the same reason. Access is enforced where mail leaves toward an external reader (§4). A remote service with a per-tenant virtual key, reached over https only (squelch-warden/src/config.rs,SQUELCH_WARDEN_LLM_BASE_URL). - The embedding service (
squelch-embed,docs/EMBED-SERVICE.md) receives the subject and body, cut toprotocol::MAX_CHARS_PER_TEXT(8192 characters), of only the messages that pass the sealed gate below, the English of a translated one under the same gate, plus the text of every search query that takes the vector leg (query_embed_textinsquelch-core/src/store/sqlite/search.rs). It is in use only when the operator setsSQUELCH_WARDEN_EMBED_URL; unset, each tenant embeds in-process, behind the same gate.
Hosted: where mail text goes outside your instance
logs no textstores nothingone request per passno cacheno tenant nameno egressThe sealed gate, at the store side. One predicate,
embeddable_mail_sql! (squelch-core/src/store/sqlite/mod.rs), built from
the canonical sealed predicates (known_sealed_mail_sql!, sealed_mail_sql!)
rather than written a third time. A message may be embedded only when it is
not spam, is not KNOWN sealed (an auth decision, a restriction by the model or
a person, or the retired triage.sensitivity column), and EITHER has a stored
triage decision for its current content (the decision's revision equals
agent_message_state.revision) with its access assessment settled, OR is the
owner's own mail (own_mail_sql!) whose access check has settled and did not
restrict it. NOTHING IS EMBEDDED UNTIL IT IS KNOWN NOT TO BE SEALED.
- Where it is asked. Before any text is handed to an embedder: at ingest
(
SyncEngine::ingest_one, throughStore::message_embeddable), by the drain (messages_missing_vectors,translations_missing_vectors), and on the translation path (SyncEngine::embed_translation). Again under the store lock at the write (write_embedded_vectors,upsert_translation_vector), so a decision that sealed a message while its text was at the embedder wins. - Undecided mail waits. At ingest an inbound message has no decision, so it
could still be a login code: it gets no vector until a decision that does not
seal it lands, and the applied decision raises the drain's flag. A decision
about older content is not a decision: when a message's content changes, its
revision moves past the decision's, and it waits again for one about the new
text. Mail whose
triage is DELAYED (the daily run cap, a spent budget, a failing model, no
model configured on a self-host) has no vector meanwhile, and mail never
triaged by design (an archive row nobody promoted, an old message given only
a
source_accesscheck) has none at all. Keyword search still finds all of it: FTS is not gated by this. That is the accepted trade-off. - The owner's own mail is the one class embedded without a triage decision, and it waits for its access check. It is filed access-only and never triaged, and it is not a login code someone sent the owner, but it can still carry one (a forwarded code), which is what the access check is for. So it is embedded only once that check has settled, not while it is pending, and only if it did not restrict the message. If a person restricts it later, its vector goes at that event; if a restriction is lifted, the store is marked for the drain and the vector comes back.
- Vectors that already exist go.
purge_forbidden_vectors_conn(search.rs) deletes the original's and the translation's vector for a message that is known sealed, or is in the agent pipeline and no longer passes the gate (new content awaiting its re-triage). It runs inside the transaction of every event that can seal: a triage commit, an access commit, a person's correction, the compatibility sensitivity endpoint, and a content change. It also runs as a sweep at every store open, which removes vectors embedded before the gate existed: on one real mailbox (2,781 messages, 2,499 vectors) the first open removed 360, 143 of them known sealed, in about 30 ms. It is a scan of the vectors table, so it costs about 1 s at 100k vectors, at every open. The FTS index and the message are untouched. - A daemon from before the gate undoes it. The schema is compatible both
ways, so rolling a tenant back to an older daemon works, and that daemon's
drain re-embeds every vector the sweep deleted, sealed mail included. The next
open on a gated daemon deletes them again. A rollback past the gate is a
return of the exposure for as long as it lasts, and
deploy/hosted/ROLLOUT.mdsays so where rollbacks are planned. - Spam is excluded by the same predicate (
m.is_spam = 0), as before.
The embedder itself checks nothing (squelch-core/src/embed/remote.rs, module
docs): a second gate inside it would be one more thing to keep in step with the
first. Tests: squelch-core/src/store/sqlite/tests/embed_gate.rs (every case
seals through the agent pipeline with the legacy column still normal),
sync::tests::no_embedder_ever_reads_a_login_code, and
translate_pass::tests::the_english_of_mail_that_may_be_sealed_reaches_no_embedder.
What the embedding service does not do, each backed by code or a test:
- Logs no text. Its log lines carry counts, statuses and error words. The
session's error type carries nothing (
squelch-embed/src/session.rs,SessionError), andno_text_or_address_reaches_metrics_or_logsinsquelch-embed/src/http.rsdrives every path with a canary text and fails if it reaches a log line or the metrics. A panic prints a fixed word, the thread name and a source location from the process's own hook, never the panic's payload, and a canary test holds that too. The daemon-side client's errors carry a status, a closed error word or a transport class, never request or response text, and its config'sDebugredacts the token. - Stores nothing. Read-only root filesystem, the baked weights read-only,
one 64Mi
/tmpthe service does not write to (deploy/hosted/65-embed.yaml). A request's texts live for one forward pass and are dropped; a request whose caller hung up, or that waited past its lane's deadline, is skipped, never run. - Never puts two requests in one pass. Each request is its own forward
pass. Batching requests together was a cross-tenant side channel: a pass pads
every text to the longest, so tenant B's latency carried the token length of
tenant A's text it happened to share a pass with. One pass per request
removes the sharing; what tenant B can still observe is that the single
worker was busy, which says someone was embedding something and nothing about
what (
squelch-embed/src/coalescer.rs, module docs). - Caches nothing, and dedups nothing. A content-keyed shortcut is a cross-tenant oracle on latency: tenant B would learn that tenant A received a byte-identical message.
- Runs as its own uid. 10003, not the tenants' 10001 or the warden's 10002, so where user namespaces are off a tenant process and the embedder are still different users on the node.
- Is never told a tenant's name. No tenant name or account id is in the
path, a header or the body (
EmbedRequestisdeny_unknown_fields, one field). The bearer token is one fleet-wide value, deliberately not per tenant. The accept loop discards the peer address before any handler runs (squelch-embed/src/http.rs,serve), so nothing can key a log, metric, cache or limit on it. - Has no egress. Its NetworkPolicy has
egress: [], not even DNS, and the weights are baked into the image, so a compromised embedder holding text in flight has nowhere to send it. Ingress is tenant pods on 8850 and the monitoring agent on 9464. - Exports closed labels only. Every metric label value comes from an enum
(
lane:interactive,backfill,none;outcome: a closed set), and a test refuses any label outside those sets or anything shaped like an IP address (squelch-embed/src/metrics.rs).
What the shared token is worth. Against a compromised tenant, nothing: every
tenant holds it. The boundary is the NetworkPolicy pair (each tenant's one
egress rule to the service, the service's ingress rule). The token is for the
day the CNI is not enforcing policy and something that is not a tenant lands on
the pod network: it gets a 401. Rotating it restarts every tenant
(deploy/hosted/SETUP.md §10b, Rollback).
One tenant can deny service to the others, and that is accepted. The
service is never told who is calling, so it has no per-tenant limit to apply.
A compromised tenant pod (or one daemon backfilling hard) can keep the single
worker busy and fill a lane's share, and the other tenants' requests wait,
expire or get 429s. What it cannot do is make that unbounded or read anything
by it: every lane has a fixed share of queued texts (INTERACTIVE_SHARE 16,
BACKFILL_SHARE 96 in squelch-embed/src/coalescer.rs), interactive is served
first, every job has a deadline (INTERACTIVE_DEADLINE, BACKFILL_DEADLINE in
protocol.rs), a body is at most 256 KiB (MAX_BODY_BYTES), and body parsing
and open connections are capped (BODY_PERMITS, about 32, and
MAX_CONNECTIONS, about 256, in http.rs). The cost of the attack is degraded
search for everyone on the node, which falls back to keyword results, and no
mail is lost. A per-tenant limit would need the tenant's identity on the wire,
which is the thing this section forbids.
Plaintext on the pod network, on purpose. The traffic never leaves the node,
and the only observer of it would already hold the node and could read the
tenants' SQLite files directly. The warden's validator therefore allows http://
for SQUELCH_WARDEN_EMBED_URL only for an in-cluster host (a single-label
Service name, *.svc, *.svc.cluster.local) and refuses public hostnames and
IP literals, the mirror image of the https requirement on the gateway URL.
Self-host. A self-host daemon sets no SQUELCH_EMBED_URL and no gateway: it
builds its own embedding session in-process and talks to no process of ours.
Do not break
- Never add a cache, a dedup or a persisted queue to
squelch-embed. - Never put a tenant name, account id or per-tenant credential on the wire to it, and never key anything on the peer address.
- Never give its NetworkPolicy an egress rule; weights stay baked.
- Never add a metric label whose value comes from a request.
- Never embed through a raw vector write (
upsert_message_vector(s), which exist for fixtures); production writes go throughwrite_embedded_vectors. - Never widen
embeddable_mail_sql!to a class of mail that could be inbound auth mail nobody has triaged. Never gate it ontriage.sensitivityalone. - An event that can seal a message must call
purge_forbidden_vectors_connin its own transaction.
§5
Outbound secret guard
Before mail leaves, Passband looks for anything shaped like a secret. If it finds one it stops and asks, and it never copies the secret anywhere.
Invariant
An agent's send that looks like it carries a secret is refused, with no override, and the matched text is never copied anywhere: not into a response, a log, or the audit row.
Scope: the agent door only. The guard runs on agent_write's send_email
(§4c), where mail goes out under the owner's standing consent
and no human reads the body first. That is the prompt-injection case: mail the
agent read tells it to send a key somewhere. The owner's own sends (compose,
reply, forward, scheduled send, invites, and the in-app assistant, whose every
send the owner approves on a card) are NOT scanned. A person who wrote or chose
the words and pressed send decides what they mail. It used to scan those too,
with an override; on real mail it fired on nearly every forwarded newsletter (an
unsubscribe id is a long hex run), so the override became a reflex and the
guard protected nothing. An older client's override_guard field is ignored.
The outbound check, on agent sends
pem_blockapi_keyotp_codelong_hexlong_base64Enforcement
squelch-api/src/guard.rs:scan / scan_kinds, called from
agent_write before perform_send. Detects pem_block, api_key (vendor
prefixes), otp_code (6–8 digits near an auth word), long_hex (≥32) and
long_base64 (≥40). URLs are scanned like everything else: a link
(https://x/?t=<token>) is the natural way for an injected agent to carry a
token out, and a false positive costs only a refusal the agent reports to the
owner, who can send the mail themselves. A match refuses the send, tells the agent only the
kinds, and writes a blocked:guard audit row. Agent sends are never
forwards and carry no files, so there is no original or attachment to scan.
Known gaps, stated rather than implied.
- The in-app assistant's approval card renders markdown, so a link shows its
text, not its target:
[here](https://x/?k=<token>)reads as "here". That card is the only check on an assistant send now. Showing link targets on the card would close it; not built. - Agent drafts are not scanned (
create_draft, here or in §4c). The owner sends a draft from the composer, which shows the raw text, so it is a human send like any other.
Forwarded HTML goes out verbatim, trackers included. The original's markup is
embedded as it arrived (the only edit is stripping the <meta> tags that DECLARE a
charset - a charset attribute or the http-equiv="Content-Type" spelling - which
would contradict the part's own charset="UTF-8"; a <meta> that merely mentions the
word survives). The forward's two body parts then ship
Content-Transfer-Encoding: quoted-printable, which is an encoding rather than an
edit: a stranger's newsletter html is routinely one line far past RFC 5322's 998-octet
limit, and re-emitting it raw put illegal lines and raw 8-bit bytes on the wire. It
decodes back byte-for-byte at the far end. All of which means the ORIGINAL sender's
tracking pixels are re-armed and will fire toward whoever the forward is addressed to.
This is deliberate and matches every mainstream mail client: passband strips trackers
out of what it RENDERS to its user, not out of what its user chooses to pass on.
Rewriting a stranger's markup on the way out would forward something other than what
arrived, and the user's decision is to send this message.
Scheduled sends carry a visible fingerprint (privacy). Every mail the outbox
fires goes out with X-Passband-Outbox: <token> and a Message-ID of
<token@passband.app>, where the token is 48 hex chars of fresh randomness minted
per row (docs/SCHEDULED-SEND.md §4). Recipients can see both: they reveal that the
mail was sent by Passband's scheduler, and carry nothing else, no account, mailbox,
user or row id, so two scheduled mails from one user are not linkable by them.
Interactive sends carry neither.
Do not break
GuardMatch::kind() is the only thing that may leave the process -
never return, log, or audit the matched substring. Never add an override to the
agent path: it is exactly the decision a prompt-injected agent would be talked
into making.
Post-send echo (audit contract)
After a successful send,
handlers::echo_sent fetches the sent message back (format=raw, write token) and
ingests it locally so the thread shows the reply immediately. It is strictly
best-effort - the mail has already left, so nothing here may fail the request - and
audits under its own action send.echo, alongside the send row's own outcomes
(rejected:confirm, rejected:forward_and_reply, rejected:empty_body - skipped for
a forward, whose note may legitimately be empty - blocked:guard (agent sends
only), rejected:no_write_credential (including the forward whose
raw fetch found the write credential dead - a 403 telling the user to re-run
squelchd auth --write, never the 502 that would blame Gmail), failed:target,
rejected:no_recipient, rejected:too_large (the original exceeds
MAX_FORWARD_RAW_BYTES = 20 MiB decoded, refused with a 413 before the four-to-five-x
re-encode allocates - or the send's own staged attachments total more than 25 MB),
rejected:attachment_missing (an attachment_ids entry names no staged file - swept,
deleted, or another account's - so the whole send is refused rather than going out
with fewer files than the tray showed), rejected:attachment_cid_clash (two staged
files carry one content_id, so the body's reference would resolve to a coin flip),
failed:fetch_original (the forwarded
original could not be read back, so nothing was sent), rejected:compose,
paced (the mailbox's Gmail budget held a call on the way, the send itself, a
forward's original or a reply's parent, so nothing was posted; answered 503),
failed:gmail, ok, ok:forward). A fired scheduled send's send.echo detail
is marked scheduled:<id>: exactly as its send row is:
send.echo detail |
meaning |
|---|---|
skipped:no_id |
Gmail's send response carried no message id; nothing to fetch back. |
skipped:sealed |
not written today: ingest_sent always commits (see below). The branch stays so the audit vocabulary is stable. |
failed:fetch |
the read-back GET timed out (5s), failed, decoded badly, or returned zero bytes. |
failed:ingest |
the local store write failed. |
ok:<local id> |
echoed; <local id> is the local messages.id. |
The echoed row goes through the SAME ingest as any other message
(sync::ingest::ingest_sent, is_sent: true), so it runs no LLM, and it is
always committed, including a reply that quotes a one-time code. There is no
deterministic sealing detector at ingest any more (§4): the copy is stored like any
other row and is pending until the durable agent assesses its access, so the human
door shows it at once while the agent door does not, and a restricted verdict
closes it to agents as it would any message. The human thread does not 404 on it
(auth_sent_echo_is_stored_and_the_human_thread_still_opens). ingest_sent still
returns an Option and the skipped:sealed branch in echo_sent remains, but
nothing reaches it today. The read-back is also capped at 5s
(ECHO_FETCH_TIMEOUT_SECS): the client's POST timeout is 15s and action_send has
already made two serial Gmail calls, so bookkeeping may not spend the rest of that
budget. Keep the echo's failures audited and swallowed; keep it out of core's write
surface (the fetch lives in squelch-api/src/write/gmail.rs, core takes bytes).
Scheduled send (audit contract)
The outbox door audits under three actions of
its own, target = the message a reply or forward is about (schedule) or the row id
(schedule.cancel, schedule.now), details ids and reasons only. A FIRED send
writes the ordinary send row, marked with its outbox row:
| action / detail | meaning |
|---|---|
schedule · every send refusal above |
the same ordered refusals a send meets (rejected:confirm, rejected:attachment_missing, rejected:no_write_credential, failed:target, ...), in the same order. |
schedule · rejected:send_at |
send_at missing, not RFC3339, not in the future, or more than 366 days out. |
schedule · rejected:group_fanout |
an individual group: one minted Message-ID cannot name N messages. |
schedule · rejected:too_many_scheduled |
the account already holds 100 live outbox rows. |
schedule · failed:group / failed:store |
the group did not resolve / the insert failed. |
schedule · ok:<id> |
scheduled; <id> is the outbox row. |
schedule.cancel · ok / rejected:sending / failed:not_found |
cancelled / in Gmail's hands (409) / unknown, another account's, sent or cancelled (404). |
schedule.now · ok / rejected:sending / failed:not_found |
queued for the next tick / in Gmail's hands (409) / nothing to queue (404). |
send · scheduled:<id>:<outcome> |
a fired row went out; <outcome> is the plain send's (ok, ok:forward, ok:bcc:N, ...). |
send · scheduled:<id>:recovered |
Gmail already held the row's mail, found by its minted Message-ID or its X-Passband-Outbox token (a lapsed lease, or a re-fire after an ambiguous failure): settled, NOT sent again. |
send · scheduled:<id>:failed:* / scheduled:<id>:rejected:* / scheduled:<id>:paced |
a fired row's refusal, the plain send's detail marked with the row. paced spent no attempt. |
send · scheduled:<id>:failed:request / failed:attachment_missing / failed:crash_loop / failed:recovery / failed:group_fanout |
a TERMINAL outcome the worker decided without reaching the send (so no plain send row exists to mark): an unreadable request, a lost file, a row that kept dying mid-send, a recovery or pre-fire search never able to ask Gmail, an (unreachable) fan-out. The worker writes these itself. |
send · scheduled:<id>:rejected:no_write_credential / failed:search / failed:store |
a worker-side attempt that never reached the send: no credential before the pre-fire search, the search refused (a revoked grant surfaces here, or a 4xx other than 403/429, which are "not now" like a transport error), the row's files unreadable. Each spends an attempt, and is written only when the settle moved the row (a worker that outlived its claim writes nothing). |
send.echo · scheduled:<id>:<detail> |
the echo of a fired or recovered row, the plain detail marked with the row. |
The fire-time grace deferral (recovery:grace) and an unreachable pre-fire
search (recovery:unreachable) are "not now" answers that spend no attempt and
write no ledger row; the listing's last_error says why the row waits.
§6
The embedded assistant
The assistant sees only what the app is allowed to show it, and anything that would change your Gmail waits for your tap.
The dedicated
/client/agent/read routes and source checks in §4 supersede references below to using ordinary human endpoints or legacy sealing.
Invariant
The agent inside Passband reads only what the human door serves, so sealed mail is absent from it for the same reason it is absent from the door; and it cannot touch the mailbox without a human tap that has already happened by the time the call goes out.
The assistant's fourteen tools, in three tiers
Enforcement
- Reads inherit §4. Every tool call in
passband/Sources/Passband/Assistant/AgentTools.swiftgoes throughAPIClient, i.e./client/*. The assistant cannot read a 2FA code because the door it knocks on has none - nothing is filtered here, and nothing needs to be. It holds no sealed tool:/client/sealedand the reveal route are not in its inventory. - Three tiers, and only one of them stops. Fourteen tools: reads are never
gated; safe writes touch this database only (status, sender rules, drafts) and are
reversible in the app; the four that touch Gmail (archive, label, send,
unsubscribe) route through
AgentTools.confirmed(_:_:), where only.approvedreachesperform. That single funnel is the entire reason theconfirm: truethose calls carry is a true statement rather than a default. - The daemon does not take the client's word for it. Every mutating
/client/*route demands the flag itself and audits the attempt. A client that lied would be recorded doing it. - A half-arrived instruction is not an instruction.
passband/Sources/Passband/Assistant/Assistant.swiftexecutes atool_useonly after its message has closed, so a partially streamed tool input never runs. - The key. Self-host is BYOK and the key is read only inside
LLMProxy(passband/Sources/Passband/Model/Keychain.swift) at call time - never a parameter, a return value, or an error string - over sessions that refuse every redirect, because URLSession carriesx-api-keyverbatim across a hop. Hosted holds no key in the app at all:POST /client/assistant/messages(squelch-api/src/assistant.rs) relays with a daemon-held credential and streams the bytes back, human door only, and 404s when no gateway is configured. The conversation body is treated like mail content and logged in neither direction. - Saved chats on disk. The conversation behind a question in search history is
the one place the client keeps a transcript at rest
(
passband/Sources/Passband/Lib/QuestionChats.swift): one file per account under Application Support'sPassband/QuestionChats, excluded from backup, 0600 in a 0700 directory on the Mac and under complete file protection on the phone. A chat expires 30 days after it was last saved, goes when its question leaves history, and the file is deleted when history is cleared or the account is removed. A restored chat is re-sanitized (no card comes back live) and is refused if its history names a tool the chat no longer offers. - The last board on disk. The client also keeps at rest the board a launch
paints before the daemon answers
(
passband/Sources/Passband/Lib/BoardFile.swift): the bands' rows (subject, sender, the triage's one-line summary and preview), the calendar and shipment cards, the hero's headline, the head of each mail page, and the ids (ids only) of the threads that hold auth mail, so the prefetch fence is up before the sealed list is fetched again. It is written through the same code as the saved chats (PrivateFile.swift): one file per account under Application Support'sPassband/Boards, excluded from backup, written atomically, 0600 in a 0700 directory on the Mac, complete file protection on the phone, contents never logged. NEVER on disk: the sealed list and the banking and receipt cards (the amounts and balances extracted from that mail), which the persisted type has no place for, and auth mail by every signal there is for it, since a preview can be the code. A row goes when its decision names an auth kind (feed rows), when its zone isauth(mail-page rows, which carry no decision), or when its thread is one the store fences (the sealed list, the bands' auth rows), and every other row and card of that thread goes with it. So does a mail-page row triage has not placed yet: the newest mail is where a code that just arrived sits. A page is cut at its first such row rather than holed. The rules run on the way in and again on the way back out, so a file written any other way still cannot paint auth mail. Billing and receipt mail is otherwise mail like any other: its rows on a mail page are kept. The headline is kept only for the band it was written about, and not when a band row was taken out. Only what the daemon has answered for this session is written: never a board still repainted from disk or a switch, never a page that has not loaded since, and nothing after the credentials are re-validated in Settings until the next account change. A band that is nothing but auth mail is not written at all, and the file goes. A board the daemon last answered for more than a week ago is not painted and is deleted, by the read that finds it or by the launch sweep, which also deletes the board of every account no longer on the install. The file goes when the account is removed, when the same daemon is connected again or re-validated (the credentials may open another mailbox), and whenever the daemon refuses the saved credentials. Never written for the fixture inbox. - The last calendar on disk. The client keeps the calendar feed it last
loaded for each account, so the Calendar page paints before the daemon
answers (
passband/Sources/Passband/Model/CalendarDisk.swift): events (titles, places, descriptions, guests and their addresses), ghosts and the calendars' names. Kept by the board's rules: written throughPrivateFile.swift, one file per account (named by a hash of the account id) under Application Support'sPassband/CalendarCache, contents never logged; not painted and deleted a week after it was saved; swept at launch with the boards (stale, or an account no longer on the install); deleted when the account is removed, when the same daemon is connected again or re-validated (and nothing written again under that epoch), when the daemon refuses the saved credentials, and when thecalendarfeature turns off; never written for the fixture inbox. Only what the daemon answered is written.
Do not break
- Prompt injection is the standing threat, and the gate is the answer to it.
Mail bodies reach this model as data, so assume the text is adversarial and
assume it will eventually ask for an action. Everything that touches the mailbox
must keep going through
confirmed; a new ungated write is how this section stops being true. - Do not hand the assistant a sealed route, and do not "helpfully" widen a tool's read to a store call that bypasses the door.
- Do not log the relay body, in either direction, at any level.
- A decline is a legitimate answer with
is_errorfalse. Do not turn it into a failure the model feels invited to retry.
§7
Automatic unsubscribe
Passband follows an unsubscribe link only after you ask, only for authenticated mail, only out to the public internet, and presses at most one button.
Invariant
The daemon fetches a URL a stranger chose only after the user asked to unsubscribe from that stranger, reaches only the public internet when it does, and takes at most one action on the page it finds: the one a person would take by pressing its single button.
This reverses an older rule ("the server never fetches the unsubscribe link") on purpose. The link is no more dangerous than it was; what changed is who follows it. That move is what the guard below exists for.
Every check an automatic unsubscribe has to pass
- You askedAnd confirmed; nothing prefetches a link
- Mail we trustNot spam, and Gmail's auth passed
- Sender's own linkA footer link must match the From domain
- Public internetNever a private or local address
- One buttonAt most the one a person would press
Enforcement
- Never before the ask. Nothing prefetches, previews or probes an unsubscribe
link: an unsubscribe may complete on a plain GET. The job starts from
POST /client/unsubscribewithauto: true(squelch-api/src/unsub_job.rs), human door only; the agent door has no unsubscribe route at all, and the assistant's tool goes through the confirm card (§6).auto: truealso needsconfirm: true, like every other route that acts for the user: without it the daemon answers 400 and auditsrejected:confirmbefore reading the message or fetching anything (auto_without_confirm_is_refused_before_anything_is_fetched). A request withoutautoneeds noconfirm: its answer is a link the person opens in their own browser. - Not for mail we cannot trust. Spam (
is_spam) and mail without a passing authentication verdict (auth_passnot true) are never unsubscribed from automatically: fetching a spammer's link confirms the address is live, and an unauthenticated From line may not be the link's real author. Mail from before the verdict was recorded (auth_passNULL) is treated the same way. All fall back to the browser, with the reason (auto_refused) for the client to say. - A footer link must be the sender's own. A header link is the sender naming
its endpoint. A link picked out of the body runs automatically only when its
site matches the From domain's (
unsub_job::choose_plan): a colleague's forward of a newsletter is authenticated mail from the colleague carrying someone else's footer, and following it would unsubscribe the colleague. - SSRF guard, in code, for every deployment (
squelch-api/src/unsub_fetch.rs). http/https only, ports 80/443 only, no userinfo. Names resolve through a resolver that returns PUBLIC addresses only (no loopback, private, link-local, CGNAT, multicast, reserved, or v4-in-v6 forms of those); reqwest connects to exactly what it returned, so DNS rebinding has nothing to rebind. IP literals, which never reach a resolver, are checked per hop. Redirects are followed by hand so every hop passes both checks; a 307/308 never carries a body to another host, and the https-first retry falls back to http only when nothing was sent. Hosted tenants also sit behind their NetworkPolicy; a self-host has only this, which is why it is not optional. One gap is inherent: a self-host on a network with global IPv6 can reach its LAN's devices at their global addresses on 80/443, because those addresses ARE public. - One action, and it has to prove itself (
squelch-api/src/unsub_html.rs). A form is submitted only when exactly one submit control plausibly unsubscribes (one lone button, or the one among several that says so), with no password or file field, no required choice left unmade, and NOTHING TYPED: an empty field stays empty, and a required one refuses the form. A link is followed only when its own text says unsubscribe; it may point at any public host, which is no more than the sender's redirects could already do. "Undo", "cancel" and "resubscribe" controls are never actions. Hidden text never counts as the page's answer. The page that comes back must say the user is unsubscribed (in so many words, or by the page reader's verdict); otherwise the job hands the link to the user. Optional surveys are never answered. - The page reader is fenced, cheap, capped and billed
(
squelch-core/src/triage/unsub_page.rs). The page is untrusted input inside the usual fence; any non-answer isunclear, which costs the user one click. Its calls bill tounsub_pageand stop at a daily cap. - Bounded. 1 MiB bodies, per-request timeouts, a 45s job budget (slot wait included), four jobs at once, a cookie jar that lives for one job. Parsing is linear (a tag budget, a per-form control cap, no inner scan past its parent) and runs on the blocking pool. A panic inside a job still settles its row.
- The link stays out of logs and the audit. Audit rows carry the sender and the verdict; transport errors are reduced to their kind before they can be printed.
The browser rung (squelch-api/src/unsub_browser.rs, unsub_cdp.rs,
squelch-core/src/triage/unsub_agent.rs). Everything above describes the plain
reader, which holds on every daemon. A daemon that was given a Browserbase key
(every hosted tenant; a self-host only if it brings its own) has one more
rung: a page the reader gave up on for a reason a real browser could fix
(choose_topics, needs_browser, unclear; never a dead link) is opened in a
rented remote browser that the notify model steers. On that rung the "one
action, nothing typed" rule above is deliberately replaced by this one:
- The page's scripts never run near the daemon. The browser is Browserbase's, on their network, reached over one WebSocket. Nothing the page executes can reach the daemon, its volume, its loopback doors or its neighbours. The same per-hop URL check the fetcher uses still decides whether a link is handed over at all.
- The agent has a closed vocabulary, enforced on the page and not in the
prompt (
unsub_browser::permit). Press a link or button, turn a checkbox, radio or switch on or off, choose a select's option, fill an email field. Every action names a control by number, and the number is checked against the controls the daemon itself found, kind included. A short list of labels is never pressed whoever asks: resubscribe, undo, sign in, create or delete an account, download, buy. - Exactly one string can be typed, the address the mail was sent to
(
unsub_job::address_to_type: the account's address, or its plus address when that is what the message was addressed to), and only into a field that is for an email address. The model's answer carries no text of its own: there is no action that types what the model says, and no free-text field in its output for a page to have it fill in. A password, a name or a card number cannot be entered because nothing can enter one. - What the model reads is what a person would see. The page is read from an isolated JavaScript world the page's own scripts cannot see into. Text that is in the markup but not on screen (hidden, collapsed, clipped, off the page) is not read, because the agent's "done" is taken from this text. The page's own square brackets are rewritten, so it cannot draw a control that is not there. It goes to the model inside the usual fence.
- Bounded and billed. Eight steps a job, sixteen actions a step, 100 seconds
on top of the plain ladder, two browsers at once, a daily cap on steps, and
usage under
unsub_agent. A step that changes nothing twice ends the job. - A person is asked when a person is what the page wants. A sign-in, a
captcha (sessions are created with captcha solving OFF), a confirmation link
sent by mail, a question only the user can answer: each ends the job as
needs_youwith that reason and the original link. - Failure of the machinery changes nothing. No browser to rent, a dropped socket, the model's cap: the outcome is whatever the plain reader had already decided.
- What Browserbase learns. The unsubscribe link (which already names the recipient to the sender), the page it leads to, and the address if a form asks for it. Sessions are created with recording and logging off. No mail, no credentials and no other mailbox data are reachable from the rung; it has no route to the store at all. Browserbase is a subprocessor for every hosted mailbox.
Do not break
- Do not build a second outbound client for sender-chosen URLs, or call
reqwestdirectly on one:Fetcheris the guard, and a client without it is an SSRF primitive aimed at the daemon's own door on loopback. - Do not prefetch an unsubscribe link to "show a preview" or to classify it early.
- Do not make
Action::submissiontype anything. The page's host is the sender's choice at every hop, so there is no site an address could be safely typed into, and the account's primary address is not necessarily the one the list knows. - Do not give the browser agent a way to type anything but the one address, and do not add a free-text field to its output. The vocabulary is the fence.
- Do not move a check out of
permitand into the prompt. The prompt is advice to a model reading a hostile page;permitis what actually happens. - Do not run the browser inside a tenant pod or on a self-host's own machine without first giving it the fetcher's network guard: a real browser loads subresources and runs scripts, and the public-only resolver covers neither.
- Do not turn session recording, logging or captcha solving on.
needs_yousends the user to the ORIGINAL link, not a post-redirect hop that may depend on this job's cookies.
§8
Agent door authentication
On hosted, only you can use your mailbox's agent door, with a sign-in that opens nothing else. If the setup is incomplete the door stays shut rather than falling open.
Invariant
On a hosted tenant, only the owner of that tenant's mailbox can use
its agent door, and only through a credential that cannot open the human door. A
daemon whose agent-door settings are incomplete serves nothing on /mcp. A
self-hosted daemon with no settings behaves exactly as it always has.
The agent door runs in one of three modes, fixed at startup
Three modes, chosen once at startup by squelch-mcp/src/auth.rs:AgentDoorAuth::from_env
and applied by squelch-mcp/src/lib.rs:agent_door_router, the only public way to
build the HTTP agent door (the bare rmcp service is private to the crate):
- Open. Neither
SQUELCH_MCP_OAUTH_ISSUERnorSQUELCH_MCP_PUBLIC_URLis set. No auth layer at all; the only guard is rmcp's Host allow-list (loopback plusSQUELCH_MCP_ALLOWED_HOSTS). This is self-host, and it is fit only for a listener the owner controls the reach of. No metadata routes exist. - OAuth. Both are set, each a valid
httpsURL (httponly on loopback), and the public URL's path is exactly/mcpwith no query or fragment (the resource IS the door, so any other path could never match a token'saud). Every/mcprequest needs a bearer token, verified below. Two unauthenticated GET routes publish RFC 9728 protected-resource metadata naming the issuer. - Misconfigured. Exactly one is set, one does not parse, the public URL's path
is anything but exactly
/mcp, or OAuth is configured but the daemon has no real account email to bind tokens to (unset, or theme@localhostplaceholder;AgentDoorAuth::for_account, applied insideagent_door_routerso no host can skip it)./mcpanswers 503 to everything, no metadata is published, and startup logs which setting is at fault. It never falls back to Open: the likeliest way to get here is a hosted pod that lost one variable, and that pod's/mcpis published. The human door is unaffected.
Roles. The daemon is a resource server only. WorkOS AuthKit is the authorization server: it runs registration, consent and token issuance, and our control plane supplies the identity (a Google sign-in, mapped to a mailbox) through AuthKit's Standalone Connect hop. The daemon never issues a token, never sees the user's Google credential, and never calls AuthKit about a specific token. It verifies AuthKit's RS256 access tokens locally against the issuer's JWKS.
Verification, in order (auth.rs:Verifier::verify). Each step is a refusal on
failure, and the cheap, local steps come before anything that touches keys:
- An
Authorization: Bearerheader (squelch-httpauth:parse_bearer). Absent or not Bearer: 401 withWWW-Authenticate: Bearer resource_metadata="...". - The token is at most 8 KiB. Checked before any decoding.
- The JWT header parses,
algis exactlyRS256, and there is nocritheader (RFC 7515 §4.1.11: we understand no extensions, so we accept none).nonedoes not parse, and every HS* algorithm is refused here, before any key is looked up, so the RSA public key can never be fed to HMAC. - The header has a
kid, and the JWKS cache has an RSA signing key of at least 2048 bits for it. See "The key set" below for when the cache refreshes. If no refresh has ever succeeded, or none has for 24 hours, the answer is 503, never a pass. - The signature verifies under that key.
issequals our issuer andaud(a string, or an array) contains our public URL. Both are exact string compares after trimming one trailing/.expis present and not past, with 60 seconds of leeway;nbf, if present, is not in the future, with the same leeway.emailequals this daemon's account email, trimmed and ASCII-case-insensitive. A token with noemailclaim is refused.
Every refusal after step 1 is a 401 with error="invalid_token" added to the
challenge, and an empty body, with one exception: when no key set can be trusted
(step 4: never fetched, or no successful fetch for 24 hours) the answer is a 503
with no challenge, because the token was never judged. A token that passes bounds
its response (auth.rs:response_lifetime): the body is ended when the token's exp
plus the leeway passes, so a long-lived GET event stream closes and the client
must come back with a fresh token. Two clamps keep that bound from misfiring. A
FLOOR of 30 seconds lets a response opened by a token accepted late in its leeway
finish, rather than return an empty body after the tool has already run. A CAP of
24 hours applies whatever exp says, and the arithmetic is total, so an absurd
exp (1e300) gets a normal response instead of a panicked connection. Nothing an
agent receives outlives the credential that asked for it by more than the floor.
The library verifies the signature and nothing else:
every claim check is ours, because its defaults check aud and iss only when
present and leave nbf off.
The key set (auth.rs:JwksClient). Fetched lazily from <issuer>/oauth2/jwks
(or the override), with no redirects followed (a redirect is a failed fetch, since
the https check covered only the configured URL) and the body read in chunks that
stop at 256 KiB. A fetch happens when a kid is unknown or when the cached set is
older than one hour, and never more than once per 60 seconds whatever arrives: a
spray of random kids cannot drive outbound fetches, and since a throttled request
does not extend the window, a newly published key is usable at most 60 seconds
after the last fetch. A successful fetch REPLACES the set, so a key the issuer
withdraws stops verifying within about an hour, and a JWKS with no usable keys
empties the set. A failed fetch keeps the old set, so an issuer blip does not log
every agent out, but only until 24 hours have passed without one success; then
nothing is trusted (503). A fresh cached key is never delayed by a refresh in
flight.
Step 8 is the tenant boundary. One wildcard resource indicator,
https://*.passband.email/mcp, covers the whole fleet, so any Passband user can
obtain a correctly signed, unexpired token whose aud names someone else's tenant.
aud says "this token is for a Passband daemon"; only email says whose. Without
step 8, tenant A's owner could read tenant B's mail with a token AuthKit issued
quite properly.
The email claim exists only because the operator puts it there. A WorkOS
Connect access token carries iss, aud, sub, client_id, org_id, sid,
scope, jti, exp and iat, and no email. The claim appears only when the
environment's JWT template is set to {"email": {{ user.email }}}. Without the
template every token is refused as missing_email, which is the fail-closed
direction: a forgotten template locks agents out, it does not let them in. sub
is WorkOS's own user id, not anything the control plane chose, so it cannot stand
in for the mailbox.
The doors do not share credentials. /mcp never accepts an sqd_ device
token or the SQUELCH_API_TOKEN master token: neither is a JWT, so step 3 refuses
it, and nothing on the agent path consults the device-token table. /client/*
never accepts an agent JWT: the bearer check there knows only the master token and
issued device tokens. The two routers are merged side by side in
squelchd:build_serve_router, so neither inherits the other's auth, CORS or body
limits. Both directions are pinned against the real combined router in squelchd's
tests. The agent door still has no path to the write credential, and sealed
material stays structurally absent from it under §4: nothing here touches tool
logic.
Rollout fails closed. The warden never renders SQUELCH_MCP_ALLOWED_HOSTS. The
public hostname reaches the rmcp Host allow-list only from agent_door_router, and
only in OAuth mode. So an older daemon image behind a newly published /mcp
Ingress path answers 403 from the Host guard rather than serving mail without a
token.
Logging. A refusal is counted under one closed category (auth.rs:Refusal,
e.g. wrong_mailbox, expired), and only the counts are ever written. No token, no claim value and no email
address reaches a log line or a response; a misconfiguration names the setting,
not its value. Both binaries' startup lines (squelchd serve and the standalone
squelch-mcp --http) print the same mode text (AgentDoorAuth::describe): the
mode and, in OAuth mode, the public URL, which the metadata publishes anyway.
Accepted costs.
- WorkOS can mint a token that reads a mailbox. AuthKit holds the signing key, so a compromise of WorkOS (or of our WorkOS API key, which completes the identity hop) can produce a token that passes every step above. That is the price of not running our own authorization server.
- Revocation is a WorkOS-side operator action, not instant, and not ours. The
daemon checks no revocation list. Access tokens are short-lived, but AuthKit
also issues refresh tokens (
offline_accessis among the standard scopes a dynamically registered client gets), so an approved agent keeps access for as long as its grant lives on the WorkOS side. Ending that access means revoking the grant in WorkOS, which today is something an operator does. The access-token lifetime only bounds how long a revoked grant's last token keeps working, and the stream bound above makes that true for open streams too.
The phishing shape that remains. An attacker can register their own MCP client
with AuthKit, start a flow, and send the victim the resulting login link. If the
victim signs in and approves, the attacker's client gets a token for the victim's
mailbox. Client names under dynamic registration are chosen by whoever registers
the client and are not verified by anyone, so "Claude" on a consent screen proves
nothing. The defence is two screens the victim must get past: our control plane's
own confirmation page, which names the mailbox and is shown after Google sign-in
and before anything is sent to WorkOS (/agent/auth, then Google, then confirm,
then the POST that completes the hop), and then AuthKit's consent screen naming
the client. Both depend on the victim reading them.
Not proxied on purpose. The daemon does not serve
/.well-known/oauth-authorization-server. Clients that predate the 2025-06-18
authorization spec and expect the resource server to also be the authorization
server will not work, and that is accepted rather than worked around.
Browser-based MCP clients are not supported. /mcp sends no CORS headers, by
choice for now, so a page in a browser cannot call it. The protected-resource
metadata is CORS-open because it is public. Server-side and native clients, which
is every agent we document, are unaffected.
Refusal logging is rate-bounded. Refusals are unauthenticated, so they are
counted per category and written as at most one summary line per minute
(auth.rs:RefusalLog), never one line per request.
Do not break
- Do not drop or weaken step 8, and never swap the binding to
sub:subis WorkOS's user id, andemail(from the JWT template) is the claim this daemon checks against its own mailbox. - Do not remove the stream bound or the key-set maximum age and ceiling.
- Do not let a misconfiguration fall back to Open, and do not mount the agent door
any way other than
agent_door_router. - Do not render
SQUELCH_MCP_ALLOWED_HOSTSfrom the warden. - Do not let the agent door accept a human door credential, or the human door an agent JWT.
- Do not log anything from a token, including on the 401 path.
§9
Google Calendar
Your calendar is shown to you and nobody else: no agents, no logs, no triage. Two narrow, documented exceptions let a small model shorten a place name and plan reminders.
Invariant
Calendar data (event titles, places, descriptions, organizers,
guests' addresses, names and answers, links, calendar names) is human-door data: served on GET /client/timeline (and
in the answer to the user's own write) and nowhere else, never in a log line,
never in a triage prompt. TWO EXCEPTIONS, each the owner's decision. First
(2026-10-28): an event's location string, alone, goes to the configured
provider's small (notify) model to be cut down to the place the card shows
(see "The card's place" below). Second: to plan an event's
reminder, the same small model reads the event (title, times, place,
description, the user's answer, busy or free, the COUNT of other guests and
whether the user organizes it) and the subjects, sender display names and
attachment filenames of the mail linked to it (see "Calendar reminders"
below). Nothing calendar-related is on /mcp or /client/agent/*. The daemon writes to a calendar when the user creates,
edits or deletes one event from the human door, and, ONE EXCEPTION (behind the
calendar_own hidden feature), when it
replaces Gmail's own copy of an event with its own and keeps that event current
from authenticated mail (see "Passband's own events" below). Nothing else
writes on its own. Google emails an event's guests only when a human-door
write says notify: true; no other input, default or code path can ask for
it, and Passband's own writes always send sendUpdates=none.
Where calendar data can go
- Your timeline in the appThe human door only
- Small model: the placeThe location text alone, cut to the place
- Small model: remindersThe event and its linked mail's subjects
- Agent door and MCPNothing calendar-related
- LogsNever a title, place or address
- Triage promptsNever
Enforcement
- Five tables, human door only.
calendar_calendars,calendar_events,calendar_places(the card's place per location text),calendar_owned(which event Passband made for which ghost,gcal/own.rs) andcalendar_event_guests(an event's attendees, deleted with it by a trigger) (squelch-core/src/store/sqlite/calendar.rs) are written by the sync loop (squelch-core/src/gcal/sync.rs) and, one row at a time after Google accepted it, by the user's own write (squelch-api/src/calendar_write.rs); they are read only byhandlers.rs:get_timelineand those write handlers. Every query is scoped byaccount_id. A log line names a calendar by its position in the pass, never its id, which is usually an address. - Laundered and capped (
gcal/mod.rs:read_event). A shared calendar is written by other people, so its text is treated like mail: titles and places lose control, bidi and invisible characters and stop at 200 characters, a meeting link loses its query, an organizer is kept only when it is an address, ids are bounded. At most 25 calendars, 5000 events per calendar (the nearest to now kept), 40 pages of 250 per calendar per pass, 8 MB per response, one listing of 5000. Google is asked for less in the first place: afieldsmask names only what is read, andmaxAttendees=100bounds the guest list, so an invitation anyone can send never delivers attachments, conference data, a guest's comment or more than 100 guests to the daemon. Of each guest only the address (kept only when it is one), the name (laundered like a title), the answer and two flags are stored; past 100, Google sends the user's entry alone and the event is marked truncated. Each event'seventTypeis read only to mark Gmail's own events (fromGmail), which the edit guard then refuses, since Google does. Its description is read (the app shows it): markup becomes text, each line is laundered like a place, and it stops at 2000 characters. Google's own boilerplate on an event it made from mail ("To see detailed information for automatically created events like this one...") is dropped with its link (triage/ghost.rs:strip_google_boilerplate). - Passband's own events (
gcal/own.rs,gcal/sync.rs:own_events). Off unless thecalendar_ownhidden feature is ticked for the tenant. It touches only events on the PRIMARY calendar: Gmail's own (fromGmail) that are the user's by every organizer test and carry no other guest, and the events Passband made in their place. An invitation from anyone else is never written. Every write sendssendUpdates=none, never names attendees, and is audited (actorpassband-calendar, targetcalendar:<calendar>/<event>, detailokor an error kind; never a title or a place). Only a ghost from mail that passed sender authentication adopts or moves an event. A cancellation from mail marks the event ("Canceled: ", free), it does not delete it. The user's deletion of Passband's event, or dismissal of the ghost, ends Passband's hold on it for good. At most 10 writes a pass, 100 a day. KNOWN GAP against docs/CALENDAR.md "Before anything writes to a real calendar": a time or place change comes from what the triage model read in the mail, not from structured calendar data (an iCalendar UID), so on a shared sending platform another host's mail that the model reads as the same booking could move the event. The marking-not-deleting of cancellations and the authentication rule bound it; structured updates are the fix. - The card's place (
gcal/place.rs). After a pass that synced, the loop sends up to 10 stored locations longer than 40 characters (upcoming first) to the provider andnotifymodel that already read the owner's mail, each on its own, fenced as untrusted, with nothing else of the event: no title, guests, notes, ids or account. The answer, anameand anaddress, is laundered like a title and HELD TO THE INPUT IN CODE: every word of each must be a word of the location (place::grounded), so the model can only drop text, never add an address, a link or anything a hostile location asked for; an answer that adds a word is discarded and the location kept. Stored incalendar_placeskeyed by the location text (one call per distinct text), pruned when no event holds it, served onGET /client/timelineonly, asplace(name, address and the original location) beside the untouchedlocation. An event Passband owns is written with the name and address as its location, so the address survives Gmail's copy being deleted. At most 100 calls per account per day (attempts counted in memory, spend in the usage ledger undercalendar_place). No key, no call. The location text is never logged. - Calendar reminders (
gcal/remind.rs,squelch-api/src/calendar_remind.rs,store/sqlite/calendar_remind.rs; the owner's decision of 2026-10-29, against the recommendation to leave the description out and to template the banner's line). ONLY THE USER'S EVENTS (remind::attends): an event is planned, fired, given the fallback, links ghosts or claims one only when it is on one of the owner's own calendars (primary, orowneraccess) and they accepted or said maybe, or it is their own (users_own), or it is unanswered and its organizer is a known contact (the owner has written to them); never when declined. A stranger's invitation, which Google adds unasked, gets no model call and no banner, cannot capture the user's booking, and its tickets are only ever the fallback from its own linked ghosts. Once per item version (a hash of what it read), the worker sends each upcoming event of the user's and committed or going ghost to the provider andnotifymodel that already read the owner's mail, fenced as untrusted: title, start and end, place, description (at most 1500 characters), the user's answer, busy or free, how many other guests (a number: never a guest's name or address, never the organizer's address), whether the user organizes it; for each linked mail its subject, sender DISPLAY NAME (never the address, and none when the name holds an@), received date and non-inline attachment filenames and types (never a body, never bytes); and the same day's other bookings (title, start, kind). Linked mail is only the sources of ghosts from mail that passed authentication, and the store's read of it (calendar_linked_mail) re-applies the ghost rule itself: never spam, sent, sealed (SEALED_SQL) or unreviewed mail. The answer is HELD TO THE INPUT IN CODE (remind::settle): the lead is one of 10, 30, 60, 120, 180 minutes; the line, which reaches the lock screen, loses control, bidi and invisible characters (order_link::plain), is NFKC-folded, and is DROPPED whole byremind::line_shaped(its own rule; triage'sghost::mail_shapedis unchanged), tested after invisible characters and combining marks are dropped: a URL or any dotted host (last label a country code, 3+ ASCII letters, or any non-ASCII letters), a dot written or spaced out before a top-level domain (with a space before the dot in any case, else with the domain in lower case),hxxp,[at], an@touching a letter or digit, 6+ digits unbroken or in 3+ hyphen-joined groups, 7+ across the separators a number is printed with (/ , _ ·and every dash included, look-alikel I ocounted inside a run, dates with a year masked first), and vanity or keypad-spelled numbers; then cut to 90 characters (docs/CALENDAR.md has the measured corpus). An entry code (remind::settle_code) is served only when it appears word for word in the item's own title, location or description (not a piece of a longer hyphenated number), is at most 2 letters then digits,#or*, 3 to 12 characters with 2 to 6 digits (no phone number in any country, no spelled number), and comes from a current plan; its label uses only a closed list of words naming what a code opens, else "Code", so it can never ask the reader to call or pay. The title, place and line are stripped of bidi controls and invisible marks again on the device; every mail, attachment and booking number is bounds-checked (out of range is none), and a booking's mail counts only once the answer names that booking as the same thing; a stored pick is served only while its mail is still one of the item's (eligible) sources. At most 200 calls per account per UTC day, every ATTEMPT counted durably (calendar_reminder_calls, so timeouts and restarts buy none; spend in the usage ledger undercalendar_remind) and 15 per pass, nearest first; an item whose ask failed backs off 30 minutes, doubling to 4 hours. No key: no call, and every reminder is a fixed 30-minute one with no line. Failure stores no plan. BANNER CAPS: once per start instant, at most two per item per owner-local day however its start moves, and never twice for one real thing under its ghost's and its event's keys within 24 hours. Logs carry counts only. The plan (calendar_reminders) and each firing (calendar_reminder_fires, its frame kept 7 days) are human door only: the frame goes to open Mac streams (calendar_reminder, noid:) and is served byGET /client/calendar/reminders/{id}, account-scoped, the same 404 for unknown, expired and another mailbox's; fire ids are AUTOINCREMENT (never reused), and the app and the extension show a fetched frame only when itsatis the push'sfired. The phone's push carries only"<tag>:cal:<fire id>:<fired>"and the relay's fixedkind: "calendar"line ("An event is coming up"); no title, time or place crosses the relay. A booking from mail that failed authentication is never reminded. The owner preference (app_settingskeycalendar_reminders,GET/PUT /client/calendar/reminders/settings) turns off every plan, call and firing; the timeline'sticketsfield is served either way. html_linkis an allow-list (gcal/mod.rs:google_link): https, a host that isgoogle.comor ends in.google.com, no userinfo, at most 2048 characters. Anything else is dropped, because the client opens it.- No redirects, no bodies in errors (
gcal/client.rs). The client refuses redirects (every request carries a bearer token) and reduces a failure to its kind (auth,quota,unavailable). - Calendar failures stay out of Gmail. The loop is its own task; no failure of it touches Gmail health or sync.
- Sealed mail feeds no ghost (
store/sqlite/ghosts.rs:ELIGIBLE,may_feed). A calendar ghost is built only from a message that is not sent, not spam and not sealed, where sealed is what the Auth page'ssealed_messagesreads today: a stored decision naming an auth kind (a sign-in alert, a login code), or an access review that is notallowed(restricted, or stillpendingafter the message changed). The retiredtriage.sensitivitycolumn is NOT the test: the agent pipeline no longer writes it. The same predicate runs at reconcile, at rebuild and in SQL at listing time, so a message that becomes sealed after its ghost was built stops serving it at once; a decision that does not parse feeds nothing.
The user's own writes (squelch-api/src/calendar_write.rs). POST /client/calendar/events, PATCH and DELETE /client/calendar/events/{calendar_id}/{id},
on the human door only, bearer authed, no-store. The order is fixed: the grant,
the body (title 1 to 200 characters, refused rather than cut; one precision; the
end after the start; place and description cut to 200 and 2000; all three lose
control and invisible characters but are otherwise sent as typed, so a link keeps
its query at Google, while the stored copy is Google's answer laundered by
read_event),
the calendar (one being read, on which the user is owner or writer), the
stored event and the edit guard (gcal/mod.rs:SyncedEvent::editable: the user's
own event, by organizer.self, no organizer named at all, or the mailbox's own
address; it FAILS CLOSED, so an organizer Google names whose address does not
read, stored as organizer_unreadable, and an event whose stored guest list
marks someone other than this calendar as organizer, are somebody else's), and
only then Google, through the same CalendarCredentials resolver the sync uses,
with sendUpdates=none unless the request asked otherwise (below). Only Google's success
changes a row, and what is stored is Google's answer read by the sync's own
read_event. A write is never retried. A refusal Google answered is a 502; a
write that went out and got no usable answer (a timeout or a lost connection after
sending, a 2xx body that does not read: CallError::Uncertain) is a 504 saying
Google may have made the change, kicks a sync and stores nothing, so the client
refreshes before offering to send again (a "tell guests" is not repeated on a
guess). A write Google accepted whose answer the daemon could not read or store
is that same 504, never a refusal. Every error body names its kind in a code
from a closed list (docs/CALENDAR.md section 8, "What a refusal says"), and the
app gives a pane's fields back ONLY on a code that says nothing reached Google
(or Google refused it), with that code's own status; anything else, including
no code, an unknown code, any 500 and a bare 502, freezes the pane. The whole write (token refresh, guest reread, write) runs under one 35
second deadline, below the app's 45 second wait; past it the answer is the same
504 and kick, and the app treats a wait of its own that runs out, or a
connection lost mid-request, as that 504 too. A create may name its own event id
(base32hex, 5 to 1024 characters, else a 400); the daemon reads that id before
inserting. An event already there (or found after a 409 duplicate from Google)
is answered, with no insert and no second invitation (audited ok:existing ... notified=false), ONLY when it is the event the create would make: same
calendar, laundered title, location and notes, start, end, all-day flag and
guest set. Anything else under the id is a 409 with no write
(rejected:id_in_use), so an id cannot be used to claim an event with another
title, place, notes, time or guest list than the request described. What it does
NOT compare is notify: Google's event does not say whether its guests were
mailed, so a create asking for invitations could be answered by an event made
without them. The app never sends that, because a create is resent only from a
frozen pane and only as the exact body first sent, so a resend never changes
notify; a different client could, and would be told 200 with nobody invited
(the audit row says notified=false). A second
create with an id whose first create is still in flight (an in-memory set of
account, calendar and id, released however the first ends) is a 409 before
Google hears it (rejected:in_flight). This makes a duplicate from a retried
create unlikely, not impossible: Google does not guarantee to catch a taken id
at insert time and a read may lag one just made, and the read is per calendar.
The app closes the rest from its side: after an uncertain save it freezes the
pane and can only send the same body again (docs/CALENDAR.md section 5). A delete
Google answers 410 found the event gone and removes the local row; a 404 does the
same only on the primary calendar or on a calendar calendarList.get still
lists, not deleted, with an accessRole of owner or writer; otherwise it is
a 410 "that calendar is no longer available to you" that leaves the row for the
kicked sync (Google answers 404 for a calendar the user lost, and for an event
they can no longer see). A calendar list read that fails is a 502 and deletes
nothing. The app refuses a "delete and tell guests" whose calendar may no
longer tell them rather than deleting silently. Event and calendar ids of . or .. (or empty) are refused at read
and again when the URL is built, because a URL path would read them as "here"
and "up one". Error bodies are fixed strings that never
echo the request; logs carry the action and an error kind, never an id. Every
attempt writes one audit row (calendar_event_create / _edit / _delete,
actor client-api, target calendar:<calendar_id>/<event_id>, detail ok or the
reason), never a title, place, description or guest's address. The target DOES
carry the calendar id, and a calendar id can be an address: the primary calendar
is stored as primary, but a personal calendar someone shared with the user is
named by its owner's address. Audit rows are human-door data like the rest. The
row is written even when the client disconnects mid-request: each write's checks,
Google call and audit run in a task of their own that the handler awaits, so
dropping the request does not cancel them. The calendar: prefix keeps the
audit listing's join to messages (a numeric target) from matching a calendar id
that starts with digits. The in-app assistant has no tool that reaches any of
these routes (see "The assistant's calendar tool" below).
Guests: a human-door route can make Google email third parties
(docs/CALENDAR.md §8, "Guests, invited by Google"). Since wave 5 the three write
routes can carry guests, and with notify: true Google Calendar sends each guest
its own invitation, update or cancellation. Passband writes none of that mail and
sends none through Gmail, but the effect is the same class as sending mail: it
reaches people outside the account and cannot be undone. What gates it:
- One switch, default off.
sendUpdatesisallonly when the request'snotifyis the JSON valuetrue(?notify=trueon a delete, given once). Absent orfalseisnone; any other value is a 400, not a guess (gcal/write.rs:notify,notify_query). The client type that reaches the URL (gcal/client.rs:Notify) has two values and defaults to the silent one, andsendUpdatesis always named, never left to Google's default. - The human door alone. The three routes are bearer authed on
/client; no agent route or MCP tool can name a guest or setnotify, and nothing the daemon does on its own (the sync, triage, ghosts) calls a write. The assistant's one calendar tool can put addresses in the event pane's guests line, where the user sees them as pills; it cannot send the request, andnotifyis decided only by which save control the user then presses. - Whose calendar. Guests only on the user's own calendar (the primary one, or
one they are
ownerof):gcal/mod.rs:may_invite. A shared calendar the user can write to cannot be made to invite anyone, nor to mail an update or a cancellation:notify: trueon an edit or a delete there is a 400 (calendar_write.rs:refuse_notify_unless_own, "guests can only be told about changes on your own calendar"), refused before Google hears anything. The client offers only the silent controls on such a calendar, with one line saying why. - Whose event. An existing event's guest list changes only when the event is
the user's own, the user organizes it, and the stored list is whole
(
SyncedEvent::guest_editable). Google replaces the attendee array on a patch, so the daemon never sends a list built from a truncated one: it rereads the event's attendees from Google inside the request, refuses if that read is truncated or holds a guest with no address, and hands every kept guest back exactly as read. An edit that names noguestssends noattendeeskey. - What a guest can be. At most 50 per request, each one plain ASCII
local@domain(no display name, quoting, comment, whitespace or control character), no duplicates, never the owner. The daemon sets no other attendee field and no invitation text; the title, place and description Google puts in its mail are the event's own, typed by the user. - What is audited. Every attempt's row carries
guests=<n> notified=<true|false>after its outcome, on failures too (a write that timed out may have gone through, and is auditedfailed:uncertain), even when the app disconnects mid-request, and never a guest's address. Guests' addresses are in no log line and no error body.
What this does NOT cover: a client that sends notify: true when the user did
not mean it (the client's controls must say what they send, and offer it only
where the daemon would accept it; the daemon cannot tell intent), and Google's own behaviour with sendUpdates=none, which the reference
hedges ("some emails might still be sent") and which has not been tested against
a real account.
The assistant's calendar tool (client, wave 6). The embedded assistant
(§6) has one calendar tool, create_calendar_event, and it is a draft, not a
write (passband/Sources/Passband/Lib/EventProposal.swift,
AgentTools.createCalendarEvent).
- It calls no route. The tool reads the model's proposal, validates it with
the event pane's own rules, and opens the pane on the Calendar page filled
in. The create route is reached only by the user pressing the pane's save
control, with the same body rules, create id and uncertain-write handling as
an event they typed. So a prompt injection in mail the assistant read can at
worst put a filled-in pane in front of the user. It cannot add an event or
cause an invitation BY ITSELF. Exactly: an event is added, or an invitation
sent, only by (a) a click on the pane's own save control, or (b)
↵or⌘↵pressed after the user has clicked or typed inside that pane (EventProposals.keysMayAct;↵never saves a draft with guests in any case). What the claim does NOT cover is the user's own judgement: someone who clicks "add and invite N" on a pane whose title, place and guests an injected email chose has added it. The pane shows all of them whole (below), which is the whole of that defence. - It never takes the screen or the keyboard from somebody
(
EventProposals.landing,CalendarStore.proposeEvent). A run goes on after the assistant's bar is closed (a citation clicked, "edit in composer"), so a tool call can land while the user is doing something else. The proposal is REFUSED, with a fixed sentence, unless the assistant's bar is open at that moment, and while a composer or an inline reply holds anything typed: mail being written is never closed for it. An email being read is not closed either; the pane is then parked, whole, and shown when the user goes to the Calendar page. The pane it opens never takes the caret, and↵and⌘↵do nothing in it (a toast says to click add) until the user has clicked or typed in it, so a key typed ahead, or meant for the composer or the reader that was on screen, cannot add or invite. Modelled on the agent door'sopen_draft(ComposeState.keyboardSendLocked). That path in turn treats an event pane holding anything (typed, parked, or with a write out or in doubt) as something being written: the agent's draft waits in drafts and Passband does not come forward over it, nor for a draft the composer did not actually take (EventPaneTravel.heldByUser,agentDraftMayComeForward). - The title and the place are the only text the assistant supplies. Each
is one line of at most 200 characters (the daemon's own caps, and what the
pane's one-line fields show whole); longer, or with a line break or a tab
in it, the call is refused, not trimmed (
EventProposals.oneLine). The tool has no notes or description parameter, andEventProposals.readrefuses a call that carries one under any of the names a model reaches for (notes,note,description,body,details,agenda,summary,message), in any case. Any OTHER field the tool does not list is refused by name too ("unknown field"), so nothing is dropped in silence and the model is told rather than left to believe something was saved. The reason is the guests: Google emails an event's description and place to them, and model-written prose must never reach other people through Passband. The notes line of the pane it opens is always empty. - It never invites. Proposed guests are shown as guest pills. Google emails them only if the user presses the control labelled "add and invite N"; plain Enter does not save an event with guests (§ "Guests" above).
- It does not read the calendar. No event reaches the model. A proposal
names a calendar or leaves it to the primary; a name that matches no writable
calendar is refused with a fixed sentence that lists none of them. The one
thing the tool may ask the daemon is the page's own timeline read
(
refreshCalendar), once, when the client holds no feed yet (the Calendar page has not been opened this session): the answer stays in the store and none of it goes to the model. A feed that still is not there is "could not reach the calendar", never "no calendar". - It never replaces the user's work. A pane holding anything (typed, or an earlier proposal, on screen or parked) refuses the tool with a fixed sentence the model relays.
- Mac only. Offered only on the Mac (
AgentTools.Tool.isAvailable); on the phone a call naming it is answered as any unknown tool. It is also gated on thecalendarflag (CALENDAR.md §11). No edit or delete tool. Nothing was added to/mcp,/client/agent/*or the relay.
Who a name is: GET /client/calendar/people (wave 6). The event pane reads
"with steven" out of a new event's title (a deterministic parser in the client,
no model) and asks the daemon who that most likely is, to SUGGEST a guest.
- With the other calendar routes (
calendar_routes, behind thecalendarflag), bearer authed on the human door,no-store, at most three answers. Not on the agent door,/mcpor the assistant relay. - A candidate is somebody the user has written to (
contacts,sent_count > 0), never the account's own address. Receiving mail from someone never makes them a candidate, so mail under a matching display name cannot plant a guest. - A list or a help desk is not a person. A candidate with no name from the
user's own Sent mail is named by the newest From name on its readable mail,
and a group or a desk the user once wrote to sends as
"Steven Ng via Team"or"Steven from Acme". So an address with no stored name is not offered AT ALL when any of its readable mail carriesList-Unsubscribeor a From name of that shape (a later word that is "via", "from" or "at";contacts.rs::list_shaped). It is offered only under a name the user's own Sent mail gave it. What this does not catch: a relay that rewrites From to the bare poster's name and sends noList-Unsubscribe. - Ranking reads mail only where mail may be read. Inbound mail counts toward
"most recently exchanged" only when it is not sent, not spam, not sealed (the
ghosts'
ELIGIBLEtest plus the legacy column, nevertriage.sensitivityalone) and passed sender authentication (auth_pass = 1; never evaluated reads as no). - Nothing is added by the answer. The client draws it as a dashed suggestion with a checkmark and an x; it is not in the draft's guests, does not change the save control and is never sent until the user accepts it, and then only as any typed guest would be.
- A name under two characters (empty included) is a 400 with a fixed body and
never reaches the store: one letter matches a large part of anybody's
contacts, and every match is a probe of that person's mail under the
store's one mutex. Over-long (more than 64 characters) or
@-bearing input answers an empty list. The input is never echoed. The probes are index seeks on one sender with no sort (MAX(received_at), or one unsorted pass for a contact with no stored name).
What the event pane sends to Apple (client). The location line of the
client's event pane suggests places from Apple's MapKit
(MKLocalSearchCompleter, then MKLocalSearch for the one picked). The text
typed into that one field leaves the device for Apple, from the client and
only once it is two characters long; the title, the times, the notes, the
calendar and the account do not, the daemon is not involved, and no location
permission is requested. Google's Places API is not used.
What the event card fetches (client, Model/EventVisuals.swift). The
calendar card's picture is, in order: a photo from the mail behind the event,
or the venue from Apple MapKit, or nothing. The photo is fetched only while
"load remote images" is on, from the newest message by someone else in the
event's threads, at most four of its images, through HeroBytes (ephemeral
session, no cookies, no referrer, no cache, 8 MB, image/* only), exactly
like a Reading card's hero; a sender learns no more than opening the mail
would tell them. The venue lookup sends the card's place text alone to Apple
(MKLocalSearch, then MKLookAroundSceneRequest for the item it returns),
never for a link or a call, and Apple's answer is used only when its name
matches the place. No location permission, no key, no Google.
The picture each card resolved is kept on disk (EventVisualDisk, in
Application Support's Passband/EventVisualCache) so the card paints it on its
first frame: an index of kind, source thread id, venue point and name, plus a
PNG per photo or Look Around still. It is mail content at rest and is written
the §6 way, through PrivateFile (excluded from backup, 0600 in 0700 on the
Mac, complete protection on the phone). File names are sha256 of the account
id plus the card's key, so no event id, thread id or place shows in them.
Nothing is written for the practice inbox, an entry older than 30 days is
dropped unpainted, a photo is not painted while "load remote images" is off,
and an account's entries go when the account is removed.
The token is the union; the slot is the boundary. Self-host
squelchd auth --calendar stores its token in its own credential slot
(CredentialKind::Calendar). Google unions grants per Cloud project and the consent
asks with include_granted_scopes, so that token carries EVERYTHING this OAuth
client was granted for the account, Gmail scopes included (gmail.modify, the
one Gmail scope since 2026-10-27; legacy grants may also carry gmail.readonly
and gmail.send). Asking for less cannot
narrow it; only a separate Cloud project could. What keeps calendar code away from
mail is the slot and the code paths: the calendar loop loads only the Calendar slot
(or, without one, the Read slot when it records calendar.events) and only calls
the Calendar API, and no mail path loads the Calendar slot. Hosted is unchanged: the
control plane seals ONE token into the Read and Write slots, as before, and a
calendar grant adds calendar.events and calendarlist.readonly to that one token.
The loopback consent endpoint (self-host only). POST /client/calendar/connect
(human door, bearer authed) lets the app's Connect button grant calendar on a
daemon with no control plane. A daemon that knows a console origin answers 409
and starts nothing; one with no OAuth client answers 409 too. Otherwise it mints a
fresh state and PKCE pair through squelch_core::auth::LoopbackConsent, the
builder squelchd auth --calendar uses, so the URL is the CLI's (calendar scopes,
access_type=offline, prompt=consent, include_granted_scopes=true,
login_hint set to the configured mailbox). The redirect lands on a listener bound
to 127.0.0.1 only (port 8847, or SQUELCH_AUTH_PORT), which refuses non-loopback
peers and serves one route, /. One consent is pending per daemon: a new call
replaces it, a matching redirect consumes it, it expires after ten minutes, and
the listener stops when nothing is pending. A redirect is redeemed only when its
state matches the pending one in constant time; a wrong or stale state gets a
plain page and changes nothing. The code then goes through the same guarded
exchange as every other consent: the grant must include calendar.events, and
Google's profile must name the configured mailbox (case-insensitive). Any refusal
stores nothing and shows a page that names no address. A good consent is stored
in the Calendar slot through the configured backend (keyring on macOS, file
elsewhere) by the same call the CLI makes, the granted cache is dropped, and the
calendar sync is kicked. Pages carry no scripts and no links, under
default-src 'none'. Logs name the event kind only, never the code, the state,
a token or an address. Anything on this machine can reach the listener while it is
up, but a code it delivers is useless without the PKCE verifier held in the
daemon, and only the redirect carrying the pending state is redeemed.
Credentials file writes. The calendar resolver is a third writer of the
credentials file beside the Gmail Read and Write stores, so every write goes
through one read-modify-write (credentials.rs:update_credentials_file) under a
process-wide mutex and an advisory lock on <credentials file>.lock (owner-only),
which also covers squelchd auth run beside a running daemon. A writer waits at
most 10 seconds for the lock and then fails like any other credential write. Each
write has its own temp name; temp files a crashed writer left are removed after a
minute.
Do not break
- Do not add a calendar route to the agent door or MCP, or put calendar text in a triage prompt, without a decision recorded in docs/CALENDAR.md §10.
- Do not log an event's title, place, description, organizer, attendee, link or a calendar id.
- Do not change a local calendar row before Google accepted the write, retry a write, or audit a write's text or a guest's address.
- Do not send
sendUpdates=allfrom anything but a request whosenotifyis literallytrue, giveNotifyanother default or a third value, or let a write leavesendUpdatesoff the URL. - Do not build an
attendeesarray from stored rows, from a truncated list, or for an edit that did not nameguests; do not widenmay_invitepast the user's own calendars, or acceptnotify: trueon a calendar it refuses; do not let anything derived from mail set guests ornotify. - Do not read an organizer that is present but unreadable as "no organizer"; the edit guard fails closed.
- Do not report a write whose answer was lost as refused (it may have happened), or run a write's Google call and audit inside the request future alone.
- Do not gate ghosts on
triage.sensitivity; use the sealed testsealed_messagesuses. - Do not give the assistant's calendar tool a notes or description parameter, or let anything it supplies but the title, the place and the times reach an event's body; do not trim an over-long title or place, or drop a field it does not know, in silence.
- Do not let the assistant's calendar tool call a write route, set
notify, skip the pane, replace a pane that holds anything, or be offered while the Calendar page is not; do not hand the model an event or a list of calendars. - Do not let a proposed pane take the caret, act on
↵or⌘↵before the user has clicked or typed in it, open while the assistant's bar is closed, or close mail being written or an email being read. Do not let an agent'sopen_draftbring Passband forward over an event pane that holds anything. - Do not offer a contact with no stored name under a list-shaped From name.
- Do not let
/client/calendar/peoplereturn somebody the user has not written to, read sealed, spam or auth-failed mail for its ranking, or move to the agent door. - Do not let a mail path load the Calendar slot, or the calendar loop load the Write slot: the token in either can do more than its job.
- Do not loosen
google_link, drop thefieldsmask ormaxAttendees, or ask for more of an attendee than the mask names in a request whose answer is stored. - Do not write the credentials file except through
update_credentials_file.
Reporting a vulnerability
Email braelyn@passband.app. We will acknowledge within 72 hours.