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

Part III — Connected apps: requirements, grants, and credential custody

A connection binds one app to one third-party provider, so that the host — never the app — may attach real credentials to outbound requests on that app’s behalf.

The concept is split in two, and the split is the substance of this part:

  • A connection requirement describes what the app needs: the provider, the auth kind, the credential fields, the registration walkthrough, endpoints and scopes, the header/query template, and the hosts it declares it will call. It is credential-free and is written at authoring moments — app build, an auth-touching edit, or a starter install.
  • A connection grant describes what the user allowed: approved status, the frozen host ceiling, the approval timestamp, and the revocation tombstone. It is written only on an explicit user approval act.

Credential values are in neither. They live in snug_secrets under the auth: namespace (§13) and never enter a requirement, a grant, an LLM prompt, or an app iframe.

An app holding requirements but no grant is in the normal pre-connect state: its network calls fail closed and the user is offered a connect flow. A conforming host never treats the presence of a requirement as permission to attach a credential.

A running app may never propose a connection. There is no frame, no SDK call, and no announce field through which app code can ask for a credential grant. (The open-url frames of §4 carry no credential seat and open nothing without the host’s own confirm.) Exactly three proposers exist:

ProposerChannelReview
the userSettings / connect CTAmanual entry
the app’s builder assistanta connection_requirement directive in the build conversationstrong, unless the registry rung pinned the values
the install actthe starter’s own connection.json, vouched at installalways strong (field-by-field)

Two obligations bind all three: a proposer may write declared rows only (a write aimed at an approved row stages instead — §12.3; a write aimed at a revoked row is refused outright, and reconnecting discloses the prior revocation), and approval is the only writer of grants.

One distinction this doctrine does not blur, stated here because readers reasonably ask: proposing a grant (never available to a model at runtime) is different from composing a request under an existing grant (available to the provider chat lane of §16, gated by the unchanged executor). The LLM can author a request; it can never widen what a request may reach, place a credential, or see one.

Starter vouching is a two-fact check: the app’s install_source must resolve to a bundled starter manifest AND the installed HTML (both the pinned factory version and the current version) must match the bundled starter bytes. A mismatch means the declaration is not vouched and is treated as absent.

PRAGMA user_version = 6 (Part II). Normative DDL is USERDB_DDL in userdb-schema.ts:

CREATE TABLE IF NOT EXISTS snug_connections (
app_id TEXT NOT NULL,
slot TEXT NOT NULL,
requirement_json TEXT NOT NULL,
requirement_version INTEGER NOT NULL,
provenance TEXT NOT NULL,
confidence REAL,
status TEXT NOT NULL,
pending_requirement_json TEXT,
imported INTEGER NOT NULL DEFAULT 0,
allowed_hosts TEXT NOT NULL DEFAULT '[]',
approved_at TEXT,
revoked_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (app_id, slot)
)
ColumnNormative meaning
app_idhost-assigned app identity. Never app-claimed.
slotstable connection id within the app, ^[a-z0-9][a-z0-9-]{0,39}$. Lowercase and dash-only by construction: SQLite compares bytes exactly, so a mixed-case form would fork one provider into two rows.
requirement_jsonthe requirement (§12.5), credential-free, schema-valid.
requirement_versioninteger, bumped on every persisted replacement whose canonical form differs.
provenanceregistry | inference | user_docs | starter | user. Drives review posture.
confidencemodel-derived confidence when provenance is model-derived. Display-only — never an approval input.
statusdeclared | approved | revoked. Exactly three values.
pending_requirement_jsona changed requirement staged against an approved row (§12.3).
imported1 when the row arrived by DB import and must be re-reviewed.
allowed_hoststhe FROZEN host union, computed at approval. Sorted, unique, normalized.
approved_at / revoked_atgrant timestamps. revoked_at is a tombstone and survives.

A conforming host MUST bound declared slots per app: AUTH_MAX_SLOTS_PER_APP = 8. Replacing an existing slot does not count against the cap; revoked tombstones do count, since they are exactly what a flooding attacker leaves behind.

