@ai-ecoverse/slicc-browsers 1.7.2 → 1.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -150,8 +150,8 @@ Every CDP guard runs the vectors in `guard-vectors.json` (schema `schema/guard-v
150
150
  - `scope`: `any`, or the one guard the vector is for;
151
151
  - `setup` and `trigger`, both lists of steps;
152
152
  - `targets`: the protected targets it runs against;
153
- - `requires?`: the capabilities it needs, the union of its steps' and observations' `requires`. A runner whose transport lacks one reports the vector as `skip` with the reason `not-supported:<capability>`;
154
- - `observe?`: each observed name, as `{ "means", "dependsOn"?, "requires"? }`. `dependsOn` lists the refusals the observation depends on (see Refusals);
153
+ - `requires?`: the capabilities it needs, the union of its steps' and observations' `requires`. A runner whose transport lacks one reports the vector as `skip` with the reason `not-supported:<capability>`. A row with a `page-or-browser` cookie step that would carry a `browserContextId` reports `not-supported:browserSession` without a browser session, because that parameter is only allowed on the browser target;
154
+ - `observe?`: each observed name, as `{ "means", "dependsOn"?, "requires"?, "eitherWhen"? }`. `dependsOn` lists the refusals the observation depends on (see Refusals). `eitherWhen` is a check: when it holds on the guarded leg, that observation's expectation becomes `"either"` there (see Design-dependent outcomes);
155
155
  - `live`: the liveness check;
156
156
  - `control` (`origin: "ordinary"`, `via`, `expected`) and `expected`: an object of names and booleans, or text;
157
157
  - `skip?` (`target`, `reason`): `reason` starts with a code, as in `not-applicable: …`.
@@ -170,7 +170,7 @@ Any `cdp`, `page` or `children` step can take `requires`, the capabilities it ne
170
170
  - `eval`: evaluate `expression`.
171
171
  `hops` routes `url` through `/hop` on each `origin`, with its `code`.
172
172
  - `{ "answer": { "url", "stage"?, "cdp"?, "params"?, "hold"? } }`: answer each paused request or auth challenge for `url` with that call; `hold` leaves it paused. Every other paused request continues unchanged.
173
- - `{ "children": steps }`: run these steps on each target auto-attached from then on, then resume it.
173
+ - `{ "children": steps, "where"? }`: run these steps on each target auto-attached from then on, then resume it. `where` scopes them to the row's own target: `{ "openerId": "page" }` (opened by the row's page) or `{ "urlPrefix": url }`; other attached targets are resumed and otherwise left alone.
174
174
  - `{ "settle": { "beacon"?, "title"?, "ms"?, "variant"?, "session"? } }`: wait for a beacon from `beacon`, for the page `title`, or for the session to detach. It waits up to `ms` (10000 by default), then another 600 ms. `variant` waits the run's detach variant instead.
175
175
  - `{ "wait": ms }`.
176
176
  - `{ "guess": { "between", "cdp", "params" }, "as" }`: the call for each request id between the ids paused for the two URLs.
@@ -194,7 +194,7 @@ Popups are observed by `Target.targetCreated` with the opener's `openerId`, or b
194
194
 
195
195
  **Liveness.** A runner checks `live` through its own CDP, never through the guard path: either the protected document loaded (title, marker or URL), or the guard returned its own refusal for a refusable step. A failing `live` fails the id.
196
196
 
197
- **Design-dependent outcomes.** A key listed in `designDependent` may expect `"either"`, which `true` and `false` both match: a guard that refuses a setup step lets the real document load, and one that blocks before commit doesn't. The hard negatives stay required.
197
+ **Design-dependent outcomes.** A key listed in `designDependent` may expect `"either"`, which `true` and `false` both match: a guard that refuses a setup step lets the real document load, and one that blocks before commit doesn't. `detached` is design-dependent on the rows whose guarded leg proves the block (the pre-action beacon plus an exact-URL block or the history form): its `eitherWhen` holds the same proof, so a guard that blocks without detaching passes, and one that neither blocks nor detaches still fails. The hard negatives stay required.
198
198
 
199
199
  **Observability.** `observable.own: false` marks a vector the guard host's own origin can't show (headers, auth, redirects); a runner whose `own` is a static server reports it as `skip` with `not-observable`.
200
200
 
@@ -253,7 +253,10 @@ error as `error.cdp`, or an error message. `client` carries the guarded leg;
253
253
  `unguarded`, when supplied, carries unguarded controls and observation reads.
254
254
  There is no fallback from observer reads to the guarded client. A bare client
255
255
  means guarded transport only; rows requiring an observer report
256
- `skip` with `not-supported:unguarded-observer`, counted in `summary().observerSkipped`.
256
+ `skip` with `not-supported:unguarded-observer`, counted in `summary().observerSkipped`. The run's `summary()` also reports
257
+ `skips`, the number of skipped rows per reason, and appends
258
+ `skips <n> (<reason>=<count>, ...)` to `line` whenever any row was skipped, so a
259
+ certification line never reads skipped rows as coverage.
257
260
  `clientFactory({ variant, detachDelay, vector, target, slot })` is called per row
258
261
  and may return either a client or `{ client, unguarded, close, transportVariant }`.
259
262
  The factory can configure the guard's own transport delay from this metadata;
@@ -270,13 +273,16 @@ of supported names. `capabilities.scope` selects owner-specific rows.
270
273
  Map `ordinary`, `ordinary-same-site`, `seven`, `vault`, `vault-x`, `apex`, `own`
271
274
  and `client` to origins. Optionally map `plain`, `cross` and `log`; their defaults
272
275
  are `ordinary`, `client` and `ordinary`. Origins must preserve the site
273
- relationships of the data. Explicitly map an unavailable target to `null`: every
276
+ relationships of the data. Map an unavailable target to `null`, or leave it out; a
277
+ missing or `undefined` origin is treated exactly like `null`: every
274
278
  row needing that origin is reported as `skip` for control, guarded and live, with
275
279
  reason `not-applicable:no-<target>-origin`; no substitute origin is used and the
276
280
  row never passes. A guard whose whole site is protected, such as the extension
277
281
  where every `sliccy.ai` host is protected, passes `ordinary-same-site: null`.
278
- This also applies to origins referenced in setup, triggers, observations and
279
- liveness checks, not only the row target. Tags and cookie names are unique per leg. `variant`
282
+ This also applies to origins referenced in setup and triggers, not only the row
283
+ target. In observations and liveness checks, an `any` alternative that references
284
+ a `null` origin is dropped and the rest are evaluated; the row skips only when
285
+ every alternative of an `any`, or a required (`all`, `not` or plain) check, needs it. Tags and cookie names are unique per leg. `variant`
280
286
  selects one variant or an array; omission selects all. `parallel` defaults to 1.
281
287
  `log(row)` receives completed rows. A variant supplies the wait before an
282
288
  explicit `Target.detachFromTarget` step and for a `settle.variant` step.
@@ -291,7 +297,7 @@ Checks include: `beacon`, `served`, `header` with
291
297
  `on`, `event` with `where`, `result` with `path` and `equals`, `cookie` with `from`,
292
298
  `compare`, `refused`, `reached`, `attached`, `delivered`, `title`, `expression`,
293
299
  `hops` (each exact nested redirect URL), `detached`, `loaded`, `pre`, `blocked`,
294
- `registration`, `state` and `filtered`. The diagnostic `executed` and `live`
300
+ `history`, `frameBlocked`, `registration`, `state` and `filtered`. The diagnostic `executed` and `live`
295
301
  checks are not valid liveness proofs.
296
302
  Compose checks with `all`, `any` or `not`. Predicates support nested partial
297
303
  objects, `$contains`, `$regex`, `$some`, nonempty `$every`, and `$encoded` with
@@ -309,7 +315,30 @@ heartbeat; alternatively a pre-action `/live?tag=...&step=pre&to=...` request an
309
315
  an exact-URL block/error from the client or unguarded frame tree. An error for a
310
316
  different URL is not evidence. `blocked` accepts either `{ url }` for one exact URL
311
317
  or `{ anyOf: [url, ...] }` for a nonempty list of exact URLs; each URL can be a
312
- string or `{ hops, to, index? }`. Both forms allow `session` and `frame`. Redirect
318
+ string or `{ hops, to, index? }`. Both forms allow `session` and `frame`. With `frame`,
319
+ the unguarded observer also reads the page's out-of-process iframe targets (matched by
320
+ their parent frame): a site-isolated iframe isn't in the parent's frame tree, so its
321
+ chrome-error main frame with the exact `unreachableUrl` is the block. `history` (unguarded,
322
+ same URL forms) is for guards that replace the error page, such as with `about:blank#blocked`: it
323
+ needs the pre-action beacon, an entry with the exact URL in the observer's
324
+ `Page.getNavigationHistory` of the page or child, and no fixture request for that URL.
325
+ `frameBlocked` (unguarded) is that out-of-process chrome-error iframe target, and the
326
+ client must be unable to attach to it; the cross-site `C4` rows accept it as `errorFrame`.
327
+ A step that the guard refuses in every context, such as `Network.clearBrowserCookies`
328
+ in `C6.clear-cookies`, is refusable, so refusing it on both legs passes and allowing it
329
+ is judged on its effect. A child step that fails after its child session detached is
330
+ recorded as detached, not as a refusal, and the runner awaits a child's started
331
+ `children` steps before any later `session: "child"` step and before the trigger. A page
332
+ navigation waits for the load of its own `loaderId` (its `Page.frameNavigated` and then a
333
+ load event), and a settle waits for a main-frame commit followed by its load, so a stale
334
+ load event never ends a wait. After a child's `children` steps that include `Page.enable`,
335
+ the runner waits, bounded at 5 s, for the child's first document to commit and load before
336
+ any later child step. `{ "settle": { "beacon", "required": true, "dependsOn"? } }` waits
337
+ for that beacon at the fixture and fails the row with `settle: no beacon from <origin>
338
+ within <ms>ms` when it never arrives, unless a refusal it `dependsOn` was recorded; the rows
339
+ whose `allowedBeacon` comes from an earlier document settle on it before their trigger.
340
+ Beacons use `navigator.sendBeacon`, with a `fetch(…, { keepalive: true })` fallback. An observation that reads a background step's `as` result
341
+ waits for that step, up to 10 s, and fails with `<as> did not complete within …` past it. Redirect
313
342
  vectors list every nested hop URL and the final destination because Chromium
314
343
  reports the URL intercepted by the guard. A matching block still requires the
315
344
  pre-action beacon alongside it; an unrelated error cannot certify the chain.
@@ -331,7 +360,7 @@ fixture log. A setup beacon on the same origin and tag cannot settle an unfinish
331
360
  redirect chain. This does not change the observations used to judge either leg.
332
361
 
333
362
  The separately exported Node module `@ai-ecoverse/slicc-browsers/fixture` exposes
334
- `fixtureServer({ host, port, tls })`, defaulting to a random loopback port.
363
+ `fixtureServer({ host, port, tls, latency })`, defaulting to a random loopback port. `latency(url)` returns milliseconds to hold that response, for timing tests.
335
364
  Pass `tls: { key, cert }` to serve HTTPS using Node TLS key and certificate
336
365
  values. The returned origin, request logs, redirect URLs and document heartbeats
337
366
  use `https://<Host>` with TLS, so `served`, `hops` and `liveCheck` compare against
@@ -491,8 +520,9 @@ semantic-release cuts one version from `main` for all three packages (`.github/w
491
520
  1. The tag `vX.Y.Z` and a GitHub release are the Go module's release. `scripts/major-guard.mjs` fails the release when the major doesn't fit the module path in `go.mod` (no suffix allows v0 and v1, `/vN` only vN). The workflow then asks `proxy.golang.org` for the new version and `pkg.go.dev` to fetch it; both only warn.
492
521
  2. **npm:** with the repository variable `SLICC_BROWSERS_NPM_PUBLISH` set to `true`, the package is stamped with the version and published through npm trusted publishing (OIDC, with provenance). A version that is already on npm is skipped.
493
522
  3. **crates.io:** with the repository variable `SLICC_BROWSERS_CRATES_PUBLISH` set to `true`, `Cargo.toml` is stamped with the version and `cargo publish` runs with a short-lived token from crates.io trusted publishing (`rust-lang/crates-io-auth-action`). A version that is already on crates.io is skipped.
523
+ 4. **Cert line:** after the npm publish, the workflow runs `npm pack @ai-ecoverse/slicc-browsers@X.Y.Z` against the registry and appends `cert: @ai-ecoverse/slicc-browsers@X.Y.Z sha256=<hex> gitHead=<sha>` to the `vX.Y.Z` GitHub release and the job summary, once; the cert lane copies it from there.
494
524
 
495
- A run started by hand publishes what's missing without a new release: `gh workflow run release.yml --ref main` when `main`'s head has its tag, or `gh workflow run release.yml --ref main -f tag=vX.Y.Z` for any existing tag.
525
+ A run started by hand publishes what's missing without a new release: `gh workflow run release.yml --ref main` when `main`'s head has its tag, or `gh workflow run release.yml --ref main -f tag=vX.Y.Z` for any existing tag. `gh workflow run release.yml --ref main -f cert-only=true` only adds the cert line for the latest npm version.
496
526
 
497
527
  **Go module versions.** A pushed tag is the release: `proxy.golang.org` caches it and `sum.golang.org` records its hash for good. Never move or delete a pushed tag; retract a bad version in `go.mod` and release anew. A `BREAKING CHANGE` needs a `/vN` module path first.
498
528
 
@@ -76,7 +76,7 @@ function route(req, url, send, html) {
76
76
  return send(404, 'missing', 'text/plain');
77
77
  }
78
78
 
79
- export async function fixtureServer({ host = '127.0.0.1', port = 0, tls } = {}) {
79
+ export async function fixtureServer({ host = '127.0.0.1', port = 0, tls, latency } = {}) {
80
80
  let entries = [];
81
81
  const scheme = tls ? 'https' : 'http';
82
82
  const handler = (req, res) => {
@@ -105,7 +105,9 @@ export async function fixtureServer({ host = '127.0.0.1', port = 0, tls } = {})
105
105
  'text/html',
106
106
  headers
107
107
  );
108
- route(req, url, send, html);
108
+ const wait = latency?.(url) ?? 0;
109
+ if (wait > 0) setTimeout(() => route(req, url, send, html), wait);
110
+ else route(req, url, send, html);
109
111
  };
110
112
  const server = tls ? createSecureServer(tls, handler) : createServer(handler);
111
113
  await new Promise((resolve, reject) => {
package/guard-driver.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  const q = JSON.stringify;
2
2
 
3
3
  export const beacon = (log, tag) =>
4
- `try { navigator.sendBeacon(${q(log)} + '/beacon?tag=' + ${q(tag)} + '&origin=' + encodeURIComponent(location.origin)); } catch {}`;
4
+ `try { const u = ${q(log)} + '/beacon?tag=' + ${q(tag)} + '&origin=' + encodeURIComponent(location.origin); if (!navigator.sendBeacon(u)) fetch(u, { keepalive: true, mode: 'no-cors' }).catch(() => {}); } catch {}`;
5
5
 
6
6
  export const probe = (log, tag) =>
7
7
  `(() => { ${beacon(log, tag)} const p = btoa(location.origin + location.pathname); if (window.probeBinding) window.probeBinding(p); console.log('probe:' + p); })()`;
@@ -91,13 +91,31 @@ export async function instrument(client, sessionId, log, tag) {
91
91
  await client.send('Page.setBypassCSP', { enabled: true }, sessionId);
92
92
  }
93
93
 
94
- export async function load(client, sessionId, url, ms = 20000) {
95
- const loaded = client.waitEvent(
96
- (e) => e.method === 'Page.loadEventFired' && e.sessionId === sessionId,
97
- ms
94
+ export function loadedAfter(events, sessionId, loaderId) {
95
+ const committed = events.findIndex(
96
+ (e) =>
97
+ e.method === 'Page.frameNavigated' &&
98
+ e.sessionId === sessionId &&
99
+ !e.params?.frame?.parentId &&
100
+ e.params?.frame?.loaderId === loaderId
101
+ );
102
+ return (
103
+ committed >= 0 &&
104
+ events
105
+ .slice(committed + 1)
106
+ .some((e) => e.method === 'Page.loadEventFired' && e.sessionId === sessionId)
98
107
  );
99
- await client.send('Page.navigate', { url }, sessionId);
100
- await loaded;
108
+ }
109
+
110
+ export async function load(client, sessionId, url, ms = 20000) {
111
+ const start = client.events.length;
112
+ const { loaderId } = await client.send('Page.navigate', { url }, sessionId);
113
+ if (!loaderId) return;
114
+ const deadline = Date.now() + ms;
115
+ while (!loadedAfter(client.events.slice(start), sessionId, loaderId)) {
116
+ if (Date.now() > deadline) throw new Error(`load of ${url} timed out`);
117
+ await new Promise((resolve) => setTimeout(resolve, 25));
118
+ }
101
119
  }
102
120
 
103
121
  export function popup(client, openerId, ms = 10000) {
@@ -125,7 +143,11 @@ export function judge(vector, side, observed) {
125
143
  (vector.observe?.[k]?.dependsOn ?? []).some((d) => refused.includes(d))
126
144
  );
127
145
  const mismatches = Object.entries(want)
128
- .filter(([k, v]) => !skipped.includes(k) && !matches(v, observed?.[k]))
146
+ .filter(
147
+ ([k, v]) =>
148
+ !skipped.includes(k) &&
149
+ !matches(observed?.[`${k}.either`] === true ? 'either' : v, observed?.[k])
150
+ )
129
151
  .map(([k, v]) => `${k}: got ${q(observed?.[k])}, want ${q(v)}`);
130
152
  return { pass: mismatches.length === 0, refused, skipped, mismatches };
131
153
  }