@ai-ecoverse/slicc-browsers 1.6.0 → 1.7.0

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
@@ -237,6 +237,121 @@ It exports:
237
237
  - every `{name}` is in `placeholders`;
238
238
  - no URL uses a port from 9222 to 9229.
239
239
 
240
+ ## Reference runner
241
+
242
+ `guard-runner.mjs` is dependency-free browser ESM. `run(set, options)` returns
243
+ `{ results, summary(metadata) }`; the module also exports `summary`. Results have
244
+ `id`, `target`, `variant`, `control`, `guarded`, `live`, boolean observations and
245
+ errors. Statuses are `pass`, `fail`, `skip` and `not-run`. Text vectors are reported
246
+ as `not-run`; they are not silently certified. A missing observation check is an
247
+ error before execution.
248
+
249
+ Supply a CDP client with `send(method, params, sessionId): Promise<object>`,
250
+ `waitEvent(predicate, milliseconds): Promise<event>` and an append-only `events`
251
+ array of `{ method, params, sessionId }`. Rejections should expose the protocol
252
+ error as `error.cdp`, or an error message. `client` carries the guarded leg;
253
+ `unguarded`, when supplied, carries unguarded controls and observation reads.
254
+ There is no fallback from observer reads to the guarded client. A bare client
255
+ means guarded transport only; rows requiring an observer report
256
+ `skip` with `not-supported:unguarded-observer`, counted in `summary().observerSkipped`.
257
+ `clientFactory({ variant, detachDelay, vector, target, slot })` is called per row
258
+ and may return either a client or `{ client, unguarded, close, transportVariant }`.
259
+ The factory can configure the guard's own transport delay from this metadata;
260
+ the runner never reaches into the guard. `transportVariant: true` acknowledges
261
+ that configuration, and every result records that boolean (false by default).
262
+ `slot` allows pooling browsers while keeping each row's clients independent.
263
+ The optional `close` releases the row's clients; release pooled browsers after
264
+ `run` completes. Each parallel slot needs an independent browser for
265
+ browser-wide settings. Without a factory execution is serial. Cookie and storage
266
+ rows use fresh browser contexts when `capabilities.browserContexts` is true;
267
+ otherwise they run serially. `capabilities` is an object of booleans or an array
268
+ of supported names. `capabilities.scope` selects owner-specific rows.
269
+
270
+ Map `ordinary`, `ordinary-same-site`, `seven`, `vault`, `vault-x`, `apex`, `own`
271
+ and `client` to origins. Optionally map `plain`, `cross` and `log`; their defaults
272
+ are `ordinary`, `client` and `ordinary`. Origins must preserve the site
273
+ relationships of the data. Tags and cookie names are unique per leg. `variant`
274
+ selects one variant or an array; omission selects all. `parallel` defaults to 1.
275
+ `log(row)` receives completed rows. A variant supplies the wait before an
276
+ explicit `Target.detachFromTarget` step and for a `settle.variant` step.
277
+
278
+ Every runnable vector has a declarative `liveCheck`; every observation has a
279
+ declarative `check`. Each check declares its `observer`: `fixture` reads request
280
+ logs, `client` reads already-delivered transport data, and `unguarded` requires
281
+ an independent CDP observer. `all` needs the observers for every operand; `any`
282
+ can use an available fixture or client alternative without an unguarded observer.
283
+ Unavailable optional observer alternatives are false, never substituted with guarded reads.
284
+ Checks include: `beacon`, `served`, `header` with
285
+ `on`, `event` with `where`, `result` with `path` and `equals`, `cookie` with `from`,
286
+ `compare`, `refused`, `reached`, `attached`, `delivered`, `title`, `expression`,
287
+ `hops` (each exact nested redirect URL), `detached`, `loaded`, `pre`, `blocked`,
288
+ `registration`, `state` and `filtered`. The diagnostic `executed` and `live`
289
+ checks are not valid liveness proofs.
290
+ Compose checks with `all`, `any` or `not`. Predicates support nested partial
291
+ objects, `$contains`, `$regex`, `$some`, nonempty `$every`, and `$encoded` with
292
+ `{ value, prefix }` for a base64-encoded value with an optional prefix. Reads of titles and
293
+ expressions use the unguarded observer. A `refused` check can declare `refusal.match` to match protocol errors. Transport timeouts, timeout sentinel values and evaluation exceptions fail result,
294
+ comparison and observer-read
295
+ observations; they are never comparable values. Registration refusals must match the
296
+ step's declared expression; dependent observations are omitted and reported by
297
+ the existing result evaluator.
298
+
299
+ `liveCheck` applies only to the guarded leg. Controls are judged by their positive
300
+ observations: a missing or failing effect fails the control. Guarded liveness
301
+ requires the exact target URL in the fixture log or a target-origin `/live`
302
+ heartbeat; alternatively a pre-action `/live?tag=...&step=pre&to=...` request and
303
+ an exact-URL block/error from the client or unguarded frame tree. An error for a
304
+ different URL is not evidence. A declared setup refusal can supply liveness only
305
+ when its `refusal.match` matched. Transport connectivity and command completion
306
+ alone never prove liveness. Cookie and storage rows seed a canary outside the
307
+ guard and read it back outside the guard; refused writes require unchanged
308
+ canary state and a refusal or filtered call response. Their control can clear or
309
+ modify state as required by its positive observations. `C5.auth` and
310
+ `C5.guessed-id.continue-with-auth` use fixture Authorization logs and refusal of
311
+ candidate auth ids; `C5.auth` also checks delivered auth events. Their liveness
312
+ requires an auth request at the fixture, not a completed fetch. Neither uses a
313
+ plain-tab baseline: headless Chromium can leave a credentials-including fetch
314
+ to a Basic 401 pending even without an attached client.
315
+
316
+ Beacon settlement uses only requests recorded after the page action starts. For
317
+ redirect chains it also requires the exact final destination request in the
318
+ fixture log. A setup beacon on the same origin and tag cannot settle an unfinished
319
+ redirect chain. This does not change the observations used to judge either leg.
320
+
321
+ The separately exported Node module `@ai-ecoverse/slicc-browsers/fixture` exposes
322
+ `fixtureServer({ host, port })`, defaulting to a random loopback port. It returns
323
+ `origin`, `port`, `log()`, `reset()` and `close()`. Log entries contain `url`,
324
+ `method`, `headers` and `time`. Mirror that API when using another fixture.
325
+ The server serves document, frame, redirect, CSP, authentication, data, image,
326
+ canary and beacon paths. Configure host resolution in your test browser, not in
327
+ the runner. `npm run test:guard` exercises Chromium twice with no guard, one target per
328
+ invocation: controls must pass and guarded expectations must fail. For a single
329
+ target use `GUARD_TARGET=seven npm run test:guard-target`. The fixture also serves
330
+ `/live` image requests from stand-in documents, independently of injected code.
331
+
332
+ ```js
333
+ import set from '@ai-ecoverse/slicc-browsers/guard-vectors.json' with { type: 'json' };
334
+ import { run } from '@ai-ecoverse/slicc-browsers/guard-runner';
335
+ import { fixtureServer } from '@ai-ecoverse/slicc-browsers/fixture';
336
+ const fixture = await fixtureServer();
337
+ const targets = await configureTestOrigins(fixture.port);
338
+ const capabilities = {
339
+ autoAttach: true,
340
+ browserSession: true,
341
+ browserContexts: true,
342
+ };
343
+ const execution = await run(set, {
344
+ clientFactory: (metadata) => connectGuardAndObserver(metadata),
345
+ targets,
346
+ capabilities,
347
+ variant: ['prompt', 'lagging'],
348
+ parallel: 6,
349
+ fixture,
350
+ });
351
+ console.log(execution.summary({ guard: 'my-guard', sha: 'build-sha' }));
352
+ await fixture.close();
353
+ ```
354
+
240
355
  ## The process source