“Needs re-approval” is derived, never a fourth status: status = 'approved' AND pending_requirement_json IS NOT NULL. A fourth value would require a write that moves a row out of approved to signal a pending change — the silent de-grant/re-grant the staging seat exists to prevent.

A conforming host exposes exactly five writers; each is the sole legal author of one transition, so “which writer may I call?” is answerable from status alone:

WriterLegal onEffectRefuses
putDeclaredConnectionabsent or declared rowinsert or replace a declared rowapproved (stage instead), revoked (explicit reconnect only), over-cap new slot
stagePendingRequirementapproved rowwrites pending_requirement_json onlyany non-approved row
approveConnectiondeclared rowfreezes allowed_hosts from requirement_json (never from a pending column storage may have forged), stamps approved_at, status → approved
reapproveConnectionapproved row with pendingre-validates the pending requirement (it is reachable without passing the stager), promotes pending → current, re-freezes hosts, clears pending
revokeConnectionapproved rowstatus → revoked, stamps revoked_at, keeps the row, wipes the auth:<appId>:<slot>:* credential slice

While a change is staged, the grant continues serving requirement_json and its old frozen hosts — a host MUST NOT bind the executor to pending_requirement_json. The user is shown the field-by-field diff old→pending, and only re-approval promotes it. There is no path by which an edit widens a host ceiling without a human seeing the diff.

Canonicalization (for requirement_version and import comparison): recursive key sort, whitespace-free JSON. Array order is preserved and significantregistration.instructions is a numbered walkthrough, fields is the wizard’s input order, scopes is what review renders. The canonical form is compared as a string, not a digest, so comparison is exact and synchronous.

A user DB is portable, so an imported file’s connection rows are untrusted input. On import, for each incoming snug_connections row:

  • byte-identical (app_id, slot, requirement_json, allowed_hosts) to a locally approved pre-import row → the local grant is restored. Identical rows carry no new attack surface, and blanket demotion would revoke every approval on each routine two-device sync and train approval fatigue.
  • anything else that validates strictly → lands declared with imported = 1, approved_at cleared, and allowed_hosts recomputed from the requirement rather than trusted from the column.
  • structurally unusable rows → dropped, and reported to the user.

Because branch 1 compares stored allowed_hosts bytes, host-union output stability is normative: deriveConnectionAllowedHosts MUST be sorted, unique, and normalized, or an otherwise-unchanged connection mass-demotes on the first sync pull.

Schema: connectionRequirementSchema (connection-requirement.ts). Strict at every level — an unknown key anywhere is a rejection, never a passthrough, so a seat added in a future version cannot ride in unreviewed on a channel that predates it.

connectionRequirement = {
slot, // ^[a-z0-9][a-z0-9-]{0,39}$
provider: { name, // ≤120, printable ASCII, NFC (§12.6)
homepageUrl?, docsUrl? }, // https, ≤300
kind, // api_key | bearer_token | basic_auth
// | oauth2_client_creds | oauth2_auth_code
// | linked_device | none — SEVEN kinds
fields?, // 1..8 × { key ^[a-z0-9_]{1,40}$, label ≤80,
// type text|secret|password|url,
// description? ≤200, placeholder? ≤60, required? }
registration?: { consoleUrl?, // https, ≤300
instructions? }, // ≤10 × ≤300, PLAIN TEXT (§12.7)
endpoints?, // authorize/token/refresh/revoke, https, ≤300
scopes?, // ≤64 × ≤200
pkce?, authorizeParams?,
request?: { headerTemplate?, // ≤8 entries, name ^[A-Za-z0-9-]{1,64}$,
// value ≤300 (§12.8)
queryTemplate? }, // ≤8 entries, name ^[A-Za-z0-9_.\[\]-]{1,64}$,
// value ≤300 (§12.9)
userLayer?, // registry-synthesized ONLY (§12.10)
lanHost?: { class, // 'rfc1918-ipv4-literal' (single-member union; additive)
label }, // ≤80 (§12.11)
declaredApiHosts?, // 1..32 × ≤253, bare hostnames, normalized (IDNA toASCII,
// lowercase). Presence rules: §12.11 (XOR with lanHost)
testRequest? // { method: 'GET', pathAndQuery ≤200, leading '/' }
}

