@ai-ecoverse/slicc-browsers 1.6.0 → 1.7.1

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,132 @@ 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. Explicitly map an unavailable target to `null`: every
274
+ row needing that origin is reported as `skip` for control, guarded and live, with
275
+ reason `not-applicable:no-<target>-origin`; no substitute origin is used and the
276
+ row never passes. A guard whose whole site is protected, such as the extension
277
+ 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`
280
+ selects one variant or an array; omission selects all. `parallel` defaults to 1.
281
+ `log(row)` receives completed rows. A variant supplies the wait before an
282
+ explicit `Target.detachFromTarget` step and for a `settle.variant` step.
283
+
284
+ Every runnable vector has a declarative `liveCheck`; every observation has a
285
+ declarative `check`. Each check declares its `observer`: `fixture` reads request
286
+ logs, `client` reads already-delivered transport data, and `unguarded` requires
287
+ an independent CDP observer. `all` needs the observers for every operand; `any`
288
+ can use an available fixture or client alternative without an unguarded observer.
289
+ Unavailable optional observer alternatives are false, never substituted with guarded reads.
290
+ Checks include: `beacon`, `served`, `header` with
291
+ `on`, `event` with `where`, `result` with `path` and `equals`, `cookie` with `from`,
292
+ `compare`, `refused`, `reached`, `attached`, `delivered`, `title`, `expression`,
293
+ `hops` (each exact nested redirect URL), `detached`, `loaded`, `pre`, `blocked`,
294
+ `registration`, `state` and `filtered`. The diagnostic `executed` and `live`
295
+ checks are not valid liveness proofs.
296
+ Compose checks with `all`, `any` or `not`. Predicates support nested partial
297
+ objects, `$contains`, `$regex`, `$some`, nonempty `$every`, and `$encoded` with
298
+ `{ value, prefix }` for a base64-encoded value with an optional prefix. Reads of titles and
299
+ 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,
300
+ comparison and observer-read
301
+ observations; they are never comparable values. Registration refusals must match the
302
+ step's declared expression; dependent observations are omitted and reported by
303
+ the existing result evaluator.
304
+
305
+ `liveCheck` applies only to the guarded leg. Controls are judged by their positive
306
+ observations: a missing or failing effect fails the control. Guarded liveness
307
+ requires the exact target URL in the fixture log or a target-origin `/live`
308
+ heartbeat; alternatively a pre-action `/live?tag=...&step=pre&to=...` request and
309
+ an exact-URL block/error from the client or unguarded frame tree. An error for a
310
+ different URL is not evidence. A declared setup refusal can supply liveness only
311
+ when its `refusal.match` matched. Transport connectivity and command completion
312
+ alone never prove liveness. Cookie and storage rows seed a canary outside the
313
+ guard and read it back outside the guard; refused writes require unchanged
314
+ canary state and a refusal or filtered call response. Their control can clear or
315
+ modify state as required by its positive observations. `C5.auth` and
316
+ `C5.guessed-id.continue-with-auth` use fixture Authorization logs and refusal of
317
+ candidate auth ids; `C5.auth` also checks delivered auth events. Their liveness
318
+ requires an auth request at the fixture, not a completed fetch. Neither uses a
319
+ plain-tab baseline: headless Chromium can leave a credentials-including fetch
320
+ to a Basic 401 pending even without an attached client.
321
+
322
+ Beacon settlement uses only requests recorded after the page action starts. For
323
+ redirect chains it also requires the exact final destination request in the
324
+ fixture log. A setup beacon on the same origin and tag cannot settle an unfinished
325
+ redirect chain. This does not change the observations used to judge either leg.
326
+
327
+ The separately exported Node module `@ai-ecoverse/slicc-browsers/fixture` exposes
328
+ `fixtureServer({ host, port, tls })`, defaulting to a random loopback port.
329
+ Pass `tls: { key, cert }` to serve HTTPS using Node TLS key and certificate
330
+ values. The returned origin, request logs, redirect URLs and document heartbeats
331
+ use `https://<Host>` with TLS, so `served`, `hops` and `liveCheck` compare against
332
+ the mapped HTTPS origins, including an extension client at `https://*.sliccy.ai`.
333
+ Without `tls`, the server and logged URLs remain HTTP. It returns
334
+ `origin`, `port`, `log()`, `reset()` and `close()`. Log entries contain `url`,
335
+ `method`, `headers` and `time`. Mirror that API when using another fixture.
336
+ The server serves document, frame, redirect, CSP, authentication, data, image,
337
+ canary and beacon paths. Configure host resolution in your test browser, not in
338
+ the runner. `npm run test:guard` exercises Chromium twice with no guard, one target per
339
+ invocation: controls must pass and guarded expectations must fail. For a single
340
+ target use `GUARD_TARGET=seven npm run test:guard-target`. The fixture also serves
341
+ `/live` image requests from stand-in documents, independently of injected code.
342
+
343
+ ```js
344
+ import set from '@ai-ecoverse/slicc-browsers/guard-vectors.json' with { type: 'json' };
345
+ import { run } from '@ai-ecoverse/slicc-browsers/guard-runner';
346
+ import { fixtureServer } from '@ai-ecoverse/slicc-browsers/fixture';
347
+ const fixture = await fixtureServer();
348
+ const targets = await configureTestOrigins(fixture.port);
349
+ const capabilities = {
350
+ autoAttach: true,
351
+ browserSession: true,
352
+ browserContexts: true,
353
+ };
354
+ const execution = await run(set, {
355
+ clientFactory: (metadata) => connectGuardAndObserver(metadata),
356
+ targets,
357
+ capabilities,
358
+ variant: ['prompt', 'lagging'],
359
+ parallel: 6,
360
+ fixture,
361
+ });
362
+ console.log(execution.summary({ guard: 'my-guard', sha: 'build-sha' }));
363
+ await fixture.close();
364
+ ```
365
+
240
366
  ## The process source