241
356
 
242
357
  The process scan finds browsers started with a debugging port by something else:
@@ -0,0 +1,126 @@
1
+ import { createServer } from 'node:http';
2
+
3
+ const tall = Buffer.from(
4
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
5
+ 'base64'
6
+ );
7
+ const escape = (value) =>
8
+ value.replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;');
9
+
10
+ function document(url, html) {
11
+ const p = url.searchParams;
12
+ const csp = p.has('csp') || url.pathname === '/csp';
13
+ return html(
14
+ 'doc',
15
+ `<p>document ${escape(p.get('n') ?? '')}</p>${csp ? '<script>document.title="inline"; document.body.dataset.inline="true"</script>' : ''}`,
16
+ csp ? { 'content-security-policy': "script-src 'none'" } : {}
17
+ );
18
+ }
19
+
20
+ function trigger(p) {
21
+ const kind = p.get('k') ?? '';
22
+ const to = p.get('to') ?? '';
23
+ const frame = kind === 'iframe' ? `<iframe src="${escape(to)}"></iframe>` : '';
24
+ return `<p>trigger</p><button onclick="go()">Go</button>${frame}<script>const to=${JSON.stringify(to)};function go(){const k=${JSON.stringify(kind)};if(k==='href'||k==='deferred')location.href=to;if(k==='replace')location.replace(to);if(k==='popup')window.open(to);if(k==='form'){const u=new URL(to);const f=document.createElement('form');f.action=u.origin+u.pathname;for(const [name,value] of u.searchParams){const i=document.createElement('input');i.type='hidden';i.name=name;i.value=value;f.append(i);}document.body.append(f);f.submit();}return 'went';}${kind === 'deferred' ? `setTimeout(go,${Number(p.get('defer') ?? 3000)});` : ''}</script>`;
25
+ }
26
+
27
+ function route(req, url, send, html) {
28
+ const p = url.searchParams;
29
+ const routes = {
30
+ '/beacon': () => send(204, ''),
31
+ '/hop': () =>
32
+ send(Number(p.get('code') ?? 302), 'moved', 'text/plain', { location: p.get('to') ?? '/' }),
33
+ '/data': () => send(200, `real-${p.get('n') ?? ''}`, 'text/plain'),
34
+ '/live': () => send(200, tall, 'image/png'),
35
+ '/img': () => send(200, tall, 'image/png'),
36
+ '/script': () =>
37
+ send(
38
+ 200,
39
+ `globalThis.vectorScript=${JSON.stringify(`real-${p.get('n') ?? ''}`)}`,
40
+ 'text/javascript'
41
+ ),
42
+ '/page': () => html('trigger', trigger(p)),
43
+ '/api/cookies': () => send(200, req.headers.cookie ?? 'none', 'text/plain'),
44
+ '/plant': () =>
45
+ html('plant', '<script>localStorage.setItem("canary","planted")</script>', {
46
+ 'set-cookie': [`canary=planted-${url.hostname}; Path=/`, 'del=1; Path=/'],
47
+ }),
48
+ '/rewritten': () => html('rewritten', '<p>REWRITTEN</p>'),
49
+ '/framed': () => {
50
+ const to = p.get('url') ?? 'about:blank';
51
+ const log = p.get('log') ?? url.origin;
52
+ const pre = `${log}/live?tag=${encodeURIComponent(p.get('tag') ?? '')}&step=pre&to=${encodeURIComponent(to)}`;
53
+ return html(
54
+ 'framed',
55
+ `<script>navigator.sendBeacon(${JSON.stringify(pre)})</script><iframe src="${escape(to)}"></iframe>`
56
+ );
57
+ },
58
+ '/doc': () => document(url, html),
59
+ '/csp': () => document(url, html),
60
+ };
61
+ if (req.method === 'OPTIONS') return send(204, '');
62
+ if (url.pathname.startsWith('/auth')) {
63
+ if (req.headers.authorization === 'Basic dTpw') return send(200, 'authed', 'text/plain');
64
+ return send(401, 'unauthorized', 'text/plain', {
65
+ 'www-authenticate': `Basic realm="${escape(url.pathname)}"`,
66
+ });
67
+ }
68
+ if (url.pathname.startsWith('/canary'))
69
+ return html(
70
+ 'canary',
71
+ `<p data-canary="${escape(p.get('value') ?? 'planted')}">canary-marker ${escape(url.host)}</p>`
72
+ );
73
+ const handler = routes[url.pathname];
74
+ if (handler) return handler();
75
+ return send(404, 'missing', 'text/plain');
76
+ }
77
+
78
+ export async function fixtureServer({ host = '127.0.0.1', port = 0 } = {}) {
79
+ let entries = [];
80
+ const server = createServer((req, res) => {
81
+ const url = new URL(req.url, `http://${req.headers.host}`);
82
+ entries.push({
83
+ url: url.href,
84
+ method: req.method,
85
+ headers: { ...req.headers },
86
+ time: Date.now(),
87
+ });
88
+ const p = url.searchParams;
89
+ const cors = {
90
+ 'access-control-allow-origin': req.headers.origin ?? '*',
91
+ 'access-control-allow-credentials': 'true',
92
+ 'access-control-allow-headers': '*',
93
+ 'access-control-allow-methods': 'GET, POST, OPTIONS',
94
+ };
95
+ const send = (code, body, type = 'text/html', headers = {}) => {
96
+ res.writeHead(code, { 'content-type': `${type}; charset=utf-8`, ...cors, ...headers });
97
+ res.end(body);
98
+ };
99
+ const html = (title, body = '', headers = {}) =>
100
+ send(
101
+ 200,
102
+ `<!doctype html><meta charset="utf-8"><title>${title}</title>${body}<img hidden src="/live?tag=${encodeURIComponent(p.get('tag') ?? p.get('n') ?? p.get('case') ?? p.get('value') ?? '')}&to=${encodeURIComponent(url.href)}">`,
103
+ 'text/html',
104
+ headers
105
+ );
106
+ route(req, url, send, html);
107
+ });
108
+ await new Promise((resolve, reject) => {
109
+ server.once('error', reject);
110
+ server.listen(port, host, resolve);
111
+ });
112
+ const address = server.address();
113
+ return {
114
+ port: address.port,
115
+ origin: `http://${host}:${address.port}`,
116
+ log: () => structuredClone(entries),
117
+ reset: () => {
118
+ entries = [];
119
+ },
120
+ close: () =>
121
+ new Promise((resolve, reject) => {
122
+ server.closeAllConnections();
123
+ server.close((error) => (error ? reject(error) : resolve()));
124
+ }),
125
+ };
126
+ }