Seven kinds. The kind set grew from six to seven when linked_device was appended (never inserted — a stored row’s kind must never be re-read as a different kind). Widening the set remains a schema version change, not a configuration change. Three kinds carry coherence rules enforced at parse:

  • none — the keyless provider: a public API needing no credential but still needing a host ceiling. It MUST carry no fields and no request template (either placement); declaredApiHosts stays required — keyless means “no credentials”, never “no host gate”. A none connection with no grant still fails closed.
  • linked_device — a provider that authenticates a device session rather than a request (Part V). It MUST declare at least one credential field (the minted helper token’s slot — a row with no field would parse cleanly and then fail mid-send); it MUST carry no endpoints (a linked device never redirects, and a tolerated refreshUrl would widen the derived ceiling for a kind that cannot use it); and it MUST carry no lanHost seat (a helper is a capability, not a network host — §18).
  • oauth2_auth_code / oauth2_client_credspkce defaults to true (public-client posture).

declaredApiHosts is a request for a ceiling, never the ceiling. The frozen ceiling is snug_connections.allowed_hosts, derived at approval as: declaredApiHosts ∪ every OAuth endpoint host (authorize, token, refresh — it receives long-lived credentials — revoke) ∪ the embedded userLayer’s declared hosts and endpoint hosts. lanHost contributes no host; a pre-collection LAN row derives an empty ceiling, which refuses every host — the correct answer before an address exists.

12.6 Provider name: the confusable guard, and its stated limits

Section titled “12.6 Provider name: the confusable guard, and its stated limits”

provider.name is printable ASCII (U+0020–U+007E) and NFC-normalized. It stops non-ASCII homoglyphs (ѕpotify with Cyrillic U+0455; fullwidth Latin; zero-width and bidi characters) and registry-key evasion (a homoglyph name normalizes to a different registry key, misses the borrow ban of §12.12, and keeps attacker-authored endpoints while looking pinned).

It does not stop pure-ASCII lookalikes: 5potify and C0inbase are accepted, and no charset rule can reject them without rejecting legitimate names. Those are carried by the borrow ban’s host-intersection trigger and by the review screen’s provenance disclosure. A conforming host MUST NOT present this guard to users as protection against lookalike names.

12.7 Registration walkthroughs are plain text

Section titled “12.7 Registration walkthroughs are plain text”

registration.instructions are rendered as a numbered list of plain text — never as HTML, never as links. They arrive from an untrusted channel and are displayed with the host’s own chrome and legitimacy; markup here would be phishing wearing the host’s clothes. consoleUrl is https-only and must be rendered with its full host visible.

12.8 Header templates and the pinned helper enum

Section titled “12.8 Header templates and the pinned helper enum”

request.headerTemplate places credentials into outbound requests. The schema bounds the envelope (entry count, header-name charset, value length); the content rule depends on the sibling fields list, so it is a separate lint a conforming host MUST apply before a requirement is reviewed, stored, or rendered.

A template value may reference only:

  • a declared field key from this requirement’s own fields;

  • a pinned request token: request.method · request.url · request.pathAndQuery · request.body · request.timestamp;

  • a pinned helper — the enum has five members with fixed arities:

    HelperAritySemantics
    timestamp0unix seconds, memoized per render pass (§12.8.1)
    base641UTF-8 in → base64 out
    hmac_sha2562hex HMAC-SHA256
    hmac_sha256_b642–6base64(HMAC-SHA256(base64decode(secret), concat(parts))) — the three transforms fused, because the composition is otherwise inexpressible in a flat grammar; the message tail is variadic because real prehash strings are multi-part
    cdp_jwt2a provider-scoped Ed25519 JWT signer (Coinbase CDP). Both arguments MUST be declared field keys — never quoted literals, never request tokens. Ed25519 only; no algorithm negotiation.
  • a quoted literal.

