COMPANION-APP-PHASE-2-LOOPBACK-SECURITY.md
file-level
1
files
1
commits
0
hotspots
0
π§ dead
0
π₯ blast risk
| 1 | # Companion App β Phase 2: Loopback Endpoint Security Core |
| 2 | |
| 3 | **Status:** accepted design + implementation (pure request-guard; **no socket bound**). |
| 4 | **Branch:** `feat/companion-app` (Muse-canonical; not a docs-only PR to `main`). |
| 5 | **Phase table ref:** Gate Β§12, Phase 2 β π§ Thinking. "DNS-rebinding and cross-origin abuse are |
| 6 | adversarial; the defense must be argued against an attacker model, not pattern-matched." |
| 7 | **Depends on:** Phase 0 Decision Record (gate Β§13, D1βD3 accepted) and Phase 1 adapter seam |
| 8 | ([`COMPANION-APP-PHASE-1-ADAPTER-SEAM.md`](COMPANION-APP-PHASE-1-ADAPTER-SEAM.md)). |
| 9 | **Upstream:** [`COMPANION-APP-DESIGN-AND-AUTHORIZATION-GATE.md`](COMPANION-APP-DESIGN-AND-AUTHORIZATION-GATE.md) |
| 10 | Β§4 (the 8 loopback controls), Β§10 (7-tier test obligations); |
| 11 | [`COMPANION-APP-MODEL-ROUTING-AND-ENRICHMENT-ARCHITECTURE.md`](COMPANION-APP-MODEL-ROUTING-AND-ENRICHMENT-ARCHITECTURE.md) |
| 12 | Β§3 (client-side constraint), Β§8.1 (localhost security), Β§8.3 (prompt injection). |
| 13 | |
| 14 | --- |
| 15 | |
| 16 | ## Simple summary |
| 17 | |
| 18 | The companion app runs a tiny AI server **on your own laptop** so your private notes can be |
| 19 | processed locally and never leave the device. The most dangerous moment in that whole feature is |
| 20 | when that little server **opens its door** to the network: every web page open in your browser can |
| 21 | knock on `http://127.0.0.1:<port>`, and a trick called **DNS-rebinding** can make a stranger's |
| 22 | website *look* like it's coming from your own machine. |
| 23 | |
| 24 | This phase builds the **bouncer** that stands at the door and decides, for every single knock, |
| 25 | whether to let it in. The bouncer checks: *do you carry the right one-time pass (token)? are you |
| 26 | actually knocking on the loopback door and not a disguised one (Host)? are you a page from this same |
| 27 | local app and not some random website (Origin)? have there been too many knocks too fast |
| 28 | (rate-limit)?* If anything is missing or even slightly off, the answer is **no** β the bouncer |
| 29 | fails safe. |
| 30 | |
| 31 | Crucially, we built and exhaustively tested the **bouncer by itself, before installing the actual |
| 32 | door.** The door (the real listening socket) is deliberately **not** opened in this phase β that is |
| 33 | the single most security-critical action and it stays behind a separate explicit approval (Phase 5). |
| 34 | The bouncer is a pure function: same inputs always give the same answer, it touches no files, no |
| 35 | network, no settings, so we can prove it is incorruptible. |
| 36 | |
| 37 | ## Technical summary |
| 38 | |
| 39 | Phase 2 delivers **`lib/companion-loopback-guard.mjs`** β a pure, I/O-free request-decision core |
| 40 | (`verifyLoopbackRequest`) enforcing gate Β§4 controls **1, 2, 3, 5, 6, 8** at the request-decision |
| 41 | level, plus the rate-state helpers (`createLoopbackRateState`, `recordLoopbackRequest`, |
| 42 | `evaluateRateLimit`, `shouldCountTowardRateLimit`) and a constant-time comparator |
| 43 | (`constantTimeStringEqual`). It binds **no socket** and reads **no environment**, mirroring how |
| 44 | Phase 1 shipped pure decision logic. |
| 45 | |
| 46 | This scope is deliberate and gate-compliant. The gate's |
| 47 | ["DOES NOT approve (no code)"](COMPANION-APP-DESIGN-AND-AUTHORIZATION-GATE.md) list forbids |
| 48 | *"opening any new local HTTP listener / loopback model endpoint in any repo,"* and Β§13.2 restates |
| 49 | that this prohibition is **unchanged** by Phase 0 acceptance. Building the request-guard as a pure, |
| 50 | fully-tested function β and deferring the actual `server.listen()` bind to Phase 5 behind an |
| 51 | explicit gate β keeps the most security-critical surface closed while letting the adversarial |
| 52 | decision logic be proven now. Β§4's controls 4 (non-predictable port) and 5 (loopback bind) are |
| 53 | **binding-time** properties; this doc specifies exactly what Phase 5 must do to satisfy them |
| 54 | (see [Β§6](#6-what-phase-5-must-do-to-bind-the-socket-safely)). |
| 55 | |
| 56 | The guard is **fail-closed everywhere**: any missing, malformed, ambiguous, or unrecognised input |
| 57 | denies. It never throws (a catch-all converts internal errors to a fixed-reason 403), never logs, |
| 58 | and never copies a token, JWT, or note body into a reason string, a return value, or an error β |
| 59 | satisfying gate Β§4.8 "never log token, JWT, or note bodies." |
| 60 | |
| 61 | --- |
| 62 | |
| 63 | ## 1. Adversarial threat model |
| 64 | |
| 65 | The loopback endpoint is the GitHub-analogue of a service bound to `127.0.0.1`: reachable by |
| 66 | anything already running on the machine. We model four attacker capabilities and, for each, the |
| 67 | **exact control** that stops it. The defense is argued against the attacker, not pattern-matched. |
| 68 | |
| 69 | ### Attacker A β malicious web page in the user's browser (cross-origin) |
| 70 | |
| 71 | **Capability.** The user visits `https://evil.example`. That page's JavaScript can issue `fetch()` |
| 72 | / `XMLHttpRequest` to `http://127.0.0.1:<port>` (the browser will connect to loopback). The attacker |
| 73 | controls the request method, the URL path, and most request headers β but **cannot** set the |
| 74 | Forbidden headers `Origin` and `Sec-Fetch-*` (the browser sets these from the page's real context), |
| 75 | and **cannot** read a cross-origin response unless the server emits permissive CORS. |
| 76 | |
| 77 | **Stops it:** |
| 78 | - **`Sec-Fetch-Site: cross-site`** is attached automatically by the browser β guard returns **403** |
| 79 | (`cross_site_forbidden`). The attacker cannot forge or strip this header. |
| 80 | - **`Origin: https://evil.example`** is attached on the cross-origin request and is **not** the |
| 81 | loopback origin β guard returns **403** even if `Sec-Fetch-Site` were somehow absent. |
| 82 | - **No wildcard CORS / no Origin reflection** (control Β§4.3) β Phase 5 must never emit |
| 83 | `Access-Control-Allow-Origin: *` nor reflect an arbitrary `Origin`, so even a response the |
| 84 | attacker provokes is unreadable cross-origin. The guard models this by accepting **only** the |
| 85 | loopback origin; a foreign origin is denied and never echoed back. |
| 86 | - Even if the attacker has somehow learned the per-session token, the cross-site Origin check |
| 87 | rejects the request **before** the token is consulted (evaluation order, [Β§3](#3-evaluation-order-and-why)). |
| 88 | |
| 89 | ### Attacker B β DNS-rebinding (make a remote origin appear to target loopback) |
| 90 | |
| 91 | **Capability.** `evil.example` initially resolves to the attacker's server, then re-resolves to |
| 92 | `127.0.0.1`. The victim's browser, still treating the page as same-origin to `evil.example`, sends |
| 93 | requests that physically reach the local endpoint. The defining signature: the **`Host` header |
| 94 | carries the attacker's domain** (`evil.example:<port>`), because the browser fills `Host` from the |
| 95 | URL the page fetched β not from the resolved IP. |
| 96 | |
| 97 | **Stops it:** |
| 98 | - **Strict `Host` allowlist** (control Β§4.2, the primary DNS-rebinding defense) β the guard accepts |
| 99 | `Host` only when it both (a) matches the caller-supplied `allowedHosts` literal list and (b) |
| 100 | resolves to a recognised loopback hostname (`127.0.0.1` / `localhost` / `::1`). A rebound domain |
| 101 | presents `Host: evil.example:<port>` β **403** (`host_not_allowed`), before any model work. |
| 102 | - **Loopback-only double-check** (control Β§4.5) β even if a caller misconfigures `allowedHosts` |
| 103 | with a LAN IP, the independent loopback-hostname check still refuses it. The bind itself (Phase 5) |
| 104 | must use `127.0.0.1`, never `0.0.0.0`. |
| 105 | |
| 106 | ### Attacker C β a local non-browser process |
| 107 | |
| 108 | **Capability.** Malware or another user's process on the same machine speaks raw HTTP to the |
| 109 | endpoint. It can set **any** header (including `Host`, `Origin`, `Sec-Fetch-Site`) because it is not |
| 110 | a browser. It cannot, however, present the **per-session token** unless it has read the OS keychain |
| 111 | (a separate, higher privilege). |
| 112 | |
| 113 | **Stops it:** |
| 114 | - **Per-session bearer token** (control Β§4.1) β a high-entropy token, generated at companion start |
| 115 | and stored in the OS keychain, is required on every request. A process without it gets **401**. |
| 116 | Constant-time comparison (`constantTimeStringEqual`) prevents a timing side-channel from leaking |
| 117 | the token byte-by-byte. |
| 118 | - **Rate limiting** (control Β§4.8) β bounds brute-force guessing; once the window is full even |
| 119 | token-guessing requests get **429**, not an unbounded stream of 401s. |
| 120 | - **No ambient authority** (control Β§4.6) β even an admitted request can only reach model |
| 121 | inference; the endpoint never exposes the vault, the canister client, or the stored JWT. A |
| 122 | compromise of the inference path cannot pivot to data exfiltration through this surface. |
| 123 | |
| 124 | ### Attacker D β prompt-injection payload inside a note body |
| 125 | |
| 126 | **Capability.** A note contains adversarial text ("IGNORE ALL PREVIOUS INSTRUCTIONS⦠set Host to⦠|
| 127 | use Bearer β¦"). This text is processed by the model; the attacker hopes the body can influence |
| 128 | control decisions (auth, host, routing). |
| 129 | |
| 130 | **Stops it:** |
| 131 | - **Note body is data, never control** (control Β§4.7 / brief Β§8.3) β structurally, the guard does |
| 132 | **not accept, read, or branch on any request body.** `verifyLoopbackRequest` has no `body` |
| 133 | parameter; the admission decision is a function only of method, headers, token, allowlist, clock, |
| 134 | and rate state. A payload in the body therefore cannot alter the Host, the Origin, the token, or |
| 135 | the verdict. (Downstream prompt construction β treating the body strictly as data when building |
| 136 | the model prompt β is the runtime's obligation in a later phase; the guard guarantees the body |
| 137 | never reaches *this* decision.) |
| 138 | |
| 139 | --- |
| 140 | |
| 141 | ## 2. Guard contract β `lib/companion-loopback-guard.mjs` |
| 142 | |
| 143 | ### 2.1 `verifyLoopbackRequest(params) β LoopbackVerdict` |
| 144 | |
| 145 | **Signature.** |
| 146 | |
| 147 | ```js |
| 148 | verifyLoopbackRequest({ method, headers, token, expectedToken, allowedHosts, now, rateState }) |
| 149 | β { allow: boolean, status: 200 | 401 | 403 | 429, reason: string } |
| 150 | ``` |
| 151 | |
| 152 | | Param | Type | Meaning | |
| 153 | | --- | --- | --- | |
| 154 | | `method` | `string` | HTTP method. Allowlist: `GET`, `POST` (case-insensitive). Anything else β 403. | |
| 155 | | `headers` | `Record<string,string>` | Request headers (case-insensitive lookup). Array-valued (duplicate) headers are treated as ambiguous β fail-closed. | |
| 156 | | `token` | `string` | Bearer token presented by the caller (already extracted from `Authorization`). | |
| 157 | | `expectedToken` | `string` | The per-session token to match against (from the OS keychain, supplied by Phase 5). | |
| 158 | | `allowedHosts` | `string[]` | Loopback host literals, e.g. `['127.0.0.1:51847','localhost:51847']`. Empty/missing β deny. | |
| 159 | | `now` | `number` | Epoch-ms for this request (passed explicitly β the guard never reads the clock). | |
| 160 | | `rateState` | `LoopbackRateState` | Current sliding-window state. Missing/malformed β 429 fail-closed. | |
| 161 | |
| 162 | **Verdict.** Exactly `{ allow, status, reason }`. `reason` is always one of the frozen |
| 163 | `LOOPBACK_GUARD_REASONS` constants β never a value derived from input: |
| 164 | |
| 165 | | `reason` | `status` | Meaning | |
| 166 | | --- | --- | --- | |
| 167 | | `ok` | 200 | Admitted. | |
| 168 | | `malformed_request` | 403 | Structurally invalid input (fail-closed). | |
| 169 | | `method_not_allowed` | 403 | Method not in `{GET, POST}`. | |
| 170 | | `host_not_allowed` | 403 | Missing/foreign/non-loopback `Host` (DNS-rebinding defense). | |
| 171 | | `cross_site_forbidden` | 403 | Cross-site `Sec-Fetch-Site` or foreign `Origin`. | |
| 172 | | `rate_state_unavailable` | 429 | Rate state missing/malformed β cannot prove the rate is bounded. | |
| 173 | | `rate_limited` | 429 | Window full. | |
| 174 | | `missing_token` | 401 | No token presented. | |
| 175 | | `invalid_token` | 401 | Token mismatch, or no `expectedToken` configured. | |
| 176 | |
| 177 | **Guarantees (all under test):** |
| 178 | - **Pure:** no I/O, no `process.env`, no network, no logging, no clock read. Deterministic. |
| 179 | - **Fail-closed:** anything missing/malformed/ambiguous denies. No fail-open branch exists. |
| 180 | - **Never throws:** a catch-all converts any internal error to `403 malformed_request`, so no |
| 181 | exception can carry input data outward. |
| 182 | - **No ambient authority:** the verdict is the only output. No vault, canister, or JWT handle. |
| 183 | - **No secret in output:** the presented token, expected token, JWT, and any note body never appear |
| 184 | in a reason, a return value, or an error. |
| 185 | |
| 186 | ### 2.2 Rate-limit helpers |
| 187 | |
| 188 | - `createLoopbackRateState({ windowMs = 60_000, maxRequests = 60 })` β fresh `{ windowMs, |
| 189 | maxRequests, timestamps: [] }`. |
| 190 | - `evaluateRateLimit(rateState, now)` β `{ ok: true }` or `{ ok: false, reason }`. Pure; counts |
| 191 | in-window timestamps; β₯ `maxRequests` β `rate_limited`. |
| 192 | - `recordLoopbackRequest(rateState, now)` β **new** state with `now` appended and out-of-window |
| 193 | timestamps pruned (pure; input not mutated). The array is bounded by `maxRequests`. |
| 194 | - `shouldCountTowardRateLimit(verdict)` β `true` **only** for verdicts that reached the token stage |
| 195 | (`ok` / `missing_token` / `invalid_token`). See [Β§4](#4-the-rate-limit-recording-contract). |
| 196 | |
| 197 | ### 2.3 Why the Origin allowlist is the loopback origin only |
| 198 | |
| 199 | The signature intentionally has **no `allowedOrigins`** parameter. The guard derives the permitted |
| 200 | browser origins from `allowedHosts` (i.e. `http(s)://<allowedHost>`), so the **only** browser origin |
| 201 | that may call the endpoint is its **own loopback origin** (same-origin). A remote origin β including |
| 202 | the hosted Knowtation web app (`https://knowtation.store`) β is cross-origin and is **rejected**. |
| 203 | This is the strictest reading of control Β§4.3 ("no reflecting arbitrary Origin") and it cleanly |
| 204 | resolves the DNS-rebinding + cross-origin story: the loopback endpoint trusts only same-origin |
| 205 | loopback browser context and non-browser local clients (which send no `Origin`/`Sec-Fetch-Site` and |
| 206 | still must present a valid token). |
| 207 | |
| 208 | If a future product decision requires the hosted web tab to *drive* the local companion, that is a |
| 209 | **deliberate, documented allowlist extension** decided at the Phase 5 bind gate β not a silent |
| 210 | default of this guard. Per brief Β§3/Β§2, in-browser inference today runs **in the tab via WebGPU** |
| 211 | (reusing the web session), not through the loopback endpoint, so the same-origin-only default is |
| 212 | correct for Phase 2. |
| 213 | |
| 214 | --- |
| 215 | |
| 216 | ## 3. Evaluation order (and why) |
| 217 | |
| 218 | The order of checks is itself a security decision: |
| 219 | |
| 220 | ``` |
| 221 | 1. Structural validity β 403 malformed_request (fail-closed on bad input) |
| 222 | 2. Method allowlist β 403 method_not_allowed |
| 223 | 3. Host allowlist+loopback β 403 host_not_allowed (DNS-rebinding; cheap, rejects most abuse) |
| 224 | 4. Origin / Sec-Fetch-Site β 403 cross_site_forbidden |
| 225 | 5. Rate limit β 429 rate_limited / rate_state_unavailable |
| 226 | 6. Token (constant-time) β 401 missing_token / invalid_token |
| 227 | 7. Admit β 200 ok |
| 228 | ``` |
| 229 | |
| 230 | - **Host/Origin before rate-limit.** A cross-origin or DNS-rebinding flood is rejected at steps 3β4 |
| 231 | and is **not** recorded against the rate window (see Β§4). If those checks came *after* rate-limit, |
| 232 | an attacker could exhaust the shared budget with cheap 403'd probes and **deny the legitimate |
| 233 | client** (a budget-exhaustion DoS). Rejecting them first, without consuming budget, prevents that. |
| 234 | - **Rate-limit before token.** Placing the rate check *before* the token check is what **bounds |
| 235 | token brute-force**: once the window is full, even token-guessing requests receive **429** rather |
| 236 | than an unbounded stream of `401`s. If token came first, the function would short-circuit at the |
| 237 | token check and never reach the 429 gate, leaving guessing unbounded. |
| 238 | |
| 239 | --- |
| 240 | |
| 241 | ## 4. The rate-limit recording contract |
| 242 | |
| 243 | `verifyLoopbackRequest` is pure and does **not** mutate `rateState`. The caller (Phase 5 listener) |
| 244 | advances the window: |
| 245 | |
| 246 | ```js |
| 247 | const verdict = verifyLoopbackRequest({ ...req, expectedToken, allowedHosts, now, rateState }); |
| 248 | if (shouldCountTowardRateLimit(verdict)) { |
| 249 | rateState = recordLoopbackRequest(rateState, now); |
| 250 | } |
| 251 | ``` |
| 252 | |
| 253 | `shouldCountTowardRateLimit` returns `true` **only** for verdicts that reached the token stage |
| 254 | (`ok`, `missing_token`, `invalid_token`). This is the precise contract that makes two properties |
| 255 | hold simultaneously: |
| 256 | |
| 257 | - **Brute-force is bounded** β failed-auth (`401`) requests consume a slot, so a guessing flood |
| 258 | fills the window and trips `429`. |
| 259 | - **No budget-exhaustion DoS, and the array stays bounded** β pre-rate rejections |
| 260 | (`malformed`/`method`/`host`/`cross_site`) and rate rejections (`rate_limited`/ |
| 261 | `rate_state_unavailable`) are **not** recorded, so cross-origin/rebinding floods cannot drain the |
| 262 | budget, and the `timestamps` array can never grow past `maxRequests`. |
| 263 | |
| 264 | --- |
| 265 | |
| 266 | ## 5. Mapping: gate Β§4 controls β Phase 2 enforcement |
| 267 | |
| 268 | | Gate Β§4 control | Where enforced | Status | |
| 269 | | --- | --- | --- | |
| 270 | | **1. Bearer token on every request** | `verifyLoopbackRequest` token stage; `constantTimeStringEqual` | β request-decision | |
| 271 | | **2. Strict `Host` allowlist (DNS-rebinding)** | `allowedHosts` match + `isLoopbackHost` | β request-decision | |
| 272 | | **3. Strict `Origin`/`Sec-Fetch-Site`, no wildcard CORS** | Sec-Fetch-Site allowlist + loopback-origin-only check | β request-decision | |
| 273 | | **4. Non-predictable ephemeral port** | β | β **Phase 5 (bind-time)** β see Β§6 | |
| 274 | | **5. Loopback bind only (`127.0.0.1`)** | `isLoopbackHost` double-check at decision level | β partial (decision); bind β Phase 5 | |
| 275 | | **6. No ambient authority** | Narrow verdict shape; no vault/canister/JWT reachable | β structural | |
| 276 | | **7. Untrusted input (note body as data)** | Guard never reads a body β structurally outside the decision | β structural | |
| 277 | | **8. Rate limiting + minimal logging** | Sliding-window rate gate; guard never logs; no secret in output | β request-decision | |
| 278 | |
| 279 | > Gate Β§4: *"A future implementation that omits any of items 1β3, 5, or 6 fails this gate."* Items |
| 280 | > 1, 2, 3, 6 are fully enforced at the request-decision level; item 5's loopback **assertion** is |
| 281 | > enforced at the decision level and its **bind** is specified for Phase 5 below. No required item |
| 282 | > is omitted. |
| 283 | |
| 284 | --- |
| 285 | |
| 286 | ## 6. What Phase 5 must do to bind the socket safely |
| 287 | |
| 288 | The pure guard is the bouncer; Phase 5 (companion shell) installs the door. Binding the listener is |
| 289 | the single most security-critical action and **requires an explicit gate**. When Phase 5 binds, it |
| 290 | MUST: |
| 291 | |
| 292 | 1. **Bind loopback only.** `server.listen(port, '127.0.0.1')` β never `0.0.0.0`, never a public |
| 293 | interface (control Β§4.5). Do not bind `::` ; if IPv6 loopback is offered, bind `::1` explicitly. |
| 294 | 2. **Allocate a non-predictable ephemeral port.** Let the OS assign an ephemeral port (`listen(0, |
| 295 | '127.0.0.1')`) and treat the chosen port as a secret-ish capability; do not use a fixed |
| 296 | well-known port (control Β§4.4). Persist it only for the local session. |
| 297 | 3. **Generate the per-session token with a CSPRNG.** `crypto.randomBytes(32)` (β₯ 256-bit), |
| 298 | base64url-encoded, stored in the **OS keychain** (Keychain / DPAPI / libsecret), regenerated each |
| 299 | companion start. Pass it to the guard as `expectedToken`. Never log it; never place it in a URL. |
| 300 | 4. **Build `allowedHosts` from the actual bound port** β `['127.0.0.1:<port>', 'localhost:<port>']` |
| 301 | β and pass it to every `verifyLoopbackRequest` call. |
| 302 | 5. **Extract the presented token** from `Authorization: Bearer <token>` and pass it as `token`. |
| 303 | Pass `Date.now()` as `now` and maintain `rateState` per the Β§4 recording contract. |
| 304 | 6. **Call the guard before any model work.** On `allow === false`, return `verdict.status` with a |
| 305 | generic body and **no** secret; do not proceed to the runtime. On `allow === true`, proceed. |
| 306 | 7. **Emit no permissive CORS.** Never `Access-Control-Allow-Origin: *`; if any CORS header is |
| 307 | emitted at all, set `Access-Control-Allow-Origin` to the **validated loopback origin only** and |
| 308 | never reflect an arbitrary `Origin`. (Contrast `hub/bridge/server.mjs`, which defaults to |
| 309 | `Access-Control-Allow-Origin: *` β that pattern MUST NOT be copied to the loopback endpoint.) |
| 310 | 8. **Minimal logging.** Log admission decisions by `reason` code only; never log the token, JWT, |
| 311 | `Authorization` header, `Origin`, or any note body (control Β§4.8). |
| 312 | 9. **Ship its own 7-tier suite** for the bind/lifecycle layer (socket bind assertion, ephemeral-port |
| 313 | randomness, keychain read/write, concurrent-connection handling) per gate Β§10 β the pure guard's |
| 314 | suite does not absolve the listener of its own tests. |
| 315 | |
| 316 | Until that explicit Phase 5 gate is approved, **no socket is bound** and the gate's no-listener |
| 317 | prohibition remains in force. |
| 318 | |
| 319 | --- |
| 320 | |
| 321 | ## 7. Test obligations satisfied (gate Β§10, 7 tiers) |
| 322 | |
| 323 | All under `test/companion-loopback-guard-*.test.mjs` (102 cases, all green): |
| 324 | |
| 325 | | Tier | File | Focus | |
| 326 | | --- | --- | --- | |
| 327 | | Unit | `β¦-unit.test.mjs` | Each control in isolation; helpers (`parseHostHeader`, `constantTimeStringEqual`, rate helpers). | |
| 328 | | Integration | `β¦-integration.test.mjs` | Evaluation order under combined faults; rate-state lifecycle; brute-force bounding; budget-DoS prevention. | |
| 329 | | End-to-end | `β¦-e2e.test.mjs` | Realistic callers: companion UI, local CLI, cross-origin page, DNS-rebinding, stolen-token-still-blocked, full interleaved session. | |
| 330 | | Stress | `β¦-stress.test.mjs` | 100k wrong-token attempts (zero accidental allows); bounded window under 50k load; 10k-entry allowlist; pathological header bags. | |
| 331 | | Data-integrity | `β¦-data-integrity.test.mjs` | Determinism (10k identical calls); no input mutation; verdict shape; reason domain; env-independence. | |
| 332 | | Performance | `β¦-performance.test.mjs` | Sub-ms mean per-decision; 100k decisions < 2s; no super-linear blowup with window size. | |
| 333 | | **Security** | `β¦-security.test.mjs` | **Centerpiece:** missing/wrong token (constant-time, no length oracle); DNS-rebinding 403; cross-site 403; no wildcard CORS / no Origin reflection; rate-limit 429; no ambient authority; note-body-as-data; no secret in any output/reason/error; global fail-closed posture. | |
| 334 | |
| 335 | --- |
| 336 | |
| 337 | ## 8. Deferred (explicitly not Phase 2) |
| 338 | |
| 339 | - The real listening socket, ephemeral-port allocation, and loopback bind β **Phase 5** behind an |
| 340 | explicit gate (Β§6). |
| 341 | - OS-keychain read/write of the per-session token β Phase 3 (OAuth/keychain) / Phase 5. |
| 342 | - Downstream prompt construction that treats the note body strictly as data when building the model |
| 343 | prompt β runtime phase (the guard guarantees the body never reaches the admission decision). |
| 344 | - Any change to OAuth client registration or scopes (gate "DOES NOT approve" list β unchanged). |