Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ 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); 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
Expand Down
7 changes: 5 additions & 2 deletions docs/runtime/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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;
Expand Down
9 changes: 7 additions & 2 deletions docs/runtime/OPTIONS-SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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@<sample>` or `observe@<sample>` (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.

Expand All @@ -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.
Expand All @@ -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 }`.
Expand Down
3 changes: 3 additions & 0 deletions docs/runtime/RESULTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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@<sample>` 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, 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
Expand Down
32 changes: 25 additions & 7 deletions docs/runtime/THREAT-MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<meta name="genclass">` tags inside `<head>` 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)
Expand All @@ -250,11 +253,26 @@ absolute once, at init ([cdn/sinks.ts](../../packages/runtime/src/cdn/sinks.ts))
move it. An injected `sink=webhook:<url>` 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 `<head>` can still set the mode and same-origin sinks (not a cross-origin model or
webhook): pages that let untrusted HTML into `<head>` 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:<port>`) 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

Expand Down
Loading
Loading