The grammar is flat: a helper call is a placeholder form, never an argument form. A conforming host MUST reject a nested call and MUST NOT evaluate it. Helpers not in the enum do not exist; adding one is a reviewed spec-level change, never a configuration knob — an unused helper is reachable signing surface.

The lint’s load-bearing job is making the render engine’s unknown-token-as-literal fallback unreachable: {{hmac_sha256(api_secrt, request.body)}} — one transposed character — would silently sign the eight-byte string "api_secrt" instead of the credential. A conforming host rejects that at review time, not at signing time.

A quoted argument is a literal in the ENGINE, not only in the lint. A conforming host MUST render a quoted argument verbatim — it MUST NOT resolve the quoted text against credential fields or request tokens. {{base64('api_key')}} MUST render base64("api_key"), the literal token text, even when api_key is a declared field holding a live credential. A host that strips the quotes and resolves the bare text emits the credential from a template that passed review precisely because the quotes made it look inert. This is a credential-disclosure requirement, not a formatting one.

12.8.1 request.timestamp and the one-timestamp rule

Section titled “12.8.1 request.timestamp and the one-timestamp rule”

The timestamp MUST be evaluated once per render pass and memoized. Two independent evaluations can straddle a second boundary, so a signed timestamp and a sent timestamp would disagree intermittently.

request.timestamp is the token that makes the memoization reachable: every signing scheme of this shape sends the timestamp in one header and signs it inside another, so the timestamp must be writable in argument position — and the helper form timestamp() cannot go there, because the grammar admits no nesting. It is a render fact, not a request fact: minted during the render pass, served from the same memoized value as {{timestamp()}}. The conformance property: an HMAC recomputed independently from the timestamp value the host actually sent equals the signature the host sent.

CB-ACCESS-TIMESTAMP: {{request.timestamp}}
CB-ACCESS-SIGN: {{hmac_sha256_b64(api_secret, request.timestamp, request.method, request.pathAndQuery, request.body)}}

request.queryTemplate places credentials into the query string — the placement some providers require and header templates cannot express (OpenWeather’s ?appid=, CoinGecko’s demo key). Query-parameter names get their own charset, ^[A-Za-z0-9_.\[\]-]{1,64}$ (real query names carry underscores, dots, and bracketed forms the header rule rejects); both charsets still exclude every character that could smuggle URL structure or template metacharacters. Values follow §12.8 in full: same bounds, same vocabulary, same flat grammar — and a conforming host MUST derive both templates’ lints from one resolution of the declared field keys.

Rendered query values are credentials inside a URL, which makes the URL itself secret-bearing. Two host obligations follow:

  • Placement after the ceiling. Query credentials are rendered into the URL only after the frozen-ceiling host checks have passed, so the ceiling decision is always made against the app-supplied URL.
  • Scrubbing is enumerated, not aspirational. The credentialed URL MUST NOT appear in any surface the app, the model, or the user’s logs can read: fetch-error messages, response echo surfaces, LLM-visible inspectors, host UI. The request URL returned to the app is the URL the app asked for, never the credentialed one.

12.10 userLayer is registry-synthesized only

Section titled “12.10 userLayer is registry-synthesized only”

The embedded org→user second layer keeps two-layer providers expressible. It is rejected on the assistant, manifest, user-docs and user channels — on the basis of where it came from, never what it says. A userLayer pointing at genuine provider URLs is still a model-authored seat, and the next one will not point at a genuine provider.

12.11 LAN-class providers: lanHost and the host XOR

Section titled “12.11 LAN-class providers: lanHost and the host XOR”

A provider whose API lives on a device on the user’s own network — a Philips Hue bridge is the archetype — has no host any registry or author can pin: the address belongs to the user’s router. lanHost is a DECLARATION THAT A HOST WILL BE COLLECTED, never a host:

lanHost = { class: 'rfc1918-ipv4-literal', label: 'Bridge IP address' }