241
367
 
242
368
  The process scan finds browsers started with a debugging port by something else:
@@ -0,0 +1,129 @@
1
+ import { createServer } from 'node:http';
2
+ import { createServer as createSecureServer } from 'node:https';
3
+
4
+ const tall = Buffer.from(
5
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
6
+ 'base64'
7
+ );
8
+ const escape = (value) =>
9
+ value.replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;');
10
+
11
+ function document(url, html) {
12
+ const p = url.searchParams;
13
+ const csp = p.has('csp') || url.pathname === '/csp';
14
+ return html(
15
+ 'doc',
16
+ `<p>document ${escape(p.get('n') ?? '')}</p>${csp ? '<script>document.title="inline"; document.body.dataset.inline="true"</script>' : ''}`,
17
+ csp ? { 'content-security-policy': "script-src 'none'" } : {}
18
+ );
19
+ }
20
+
21
+ function trigger(p) {
22
+ const kind = p.get('k') ?? '';
23
+ const to = p.get('to') ?? '';
24
+ const frame = kind === 'iframe' ? `<iframe src="${escape(to)}"></iframe>` : '';
25
+ 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>`;
26
+ }
27
+
28
+ function route(req, url, send, html) {
29
+ const p = url.searchParams;
30
+ const routes = {
31
+ '/beacon': () => send(204, ''),
32
+ '/hop': () =>
33
+ send(Number(p.get('code') ?? 302), 'moved', 'text/plain', { location: p.get('to') ?? '/' }),
34
+ '/data': () => send(200, `real-${p.get('n') ?? ''}`, 'text/plain'),
35
+ '/live': () => send(200, tall, 'image/png'),
36
+ '/img': () => send(200, tall, 'image/png'),
37
+ '/script': () =>
38
+ send(
39
+ 200,
40
+ `globalThis.vectorScript=${JSON.stringify(`real-${p.get('n') ?? ''}`)}`,
41
+ 'text/javascript'
42
+ ),
43
+ '/page': () => html('trigger', trigger(p)),
44
+ '/api/cookies': () => send(200, req.headers.cookie ?? 'none', 'text/plain'),
45
+ '/plant': () =>
46
+ html('plant', '<script>localStorage.setItem("canary","planted")</script>', {
47
+ 'set-cookie': [`canary=planted-${url.hostname}; Path=/`, 'del=1; Path=/'],
48
+ }),
49
+ '/rewritten': () => html('rewritten', '<p>REWRITTEN</p>'),
50
+ '/framed': () => {
51
+ const to = p.get('url') ?? 'about:blank';
52
+ const log = p.get('log') ?? url.origin;
53
+ const pre = `${log}/live?tag=${encodeURIComponent(p.get('tag') ?? '')}&step=pre&to=${encodeURIComponent(to)}`;
54
+ return html(
55
+ 'framed',
56
+ `<script>navigator.sendBeacon(${JSON.stringify(pre)})</script><iframe src="${escape(to)}"></iframe>`
57
+ );
58
+ },
59
+ '/doc': () => document(url, html),
60
+ '/csp': () => document(url, html),
61
+ };
62
+ if (req.method === 'OPTIONS') return send(204, '');
63
+ if (url.pathname.startsWith('/auth')) {
64
+ if (req.headers.authorization === 'Basic dTpw') return send(200, 'authed', 'text/plain');
65
+ return send(401, 'unauthorized', 'text/plain', {
66
+ 'www-authenticate': `Basic realm="${escape(url.pathname)}"`,
67
+ });
68
+ }
69
+ if (url.pathname.startsWith('/canary'))
70
+ return html(
71
+ 'canary',
72
+ `<p data-canary="${escape(p.get('value') ?? 'planted')}">canary-marker ${escape(url.host)}</p>`
73
+ );
74
+ const handler = routes[url.pathname];
75
+ if (handler) return handler();
76
+ return send(404, 'missing', 'text/plain');
77
+ }
78
+
79
+ export async function fixtureServer({ host = '127.0.0.1', port = 0, tls } = {}) {
80
+ let entries = [];
81
+ const scheme = tls ? 'https' : 'http';
82
+ const handler = (req, res) => {
83
+ const url = new URL(req.url, `${scheme}://${req.headers.host}`);
84
+ entries.push({
85
+ url: url.href,
86
+ method: req.method,
87
+ headers: { ...req.headers },
88
+ time: Date.now(),
89
+ });
90
+ const p = url.searchParams;
91
+ const cors = {
92
+ 'access-control-allow-origin': req.headers.origin ?? '*',
93
+ 'access-control-allow-credentials': 'true',
94
+ 'access-control-allow-headers': '*',
95
+ 'access-control-allow-methods': 'GET, POST, OPTIONS',
96
+ };
97
+ const send = (code, body, type = 'text/html', headers = {}) => {
98
+ res.writeHead(code, { 'content-type': `${type}; charset=utf-8`, ...cors, ...headers });
99
+ res.end(body);
100
+ };
101
+ const html = (title, body = '', headers = {}) =>
102
+ send(
103
+ 200,
104
+ `<!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)}">`,
105
+ 'text/html',
106
+ headers
107
+ );
108
+ route(req, url, send, html);
109
+ };
110
+ const server = tls ? createSecureServer(tls, handler) : createServer(handler);
111
+ await new Promise((resolve, reject) => {
112
+ server.once('error', reject);
113
+ server.listen(port, host, resolve);
114
+ });
115
+ const address = server.address();
116
+ return {
117
+ port: address.port,
118
+ origin: `${scheme}://${host}:${address.port}`,
119
+ log: () => structuredClone(entries),
120
+ reset: () => {
121
+ entries = [];
122
+ },
123
+ close: () =>
124
+ new Promise((resolve, reject) => {
125
+ server.closeAllConnections();
126
+ server.close((error) => (error ? reject(error) : resolve()));
127
+ }),
128
+ };
129
+ }