From 69aace7e52e9ccf3a75519165536919e2111f7ac Mon Sep 17 00:00:00 2001 From: Karan Date: Tue, 27 Oct 2026 23:36:31 -0400 Subject: [PATCH 1/4] docs: draft security advisory for the mode-raise issue Co-Authored-By: Claude Fable 5.1 --- SECURITY.md | 2 + docs/security/2026-10-mode-raise.md | 316 ++++++++++++++++++++++++++++ 2 files changed, 318 insertions(+) create mode 100644 docs/security/2026-10-mode-raise.md diff --git a/SECURITY.md b/SECURITY.md index b9966972..c315abf3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -68,6 +68,8 @@ infrastructure we do not operate. - [packages/runtime/TELEMETRY.md](packages/runtime/TELEMETRY.md) and [PRIVACY.md](PRIVACY.md): what leaves the page. - [Verifying a published tarball](#verifying-a-published-tarball) (below): how to check that a published tarball was built from this repository. +- Advisories: [docs/security/](docs/security/). Latest: [2026-10, a link could raise the mode](docs/security/2026-10-mode-raise.md), + fixed in `0.3.0-beta.2` (`0.2.3` pending). Recommended for production: keep the default `observe` mode until you have looked at what GenClass reports for your app; set `mode` explicitly (a URL parameter can then only lower it); keep payment, checkout and sign-in endpoints diff --git a/docs/security/2026-10-mode-raise.md b/docs/security/2026-10-mode-raise.md new file mode 100644 index 00000000..b14ad69c --- /dev/null +++ b/docs/security/2026-10-mode-raise.md @@ -0,0 +1,316 @@ +DRAFT for the owner to publish through GitHub's security advisory form. Do not publish this file as is. + + + +# `@genclass/runtime`: a link could raise GenClass from observe to guard or heal on sites with `debug: true` and on hosts that only look local + +| field | value | +|---|---| +| Ecosystem | npm | +| Package | `@genclass/runtime` | +| Affected versions | `>= 0.1.0-alpha.0, <= 0.2.2` (every 0.1.x prerelease, 0.2.0, 0.2.1, 0.2.2, npm's `latest`); `>= 0.3.0-beta.0, <= 0.3.0-beta.1` | +| Patched versions | `0.3.0-beta.2` (npm dist-tag `beta`, 2026-10-25); `0.2.3` on dist-tag `latest` (pending, not published at the time of this draft) | +| Not affected | `0.0.0-stage` (a placeholder package with no code), `0.3.0-beta.2` and later | +| Severity | Medium, CVSS 3.1 5.4 (`CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:L`), see "Severity" | +| CWE | CWE-15 (External Control of System or Configuration Setting); secondary CWE-489 (Active Debug Code) for the `debug` bypass | +| Credits | the GenClass maintainers (found while testing 0.3.0-beta.0 in a pilot app, 2026-10-17) | +| CVE | none requested yet (owner: tick "Request CVE" in the form if wanted) | + +## Summary + +`@genclass/runtime` runs inside a web app in one of three modes: `observe` (the default, report only), `guard` (opt-in: +holds and small reversible actions at the network boundary) and `heal` (experimental: blocking, cached and retried +requests, store rollbacks). The URL switch `?genclass=observe|guard|heal` and `localStorage.genclass` exist so a +developer can try another mode on one page load. In the affected versions that switch could also *raise* the mode on a +production site, so a crafted link opened by a visitor turned guard-tier or heal-tier actions on for that visitor's page +load, in two situations: + +1. the app had `debug: true` set (documented as console logging only; from `GenClass.init()`, `window.GENCLASS_CONFIG`, + the options file `init` writes, `` or `data-debug` on the script tag); then + `?genclass=guard|heal`, `localStorage.genclass`, `?genclass-mode=`, `?genclass-aggr=` and `?genclass-sample=1` + could raise the mode, the aggressiveness and the acting cohort on any host, and `?genclass-mode=` could even undo a + lowering `?genclass=observe`; +2. the page's host passed telemetry's broad "local host" test (0.2.2 to 0.3.0-beta.1): intranet bare names, private + addresses, `*.internal`, `*.local`, `*.home.arpa`, Capacitor (`https://localhost`), Tauri (`tauri.localhost`) and + Electron (`file:`); then `?genclass=guard|heal` raised the mode without `debug`. + +Versions before 0.2.2 were wider open: 0.1.0-alpha.0 to 0.1.0-beta.0 applied the switch on every host with no condition, +and 0.1.0-beta.2 to 0.2.1 let `?genclass=guard` raise a site that set no `mode` (the switch compared against a `guard` +baseline while the runtime defaulted to `observe`). + +Since 0.3.0-beta.2, `debug` is console logging only; `?genclass-mode`, `?genclass-aggr` and `?genclass-sample` only +lower, on every host; `?genclass=` and `localStorage.genclass` may raise the mode only on a narrow development host +(`src/devhost.ts`: http(s) on `localhost`, `127.0.0.1`, `[::1]` or `*.localhost` with an explicit port, or a `*.test` +name). Lowering (`?genclass=off`, `?genclass=observe`) keeps working everywhere, and telemetry's own local-host rule and +opt-outs are unchanged. No model-visible text changed. + +## Details + +### Mechanism + +`GenClass.init()` (also behind the zero-code entries `@genclass/runtime/auto` and the script tag) reads one value from +`location.search` (`genclass`) or, when the URL has none, from `localStorage.genclass` +(`packages/runtime/src/index.ts`, `killSwitch`). `off` installs nothing. `observe`, `guard` or `heal` becomes the mode +when a rule allows it. The rule, per version: + +- 0.1.0-alpha.0 to 0.1.0-beta.0: no rule; the value is the mode (`index.ts:128`, `:128`, `:142`). +- 0.1.0-beta.1 to 0.2.1: `options.debug || rank(value) <= rank(options.mode ?? "guard")` (`index.ts:251`, `:265`, + `:282`, `:292`, `:292`). The `?? "guard"` baseline was correct only in 0.1.0-beta.1, whose runtime also defaulted to + guard; from 0.1.0-beta.2 the runtime defaults to observe (`runtime.ts:310` and later), so for an app that set no + `mode` the comparison allowed `guard` (rank 1 is not above the baseline's rank 1) and the page went from observe to + guard on any host, no `debug` needed. `heal` still needed `debug`. +- 0.2.2 to 0.3.0-beta.1: `options.debug || isLocalHost(location.hostname) || rank(value) <= rank(options.mode ?? "observe")` + (`index.ts:310`, `:345`, `:348`). The baseline is right, but two bypasses stay: `debug`, and `isLocalHost`, which is + telemetry's rule for "do not send from here" (`packages/runtime/src/telemetry/config.ts:26-38`): it accepts an empty + hostname (`file:` pages), `localhost`, `0.0.0.0`, `::1`, `*.localhost`, `*.local`, `*.test`, `*.internal`, + `*.home.arpa`, the private and link-local IPv4 ranges (10/8, 127/8, 0/8, 192.168/16, 172.16/12, 169.254/16, + 100.64/10), IPv6 `fc`/`fd`/`fe80:` addresses and any bare name without a dot. A false "local" there only sends less; + used for the mode switch it let a link raise every app that ships its UI from such a host. + +Independently, `createRuntime()` (reached by `GenClass.init()` too) reads `?genclass-mode=`, `?genclass-aggr=` and +`?genclass-sample=` (`packages/runtime/src/runtime.ts`). From 0.1.0-beta.1 to 0.3.0-beta.1 each had a `debug` bypass: +`if (this.debug || MODE_RANK[um] <= MODE_RANK[this._mode])`, `if (this.debug || v <= this._aggr)` and +`if (urlSample === "1" && this.debug) return true` (0.2.2: `runtime.ts:370`, `:381`, `:574`). With `debug` on, +`?genclass-mode=heal` raised the mode after `index.ts` had applied the `?genclass=` switch, so +`?genclass=observe&genclass-mode=heal` on an app in heal mode ended in heal, not observe; `?genclass-aggr=eager` raised +the aggressiveness (lower gate thresholds); `?genclass-sample=1` put a session the app's `sample` had placed in the +observe cohort into the acting cohort. + +The fix (`12f4a5c` in PR #64, ported to main as `5a6b48f` / merge `c2379a7`, released in 0.3.0-beta.2): `index.ts` +drops the `debug` term and replaces `isLocalHost` with `devHost` from the new `src/devhost.ts` (http(s) on +`localhost`, `127.0.0.1`, `[::1]` or `*.localhost` with an explicit port, or `*.test`; never Capacitor, Tauri, Electron, +intranet names, private addresses, `*.internal` or `*.local`); `runtime.ts` makes the three other switches lower-only on +every host and `?genclass-sample=1` a no-op. `test/mode-switch.test.ts` pins every case above, including the +look-alike hosts. The published 0.3.0-beta.2 tarball carries the new predicate and neither `debug` pattern (checked in +`dist/`); the published 0.2.2 tarball carries both (`dist/chunk-7ODWDTWV.js`, `dist/genclass.global.js`, +`dist/genclass.global.min.js`). + +### Exact conditions + +All of the following must hold for a link to raise a visitor's page load: + +- the page runs an affected version through `GenClass.init()`, `@genclass/runtime/auto` (or `/auto/observe`), or the + script-tag build (`createRuntime()` alone is reached by the `?genclass-mode` / `-aggr` / `-sample` part only); +- the visitor opens a URL whose query string the attacker chose (`?genclass=guard`, `?genclass=heal`, or with `debug` + also `?genclass-mode=`, `?genclass-aggr=`, `?genclass-sample=1`), or the attacker can set `localStorage.genclass` in + the page's origin (which needs script execution there and is out of scope: such a script can call + `GenClass.runtime.setMode("heal")` directly, THREAT-MODEL T1); +- and one of: the app set `debug: true` by any route (0.1.0-beta.1 to 0.3.0-beta.1); the page's host passes + `isLocalHost` (0.2.2 to 0.3.0-beta.1); the app set no `mode` and the value is `guard` (0.1.0-beta.2 to 0.2.1); or the + version is 0.1.0-alpha.0 to 0.1.0-beta.0 (no condition); +- a model is loaded (the default; `model: false` means the runtime only observes whatever the mode says), the page is + visible, and the policy gate clears a model decision for a subject that is not protected, cross-origin or in an + observe-scoped route. + +The switch is read once, when GenClass starts (a page load). Nothing is written back: no version stores a +`?genclass=` value in `localStorage` or `sessionStorage` (`killSwitch` only reads; the only `genclass`-named writes are +`genclass:bucket`, the sampling bucket, which the `?genclass-sample=1` branch returns before reaching, `genclass:learn`, +the breaker's key and, since 0.3.0-beta.2, `genclass.analytics.`). So one click raises that page load only; a +later page load without the parameter is back at the app's configuration. In a single-page app the raised mode lasts +while the page load lasts, however many client-side routes it visits. + +### What the attacker controls, and what not + +The attacker controls the query string of a link and thereby which tier is enabled (guard or heal), the aggressiveness +profile and the sampling cohort for one visitor's page load. The attacker does not control what then happens: every +action is chosen by the local model from the fixed list the runtime offers for one subject, must pass the policy gate +(`src/decide/policy.ts`), and runs only when the top diagnosis is not `expected`. The attacker cannot name an action, +a request or a store, cannot see the page, and gets nothing back; telemetry sends the same redacted diagnostics it +sends in every mode (with a `decision` event per action, no page text), and the attacker does not receive it. + +### What guard and heal may do to the page (`packages/runtime/INTERCEPTION.md`, "What each mode may change" and "Actions") + +observe never holds, delays, drops, retries, replays or answers anything and never writes app state. A raised page +load may instead see: + +- **guard tier**: requests and responses held for the model's decision, each within the hold budget ("auto" = 1.5 x + the model's median latency, clamped to 150 to 800 ms; fail open when the model is late); `coalesce` (an identical + request in flight or finished up to 2 s ago is not sent again, the app gets that response, any method including + POST); `delay` (a request sent after 250 ms x 2^(recent failure streak), at most 8 s); `discard` and `defer` of held + store writes and of a response's writes over newer data (registered stores only; reversible). +- **heal tier** adds: `block` (the request is not sent and answered 503 with `x-genclass: blocked`); `serve_cached` + (a GET is answered from the last good response of this page's memory, up to 256 KB); `retry` (an idempotent or + idempotency-keyed request sent again, up to 4 attempts); `hedge` (a second identical GET); `rollback` and `resync` + of registered stores to the last consistent snapshot. With `visual: { on: true, fix: true }` configured, the three + measured visual fixes (one colour rule, removing a leaked inline `pointer-events: none` or `overflow: hidden`) also + need guard or heal and so become reachable. + +Bounds that hold in every mode and limit the impact: a request's URL, method, headers and body are never changed; +cookies are never read or written; a non-idempotent request is never sent twice without an idempotency key; a cached +response is served only to an identical GET; requests under `requests.protect` (the payments and auth presets, when +configured), cross-origin requests and observe-scoped routes are never acted on; discovered React, Redux or Zustand +state is never written; every action is recorded with an undo where one exists, two undos in 10 minutes or three errors +after actions trip the breaker back to observe for the session, and `rt.disable()` restores the page. Impact is +therefore limited to that visitor's page load: added latency, a request answered locally (coalesced, cached, blocked +with a 503) or retried, and reversible writes to stores the app registered. + +## Affected versions + +| npm version | published (npm) | affected | why | +|---|---|---|---| +| 0.0.0-stage | 2026-10-08 | no | a 367-byte placeholder tarball (`package.json` with `"stub": true` and a README); no code | +| 0.1.0-alpha.0, 0.1.0-alpha.1, 0.1.0-beta.0 | 2026-10-08 | yes, worst | `?genclass=` sets the mode on every host, no condition (unsupported line) | +| 0.1.0-beta.1 | 2026-10-08 | yes | raise needs `debug: true` (default mode was guard); `-mode` / `-aggr` / `-sample` debug bypasses | +| 0.1.0-beta.2, 0.1.0-beta.3, 0.2.0, 0.2.1 | 2026-10-08 to 2026-10-11 | yes | `?genclass=guard` raises an app that set no `mode` on every host; `heal` and the other switches need `debug` | +| 0.2.2 (`latest`) | 2026-10-15 | yes | raise needs `debug: true` or a host `isLocalHost` accepts; `-mode` / `-aggr` / `-sample` debug bypasses | +| 0.3.0-beta.0, 0.3.0-beta.1 | 2026-10-15, 2026-10-24 | yes | same as 0.2.2 | +| 0.3.0-beta.2, 0.3.0-beta.3, 0.3.0-beta.4 (`beta`) | 2026-10-25 to 2026-10-27 | no | fixed | + +GHSA ranges: `>= 0.1.0-alpha.0, <= 0.2.2` and `>= 0.3.0-beta.0, < 0.3.0-beta.2`. Every version on npm except +`0.0.0-stage` has a matching `v*` tag in this repository; there is no other npm-only version. + +## Patched versions + +- `0.3.0-beta.2` (npm dist-tag `beta`), published 2026-10-25; `0.3.0-beta.3` and `0.3.0-beta.4` carry the fix. + `npm install @genclass/runtime@beta`; script tags: pin `@0.3.0-beta.4` (or the newest beta). +- `0.2.3` (npm dist-tag `latest`): pending. Until it is published, a project on the stable line should either move to + `@beta` or apply the workarounds below. (owner: publish 0.2.3 from a `v0.2.3` tag through `release.yml`, then fill + the form's patched version for the `<= 0.2.2` range; until then the form can state "no patched version on the 0.2 + line yet".) + +Where the fix lives: `packages/runtime/src/devhost.ts` (new), `src/index.ts` (`initUnsafe`), `src/runtime.ts` +(constructor and `sampleBucket`), `src/visual/start.ts` (re-exports `devHost`), `test/mode-switch.test.ts` (new) and +three flipped tests (`test/default-mode.test.ts`, `test/gates.test.ts`, `test/report.test.ts`). + +## Version-by-version evidence + +Paths are under `packages/runtime/src/` at the named tag (`git show :packages/runtime/src/`). "public + +debug" is a public host with `debug: true`; "look-alike" is a host telemetry's `isLocalHost` accepts +(`telemetry/config.ts`) without `debug`; "no mode" is an app that set no `mode`. "persisted" asks whether a +`?genclass=` value is ever written to storage. + +| version (tag) | `?genclass=` / `localStorage.genclass` raise: public + debug | look-alike host, no debug | public, no debug, no mode | `?genclass-mode` / `-aggr` / `-sample=1` debug bypass | persisted | script tag `data-debug` / meta `debug` | evidence | +|---|---|---|---|---|---|---|---| +| 0.0.0-stage (no tag) | n/a | n/a | n/a | n/a | n/a | n/a | npm tarball: `package.json` (`"stub": true`), `README.md`; no `dist/` | +| 0.1.0-alpha.0 | yes (no condition at all) | yes | yes (any value, any host) | no such switches | no | no script-tag build, no `cdn/` | `index.ts:128` `const mode = ks && MODES.includes(ks) ? ks : options.mode`; `runtime.ts:186` default `guard`; `killSwitch` `index.ts:74-91` reads only | +| 0.1.0-alpha.1 | yes (no condition) | yes | yes | no such switches | no | no `cdn/` | `index.ts:128` (same line); `runtime.ts:205` default `observe` | +| 0.1.0-beta.0 | yes (no condition) | yes | yes | no such switches | no | `cdn/config.ts:100-101` `case "debug": c.debug = !isOff(lv)` (moot: no condition); every `` in head or body read (`cdn/config.ts:14`) | `index.ts:142`; `runtime.ts:216` default `observe` | +| 0.1.0-beta.1 | yes | yes (debug is the only gate) | no (`heal`); `guard` is the default mode here, not a raise | yes: `runtime.ts:308`, `:319`, `:496` | no | `cdn/config.ts:87-88`; meta anywhere | `index.ts:248-251` `options.debug \|\| rank(ks) <= rank(base)`, `base = options.mode ?? "guard"` (`:250`); `runtime.ts:306` default `guard` | +| 0.1.0-beta.2 | yes | yes (debug only) | yes: `guard` passes against the `guard` baseline while the runtime defaults to `observe` | yes: `runtime.ts:312`, `:323`, `:501` | no | `cdn/config.ts:100-101`; meta anywhere | `index.ts:262-265`, `base ... ?? "guard"` (`:264`); `runtime.ts:310` default `observe` | +| 0.1.0-beta.3 | yes | yes (debug only) | yes (`guard`) | yes: `runtime.ts:329`, `:340`, `:523` | no | `cdn/config.ts:100-101`; meta anywhere | `index.ts:279-282` (`:281` baseline `guard`); `runtime.ts:327` default `observe` | +| 0.2.0 | yes | yes (debug only) | yes (`guard`) | yes: `runtime.ts:367`, `:378`, `:566` | no | `cdn/config.ts:101-102`; meta anywhere | `index.ts:289-292` (`:291` baseline `guard`); `runtime.ts:365` default `observe` | +| 0.2.1 | yes | yes (debug only) | yes (`guard`) | yes: `runtime.ts:367`, `:378`, `:571` | no | `cdn/config.ts:101-102`; meta anywhere (`cdn/config.ts:15`) | `index.ts:289-292` (`:291` baseline `guard`); `runtime.ts:365` default `observe` | +| 0.2.2 | yes | yes: `isLocalHost` (`telemetry/config.ts:26-38`) | no (fixed: baseline `observe`, `index.ts:303`) | yes: `runtime.ts:370`, `:381`, `:574` | no (`sampleBucket` returns at `:574` before the `genclass:bucket` write at `:591`) | `cdn/config.ts:103-104` sets `debug` from `data-debug` / ``, which then feeds the bypass; meta only inside `` (`cdn/config.ts:15`, `:175-176`); script `dataset` at `cdn/global.ts:92` | `index.ts:299-310`: `options.debug \|\| devHost \|\| rank(ks) <= rank(base)` (`:310`), `devHost = isLocalHost(...)` (`:306`); npm tarball: `dist/chunk-7ODWDTWV.js`, `dist/genclass.global.js` and `dist/genclass.global.min.js` carry the `debug \|\|` raise and the `=== "1" && this.debug` branch | +| 0.3.0-beta.0 | yes | yes (`isLocalHost`) | no | yes: `runtime.ts:468`, `:481`, `:721` | no | `cdn/config.ts:112-113`; meta in `` only | `index.ts:334-345` (`:345`), `isLocalHost` at `:341` | +| 0.3.0-beta.1 | yes | yes (`isLocalHost`) | no | yes: `runtime.ts:471`, `:484`, `:724` | no | `cdn/config.ts:113-114`; meta in `` only | `index.ts:337-348` (`:348`), `isLocalHost` at `:344` | +| 0.3.0-beta.2 | no | no (`devHost`, `devhost.ts:24-25`: http(s) on a loopback name with an explicit port, or `*.test`) | no | no: `runtime.ts:474`, `:487`, `:728-729` (no `=1` branch) | no | `cdn/config.ts:115-116` still parses `debug`, now logging only (`cdn/config.ts:27-29`) | `index.ts:15` imports `devHost`, `:345` `dev = devHost(...)`, `:349` `(dev \|\| rank(ks) <= rank(base))`; `test/mode-switch.test.ts`; npm tarball: `dist/chunk-NO4UMDVS.js`, `dist/genclass.global.js` and `dist/genclass.global.min.js` carry the `\.localhost$` predicate and no `debug \|\|` raise | +| 0.3.0-beta.3 | no | no | no | no | no | as beta.2 | `index.ts:345`, `:349`; `runtime.ts:474`, `:729` (identical lines) | +| 0.3.0-beta.4 | no | no | no | no | no | as beta.2 | `index.ts:345`, `:349`; `runtime.ts:474`, `:729` (identical lines) | + +Notes on the table: + +- `?genclass=observe` and `?genclass=off` lower in every version listed; `off` installs nothing and is checked before + any other switch (`index.ts` `initUnsafe`, first statement after `killSwitch`), so a `debug` bypass cannot undo it. +- Script-tag build: `data-debug` on the tag and `debug` in `` go through the same parser + (`cdn/config.ts` `parsePairs` and the `case "debug"` above) into `GenClass.init()`'s options, so they enable the + bypass exactly like `debug: true` in code. Before 0.2.2 the meta tag was read anywhere in the document, so markup + injected into the body could set `debug` (and the mode itself, which is the larger issue the 0.2.2 entry fixed); + from 0.2.2 only tags inside `` count. +- The `?genclass-mode` / `-aggr` / `-sample` switches never raised anything without `debug` in any version + (`runtime.ts`, the rank and level comparisons), and `?genclass-sample=0` always lowered. + +## Severity + +`CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:L`, base score 5.4 (Medium). + +| metric | value | why | +|---|---|---| +| Attack Vector | Network (N) | the attacker sends a link; the victim's browser does the rest over the web | +| Attack Complexity | Low (L) | once the vulnerable configuration exists (`debug: true` in production, or an app served from a host `isLocalHost` accepts, or no `mode` on 0.1.0-beta.2 to 0.2.1), one URL works every time, no race or guesswork. Base metrics are scored for the vulnerable configuration; if the owner prefers to count the configuration itself as a condition beyond the attacker's control, AC:H gives `CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:L/A:L`, 4.2 (Medium) | +| Privileges Required | None (N) | no account on the site or on genclass.dev is involved | +| User Interaction | Required (R) | the victim must open the crafted link (or paste it); nothing happens otherwise | +| Scope | Unchanged (U) | the effect stays inside the page that loaded the library, for that visitor; no other origin, server or user is touched | +| Confidentiality | None (N) | nothing new is read or sent; telemetry is the same redacted diagnostics it sends in every mode, and the attacker does not receive it | +| Integrity | Low (L) | the page's behaviour can change (a request coalesced, answered from cache, retried, a store write discarded or rolled back), but only to actions the local model picks from a fixed, bounded, mostly reversible list; the attacker controls none of the content, and request bodies, headers, cookies and protected endpoints are never touched | +| Availability | Low (L) | a request can be held (at most the hold budget), delayed (at most 8 s) or answered 503 locally (`block`), for that visitor's page load only; the breaker and `?genclass=off` end it | + +Conservative reading: this is a per-visitor, per-page-load downgrade of a safety setting, with no data exposure and no +cross-user effect. The versions with no condition at all (0.1.0-alpha.0 to 0.1.0-beta.0) are unsupported and were on +npm for days; the same vector applies to them with the same impact. + +## Workarounds + +For an affected version that cannot be upgraded yet (the stable line until `0.2.3` is out): + +1. **Do not set `debug: true` in production**, from any source: `GenClass.init({ debug: true })`, + `window.GENCLASS_CONFIG.debug`, the options file `npx @genclass/runtime init` writes, `` or `data-debug` on the script tag. With `debug` off, 0.2.2, 0.3.0-beta.0 and 0.3.0-beta.1 on a + public host (one `isLocalHost` rejects) cannot be raised by any switch. To check a deployed page: with `debug` on, + every decision is logged to the console as `[GenClass] decision ...`. +2. **Set `mode` explicitly** (`mode: "observe"` unless you opted into guard). On 0.1.0-beta.2 to 0.2.1 this closes the + `guard` raise for apps that set no mode (the baseline becomes the mode you set); on every version it keeps the + configuration visible in one place. It does not help against `debug` or the look-alike-host bypass. +3. **Apps served from a host that only looks local** (Capacitor, Tauri, Electron, intranet names, private addresses, + `*.internal`, `*.local`) on 0.2.2 to 0.3.0-beta.1: the only configuration-side mitigation short of upgrading is to + pin the mode again right after start, `GenClass.init(opts).setMode("observe")` (or `GenClass.runtime.setMode(...)` + after the auto import), and to make sure deep links or intranet links cannot carry a query string into the page's + URL. Upgrading is the real fix for these apps. +4. **A server-side or edge redirect that strips `genclass` query parameters** helps for the link vector on a site + served by a server or CDN you control: redirect requests whose query holds `genclass=guard`, `genclass=heal`, + `genclass-mode`, `genclass-aggr` or `genclass-sample` to the same URL without them, before the HTML is served (a + 303 to the cleaned URL). Keep `genclass=off` and `genclass=observe` working: they are the documented visitor-side + opt-out and lowering switch. Limits: it does nothing for `localStorage.genclass` (which needs same-origin script + access anyway), for apps served from the device (Capacitor, Tauri, Electron: there is no server in front), or for + pages whose query string the server never sees (a static host without a rules engine); and the runtime reads + `location.search` at start, so the parameter must be gone before the page's scripts run, not removed by the app's + router afterwards. +5. **`?genclass=off` still lowers in every version**: a visitor (or your support team) who sees GenClass acting can + append `?genclass=off` and nothing is installed for that page load; `?genclass=observe` keeps the reporting without + actions (on affected versions with `debug` on, prefer `off`, since `?genclass-mode=` could undo `observe`). +6. **Limit what a raised mode could touch**: keep payment, checkout and sign-in endpoints under `requests.protect` + (`protectPreset("payments", "auth")`), deny actions you never want with `policy.deny`, and leave `visual.fix` off. + These are the production recommendations in SECURITY.md and INTERCEPTION.md and they apply in every mode. + +## Credits + +Found by the GenClass maintainers while testing `0.3.0-beta.0` in a pilot app on 2026-10-17. No external reporter. + +## Timeline + +| date | event | +|---|---| +| 2026-10-08 | `0.1.0-alpha.0` published; the URL switch sets the mode on every host with no condition | +| 2026-10-08 | `0.1.0-beta.1`: the switch becomes lower-only with a `debug` bypass; `?genclass-mode`, `?genclass-aggr` and `?genclass-sample` arrive with the same bypass | +| 2026-10-08 to 2026-10-11 | `0.1.0-beta.2` to `0.2.1`: the runtime's default mode returns to `observe` while the switch keeps a `guard` baseline, so `?genclass=guard` raises an app that set no `mode` | +| 2026-10-13 (tag), 2026-10-15 (npm) | `0.2.2`: the baseline is fixed (CHANGELOG "Security: a link or an injected `` tag can no longer raise GenClass's mode"); the `debug` bypass stays and the broad `isLocalHost` exception is added | +| 2026-10-15 | `0.3.0-beta.0` published with the same code | +| 2026-10-17 | found by the maintainers testing `0.3.0-beta.0` in a pilot app | +| 2026-10-20 | fix written: `12f4a5c` ("Stop debug and look-alike local hosts from letting a link raise the mode", 00:10 -0400) with `test/mode-switch.test.ts`; look-alike cases pinned in `544085c`; PR #64 opened the same day (public, with the issue described in its CHANGELOG entry) | +| 2026-10-22 (tag), 2026-10-24 (npm) | `0.3.0-beta.1` released without the fix | +| 2026-10-24 | the fix's code and tests ported onto `main` (`5a6b48f`, merge `c2379a7`) | +| 2026-10-25 | `0.3.0-beta.2` tagged and published with the fix (npm dist-tag `beta`) | +| 2026-10-26, 2026-10-27 | `0.3.0-beta.3`, `0.3.0-beta.4` carry it | +| pending | `0.2.3` on npm's `latest`; this advisory; docs on `main` updated (see the notes below) | + +## References + +- Fix: commit `12f4a5c` (PR #64, https://github.com/genclass-dev/GenClass-lib/pull/64), tests `544085c`; on `main` + as `5a6b48f` (merge `c2379a7`); released in `v0.3.0-beta.2`. +- `packages/runtime/src/devhost.ts` (the development-host predicate and why it is narrow), + `packages/runtime/test/mode-switch.test.ts` (every case above). +- `packages/runtime/INTERCEPTION.md` ("What each mode may change", "What it never does", "Actions: preconditions and + reversibility"), `docs/runtime/THREAT-MODEL.md` T8, `SECURITY.md` ("Recommended for production"). +- `packages/runtime/CHANGELOG.md`, the `0.2.2` security entry (the earlier, partial fix). + +## Notes for the owner before publishing (not part of the advisory text) + +- The 0.3.0-beta.2 CHANGELOG section does not carry a Security entry for this fix: the port to `main` (`5a6b48f`) + took the code and tests only. The entry exists on PR #64's branch under "## Unreleased" (it already says "0.2.3 + carries the same fix"). Move it under `## 0.3.0-beta.2` (and `## 0.2.3` when that ships) in the same change that + publishes this advisory. +- The docs on `main` still describe the old behaviour: `docs/runtime/THREAT-MODEL.md` T8 ("unless the app sets + `debug: true`", "development host (`localhost`, `*.local`, `*.test`, private networks)"), `docs/runtime/CONTRACT.md` + line 540 (`?genclass=guard` "only with `debug: true` or on a development host since 0.2.2"), `docs/runtime/OPTIONS-SPEC.md` + §3 (URL overrides "with `debug:true` they may also raise"), `packages/runtime/README.md` lines 688-692, 1058 and 1132 + (the `?genclass-sample=1` with `debug: true` QA recipe) and `packages/runtime/INTERCEPTION.md` ("raises only with + `debug: true` or on a development host"). PR #64 holds the updated wording (CONTRACT §2 and §13 are binding and need + your OK). +- PR #64 is still open; the advisory can cite the commits on `main` instead if it is closed without merging. +- `0.2.3` is not tagged or published yet. The form's "patched versions" for the `<= 0.2.2` range depends on it; the + release goes through `.github/workflows/release.yml` on a `v0.2.3` tag whose version equals + `packages/runtime/package.json` on that branch. +- The CVSS Attack Complexity choice (L, 5.4, or H, 4.2) is yours; the table above explains both. From 2678d7ffff5a08e85f95681a94344d5b508349ef Mon Sep 17 00:00:00 2001 From: Karan Date: Tue, 27 Oct 2026 23:55:27 -0400 Subject: [PATCH 2/4] @genclass/runtime: port the 0.2.3 review's four fixes (route look-alikes, invalid mode, stale sample bucket, level names) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports commit 8694211 (release/0.2.3, tag v0.2.3) onto main for 0.3.0-beta.5; the 0.3.0 code differs, so the port is by hand. The version is not bumped. - routes[]: a rule matches the raw location.pathname or its normalised form (percent-decoded in try/catch, no ";" parameters, dot segments and repeated slashes resolved, no trailing slash), so /se%61rch, /search/, //search and /search;x=1 cannot step out of a rule that scopes /search to off or observe; scope.route stays raw (util/match.ts normalizeRoute, runtime.ts computeScope). - mode: any value but observe, guard or heal runs as observe with one warning (the runtime constructor, and cdn/config.ts readWindowConfig with a warn sink from auto-start.ts and global.ts); decide/policy.ts modeAllows fails closed for anything but guard or heal (an unknown value ran as heal). - sample: 0 never reads sessionStorage; the bucket is stored as act@ / observe@ and reused only under the same sample (runtime.ts sampleBucket). - aggressivenessLevel checks the level names with includes, so Object.prototype names such as "constructor" never yield a function (decide/policy.ts). - Tests ported: status-scope (15 new), options (14 new), gates (1 new), policy (1 extended): 30 new tests, 29 of which fail on origin/main's sources (checked by checking out origin/main's src, running the four files: 14 + 13 + 1 + 1 failures, then restoring). - Docs: OPTIONS-SPEC §3 (mode validation; the URL-overrides bullet now says what 0.3.0-beta.2 left in the code), §4.2, §4.3; API.md; THREAT-MODEL T8 (mitigations aligned with the code, the new findings, the residual with devtools "local"); INTERCEPTION.md; README (data-devtools="local" hosts); types.ts JSDoc; CHANGELOG "Unreleased (0.3.0-beta.5)" with a Security section; RESULTS.md §10 first-load rows; SECURITY.md advisories bullet; docs/security/2026-10-mode-raise.md gains F1 to F4 with affected and patched versions, workarounds, timeline and the CVSS note (the score is unchanged). - test/bundle.test.ts: the /auto first-load limit 108.6 -> 108.7 KB, pending the owner's OK as the earlier limits were: the fixes add 0.27 KB to /auto and 0.17 KB to the main entry (test/first-load-size.mjs against main 49c8c03's dist: 108.63 / 100.46 KB, under the 0.5 KB growth check; script tag 128.83 KB gzip, +0.28 KB). Checked: tsc clean, tsup builds, vitest 1242 passed and 14 skipped (102 files), review-perf 4 of 4 when run alone (one earlier run missed the 5,000-entity redux dispatch budget by 0.2-0.5 ms under load; store dispatch is untouched here). No model-visible text changed (situation-v2.3); no review test edited. Co-Authored-By: Claude Fable 5.1 --- SECURITY.md | 3 +- docs/runtime/API.md | 7 +- docs/runtime/OPTIONS-SPEC.md | 9 +- docs/runtime/RESULTS.md | 3 + docs/runtime/THREAT-MODEL.md | 32 ++++-- docs/security/2026-10-mode-raise.md | 125 ++++++++++++++++++++- packages/runtime/CHANGELOG.md | 45 ++++++++ packages/runtime/INTERCEPTION.md | 2 +- packages/runtime/README.md | 3 +- packages/runtime/src/cdn/auto-start.ts | 2 +- packages/runtime/src/cdn/config.ts | 13 ++- packages/runtime/src/cdn/global.ts | 2 +- packages/runtime/src/decide/policy.ts | 6 +- packages/runtime/src/runtime.ts | 30 +++-- packages/runtime/src/types.ts | 14 ++- packages/runtime/src/util/match.ts | 24 ++++ packages/runtime/test/bundle.test.ts | 7 +- packages/runtime/test/gates.test.ts | 17 +++ packages/runtime/test/options.test.ts | 98 +++++++++++++++- packages/runtime/test/policy.test.ts | 4 + packages/runtime/test/status-scope.test.ts | 62 ++++++++++ 21 files changed, 466 insertions(+), 42 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index c315abf3..c51fb1f5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -69,7 +69,8 @@ infrastructure we do not operate. - [Verifying a published tarball](#verifying-a-published-tarball) (below): how to check that a published tarball was built from this repository. - Advisories: [docs/security/](docs/security/). Latest: [2026-10, a link could raise the mode](docs/security/2026-10-mode-raise.md), - fixed in `0.3.0-beta.2` (`0.2.3` pending). + fixed in `0.3.0-beta.2` (`0.2.3` pending); the four further findings from the review of that fix (route look-alikes, + an invalid `mode`, a stale sample bucket, aggressiveness level names) are fixed in `0.2.3` and `0.3.0-beta.5` (both pending). Recommended for production: keep the default `observe` mode until you have looked at what GenClass reports for your app; set `mode` explicitly (a URL parameter can then only lower it); keep payment, checkout and sign-in endpoints diff --git a/docs/runtime/API.md b/docs/runtime/API.md index 470e5c60..3822759e 100644 --- a/docs/runtime/API.md +++ b/docs/runtime/API.md @@ -91,7 +91,7 @@ and `0.3.0-beta.0` have no analytics and ignore the option silently: install `@g ```ts interface InitOptions { - mode?: "observe" | "guard" | "heal"; // default "observe"; "guard" is opt-in, "heal" experimental + mode?: "observe" | "guard" | "heal"; // default "observe"; "guard" is opt-in, "heal" experimental; any other value runs as observe, with a warning model?: { // or false: no model (observe only) baseUrl?: string; // directory with model.json (default: jsDelivr CDN) device?: "auto" | "webgpu" | "wasm"; // default "auto" @@ -143,7 +143,10 @@ interface InitOptions { // [true]; false: nothing installed, model never downloaded; a predicate/source forces preload "lazy" sample?: number; // [1] fraction of sessions that act (stable per session); the rest observe routes?: { match: string | RegExp | ((route: string) => boolean); mode?: Mode | "off"; aggressiveness?: Aggressiveness }[]; - // first match wins; demote only + // first match wins; demote only. Matched against the raw pathname and its normalised + // form (percent-decoded, no ";" parameters, no trailing or repeated slashes), so + // "/se%61rch" and "/search/" fall under "/search"; case-sensitive; "/admin/*" does + // not cover "/admin" itself (use "/admin*" or a RegExp) requests?: { ignore?: RequestMatcher[]; // not observed at all (native pass-through) protect?: (RequestMatcher | `preset:${string}`)[]; // observed, never held/retried/cached/discarded, nor what its response causes; diff --git a/docs/runtime/OPTIONS-SPEC.md b/docs/runtime/OPTIONS-SPEC.md index cbba8aeb..850eac4f 100644 --- a/docs/runtime/OPTIONS-SPEC.md +++ b/docs/runtime/OPTIONS-SPEC.md @@ -211,7 +211,8 @@ Runtime API: - Results go in an LRU cache keyed by `method+" "+url`, 256 entries. Matchers are compiled once, in the `runtime.ts` constructor. - **`OpRec.scope`**: `{ mode, aggressiveness, protected, crossOrigin, label?, route?, correlationId? }`, set in `NetHost.startOp`. - **`runtime.ts computeScope()`** runs on init, on nav, on `setMode`/`setAggressiveness`, on a breaker trip or reset, and when `enabled` changes. It stores `this.scope`, which is snapshotted per op or mutation. -- **URL overrides** (`?genclass-mode`, `?genclass-aggr`, `?genclass-sample=0|1`) may only demote. With `debug:true` they may also raise the value, for QA. `?genclass-sample=1` under debug forces the session into the act cohort. +- **`mode`** is validated in the `runtime.ts` constructor: any value but `observe`, `guard` or `heal` (from `createRuntime`, `GenClass.init` or `window.GENCLASS_CONFIG`, which `cdn/config.ts readWindowConfig` checks too, as `fromPairs` already did for the meta tag and the data attributes) runs as `observe`, with one warning (since 0.2.3 and 0.3.0-beta.5; before, an unknown value such as `"HEAL"` fell through `minMode` and the gate's `modeAllows` and ran as heal, and `?genclass-mode=observe` could not lower it). `modeAllows` permits a non-passive tier only for `guard` and `heal`. +- **URL overrides** (`?genclass-mode`, `?genclass-aggr`, `?genclass-sample=0`) may only demote, on every host, with or without `debug:true` (debug is console logging only). `?genclass-sample=1` has no effect: no URL value puts a session in the act cohort. Before 0.2.3 and 0.3.0-beta.2, `debug:true` let them raise the value and `?genclass-sample=1` under debug forced the act cohort; a link could then raise any site that left debug on in production. The page's own `?genclass=` / `localStorage.genclass` (src/index.ts) may raise the mode only on a development host (src/devhost.ts: http(s) on a loopback name with an explicit port, or `*.test`). For QA on a staging or production host, set `mode` / `sample` per environment, call `rt.setMode()`, or use the devtools overlay. ## 4. Options @@ -239,7 +240,7 @@ Runtime API: ### 4.2 `sample` **Type** `number` in [0,1], clamped with a warning. **Default** `1`. -**Semantics.** Sessions outside the fraction get an `observe` cap. The model still loads, so they form the comparison group. The bucket is **decided once at init** as `hash(session.id)/2^32 < sample`, then frozen and stored in `sessionStorage["genclass:bucket"]` (wrapped in try/catch). A later `setSession({id})` does not change it. An app that passes a stable user id gets the same bucket in every tab, which is the intended behaviour. `?genclass-sample=0` always forces observe, and `=1` only works under debug. +**Semantics.** Sessions outside the fraction get an `observe` cap. The model still loads, so they form the comparison group. The bucket is **decided once at init** as `hash(session.id)/2^32 < sample`, then frozen and stored in `sessionStorage["genclass:bucket"]` as `act@` or `observe@` (wrapped in try/catch). A stored verdict is reused only under the `sample` it was drawn under, so a deploy that changes `sample` draws again, and `sample: 0` (like `1`) never reads or writes it (since 0.2.3 and 0.3.0-beta.5; before, a stored `act` overrode `sample: 0` until the tab closed). A later `setSession({id})` does not change it. An app that passes a stable user id gets the same bucket in every tab, which is the intended behaviour. `?genclass-sample=0` always forces observe, and `=1` has no effect (since 0.2.3 and 0.3.0-beta.2; before, it forced the act cohort under debug). **Precedence.** It is one term of the min. `setMode` cannot lift it. @@ -249,12 +250,15 @@ Runtime API: - Reload → same bucket. - Storage throws → deterministic within the page. - `setSession({id})` → bucket unchanged. +- `sample:0` with a stored act verdict → observe, storage untouched. +- A verdict stored under another `sample` (or in the bare pre-0.2.3 form) → drawn again. ### 4.3 `routes` **Type** `RouteRule[]`. **Default** `[]`. **Semantics.** - Rules are evaluated at init and on every `installNav` callback. The first match wins. A rule can only demote, and a rule that asks for a higher mode is clamped, with one warning. +- A string rule is an exact path or a glob, matched against the raw `location.pathname` **and** against its normalised form (percent-decoded, `;` path parameters dropped, dot segments and repeated slashes resolved, no trailing slash except `/`; `util/match.ts normalizeRoute`), so `/se%61rch`, `/search/`, `//search` and `/search;x=1` fall under a `/search` rule, as they reach the same page on most servers (since 0.2.3 and 0.3.0-beta.5; before, such a link escaped the rule and got the global mode). RegExp and predicate rules see both forms. Matching is case-sensitive (`/Search` is not `/search`; use a RegExp with the `i` flag), and `/admin/*` does not cover `/admin` itself (use `/admin*` or a RegExp). `status.scope.route` stays the raw path. - `mode:"off"` means ops created on that route are pass-through, though they are still recorded in the timeline. - Ops created under `off` or `observe` scope **are never the target of an action** later, even from another route (gate step 3). - The scope is snapshotted per op, so a write held under heal on route A is decided under heal after navigating to route B. @@ -268,6 +272,7 @@ Runtime API: - An op held on route A is decided with A's scope. - An op from an `off` route is not offered as a target from route B. - RegExp, glob and predicate rules all work, and a throwing predicate is skipped. +- `/se%61rch`, `/search/`, `//search` and `/search;x=1` fall under a `/search` rule; `/Search` does not; `/admin/*` does not cover `/admin`. ### 4.4 `requests` **Type** `RequestScope`. **Default** `{ ignore:[], protect:[], crossOrigin:"observe", labels:[], labelsToModel:false }`. diff --git a/docs/runtime/RESULTS.md b/docs/runtime/RESULTS.md index ab5a033f..da40d6f2 100644 --- a/docs/runtime/RESULTS.md +++ b/docs/runtime/RESULTS.md @@ -400,6 +400,9 @@ walk, which missed the two shared chunks (2.74 + 0.19 KB). | limit in `bundle.test.ts` (measured at `bff4a71` + 1.5 KB, owner-approved for 0.3.0; not re-set: the release candidate was 0.13 / 0.10 KB under it, and with the vendor-name warning `/auto` is 0.03 KB under it) | 107.74 KB | 99.71 KB | none | | + measured visual checks' first-load stub: wave2/visual `6ae209f` merged (`release/0.3.0-visual`, merge `2c7014f`, PR head `be8cb7c`, run 38676978903; §13) | **108.25 KB** (102.28 + 3.05 + 2.74 + 0.19) | **100.13 KB** (94.15 + 3.05 + 2.74 + 0.19) | **128.56 KB** (376.5 KB minified) | | limit in `bundle.test.ts` for 0.3.0-beta.0, **owner-approved 2026-10-15** (the measured sizes + 0.05 / 0.07 KB) | 108.30 KB | 100.20 KB | none | +| + interaction analytics' first-load stub (§15; limits owner-approved 2026-10-18) | 108.53 KB | 100.40 KB | not measured | +| + review of the mode-raise fix: `normalizeRoute`, the `mode` validation and its warning, the sample bucket's `act@` form, `readWindowConfig`'s mode check (branch `security/advisory-mode-raise`, 0.3.0-beta.5; local, 2026-10-27, `test/first-load-size.mjs` and gzip -9 against main `49c8c03`'s dist: 108.36 / 100.28 / 128.55 KB) | **108.63 KB** (+0.27) | **100.46 KB** (+0.17) | **128.83 KB** (387.4 KB minified, +0.28) | +| limit in `bundle.test.ts` for 0.3.0-beta.5, **pending the owner's OK** (`/auto`: the measured size + 0.07 KB; the main entry keeps its limit, 0.02 KB under it) | 108.70 KB | 100.48 KB | none | Removing the vendor adapters changes no first load (they were lazy chunks of `/auto` and files the script tag loaded from the CDN). The one warning for the names `sentry`, `posthog` and `datadog` (`src/cdn/sinks.ts` `applySinks`: the diff --git a/docs/runtime/THREAT-MODEL.md b/docs/runtime/THREAT-MODEL.md index bd858b77..b035adbf 100644 --- a/docs/runtime/THREAT-MODEL.md +++ b/docs/runtime/THREAT-MODEL.md @@ -235,11 +235,14 @@ flows. **Threat.** Someone who can make a user open a link, or inject markup, changes GenClass's configuration. **Mitigations.** `?genclass-mode`, `?genclass-aggr` and `?genclass-sample=0` can only lower the mode, aggressiveness or -sampling unless the app sets `debug: true`; `?genclass=off` turns GenClass off for that visitor only -([runtime.ts](../../packages/runtime/src/runtime.ts) constructor, [index.ts](../../packages/runtime/src/index.ts) -`initUnsafe`). Since 0.2.2 `?genclass=` and `localStorage.genclass` also only demote, from the app's mode or from -`observe` when it sets none: a crafted link can no longer raise a production site to guard or heal. Raising from the -URL needs `debug: true` or a development host (`localhost`, `*.local`, `*.test`, private networks). With the zero-code +sampling, on every host, with or without `debug: true` (`debug` is console logging only); `?genclass=off` turns +GenClass off for that visitor only ([runtime.ts](../../packages/runtime/src/runtime.ts) constructor, +[index.ts](../../packages/runtime/src/index.ts) `initUnsafe`). Since 0.2.2 `?genclass=` and `localStorage.genclass` +also only demote, from the app's mode or from `observe` when it sets none: a crafted link can no longer raise a +production site to guard or heal. Raising from the URL needs a development host +([devhost.ts](../../packages/runtime/src/devhost.ts): http(s) on `localhost`, `127.0.0.1`, `[::1]` or `*.localhost` +with an explicit port, or `*.test`); before 0.3.0-beta.2 and 0.2.3, `debug: true` or a host that only looked local +(`*.local`, an intranet name, a private address, Capacitor, Tauri, Electron) let a link raise the mode. With the zero-code entries (`/auto`, the script tag), only `` tags inside `` are read, so markup injected into the body cannot configure GenClass, and the model, onnxruntime-web and webhook sink URLs a meta tag names must be same-origin (cross-origin ones are ignored with a warning; [cdn/config.ts](../../packages/runtime/src/cdn/config.ts) @@ -250,11 +253,26 @@ absolute once, at init ([cdn/sinks.ts](../../packages/runtime/src/cdn/sinks.ts)) move it. An injected `sink=webhook:` therefore cannot send the sink records (and their evidence text) to another origin. The other sink name, `console-json`, only prints to the page's own console. The runtime has no Sentry, PostHog or Datadog adapter, so an injected `sink=sentry` loads and sends nothing: it only prints one console warning. +Route rules (`routes[]`, OPTIONS-SPEC §4.3) apply to the normalised path as well as the raw one, so a link to a +look-alike path (`/se%61rch`, `/search/`, `//search`, `/search;x=1`) cannot step out of a rule that scopes `/search` +to `off` or `observe` (since 0.2.3 and 0.3.0-beta.5; a rule only lowers the mode, so the escape gave the visitor the +app's global mode, never more; [util/match.ts](../../packages/runtime/src/util/match.ts) `normalizeRoute`). An invalid +`mode` value (`"HEAL"`, an object, from `GenClass.init` or `window.GENCLASS_CONFIG`) runs as `observe` with a warning +instead of falling through the gate as heal (since 0.2.3 and 0.3.0-beta.5; app configuration only, never a URL). The +sampling bucket in `sessionStorage` is reused only under the `sample` it was drawn under, and `sample: 0` never reads +it, so a stale or injected `act` verdict cannot lift a sampled-out session (since 0.2.3 and 0.3.0-beta.5). **Residual.** Markup injected into `` can still set the mode and same-origin sinks (not a cross-origin model or webhook): pages that let untrusted HTML into `` should configure through `window.GENCLASS_CONFIG` (it wins) or -`GenClass.init(options)`. A same-origin endpoint that forwards elsewhere is the site's own. An app that sets -`debug: true` in production lets the URL raise its mode. +`GenClass.init(options)`. A same-origin endpoint that forwards elsewhere is the site's own. A production app served +from a loopback name with an explicit port (an Electron app with an embedded server, a self-hosted local tool on +`http://127.0.0.1:`) counts as a development host, so a crafted link can raise its mode with `?genclass=`; such +apps should not rely on the URL switch being demote-only. `devtools: "local"` (and `data-devtools="local"`) mounts the +overlay on every host [cdn/config.ts](../../packages/runtime/src/cdn/config.ts) `isLocalHost` counts as local +(`file:`, loopback with or without a port, `*.localhost`, `*.local`, `*.test`), which is wider than the development-host +test above: a Capacitor, Tauri or Electron build that ships it gets the overlay, and whoever is at the device can open +it (Alt+Shift+G) and raise the mode for that session, as any script in the origin can (T1). Not reachable from a link. +Set `devtools` per environment and do not ship `"local"` in such builds. ## T9. Cached and shared responses diff --git a/docs/security/2026-10-mode-raise.md b/docs/security/2026-10-mode-raise.md index b14ad69c..961c30be 100644 --- a/docs/security/2026-10-mode-raise.md +++ b/docs/security/2026-10-mode-raise.md @@ -14,7 +14,8 @@ Everything below was checked against the tags, the npm tarballs and the commits | Package | `@genclass/runtime` | | Affected versions | `>= 0.1.0-alpha.0, <= 0.2.2` (every 0.1.x prerelease, 0.2.0, 0.2.1, 0.2.2, npm's `latest`); `>= 0.3.0-beta.0, <= 0.3.0-beta.1` | | Patched versions | `0.3.0-beta.2` (npm dist-tag `beta`, 2026-10-25); `0.2.3` on dist-tag `latest` (pending, not published at the time of this draft) | -| Not affected | `0.0.0-stage` (a placeholder package with no code), `0.3.0-beta.2` and later | +| Not affected | `0.0.0-stage` (a placeholder package with no code), `0.3.0-beta.2` and later (for the review findings F1 to F4 below: `0.2.3`, `0.3.0-beta.5` and later) | +| Review findings | F1 to F4 under "Further findings from the review of the fix" (2026-10-27): patched in `0.2.3` (tagged `v0.2.3`, npm pending) and `0.3.0-beta.5` (pending). F1 is reachable from a link and bounded by the app's own global mode; F2 and F3 need the app's configuration or a script in the origin; F4 is hardening | | Severity | Medium, CVSS 3.1 5.4 (`CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:L`), see "Severity" | | CWE | CWE-15 (External Control of System or Configuration Setting); secondary CWE-489 (Active Debug Code) for the `debug` bypass | | Credits | the GenClass maintainers (found while testing 0.3.0-beta.0 in a pilot app, 2026-10-17) | @@ -167,10 +168,11 @@ GHSA ranges: `>= 0.1.0-alpha.0, <= 0.2.2` and `>= 0.3.0-beta.0, < 0.3.0-beta.2`. - `0.3.0-beta.2` (npm dist-tag `beta`), published 2026-10-25; `0.3.0-beta.3` and `0.3.0-beta.4` carry the fix. `npm install @genclass/runtime@beta`; script tags: pin `@0.3.0-beta.4` (or the newest beta). -- `0.2.3` (npm dist-tag `latest`): pending. Until it is published, a project on the stable line should either move to - `@beta` or apply the workarounds below. (owner: publish 0.2.3 from a `v0.2.3` tag through `release.yml`, then fill - the form's patched version for the `<= 0.2.2` range; until then the form can state "no patched version on the 0.2 - line yet".) +- `0.2.3` (npm dist-tag `latest`): tagged `v0.2.3` (commit `8694211`, 2026-10-27, on origin), not on npm at the time + of this draft (`latest` is still `0.2.2`). Until it is published, a project on the stable line should either move to + `@beta` or apply the workarounds below. (owner: `release.yml` publishes from the `v0.2.3` tag; then fill the form's + patched version for the `<= 0.2.2` range; until then the form can state "no patched version on the 0.2 line yet".) + `@beta` (`0.3.0-beta.4`) carries the headline fix but not F1 to F4 until `0.3.0-beta.5`. Where the fix lives: `packages/runtime/src/devhost.ts` (new), `src/index.ts` (`initUnsafe`), `src/runtime.ts` (constructor and `sampleBucket`), `src/visual/start.ts` (re-exports `devHost`), `test/mode-switch.test.ts` (new) and @@ -213,6 +215,90 @@ Notes on the table: - The `?genclass-mode` / `-aggr` / `-sample` switches never raised anything without `debug` in any version (`runtime.ts`, the rank and level comparisons), and `?genclass-sample=0` always lowered. +## Further findings from the review of the fix (2026-10-27) + +An adversarial review of the 0.2.3 backport found four more ways page-controlled input widened GenClass's behaviour. +They are fixed in commit `8694211` on `release/0.2.3` (tag `v0.2.3`) and ported to `main` for `0.3.0-beta.5` (branch +`security/advisory-mode-raise`). None changes the headline vector; each is listed with its own conditions and affected +versions. Evidence is the named function at `git show :packages/runtime/src/`; the version ranges were +checked against every `v*` tag (`routes`, the sample bucket, `?genclass-mode` and the named aggressiveness levels all +arrived in `0.1.0-beta.1`; `window.GENCLASS_CONFIG` in `0.1.0-beta.0`; the `mode` option and `modeAllows` exist in +every version). + +### F1. A look-alike path stepped out of a route rule (`routes[]`) + +- **What.** `routes[]` lets an app lower the mode on some paths (`{ match: "/checkout", mode: "observe" }`). A rule was + matched against the raw `location.pathname`, which the browser never percent-decodes, while most servers route on + the decoded path and tolerate a trailing slash, repeated slashes, dot segments and `;` parameters. A link to + `/checkout/`, `//checkout`, `/che%63kout`, `/checkout;x=1` or `/x/../checkout` reached the checkout page with the + app's global mode instead of the rule's (`runtime.ts` `computeScope`, `util/match.ts` `routeMatches`). +- **Reachable from a link**: yes, the headline vector (AV:N, UI:R, one visitor's page load). **Bound**: a rule only + lowers the mode, so the visitor got the app's own global mode (`guard` or `heal` only where the app opted into it), + never more; `requests.protect`, the cross-origin rule and the other bounds above still hold. +- **Affected**: every version with `routes`: `0.1.0-beta.1` to `0.2.2` and `0.3.0-beta.0` to `0.3.0-beta.4` + (`0.1.0-alpha.0`, `0.1.0-alpha.1` and `0.1.0-beta.0` have no route rules). +- **Fix**: a rule matches the raw path or its normalised form (`util/match.ts` `normalizeRoute`: percent-decoded, `;` + parameters dropped, dot segments and repeated slashes resolved, no trailing slash); `status.scope.route` stays raw; + matching stays case-sensitive, and `/admin/*` still does not cover `/admin` itself (both now documented). Tests: + `test/status-scope.test.ts` (ten look-alike forms, globs, RegExp and predicate rules, and a scripted guard-tier action + that does not run under an observe rule on `/se%61rch`). + +### F2. An invalid `mode` ran as heal + +- **What.** `mode` was not validated: `"HEAL"`, `"Guard"`, `"heal "`, an object or a number from `GenClass.init()` / + `createRuntime()`, or from `window.GENCLASS_CONFIG` (copied unchecked by `cdn/config.ts` `readWindowConfig`), was + stored as the mode. `MODE_RANK[mode]` is then `undefined`, so no URL switch could lower it (`?genclass-mode=observe` + compares against `undefined` and does nothing), and the gate's `modeAllows` fell through its `observe` and `guard` + branches to `return true`, permitting guard- and heal-tier actions (`decide/policy.ts`). +- **Reachable from a link**: no. It needs the app's configuration (a typo in an env-driven value such as + `mode: process.env.GENCLASS_MODE`) or a script in the origin that sets `window.GENCLASS_CONFIG` before GenClass starts + (such a script can call `setMode("heal")` directly, T1). The meta tag and the script tag's `data-mode` were already + validated (`cdn/config.ts` `fromPairs`, `MODES.includes`), so injected markup could not use it. +- **Affected**: every version: `0.1.0-alpha.0` to `0.2.2` and `0.3.0-beta.0` to `0.3.0-beta.4` (`modeAllows` has the + same fall-through in `0.1.0-alpha.0`; `window.GENCLASS_CONFIG` since `0.1.0-beta.0`). +- **Fix**: the constructor and `readWindowConfig` accept only `observe`, `guard` and `heal`; anything else runs as + `observe` with one warning; `modeAllows` permits a non-passive tier only for `guard` and `heal`, so the gate fails + closed. Tests: `test/options.test.ts`, `test/policy.test.ts`. + +### F3. A stored sample verdict overrode `sample: 0` + +- **What.** The act/observe bucket (`sample`, OPTIONS-SPEC §4.2) is drawn once and frozen in + `sessionStorage["genclass:bucket"]` as `act` or `observe`. `sampleBucket` read the stored value before looking at + `sample`, so a tab that drew `act` under `sample: 0.5` kept acting after a deploy set `sample: 0` (until the tab + closed), and a same-origin script could write `act` to lift a session the app had sampled out (`runtime.ts` + `sampleBucket`). +- **Reachable from a link**: no (`?genclass-sample=0` always lowered, and no URL value writes storage). It needs a + deploy that lowers `sample` while tabs are open, or script access to the origin's `sessionStorage` (T1). +- **Affected**: every version with `sample`: `0.1.0-beta.1` to `0.2.2` and `0.3.0-beta.0` to `0.3.0-beta.4`. +- **Fix**: `sample: 0` (like `1`) never reads or writes the storage; a verdict is stored with the sample it was drawn + under (`act@0.5`) and reused only under that sample. Open tabs are re-bucketed once on upgrade. Tests: + `test/options.test.ts`. + +### F4. `aggressiveness` level names were looked up with `in` (hardening) + +- **What.** `aggressivenessLevel` checked a string with `a in LEVEL_AT` on a plain object, so an `Object.prototype` + member name (`"constructor"`, `"__proto__"`, `"valueOf"`) returned a function instead of a number. From the option or + a route rule it reached the gate as the level and produced `NaN` thresholds; from `?genclass-aggr=` the lower-only + comparison rejected it, so a link had no effect. +- **Impact**: nothing found that widens behaviour: a `NaN` threshold clears nothing (`decide/policy.ts`: `!(mass >= th)` + and `p >= g.report` both reject), so the page acted on and reported nothing at that gate. Listed as hardening. +- **Affected**: `0.1.0-beta.1` to `0.2.2` and `0.3.0-beta.0` to `0.3.0-beta.4` (the `in` lookup arrived with the named + levels in `0.1.0-beta.1`). +- **Fix**: only `cautious`, `balanced` and `eager` are names (`LEVEL_NAMES.includes`); anything else is `balanced`, as + documented. Test: `test/gates.test.ts`. + +### Patched versions for F1 to F4 + +- `0.2.3` (stable line): commit `8694211`, tag `v0.2.3` on origin (2026-10-27); not on npm at the time of this draft. +- `0.3.0-beta.5` (beta line): the port to `main` on branch `security/advisory-mode-raise`; not tagged or published at + the time of this draft. `0.3.0-beta.2` to `0.3.0-beta.4` carry the headline fix but not F1 to F4. + +GHSA ranges: F1, F3 and F4 `>= 0.1.0-beta.1, <= 0.2.2` and `>= 0.3.0-beta.0, <= 0.3.0-beta.4`; F2 +`>= 0.1.0-alpha.0, <= 0.2.2` and `>= 0.3.0-beta.0, <= 0.3.0-beta.4`. (owner: either widen this advisory's affected +range to `<= 0.3.0-beta.4` with `0.3.0-beta.5` as the patched version once it is published and name F1 to F4 in it, or +publish F1, the only one reachable from a link, as its own advisory and keep F2 to F4 in the CHANGELOG. The version +tables above are for the headline issue only.) + ## Severity `CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:L`, base score 5.4 (Medium). @@ -232,6 +318,13 @@ Conservative reading: this is a per-visitor, per-page-load downgrade of a safety cross-user effect. The versions with no condition at all (0.1.0-alpha.0 to 0.1.0-beta.0) are unsupported and were on npm for days; the same vector applies to them with the same impact. +The review's findings (F1 to F4) do not change the score. F1 shares the headline vector (a link, one visitor's page +load, UI:R) with a smaller effect: the app's own global mode on a path it had scoped lower, never a mode the app did +not opt into, so I:L / A:L still bound it; published on its own it would carry the same vector and score (5.4, or 4.2 +with AC:H), with that bound noted. F2 and F3 need the app's configuration or script access to the origin (out of +scope: such a script can call `setMode("heal")` directly), so on their own they are AV:L with PR:H at most, below the +headline score; F4 widens nothing. + ## Workarounds For an affected version that cannot be upgraded yet (the stable line until `0.2.3` is out): @@ -264,6 +357,11 @@ For an affected version that cannot be upgraded yet (the stable line until `0.2. 6. **Limit what a raised mode could touch**: keep payment, checkout and sign-in endpoints under `requests.protect` (`protectPreset("payments", "auth")`), deny actions you never want with `policy.deny`, and leave `visual.fix` off. These are the production recommendations in SECURITY.md and INTERCEPTION.md and they apply in every mode. +7. **For the review's findings on an affected version** (until `0.2.3` or `0.3.0-beta.5`): F1, use a predicate rule + that decodes the path and strips `;` parameters and trailing or repeated slashes before comparing, or keep the + app's global mode at the lowest level it needs rather than relying on a rule to lower it on one path; F2, set + `mode` from a checked value (`["observe", "guard", "heal"].includes(v) ? v : "observe"`); F3, a deploy that lowers + `sample` to 0 should also set `mode: "observe"` until the open tabs have closed. ## Credits @@ -284,7 +382,9 @@ Found by the GenClass maintainers while testing `0.3.0-beta.0` in a pilot app on | 2026-10-24 | the fix's code and tests ported onto `main` (`5a6b48f`, merge `c2379a7`) | | 2026-10-25 | `0.3.0-beta.2` tagged and published with the fix (npm dist-tag `beta`) | | 2026-10-26, 2026-10-27 | `0.3.0-beta.3`, `0.3.0-beta.4` carry it | -| pending | `0.2.3` on npm's `latest`; this advisory; docs on `main` updated (see the notes below) | +| 2026-10-27 | the adversarial review of the 0.2.3 backport finds F1 to F4; fixed in `8694211` on `release/0.2.3`, tagged `v0.2.3` (not yet on npm) | +| 2026-10-27 | F1 to F4 ported to `main` for `0.3.0-beta.5` (branch `security/advisory-mode-raise`), with the docs below | +| pending | `0.2.3` on npm's `latest`; `0.3.0-beta.5` tagged and published; this advisory; docs on `main` updated (see the notes below) | ## References @@ -295,6 +395,10 @@ Found by the GenClass maintainers while testing `0.3.0-beta.0` in a pilot app on - `packages/runtime/INTERCEPTION.md` ("What each mode may change", "What it never does", "Actions: preconditions and reversibility"), `docs/runtime/THREAT-MODEL.md` T8, `SECURITY.md` ("Recommended for production"). - `packages/runtime/CHANGELOG.md`, the `0.2.2` security entry (the earlier, partial fix). +- Review findings F1 to F4: commit `8694211` (`release/0.2.3`, tag `v0.2.3`); the `main` port on branch + `security/advisory-mode-raise`; `packages/runtime/CHANGELOG.md` "Unreleased (0.3.0-beta.5)" and "0.2.3"; + `packages/runtime/src/util/match.ts` `normalizeRoute`, `src/runtime.ts` (constructor, `sampleBucket`, + `computeScope`), `src/cdn/config.ts` `readWindowConfig`, `src/decide/policy.ts` (`aggressivenessLevel`, `modeAllows`). ## Notes for the owner before publishing (not part of the advisory text) @@ -314,3 +418,12 @@ Found by the GenClass maintainers while testing `0.3.0-beta.0` in a pilot app on release goes through `.github/workflows/release.yml` on a `v0.2.3` tag whose version equals `packages/runtime/package.json` on that branch. - The CVSS Attack Complexity choice (L, 5.4, or H, 4.2) is yours; the table above explains both. +- F1 to F4 are on no published version yet: `0.2.3` is tagged, `0.3.0-beta.5` is not. Decide whether this advisory + covers them (then its affected range becomes `<= 0.3.0-beta.4` and the patched versions `0.2.3` / `0.3.0-beta.5`) or + F1 gets its own; F2 to F4 can stay CHANGELOG entries either way. +- The `main` port of F1 to F4 raises `packages/runtime/test/bundle.test.ts`'s `/auto` first-load limit from 108.6 to + 108.7 KB (the fixes add 0.27 KB to `/auto` and 0.17 KB to the main entry; RESULTS.md section 10), pending your OK as + the earlier limits were. It also brings OPTIONS-SPEC §3 and THREAT-MODEL T8 in line with `0.3.0-beta.2`'s code (the + stale `debug` wording listed above); CONTRACT.md line 540, the README's `?genclass-sample=1` recipe and + INTERCEPTION.md's "raises only with `debug: true`" sentence are still as listed and need your OK (CONTRACT) or a + separate docs change. diff --git a/packages/runtime/CHANGELOG.md b/packages/runtime/CHANGELOG.md index d918ed82..c3372da4 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -1,5 +1,50 @@ # Changelog +## Unreleased (0.3.0-beta.5) + +Security fixes from the adversarial review of the mode-raise fix (`0.3.0-beta.2`); the same four fixes are in `0.2.3` +on the stable line (tag `v0.2.3`). Nothing else changed: no model-visible text (`situation-v2.3`), telemetry and its +opt-outs are unchanged, and the default model is unchanged. + +### Security + +- **A look-alike path no longer steps out of a route rule.** `routes[]` matched the raw `location.pathname`, which the + browser never percent-decodes, while most servers route on the decoded path: a link to `/se%61rch`, `/search/`, + `//search` or `/search;x=1` reached the `/search` page with the app's global mode although a rule scoped `/search` + to `off` or `observe` (a rule can only lower the mode, so the visitor got the global mode, never more). A rule now + matches the raw path or its normalised form (percent-decoded, `;` parameters dropped, dot segments and repeated + slashes resolved, no trailing slash; `util/match.ts normalizeRoute`); `status.scope.route` is still the raw path. + Matching is case-sensitive, and `/admin/*` does not cover `/admin` itself (use `/admin*` or a RegExp); both are now + documented. Affected: every version with `routes` (`0.1.0-beta.1` to `0.2.2`, `0.3.0-beta.0` to `0.3.0-beta.4`). + Tests: `test/status-scope.test.ts`; docs: OPTIONS-SPEC §4.3, API.md, THREAT-MODEL T8. +- **An invalid `mode` ran as heal.** `mode: "HEAL"`, `"Guard"`, `"heal "`, an object or a number (a typo in an + env-driven config, or `window.GENCLASS_CONFIG`, which was copied unchecked) fell through the mode ranking and the + gate's `modeAllows` and ran guard- and heal-tier actions, which `?genclass-mode=observe` could not lower. Now any + value but `observe`, `guard` or `heal` runs as `observe`, with one warning (`window.GENCLASS_CONFIG.mode` is + validated like the meta tag and the data attributes already were), and `modeAllows` permits a non-passive tier only + for `guard` and `heal`, so the gate fails closed. App configuration only, never reachable from a URL. Affected: every + version (`0.1.0-alpha.0` to `0.2.2`, `0.3.0-beta.0` to `0.3.0-beta.4`; `window.GENCLASS_CONFIG` since `0.1.0-beta.0`). + Tests: `test/options.test.ts`, `test/policy.test.ts`; docs: OPTIONS-SPEC §3, API.md, THREAT-MODEL T8. +- **A stored sample verdict overrode `sample: 0`.** The act/observe bucket is frozen in + `sessionStorage["genclass:bucket"]`, and a tab that drew `act` under `sample: 0.5` kept acting after a deploy set + `sample: 0`, until it closed (a same-origin script could write the value too). Now `sample: 0` never reads the + storage, and a verdict is stored with the sample it was drawn under (`act@0.5`) and reused only under that sample: a + change of `sample` draws again. Open tabs are re-bucketed once on upgrade. Affected: every version with `sample` + (`0.1.0-beta.1` to `0.2.2`, `0.3.0-beta.0` to `0.3.0-beta.4`). Tests: `test/options.test.ts`; docs: OPTIONS-SPEC + §4.2, INTERCEPTION.md. +- **Hardening: `aggressiveness` level names.** The three names were looked up with `in` on a plain object, so + `"constructor"`, `"__proto__"`, `"valueOf"` or another `Object.prototype` member name (from the option, a route rule + or `?genclass-aggr=`) gave a function instead of a number, and NaN gate thresholds downstream (which clear nothing, + so the page acted on and reported nothing at that gate; from the URL the value was never applied). Only `cautious`, + `balanced` and `eager` are names now; anything else is `balanced`, as documented. Test: `test/gates.test.ts`. +- **Docs: `devtools: "local"` is wider than the development-host test.** It mounts the overlay on every host + `isLocalHost` counts as local (`file:`, loopback with or without a port, `*.localhost`, `*.local`, `*.test`), so a + Capacitor, Tauri or Electron build that ships it gets the overlay, whose mode switch raises the mode for whoever is at + the device. Unchanged in code; documented in THREAT-MODEL T8 and the README. Set `devtools` per environment. +- **Docs: OPTIONS-SPEC §3 and THREAT-MODEL T8 now describe the URL switches as `0.3.0-beta.2` left them** (lower-only + on every host, `debug` as console logging only, raising only on a development host); they still carried the + pre-fix wording. + ## 0.3.0-beta.4 (2026-10-27) - **The devtools pill counts measured checks** (`src/devtools/`, VISUAL.md 9.3). The collapsed pill showed only diff --git a/packages/runtime/INTERCEPTION.md b/packages/runtime/INTERCEPTION.md index 51def310..d6e597c8 100644 --- a/packages/runtime/INTERCEPTION.md +++ b/packages/runtime/INTERCEPTION.md @@ -141,7 +141,7 @@ from ever being held. | Network: the model card, tokenizer, calibration, meta and the model variant (about 10 MB) from `model.baseUrl` (default jsDelivr `@genclass/runtime-model`), and onnxruntime-web's wasm from `model.ortWasmPaths` | with the page's fetch as it was when the module loaded (never through the wrapper) | at idle after load, `GenClass.init` in a browser with a model; not on devices reporting 2 GB or less nor on Save-Data (`model.loadIf` default) | [src/model/loader.ts](src/model/loader.ts), [src/model/backend.ts](src/model/backend.ts) | | Network: anonymous diagnostics to the GenClass collector (`DEFAULT_TELEMETRY_ENDPOINT`, a Cloudflare Worker): a summary a minute while active, records of at most 200 decisions per page load (routine ones 1 in 4) | native `fetch`/`sendBeacon` captured at module load, `text/plain`, `credentials: "omit"`, `referrerPolicy: "no-referrer"` | `GenClass.init` in a browser unless turned off ([TELEMETRY.md](TELEMETRY.md)); never in `createRuntime()` | [src/telemetry/](src/telemetry/) | | Cache Storage | `genclass-model` cache (`model.cacheName`): model files, each checked against the card's size and sha256 | model load | [src/model/loader.ts](src/model/loader.ts) `fetchFile` | -| `sessionStorage` | `genclass:bucket` (`sample`), the breaker's trip state | `sample` < 1; breaker trips | [src/runtime.ts](src/runtime.ts) `sampleBucket`, [src/decide/breaker.ts](src/decide/breaker.ts) | +| `sessionStorage` | `genclass:bucket` (`sample`: `act@` or `observe@`, reused only under the same `sample`; never read or written at `sample` 0 or 1), the breaker's trip state | `sample` < 1; breaker trips | [src/runtime.ts](src/runtime.ts) `sampleBucket`, [src/decide/breaker.ts](src/decide/breaker.ts) | | `localStorage` / `sessionStorage` | `genclass:learn` (learned flows by default; operation shapes too with `learn.persist`: signatures, routes and field names with id-like parts normalised, counts, hashed action labels; ≤ `learn.maxBytes`), `genclass:learn:probe` (set and removed once at init to check storage works), `genclass:learn:session` in sessionStorage (the tab's session ordinal) | flows by default, not in jsdom / happy-dom (`learn.persist: false` turns it all off); the runtime's own writes are not recorded as storage events | [src/runtime.ts](src/runtime.ts) `saveProfilesSoon`, `startSession`, [docs/runtime/EXPECTATIONS.md](../../docs/runtime/EXPECTATIONS.md) | | `localStorage` | `genclass.analytics.`: the visit id (16 random hex characters), the time of the visit's last page view or event, and a visited-before flag; written with the storage observer muted (`rt.selfStorage`), so it never becomes a `storage` event | interaction analytics on (`InitOptions.analytics`, `rt.analytics.start()`): each page view and each `interactions` event; removed by `rt.analytics.stop()` | [src/analytics/index.ts](src/analytics/index.ts) `startAnalytics` | | `localStorage` (read only) | `genclass` (kill switch), `genclass.telemetry` (opt-out) | init | [src/index.ts](src/index.ts) `killSwitch`, [src/telemetry/config.ts](src/telemetry/config.ts) | diff --git a/packages/runtime/README.md b/packages/runtime/README.md index afba33d3..742c457d 100644 --- a/packages/runtime/README.md +++ b/packages/runtime/README.md @@ -271,7 +271,8 @@ returns an inert runtime. It exposes `window.GenClass`, discovers the app's state like the import does, and loads the model worker, ONNX Runtime Web and the overlay on demand from the same version on the CDN. `data-mode` takes `observe` (the default), `guard` or `heal`; `data-devtools="local"` shows the -overlay only on localhost; `data-manual` skips the automatic `GenClass.init()`. Since `0.3.0`: `data-token="gc_..."` +overlay only on local hosts (loopback, `*.localhost`, `*.local`, `*.test` and `file:`, so also in a Capacitor, Tauri or +Electron build that ships it); `data-manual` skips the automatic `GenClass.init()`. Since `0.3.0`: `data-token="gc_..."` (your app's token), `data-scope="functions"` and `window.GenClass.protect`. Keep the version pinned, as above: the unversioned URL serves npm's `latest` (the older `0.2.x` until `0.3.0` is stable); the plain-HTML path of `init` writes a pinned jsDelivr URL with SRI. diff --git a/packages/runtime/src/cdn/auto-start.ts b/packages/runtime/src/cdn/auto-start.ts index 0750ed16..7434950d 100644 --- a/packages/runtime/src/cdn/auto-start.ts +++ b/packages/runtime/src/cdn/auto-start.ts @@ -23,7 +23,7 @@ export function startAuto(defaults: InitOptions = {}): Runtime { if (typeof g.window !== "object" || typeof g.document !== "object" || !g.document) return GenClass.init(defaults); const doc = g.document as Document; // automatic state discovery is on by default here (installed now, before the framework's modules run) - const { init, devtools, sinks } = splitConfig(mergeConfig(AUTO_DEFAULTS, defaults, readMetaConfig(doc, { location: g.location, warn: (m) => g.console?.warn?.(m) }), readWindowConfig(g.window))); + const { init, devtools, sinks } = splitConfig(mergeConfig(AUTO_DEFAULTS, defaults, readMetaConfig(doc, { location: g.location, warn: (m) => g.console?.warn?.(m) }), readWindowConfig(g.window, (m) => g.console?.warn?.(m)))); // sinks by name (meta tag, window.GENCLASS_CONFIG) become one lazy proxy; a webhook URL joins requests.ignore const rt = GenClass.init(applySinks(init, sinks, resolveSinks, (m) => g.console?.warn?.(`[GenClass] ${m}`), globalThis as unknown as Record)); const dt = devtoolsOptions(devtools, g.location); diff --git a/packages/runtime/src/cdn/config.ts b/packages/runtime/src/cdn/config.ts index dc8d0295..db5f6985 100644 --- a/packages/runtime/src/cdn/config.ts +++ b/packages/runtime/src/cdn/config.ts @@ -251,11 +251,18 @@ export function readMetaConfig( return c; } -/** `window.GENCLASS_CONFIG` (any InitOptions plus `devtools`). */ -export function readWindowConfig(win: unknown): PageConfig { +/** `window.GENCLASS_CONFIG` (any InitOptions plus `devtools`). A `mode` that is not observe, guard or heal is dropped, with one warning. */ +export function readWindowConfig(win: unknown, warn?: (m: string) => void): PageConfig { try { const c = (win as { GENCLASS_CONFIG?: unknown } | null | undefined)?.GENCLASS_CONFIG; - return c && typeof c === "object" ? ({ ...(c as PageConfig) } as PageConfig) : {}; + if (!c || typeof c !== "object") return {}; + const out = { ...(c as PageConfig) } as PageConfig; + const m = out.mode as unknown; + if (m !== undefined && !(typeof m === "string" && MODES.includes(m))) { + warn?.(`[GenClass] window.GENCLASS_CONFIG.mode must be "observe", "guard" or "heal" (got ${typeof m === "string" ? JSON.stringify(m) : typeof m}); ignored.`); + delete out.mode; + } + return out; } catch { return {}; } diff --git a/packages/runtime/src/cdn/global.ts b/packages/runtime/src/cdn/global.ts index 6121f3a3..e5fe006b 100644 --- a/packages/runtime/src/cdn/global.ts +++ b/packages/runtime/src/cdn/global.ts @@ -155,7 +155,7 @@ function install(): void { init(options: PageConfig = {}): Runtime { // the page's configuration (meta tag, the tag's data attributes, window.GENCLASS_CONFIG) is the default // automatic state discovery is on by default for the script tag (installed now, before the framework loads) - const { init, devtools, sinks } = splitConfig(mergeConfig({ autoState: true }, readMetaConfig(document, { location, warn: (m) => console.warn(m) }), fromDataset(ds), readWindowConfig(window), options)); + const { init, devtools, sinks } = splitConfig(mergeConfig({ autoState: true }, readMetaConfig(document, { location, warn: (m) => console.warn(m) }), fromDataset(ds), readWindowConfig(window, (m) => console.warn(m)), options)); const rt = GenClass.init(withCdnModel(applySinks(init, sinks, resolveSinks, (m) => console.warn(`[GenClass] ${m}`), window as unknown as Record))); const dt = devtoolsOptions(devtools, location); if (dt && !isKilled(window)) void mount(rt, dt); diff --git a/packages/runtime/src/decide/policy.ts b/packages/runtime/src/decide/policy.ts index 8fbdd2cd..5e5e0e63 100644 --- a/packages/runtime/src/decide/policy.ts +++ b/packages/runtime/src/decide/policy.ts @@ -112,7 +112,7 @@ const LEVEL_AT = { cautious: 0, balanced: 0.5, eager: 1 } as const; export function aggressivenessLevel(a: Aggressiveness | string | undefined | null): number { if (typeof a === "number" && Number.isFinite(a)) return Math.min(1, Math.max(0, a)); if (typeof a === "string") { - if (a in LEVEL_AT) return LEVEL_AT[a as keyof typeof LEVEL_AT]; + if ((LEVEL_NAMES as readonly string[]).includes(a)) return LEVEL_AT[a as keyof typeof LEVEL_AT]; const n = Number(a); if (a.trim() !== "" && Number.isFinite(n)) return Math.min(1, Math.max(0, n)); } @@ -230,11 +230,11 @@ export function possibleGate(raw: ModelGate | undefined, report: number): number return eager !== undefined && eager < report ? eager : undefined; } +/** Whether a mode permits a tier: observe nothing, guard the guard tier, heal every tier. Any other value fails closed. */ export function modeAllows(mode: Mode, tier: Tier): boolean { if (tier === "passive") return true; - if (mode === "observe") return false; if (mode === "guard") return tier === "guard"; - return true; + return mode === "heal"; } /** Why an action may not run under this mode and policy (null when it is permitted). */ diff --git a/packages/runtime/src/runtime.ts b/packages/runtime/src/runtime.ts index b997a32c..df1fe6b6 100644 --- a/packages/runtime/src/runtime.ts +++ b/packages/runtime/src/runtime.ts @@ -68,7 +68,7 @@ import { AuditLog, type AuditDraft } from "./decide/audit.js"; import { expandPresets } from "./presets.js"; import { Breaker, breakerConfig } from "./decide/breaker.js"; import { SinkDispatcher, SummaryTracker } from "./decide/summary.js"; -import { compileMatchers, compileValued, hash32, routeMatches, sanitizeLabel, type CompiledMatcher, type MatchInput } from "./util/match.js"; +import { compileMatchers, compileValued, hash32, normalizeRoute, routeMatches, sanitizeLabel, type CompiledMatcher, type MatchInput } from "./util/match.js"; import { browserClock } from "./clock.js"; import { GenClassUnavailableError } from "./errors.js"; import { EventLog } from "./trace/events.js"; @@ -469,7 +469,11 @@ export class RuntimeImpl implements Runtime { this.scope = o.scope === "functions" ? "functions" : "app"; // URL overrides only lower, on every host (OPTIONS-SPEC §3); `debug` is console logging only and never raises them const url = this.urlParams(); - this._mode = o.mode ?? "observe"; + // the mode option itself is validated: anything but the three modes runs as observe (an unknown value used to fall + // through minMode and modeAllows as heal) + const om = o.mode as unknown; + this._mode = om === "observe" || om === "guard" || om === "heal" ? om : "observe"; + if (om !== undefined && om !== this._mode) this.warn(`mode must be "observe", "guard" or "heal" (got ${typeof om === "string" ? JSON.stringify(om) : typeof om}); running in observe.`); const um = url.get("genclass-mode"); if (um === "observe" || um === "guard" || um === "heal") if (MODE_RANK[um] <= MODE_RANK[this._mode]) this._mode = um; if (o.policy?.maxActionsPerMinute !== undefined && o.policy?.actionLimits?.perMinute !== undefined) this.warn("policy.maxActionsPerMinute is deprecated and ignored when actionLimits.perMinute is set."); @@ -722,8 +726,10 @@ export class RuntimeImpl implements Runtime { } /** - * The session's act/observe bucket (OPTIONS-SPEC §4.2): hash(session id) < sample, frozen in sessionStorage. - * `?genclass-sample=0` puts it in the observe bucket; no URL value puts it in the act bucket. + * The session's act/observe bucket (OPTIONS-SPEC §4.2): hash(session id) < sample, frozen in sessionStorage as + * `act@` / `observe@`, so a verdict drawn under another `sample` (a deploy changed it) is drawn again. + * `?genclass-sample=0` and `sample: 0` put it in the observe bucket without reading storage; no URL value puts it in + * the act bucket. */ private sampleBucket(sample: number | undefined, urlSample: string | null): boolean { if (urlSample === "0") return false; @@ -734,16 +740,19 @@ export class RuntimeImpl implements Runtime { p = Number.isFinite(p) ? Math.min(1, Math.max(0, p)) : 1; } if (p >= 1) return true; + if (p <= 0) return false; const ss = this.storage("session"); + const actAt = `act@${p}`; + const observeAt = `observe@${p}`; try { const saved = ss?.getItem("genclass:bucket"); - if (saved === "act" || saved === "observe") return saved === "act"; + if (saved === actAt || saved === observeAt) return saved === actAt; } catch { /* deterministic within the page */ } const act = hash32(this.sessionId) / 4294967296 < p; try { - ss?.setItem("genclass:bucket", act ? "act" : "observe"); + ss?.setItem("genclass:bucket", act ? actAt : observeAt); } catch { /* ignore */ } @@ -755,15 +764,20 @@ export class RuntimeImpl implements Runtime { return typeof loc?.pathname === "string" ? loc.pathname : "/"; } - /** Route scope (OPTIONS-SPEC §4.3): the first matching rule, demote only. Recomputed on navigation. */ + /** + * Route scope (OPTIONS-SPEC §4.3): the first rule that matches the raw path or its normalised form (percent-decoded, + * no `;` parameters, dot segments and repeated slashes resolved, no trailing slash), demote only. Recomputed on + * navigation. `scope.route` stays the raw path. + */ computeScope(): void { const route = this.currentRoute(); + const norm = normalizeRoute(route); let mode: ModeOrOff = "heal"; let aggr = 1; let rule: number | undefined; for (let i = 0; i < this.routes.length; i++) { const r = this.routes[i]; - if (!r || !routeMatches(r.match, route)) continue; + if (!r || !(routeMatches(r.match, route) || (norm !== route && routeMatches(r.match, norm)))) continue; rule = i; if (r.mode === "off" || r.mode === "observe" || r.mode === "guard" || r.mode === "heal") { if (MODE_RANK[r.mode] > MODE_RANK[this._mode]) { diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index de1c5643..0acbae98 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -503,7 +503,12 @@ export type ModeOrOff = Mode | "off"; export type RequestMatcher = string | RegExp | ((r: { url: string; method: string; channel: "fetch" | "xhr" | "ws" | "sse" }) => boolean); export interface RouteRule { - /** Exact path or glob ("/admin/*"), RegExp, or predicate over the route. */ + /** + * Exact path or glob ("/admin/*"), RegExp, or predicate over the route. Matched against the raw `location.pathname` + * and against its normalised form (percent-decoded, `;` parameters dropped, dot segments and repeated slashes + * resolved, no trailing slash), so "/se%61rch", "/search/" and "//search" fall under a "/search" rule. Case-sensitive; + * "/admin/*" does not cover "/admin" itself (use "/admin*" or a RegExp). + */ match: string | RegExp | ((route: string) => boolean); /** Demote only. */ mode?: ModeOrOff; @@ -824,7 +829,7 @@ export interface InitOptions { * "guard" (opt-in) takes minimal guard-tier actions at very high confidence; "heal" is experimental. The URL * switches (`?genclass=`, `?genclass-mode=`) and `localStorage.genclass` may only lower it, with or without `debug`; * `?genclass=` and `localStorage.genclass` may raise it only on a development host (http(s) on localhost, 127.0.0.1, - * [::1] or *.localhost with an explicit port, or *.test). + * [::1] or *.localhost with an explicit port, or *.test). Any other value runs as "observe", with one warning. */ mode?: Mode; /** false: no model (observe-only). */ @@ -839,8 +844,9 @@ export interface InitOptions { /** Default true. A predicate / Promise / subscribable flag; while false nothing is decided or downloaded. */ enabled?: boolean | (() => boolean | Promise) | EnabledSource; /** - * Fraction of sessions allowed to act (default 1); the rest observe. `?genclass-sample=0` makes a session observe; no - * URL value makes it act. + * Fraction of sessions allowed to act (default 1); the rest observe. The bucket is drawn once per session and kept in + * sessionStorage with the `sample` it was drawn under; a change of `sample` draws it again, and 0 never reads it. + * `?genclass-sample=0` makes a session observe; no URL value makes it act. */ sample?: number; /** Per-route mode and aggressiveness (demote only); the first matching rule wins. */ diff --git a/packages/runtime/src/util/match.ts b/packages/runtime/src/util/match.ts index 9f3670c4..06b3f476 100644 --- a/packages/runtime/src/util/match.ts +++ b/packages/runtime/src/util/match.ts @@ -109,6 +109,30 @@ export function routeMatches(m: string | RegExp | ((route: string) => boolean), return false; } +/** + * A route's second form for rule matching (OPTIONS-SPEC §4.3): percent-decoded, `;` path parameters dropped, dot + * segments resolved, repeated slashes collapsed, no trailing slash (except "/"). The browser never decodes + * `location.pathname`, while most servers route on the decoded path, so "/se%61rch", "/search/", "//search" and + * "/search;x=1" are "/search" to them. A route rule applies when it matches the raw path or this form; rules only lower + * the mode, so the broader match is safe. Case is kept: matching is case-sensitive. A malformed escape keeps the raw path. + */ +export function normalizeRoute(path: string): string { + let p = path; + try { + p = decodeURIComponent(p); + } catch { + /* a malformed escape: keep the raw path */ + } + const out: string[] = []; + for (const raw of p.split("/")) { + const seg = raw.replace(/;.*$/, ""); + if (seg === "" || seg === ".") continue; + if (seg === "..") out.pop(); + else out.push(seg); + } + return "/" + out.join("/"); +} + /** Labels: names only. [A-Za-z0-9 _-], ≤ 5 words, ≤ 40 chars. */ export function sanitizeLabel(s: string): { label: string; changed: boolean } { const cleaned = String(s).replace(/[^A-Za-z0-9 _-]+/g, " ").replace(/\s+/g, " ").trim(); diff --git a/packages/runtime/test/bundle.test.ts b/packages/runtime/test/bundle.test.ts index 472da5ed..0f8b158e 100644 --- a/packages/runtime/test/bundle.test.ts +++ b/packages/runtime/test/bundle.test.ts @@ -89,7 +89,12 @@ describe.skipIf(!built)("dist as an app bundles it", () => { // 0.29 KB to /auto and 0.27 KB to the main entry over main (108.25 / 100.13 KB; 108.53 / 100.40 KB with it, measured // with test/first-load-size.mjs on the train VM, 2026-10-18); the recording itself is a lazy chunk (dist/analytics/index.js, asserted below). The // limits are the measured sizes plus 0.05 / 0.07 KB, as before. Both sizes are printed before either is asserted. - const LIMIT_KB: Record = { "auto.js": 108.6, "index.js": 100.48 }; + // Review of the mode-raise fix (branch security/advisory-mode-raise, 0.3.0-beta.5; the /auto limit below is pending + // the owner's OK): normalizeRoute, the mode validation and its warning, the sample bucket's act@ form and + // readWindowConfig's mode check add 0.27 KB to /auto and 0.17 KB to the main entry over main at 49c8c03 (108.63 / + // 100.46 KB, test/first-load-size.mjs against main's dist, 2026-10-27; docs/runtime/RESULTS.md section 10). /auto's + // limit is that size plus 0.07 KB; the main entry stays 0.02 KB under its limit, which is unchanged. + const LIMIT_KB: Record = { "auto.js": 108.7, "index.js": 100.48 }; it("first-load cost of /auto and the main entry, minified + gzip: under the limits", async () => { const { build } = await import("esbuild"); const uses: Record = { "auto.js": "", "index.js": "GenClass.init({});" }; diff --git a/packages/runtime/test/gates.test.ts b/packages/runtime/test/gates.test.ts index a20d6868..9c3c55ed 100644 --- a/packages/runtime/test/gates.test.ts +++ b/packages/runtime/test/gates.test.ts @@ -212,6 +212,23 @@ describe("aggressiveness (batch 11)", () => { expect(parseGate(P)?.profiles?.eager).toEqual({ report: 0.5, guard: { default: 0.6 }, heal: { default: 0.5 } }); expect([aggressivenessLevel("cautious"), aggressivenessLevel("eager"), aggressivenessLevel(0.3), aggressivenessLevel("0.8"), aggressivenessLevel("x"), aggressivenessLevel(7)]).toEqual([0, 1, 0.3, 0.8, 0.5, 1]); }); + // Review of the mode-raise fix (0.2.3, 0.3.0-beta.5): the level names were looked up with `in` on a plain object, so an + // Object.prototype member name gave a function instead of a number (and NaN gates downstream). Only the three names + // count; anything else is balanced. + it("only the three level names are levels: Object.prototype member names are balanced, from the option, a route rule or the URL", async () => { + const { aggressivenessLevel } = await import("../src/decide/policy.js"); + for (const n of ["constructor", "__proto__", "valueOf", "toString", "hasOwnProperty", "isPrototypeOf"]) { + expect(aggressivenessLevel(n), n).toBe(0.5); + expect(setup({ aggressiveness: n as never }).rt.aggressiveness, n).toBe(0.5); + expect(setup({ routes: [{ match: "/search", aggressiveness: n as never }] }).rt.status.scope?.aggressiveness, n).toBe(0.5); + // from the URL the name is an unknown value like any other: balanced, so it lowers eager and leaves cautious alone + const loc = { href: `http://app.test/search?genclass-aggr=${n}`, pathname: "/search", search: `?genclass-aggr=${n}` }; + const fromEager = setup({ aggressiveness: "eager", extraGlobal: { location: loc } }).rt; + expect(fromEager.aggressiveness, n).toBe(0.5); + expect(Number.isFinite(fromEager.gates("delivery").guard), n).toBe(true); + expect(setup({ aggressiveness: "cautious", extraGlobal: { location: loc } }).rt.aggressiveness, n).toBe(0); + } + }); it("named levels pick their profile; numbers interpolate between neighbours (report too)", () => { const c = policyConfig(undefined); expect(effectiveGates(c, P, "delivery", 0.5)).toMatchObject({ guard: 0.7, level: "balanced", levelSource: "profiles" }); diff --git a/packages/runtime/test/options.test.ts b/packages/runtime/test/options.test.ts index 1e38604b..4406e42e 100644 --- a/packages/runtime/test/options.test.ts +++ b/packages/runtime/test/options.test.ts @@ -1,7 +1,8 @@ // Batch 12: configuration options (docs/runtime/OPTIONS-SPEC.md). One block per option. -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { defaultScript, FakeClock, FakeServer, makeGlobal, setup } from "./helpers.js"; import { createRuntime, DEFAULT_LOAD_IF, deviceEnv, evalLoadIf, IdleUnloadProvider } from "../src/index.js"; +import { hash32 } from "../src/util/match.js"; import type { DecisionProvider, ModelStatus, SinkRecord } from "../src/types.js"; const DELAY = defaultScript({ request: { diagnosis: "overload", action: "delay" } }); @@ -320,3 +321,98 @@ describe("model.unloadAfterIdleMs", () => { p.dispose(); }); }); + +// `mode` is validated (review of the mode-raise fix, 0.2.3 and 0.3.0-beta.5): before, a value that was not observe, guard +// or heal ("HEAL", "Guard", an object) fell through `minMode` and `modeAllows` and ran as heal, which +// `?genclass-mode=observe` could not lower either. +describe("mode: any value but observe, guard or heal runs as observe, with one warning", () => { + it.each(["HEAL", "Guard", "heal ", "", {}, 1, true, null])("%j", async (bad) => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const s = setup({ mode: bad as never, triage: "always", script: DELAY }); + expect(s.rt.mode).toBe("observe"); + expect(s.rt.status.effectiveMode).toBe("observe"); + expect(warn.mock.calls.filter((c) => /mode must be "observe", "guard" or "heal"/.test(String(c[0]))).length).toBe(1); + s.server.on("GET", "/api/x", { body: 1, latency: 5 }); + await hit(s); + expect(s.rt.interventions().length).toBe(0); + warn.mockRestore(); + }); + + it("the three modes pass unchanged, without a warning", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + for (const m of ["observe", "guard", "heal"] as const) expect(setup({ mode: m }).rt.mode).toBe(m); + expect(setup({ mode: undefined }).rt.mode).toBe("observe"); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + it("modeAllows fails closed for a value that is not a mode", async () => { + const { modeAllows } = await import("../src/decide/policy.js"); + for (const bad of ["HEAL", "Guard", "", "off", undefined, null]) { + expect(modeAllows(bad as never, "guard"), String(bad)).toBe(false); + expect(modeAllows(bad as never, "heal"), String(bad)).toBe(false); + expect(modeAllows(bad as never, "passive"), String(bad)).toBe(true); + } + }); + + it("window.GENCLASS_CONFIG.mode is validated like the meta tag and data attributes", async () => { + const { readWindowConfig } = await import("../src/cdn/config.js"); + const warns: string[] = []; + expect(readWindowConfig({ GENCLASS_CONFIG: { mode: "guard", debug: true } }, (m) => warns.push(m))).toEqual({ mode: "guard", debug: true }); + expect(readWindowConfig({ GENCLASS_CONFIG: { mode: "HEAL", debug: true } }, (m) => warns.push(m))).toEqual({ debug: true }); + expect(readWindowConfig({ GENCLASS_CONFIG: { mode: {} } }, (m) => warns.push(m))).toEqual({}); + expect(warns.length).toBe(2); + expect(warns[0]).toMatch(/GENCLASS_CONFIG\.mode must be "observe", "guard" or "heal" \(got "HEAL"\)/); + expect(readWindowConfig({ GENCLASS_CONFIG: { mode: "HEAL" } })).toEqual({}); // no sink: still dropped + expect(readWindowConfig({})).toEqual({}); + }); +}); + +// The sample bucket (OPTIONS-SPEC §4.2) is frozen in sessionStorage. Review of the mode-raise fix (0.2.3, 0.3.0-beta.5): +// a saved "act" verdict overrode `sample: 0` (a tab that drew act under 0.5 kept acting after a deploy set 0, or a +// same-origin script wrote it); now 0 never reads storage, and a verdict is kept with the sample it was drawn under and +// reused only under the same one. +describe("sample: the stored bucket is read only for the sample it was drawn under; 0 never reads it", () => { + const storage = (init: Record = {}) => { + const m = new Map(Object.entries(init)); + return { m, getItem: (k: string) => m.get(k) ?? null, setItem: (k: string, v: string) => void m.set(k, String(v)), removeItem: (k: string) => void m.delete(k) }; + }; + const ID = "session-1"; + const draw = (p: number) => hash32(ID) / 4294967296 < p; + + it("sample: 0 ignores a saved act verdict and writes nothing", async () => { + for (const saved of ["act", "act@0.5", "act@0"]) { + const ss = storage({ "genclass:bucket": saved }); + const s = setup({ sample: 0, session: { id: ID }, triage: "always", script: DELAY, extraGlobal: { sessionStorage: ss } }); + expect([s.rt.status.sampled, s.rt.status.effectiveMode], saved).toEqual([false, "observe"]); + expect(ss.m.get("genclass:bucket"), saved).toBe(saved); + s.server.on("GET", "/api/x", { body: 1, latency: 5 }); + await hit(s); + expect(s.rt.interventions().length, saved).toBe(0); + } + }); + + it("a verdict drawn under the same sample is reused; one from another sample, or the old bare format, is drawn again", () => { + const ss = storage({ "genclass:bucket": "observe@0.5" }); + expect(setup({ sample: 0.5, session: { id: ID }, extraGlobal: { sessionStorage: ss } }).rt.status.sampled).toBe(false); + ss.m.set("genclass:bucket", "act@0.5"); + expect(setup({ sample: 0.5, session: { id: ID }, extraGlobal: { sessionStorage: ss } }).rt.status.sampled).toBe(true); + for (const stale of ["act@0.9", "observe@0.9", "act", "observe", "act@", "nonsense"]) { + ss.m.set("genclass:bucket", stale); + expect(setup({ sample: 0.5, session: { id: ID }, extraGlobal: { sessionStorage: ss } }).rt.status.sampled, stale).toBe(draw(0.5)); + expect(ss.m.get("genclass:bucket"), stale).toBe(draw(0.5) ? "act@0.5" : "observe@0.5"); + } + }); + + it("a fresh draw is stored with its sample, and reloading under the same sample keeps it", () => { + const ss = storage(); + const first = setup({ sample: 0.3, session: { id: ID }, extraGlobal: { sessionStorage: ss } }).rt.status.sampled; + expect(first).toBe(draw(0.3)); + expect(ss.m.get("genclass:bucket")).toBe(first ? "act@0.3" : "observe@0.3"); + expect(setup({ sample: 0.3, session: { id: "another-id" }, extraGlobal: { sessionStorage: ss } }).rt.status.sampled).toBe(first); + // sample: 1 never reads or writes it either + ss.m.set("genclass:bucket", "observe@1"); + expect(setup({ sample: 1, session: { id: ID }, extraGlobal: { sessionStorage: ss } }).rt.status.sampled).toBe(true); + expect(ss.m.get("genclass:bucket")).toBe("observe@1"); + }); +}); diff --git a/packages/runtime/test/policy.test.ts b/packages/runtime/test/policy.test.ts index c6b7bbcb..a458e401 100644 --- a/packages/runtime/test/policy.test.ts +++ b/packages/runtime/test/policy.test.ts @@ -43,6 +43,10 @@ describe("policy gate (CONTRACT §8)", () => { expect(modeAllows("guard", "heal")).toBe(false); expect(modeAllows("heal", "heal")).toBe(true); expect(modeAllows("observe", "passive")).toBe(true); + // a value that is not a mode fails closed (review of the mode-raise fix: it used to permit every tier) + expect(modeAllows("HEAL" as never, "heal")).toBe(false); + expect(modeAllows("" as never, "guard")).toBe(false); + expect(permittedActions(policyConfig({}), "Guard" as never, REQ).map((a) => a.name)).toEqual([]); const c = policyConfig({}); expect(permittedActions(c, "observe", REQ).map((a) => a.name)).toEqual([]); expect(permittedActions(c, "guard", REQ).map((a) => a.name)).toEqual(["coalesce", "delay"]); diff --git a/packages/runtime/test/status-scope.test.ts b/packages/runtime/test/status-scope.test.ts index 3f337183..35f38daf 100644 --- a/packages/runtime/test/status-scope.test.ts +++ b/packages/runtime/test/status-scope.test.ts @@ -30,3 +30,65 @@ describe("status.scope", () => { expect(rt.status.scope).toMatchObject({ mode: "observe", rule: 0, ceiling: "heal" }); }); }); + +// Route rules apply to the path the server sees. The browser never percent-decodes `location.pathname`, while most +// servers route on the decoded path and tolerate a trailing slash, repeated slashes, dot segments or `;` parameters, +// so a link to "/se%61rch" reached the same page as "/search" but escaped a rule scoped to "/search" (review of the +// mode-raise fix, 0.2.3 and 0.3.0-beta.5). A rule now matches the raw path or its normalised form; `scope.route` stays +// the raw path. Rules only lower the mode. +describe("route rules match look-alike paths (OPTIONS-SPEC §4.3)", () => { + const at = (pathname: string, routes: NonNullable[0]>["routes"], mode: "observe" | "guard" | "heal" = "guard") => + setup({ mode, routes, extraGlobal: { location: { href: `http://app.test${pathname}`, pathname, search: "" } } }).rt; + + it.each(["/se%61rch", "/search/", "//search", "/search;x=1", "/search;jsessionid=abc/", "/./search", "/x/../search", "/search%2F", "/%2Fsearch", "/search/."])( + "%s falls under an exact /search rule; scope.route stays raw", + (pathname) => { + const rt = at(pathname, [{ match: "/search", mode: "off" }]); + expect(rt.status.scope).toMatchObject({ route: pathname, rule: 0, mode: "off" }); + expect(rt.status.effectiveMode).toBe("off"); + }, + ); + + it("the raw path still matches as before, and a plain /search is unchanged", () => { + const rt = at("/search", [{ match: "/search", mode: "observe" }]); + expect(rt.status.scope).toMatchObject({ route: "/search", rule: 0, mode: "observe" }); + expect(at("/other", [{ match: "/search", mode: "off" }]).status.scope).toEqual({ route: "/other", mode: "guard", aggressiveness: 0.5 }); + }); + + it("matching is case-sensitive: /Search is not /search (use a RegExp with the i flag)", () => { + expect(at("/Search", [{ match: "/search", mode: "off" }]).status.scope).toEqual({ route: "/Search", mode: "guard", aggressiveness: 0.5 }); + expect(at("/Search/", [{ match: /^\/search$/i, mode: "off" }]).status.scope).toMatchObject({ rule: 0, mode: "off" }); + }); + + it("a glob /admin/* does not cover /admin itself; /admin* does, and the glob sees the normalised path too", () => { + expect(at("/admin", [{ match: "/admin/*", mode: "off" }]).status.scope.rule).toBeUndefined(); + expect(at("/admin", [{ match: "/admin*", mode: "off" }]).status.scope).toMatchObject({ rule: 0, mode: "off" }); + expect(at("/admin//users/", [{ match: "/admin/*", mode: "off" }]).status.scope).toMatchObject({ rule: 0, mode: "off" }); + expect(at("/%61dmin/users", [{ match: "/admin/*", mode: "off" }]).status.scope).toMatchObject({ rule: 0, mode: "off" }); + }); + + it("RegExp and predicate rules see both forms", () => { + expect(at("/search/", [{ match: /^\/search$/, mode: "off" }]).status.scope).toMatchObject({ rule: 0, mode: "off" }); + const seen: string[] = []; + expect(at("/se%61rch", [{ match: (r) => (seen.push(r), r === "/search"), mode: "off" }]).status.scope).toMatchObject({ rule: 0, mode: "off" }); + expect(seen).toEqual(["/se%61rch", "/search"]); + // a malformed escape keeps the raw path (and matches nothing else) + expect(at("/search%", [{ match: "/search", mode: "off" }]).status.scope.rule).toBeUndefined(); + }); + + it("a look-alike path runs no action under an observe rule (guard-tier delay scripted)", async () => { + const { defaultScript } = await import("./helpers.js"); + const s = setup({ + routes: [{ match: "/search", mode: "observe" }], + triage: "always", + script: defaultScript({ request: { diagnosis: "overload", action: "delay" } }), + extraGlobal: { location: { href: "http://app.test/se%61rch", pathname: "/se%61rch", search: "" } }, + }); + s.server.on("GET", "/api/x", { body: 1, latency: 5 }); + const p = s.fetch("/api/x").catch(() => undefined); + await s.clock.advance(2000); + await p; + expect(s.rt.status.effectiveMode).toBe("observe"); + expect(s.rt.interventions().length).toBe(0); + }); +}); From 5c6984a792d4e9ff652859802fc253cc53e9e66e Mon Sep 17 00:00:00 2001 From: Karan Date: Wed, 28 Oct 2026 00:51:40 -0400 Subject: [PATCH 3/4] bundle test: the /auto limit follows CI's gzip (108.83 KB on Node 22), 108.9 KB This Mac's Node 25 gzips /auto to 108.63 KB; CI's Node 22 measured 108.83 KB, over the 108.7 KB set here, so the limit is 108.9 KB. The growth from the four fixes is 0.28 KB either way, under the 0.5 KB first-load-size check against the release line. Co-Authored-By: Claude Fable 5.1 --- docs/security/2026-10-mode-raise.md | 2 +- packages/runtime/test/bundle.test.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/security/2026-10-mode-raise.md b/docs/security/2026-10-mode-raise.md index 961c30be..0ac43e63 100644 --- a/docs/security/2026-10-mode-raise.md +++ b/docs/security/2026-10-mode-raise.md @@ -422,7 +422,7 @@ Found by the GenClass maintainers while testing `0.3.0-beta.0` in a pilot app on covers them (then its affected range becomes `<= 0.3.0-beta.4` and the patched versions `0.2.3` / `0.3.0-beta.5`) or F1 gets its own; F2 to F4 can stay CHANGELOG entries either way. - The `main` port of F1 to F4 raises `packages/runtime/test/bundle.test.ts`'s `/auto` first-load limit from 108.6 to - 108.7 KB (the fixes add 0.27 KB to `/auto` and 0.17 KB to the main entry; RESULTS.md section 10), pending your OK as + 108.9 KB (the fixes add 0.27 KB to `/auto` and 0.17 KB to the main entry; RESULTS.md section 10), pending your OK as the earlier limits were. It also brings OPTIONS-SPEC §3 and THREAT-MODEL T8 in line with `0.3.0-beta.2`'s code (the stale `debug` wording listed above); CONTRACT.md line 540, the README's `?genclass-sample=1` recipe and INTERCEPTION.md's "raises only with `debug: true`" sentence are still as listed and need your OK (CONTRACT) or a diff --git a/packages/runtime/test/bundle.test.ts b/packages/runtime/test/bundle.test.ts index 0f8b158e..7d4b9e6c 100644 --- a/packages/runtime/test/bundle.test.ts +++ b/packages/runtime/test/bundle.test.ts @@ -94,7 +94,9 @@ describe.skipIf(!built)("dist as an app bundles it", () => { // readWindowConfig's mode check add 0.27 KB to /auto and 0.17 KB to the main entry over main at 49c8c03 (108.63 / // 100.46 KB, test/first-load-size.mjs against main's dist, 2026-10-27; docs/runtime/RESULTS.md section 10). /auto's // limit is that size plus 0.07 KB; the main entry stays 0.02 KB under its limit, which is unchanged. - const LIMIT_KB: Record = { "auto.js": 108.7, "index.js": 100.48 }; + // CI (Node 22) gzips /auto to 108.83 KB where this Mac (Node 25) gets 108.63 KB, a zlib difference; the limit + // follows CI's number (owner OK on 2026-10-28, with the 0.1 KB raise first; the growth itself is 0.28 KB). + const LIMIT_KB: Record = { "auto.js": 108.9, "index.js": 100.48 }; it("first-load cost of /auto and the main entry, minified + gzip: under the limits", async () => { const { build } = await import("esbuild"); const uses: Record = { "auto.js": "", "index.js": "GenClass.init({});" }; From 427f64932721fb19658421fe272c5e536b6db474 Mon Sep 17 00:00:00 2001 From: Karan Date: Wed, 28 Oct 2026 00:51:56 -0400 Subject: [PATCH 4/4] RESULTS.md: the /auto limit row records CI's measurement and the OK Co-Authored-By: Claude Fable 5.1 --- docs/runtime/RESULTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/runtime/RESULTS.md b/docs/runtime/RESULTS.md index da40d6f2..1bb537f0 100644 --- a/docs/runtime/RESULTS.md +++ b/docs/runtime/RESULTS.md @@ -402,7 +402,7 @@ walk, which missed the two shared chunks (2.74 + 0.19 KB). | limit in `bundle.test.ts` for 0.3.0-beta.0, **owner-approved 2026-10-15** (the measured sizes + 0.05 / 0.07 KB) | 108.30 KB | 100.20 KB | none | | + interaction analytics' first-load stub (§15; limits owner-approved 2026-10-18) | 108.53 KB | 100.40 KB | not measured | | + review of the mode-raise fix: `normalizeRoute`, the `mode` validation and its warning, the sample bucket's `act@` form, `readWindowConfig`'s mode check (branch `security/advisory-mode-raise`, 0.3.0-beta.5; local, 2026-10-27, `test/first-load-size.mjs` and gzip -9 against main `49c8c03`'s dist: 108.36 / 100.28 / 128.55 KB) | **108.63 KB** (+0.27) | **100.46 KB** (+0.17) | **128.83 KB** (387.4 KB minified, +0.28) | -| limit in `bundle.test.ts` for 0.3.0-beta.5, **pending the owner's OK** (`/auto`: the measured size + 0.07 KB; the main entry keeps its limit, 0.02 KB under it) | 108.70 KB | 100.48 KB | none | +| limit in `bundle.test.ts` for 0.3.0-beta.5, owner OK 2026-10-28 (`/auto`: CI's Node 22 gzip measures 108.83 KB where Node 25 measures 108.63 KB, so the limit follows CI plus 0.07 KB; the main entry keeps its limit, 0.02 KB under it) | 108.90 KB | 100.48 KB | none | Removing the vendor adapters changes no first load (they were lazy chunks of `/auto` and files the script tag loaded from the CDN). The one warning for the names `sentry`, `posthog` and `datadog` (`src/cdn/sinks.ts` `applySinks`: the