class is a single-member union today. Future device classes are additive — a new literal plus its own validator and its own admission rule, never a widening of this one. (A linked-device helper is deliberately NOT a lanHost class — §18.)

The host XOR (normative). Exactly one host source:

lanHostdeclaredApiHostsverdict
absent1..32 hostsaccepted — the ordinary shape
absentabsent or []refused (declaredApiHosts required)
presentabsentaccepted — the pre-collection shape a LAN registry entry emits
presentexactly one host of the declared classaccepted — the post-collection shape the wizard writes
presenta host outside the class, two or more hosts, or []refused

A public host beside a lanHost would freeze a public host into a ceiling the review screen presents as “a device on your own network”. Host obligations: (a) a pre-collection LAN row derives an empty ceiling, so the binding wizard order is collect the address → approve the row → freeze the ceiling → pair; (b) the schema is the FIRST of two seats that refuse an off-class host — the registry-borrow path re-validates the class independently, because a requirement can reach admission without passing this schema; (c) nothing platform-conditional is persisted: a LAN row opened on a web hub is disclosed as desktop-only, never refused or rewritten.

LAN transport obligations (desktop hosts): first pairing records a TOFU certificate pin — the SHA-256 fingerprint of the device’s leaf certificate — in the connection’s dynamic state (§13); every later request verifies against the pin and fails closed on mismatch. The pairing window is a disclosed residual: an attacker already on the LAN at the moment of first pairing can be pinned instead of the device.

A host MAY keep a registry of pinned providers. Where it does, five rules govern it:

The borrow ban. A requirement that names a registry provider, or whose declaredApiHosts intersect a registry entry’s hosts, has the registry’s pinned values substituted for its own: hosts, endpoints, registration block, and the display name. Declared values for those seats are discarded, not merged. Both triggers are required (name-match alone is evaded by renaming; host-match alone is evaded by trading on a brand while declaring no overlapping host), and the ban is kind-agnostic — an api_key requirement naming a known OAuth provider must not borrow its legitimacy while pointing the credential at a host of its choosing. A borrow MUST be surfaced to the user: “these values came from the host’s registry, not from the app”. For a LAN entry the borrow path preserves the declaration’s collected address (the registry has nothing to substitute) and re-validates its class independently of the schema.

Pinned scopes. A registry entry MAY pin a provider’s scope list. A pin is entry-level, never per-flow (privilege breadth is brand identity); it replaces any authored list on a borrow hit; it renders verbatim on review and re-approval diffs; and a pin change on an approved row always re-consents — never silently promotes. The governing principle, stated once: prefer a scope the token cannot exceed over a rule the app promises to follow. A capability excluded from the pinned scopes is structurally unreachable regardless of app code — the strongest control this part offers.

Auth options. An entry MAY offer alternative complete credential flows (e.g. PAT vs OAuth app). An option carries its own kind, fields, endpoints, and walkthrough — but no identity seats: display name, hosts, aliases, lanHost, and scopes belong to the entry, because which hosts may receive a credential is a per-provider decision, never a per-flow one. Substitution honors the option whose pinned field list the declaration matches; no match means the default.

Pairing families. Providers whose credential is obtained by ceremony rather than typed carry a pairing seat in the registry entry — never on the persisted requirement row. A requirement seat carrying claim mechanics would be a channel through which a prompt-injected declaration aims an uncredentialed request; registry data is host-shipped and reviewed. Three families exist, and every one carries a required verify probe (verify-before-claim: prove the counterparty is live and speaking the expected protocol before any credential is written):

FamilyShapeReference example
exchangea local pairing exchange (e.g. press-button + POST) against the collected LAN addressHue
device-linkstart → QR → poll; the poll releases a once-minted helper token (Part V)WhatsApp
token-claimthe user pastes a one-time setup token; the host decodes a claim URL, POSTs it once, and receives the durable access credentialSimpleFIN

Token-claim obligations: the decoded claim URL AND the returned access URL are both checked against the row’s frozen ceiling (https-only, exact host, no userinfo, redirects refused on both hops); the returned path must match the registry’s pinned access path exactly; credentials and the claimVerifiedAt marker are committed together (one write, no window); every refusal message is a fixed sentence, never derived from pasted bytes. Note the family is registry data over an ordinary kind (basic_auth for SimpleFIN) — pairing is not a kind.

Capability facts. browserCallable is tri-state: true/false are documented facts a wizard may disclose; absent means unknown and is disclosed as unknown — never rendered as “works”. A desktop OAuth redirect posture the registry does not vouch for means the wizard refuses honestly at entry, never guesses. A loopback-class redirect posture is representable only beside PKCE — pkce: false plus a loopback redirect leaves auth-code injection undefendable, so the combination is refused.

Web-surface capability facts (since 1.0). A registry entry MAY carry two further render-time seats: webRedirectPosture (sole member today: 'origin-callback' — the provider’s client registration can accept the connecting web origin’s /oauth/callback as an exact authorized redirect URI) and webRegistration (the web-surface console walkthrough). Structural rule: webRegistration and webRedirectPosture require each other — a posture without its walkthrough is refused, and vice versa — and both require an OAuth kind. Like the desktop posture, these are registry data resolved at wizard render time — they are never persisted and are never part of a ConnectionRequirement; fields, scopes, hosts, and templates remain the row’s, always. Absence semantics deliberately differ from the desktop posture: an absent web seat does NOT refuse — the entry-level walkthrough serves the web surface too. A pinned web walkthrough binds to the row’s endpoints, not to the provider’s name: the override applies only when the row’s authorize and token URLs byte-match the registry pin, and a row that merely carries a pinned provider’s name with endpoints of its own keeps its own registration under the copy-only honesty rules — a reviewed walkthrough must never dress a flow whose token exchange goes somewhere the registry never vouched for.

12.13 The connection_requirement directive

Section titled “12.13 The connection_requirement directive”

Requirements reach the host from a build conversation as a connection_requirement directive: { v: 1, kind: 'connection_requirement', requirement, confidence?, provenance? }. confidence and provenance on the wire are display-only: the host computes provenance from the channel it actually received the directive on and recomputes confidence from the ladder rung it resolved; no gating decision reads the claimed values. A registry-resolved proposal MAY carry the entry’s alternative flows for the user to pick between; the pick is reviewed like any declared requirement.

Credential values live in snug_secrets and nowhere else:

KeyHolds
auth:<appId>:<slot>:<fieldKey>one credential value
auth:<appId>:<slot>:_connectiondynamic connection state for that slot (below)
auth:_flow:<flowId>in-flight authorization state (the slot rides in the payload)
auth:_state_hmacapp-agnostic OAuth state-signing key

The rules of §9 carry over verbatim: secrets exist in the local runtime copy, are stripped from hub-origin pushes and default exports (VACUUMed so freed pages leak nothing), and never enter localStorage/sessionStorage, any frame posted to an app iframe, or any hub request.

The _connection state is not bookkeeping; four of its fields are security markers:

status: 'pending' | 'connected' | 'expired' | 'error'
obtainedAt?, expiresIn?, scopesGranted?, lastError?
lanPin?: { fingerprint, cn? } // TOFU leaf-cert pin; cn is diagnostic, never a trust input
lanVerifiedAt?: number // verify-before-claim marker — LAN family
linkVerifiedAt?: number // verify-before-claim marker — linked-device family
claimVerifiedAt?: number // verify-before-claim marker — token-claim family

The three verify markers are deliberately separate fields: each describes a different proof about a different transport, and collapsing them would let a stale marker from one family vouch for another. Absence of the marker on a connected row means pairing is still owed — the wizard self-repairs rather than requiring a data migration.

Two rules specific to connections:

  • Revocation wipes the slot’s credential slice (auth:<appId>:<slot>:*) while keeping the row as a tombstone.
  • A requirement never contains a credential value. It contains field definitions. A host that finds a credential-shaped value inside a requirement rejects the requirement.

OAuth obligations. Authorization-code flows default to PKCE (S256); the state parameter is HMAC-signed with auth:_state_hmac and verified constant-time; token, refresh, and revoke POSTs are ceiling-checked like any other credentialed call; access tokens refresh transparently inside a 60-second expiry skew. Every secret submitted in an OAuth POST body (client_secret, refresh_token, code, code_verifier, token) joins the scrub candidate set for that seat’s error handling (§14.3).

One seat — and only one — both reads credential values and calls fetch. Everything an app-originated network request passes through is this ordered gate sequence, and the order is normative:

#GateObligation
1Shapehand-written validation, fail closed; body on GET/HEAD rejected; body byte-capped
(resolution)connection-relative URLs (§15) resolve here — translation only, grants nothing
2Bindingthe acting appId is HOST-assigned; the request carries no identity field
3Granta connection row must exist with status = 'approved'; imported rows refuse with the distinct NET_IMPORTED_UNAPPROVED; the row must be the unique approved grant claiming this host — two claimants refuse (NET_AMBIGUOUS_CONNECTION), never tiebreak
4Ceilinghttps-only + exact-hostname membership in the frozen ceiling, punycode-normalized on both sides. One admission: http to an RFC-1918 IPv4 literal already inside the ceiling, under an explicit desktop transport policy; absent policy is byte-identical to the browser profile
5SSRF guardloopback, RFC-1918, link-local/metadata, CGNAT, IPv6 forms refused even for ceiling members; malformed fails closed. Exactly two classes stand this gate down: the LAN class of §12.11 (under the desktop policy) and the sidecar symbolic host of Part V — each stands down this gate only
6Confirmevery mutating method (POST/PUT/PATCH/DELETE) requires the user’s confirmation naming host, method, and URL, before any credential is read. The confirm seat carries optional slot and body so a standing grant (§17) can decide on what is being sent; their absence on the wizard’s probe path is what keeps standing grants off probes
6aLocal transportsa send to a local helper transport (Part V) departs here — after gate 6, never before. Transport shape never excuses consent: speech in the user’s name is gated by the confirm gate and nothing else. The helper path injects credentials itself and applies gates 7 and 10’s obligations; a missing helper transport refuses, it never falls back to the network
7Stripapp-supplied credential-shaped headers are stripped (C1 belt to the schema’s braces)
8Injectioncredentials attach host-side per kind (template render / OAuth bearer), ceiling-checked internally
9Fetchredirect: 'manual' on every transport; any 30x is NET_REDIRECT_BLOCKED, never followed
10Readresponse read under the 1 MiB cap while reading (overflow → terminal NET_SIZE_EXCEEDED); injected credential values scrubbed from body and whitelisted header values, raw and percent-encoded; headers whitelist-filtered

There is no strictness knob anywhere in this pipeline. Host-bound injection is always strict; a security property that can be disabled by configuration will be disabled in some deployment, and that deployment is the one that gets attacked.

The value scrub is exact-substring over the values this request injected (raw and percent-encoded). A provider that re-encodes a credential (base64, hex, split fields) defeats it by design; the frozen ceiling — who can receive the secret at all — is the primary wall, and the scrub is a second line. Hosts MUST NOT present the scrub as more.

14.2 LLM-bound delivery is scrubbed harder than app-bound

Section titled “14.2 LLM-bound delivery is scrubbed harder than app-bound”

The executor’s scrub is designed for app-bound delivery, where a resolved LAN address in a response body is the provider’s own data surface. When a result is bound for a model (the provider chat lane, §16), the host MUST additionally scrub every RFC-1918 IPv4 literal unconditionally — LLM-bound delivery exports the body to a third-party API, and a per-class rule would be one mis-wire away from leaking.

When a provider’s error body must be shown to a human, a conforming host bounds volume and shape — up to 160 chars, and only a named field of a recognized error envelope (or, failing that, a best-effort head that is never markup/structure) — after value-scrubbing with the candidate set only the calling seat can build: gate 10’s injected values at the executor seat; the submitted form parameters at the OAuth seat. Order is scrub first, extract second, and the extractor MUST re-scrub its own output — JSON.parse decodes \u escapes and can reconstitute a correctly-scrubbed secret. The extractor is explicitly not a credential guard; the value scrub is the control.

An app can address its OWN declared connection by slot instead of by a host it cannot know:

snug-connection://<slot><pathAndQuery>

Grammar (connection-url.ts): scheme match is case-insensitive and everything after is exact; // is required; a path is required (apps address resources, not devices); the slot must match CONNECTION_SLOT_RULE (imported, never restated); pathAndQuery may not begin // and may not contain \ or #. The parse result is three-way — not a connection URL (fall through to the literal-URL path untouched), malformed (refused loudly, never guessed at), or ok.

Resolution happens after gate 1 and grants nothing: the slot selects the connection, whose frozen ceiling must contain exactly one host (NET_AMBIGUOUS_CONNECTION otherwise — a symbolic address must have one meaning); the URL is rebuilt against that host by URL composition, never string concatenation; and the resolved host is re-checked against the ceiling canonically. Refusals: unknown slot → NET_INVALID_REQUEST; unapproved → NET_NOT_APPROVED. The resolved host is disclosed to the user (confirm dialog) and never to the app — refusal messages are host-clean, and error-path text is scrubbed of resolved forms.

This is the addressing mode every starter SHOULD use: installed apps never receive rebuilds, so a host baked into shipped HTML is a liability the slot indirection removes.

A conforming host MAY offer a chat surface that composes provider requests on the user’s behalf (Part IV classifies its intents). Where it does:

  • The turn’s context receives connection facts — slot, provider name, scope summary, symbolic or public host identity — never credentials, and never a resolved LAN address (LAN rows render symbolically; dotted-decimal RFC-1918 literals are scrubbed from rendered context).
  • Requests the model composes execute through the unchanged executor of §14 — same ceiling, same confirm gate, same injection, same scrub. The model is a request author, never a grant author: zero ceiling matches means refusal plus a connect CTA, exactly as it does for app code.
  • Mutating-call confirms MAY render inline in the chat surface, but an inline card is presentation, not authority: a card’s resolution becomes an ordinary user message, and the only approval seat remains the executor’s confirm gate. Concurrent confirms queue FIFO — a second confirm MUST NOT orphan the first’s resolver — and an aborted turn denies its own parked confirms by reference identity, so deny-after-decide is a no-op.
  • LLM-bound results are scrubbed per §14.2.

Status: provisional. The gate contract below is normative for any host that offers standing approvals; the arming channel is deliberately unspecified (the frame vocabulary has no seat for it, and minting one is a future wire revision), and the reference implementation’s grant store is in-memory (a reload disarms). This section pins the shape so implementers do not invent worse ones; it does not claim the surface is finished.

A standing approval is a pre-recorded answer to the confirm gate for a narrow, frozen scope — the mechanism behind “armed auto-reply”. Rules:

  1. Arming is an explicit user gesture on a host surface, never an app or model act.
  2. Scope is frozen at arm time: one connection slot + one target (e.g. one thread) + one trigger class. Widening requires disarm + re-arm; no request can talk its way wider.
  3. The standing gate is a separate gate consulted BEFORE the session confirm gate, and it wraps that gate rather than widening it — the session gate’s key (app, host, method) cannot tell one thread from another, so reusing it would turn one remembered send into a blanket approval. Anything outside the frozen scope returns no opinion and falls through to the ordinary confirm; the refusal to decide is never itself an approval.
  4. The target is derived from the request with two independent sources that MUST agree (e.g. path segment and body field). Trusting either alone is a vulnerability with two spellings; disagreement is refused, never resolved.
  5. Guardrails ride the grant and are host-enforced: a rate cap over a rolling window (the send is recorded before the grant answers, or the cap does not hold), quiet hours, a kill switch, and — v1 — one armed target at a time.
  6. Every unattended act is journaled and the armed state is disclosed wherever the connection is disclosed.

Armed is a recorded answer, not a bypass.