@ai-ecoverse/slicc-browsers 1.7.1 → 1.7.3

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
@@ -151,7 +151,7 @@ Every CDP guard runs the vectors in `guard-vectors.json` (schema `schema/guard-v
151
151
  - `setup` and `trigger`, both lists of steps;
152
152
  - `targets`: the protected targets it runs against;
153
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);
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: …`.
@@ -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
 
@@ -291,7 +291,7 @@ Checks include: `beacon`, `served`, `header` with
291
291
  `on`, `event` with `where`, `result` with `path` and `equals`, `cookie` with `from`,
292
292
  `compare`, `refused`, `reached`, `attached`, `delivered`, `title`, `expression`,
293
293
  `hops` (each exact nested redirect URL), `detached`, `loaded`, `pre`, `blocked`,
294
- `registration`, `state` and `filtered`. The diagnostic `executed` and `live`
294
+ `history`, `frameBlocked`, `registration`, `state` and `filtered`. The diagnostic `executed` and `live`
295
295
  checks are not valid liveness proofs.
296
296
  Compose checks with `all`, `any` or `not`. Predicates support nested partial
297
297
  objects, `$contains`, `$regex`, `$some`, nonempty `$every`, and `$encoded` with
@@ -307,7 +307,26 @@ observations: a missing or failing effect fails the control. Guarded liveness
307
307
  requires the exact target URL in the fixture log or a target-origin `/live`
308
308
  heartbeat; alternatively a pre-action `/live?tag=...&step=pre&to=...` request and
309
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
310
+ different URL is not evidence. `blocked` accepts either `{ url }` for one exact URL
311
+ 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`. With `frame`,
313
+ the unguarded observer also reads the page's out-of-process iframe targets (matched by
314
+ their parent frame): a site-isolated iframe isn't in the parent's frame tree, so its
315
+ chrome-error main frame with the exact `unreachableUrl` is the block. `history` (unguarded,
316
+ same URL forms) is for guards that replace the error page, such as with `about:blank#blocked`: it
317
+ needs the pre-action beacon, an entry with the exact URL in the observer's
318
+ `Page.getNavigationHistory` of the page or child, and no fixture request for that URL.
319
+ `frameBlocked` (unguarded) is that out-of-process chrome-error iframe target, and the
320
+ client must be unable to attach to it; the cross-site `C4` rows accept it as `errorFrame`.
321
+ A step that the guard refuses in every context, such as `Network.clearBrowserCookies`
322
+ in `C6.clear-cookies`, is refusable, so refusing it on both legs passes and allowing it
323
+ is judged on its effect. A child step that fails after its child session detached is
324
+ recorded as detached, not as a refusal, and the runner awaits a child's started
325
+ `children` steps before any later `session: "child"` step and before the trigger. Redirect
326
+ vectors list every nested hop URL and the final destination because Chromium
327
+ reports the URL intercepted by the guard. A matching block still requires the
328
+ pre-action beacon alongside it; an unrelated error cannot certify the chain.
329
+ A declared setup refusal can supply liveness only
311
330
  when its `refusal.match` matched. Transport connectivity and command completion
312
331
  alone never prove liveness. Cookie and storage rows seed a canary outside the
313
332
  guard and read it back outside the guard; refused writes require unchanged
@@ -485,8 +504,9 @@ semantic-release cuts one version from `main` for all three packages (`.github/w
485
504
  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.
486
505
  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.
487
506
  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.
507
+ 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.
488
508
 
489
- 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.
509
+ 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.
490
510
 
491
511
  **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.
492
512
 
package/guard-driver.mjs CHANGED
@@ -125,7 +125,11 @@ export function judge(vector, side, observed) {
125
125
  (vector.observe?.[k]?.dependsOn ?? []).some((d) => refused.includes(d))
126
126
  );
127
127
  const mismatches = Object.entries(want)
128
- .filter(([k, v]) => !skipped.includes(k) && !matches(v, observed?.[k]))
128
+ .filter(
129
+ ([k, v]) =>
130
+ !skipped.includes(k) &&
131
+ !matches(observed?.[`${k}.either`] === true ? 'either' : v, observed?.[k])
132
+ )
129
133
  .map(([k, v]) => `${k}: got ${q(observed?.[k])}, want ${q(v)}`);
130
134
  return { pass: mismatches.length === 0, refused, skipped, mismatches };
131
135
  }
package/guard-runner.mjs CHANGED
@@ -108,6 +108,7 @@ class Execution {
108
108
  this.answers = [];
109
109
  this.attachSettings = new Map();
110
110
  this.childSteps = [];
111
+ this.childRuns = new Map();
111
112
  this.processed = new Set();
112
113
  this.eventStart = this.client.events.length;
113
114
  this.actionEventStart = this.eventStart;
@@ -245,7 +246,7 @@ class Execution {
245
246
  }
246
247
 
247
248
  async blocked(spec, observer) {
248
- const url = address(spec.url);
249
+ const urls = (spec.anyOf ?? [spec.url]).map(address);
249
250
  const events = this.client.events.slice(this.eventStart);
250
251
  if (observer === 'client') {
251
252
  const requests = new Map(
@@ -259,10 +260,10 @@ class Execution {
259
260
  (!spec.frame ||
260
261
  event.params.frame.parentId ||
261
262
  event.sessionId === this.sessions.child) &&
262
- event.params.frame.unreachableUrl === url &&
263
+ urls.includes(event.params.frame.unreachableUrl) &&
263
264
  event.params.frame.url.startsWith('chrome-error://')) ||
264
265
  (event.method === 'Network.loadingFailed' &&
265
- requests.get(event.params.requestId) === url &&
266
+ urls.includes(requests.get(event.params.requestId)) &&
266
267
  (event.params.blockedReason || event.params.errorText === 'net::ERR_BLOCKED_BY_CLIENT'))
267
268
  );
268
269
  }
@@ -279,15 +280,103 @@ class Execution {
279
280
  const visit = (tree) =>
280
281
  Boolean(
281
282
  tree &&
282
- ((tree.frame.unreachableUrl === url && tree.frame.url.startsWith('chrome-error://')) ||
283
+ ((urls.includes(tree.frame.unreachableUrl) &&
284
+ tree.frame.url.startsWith('chrome-error://')) ||
283
285
  (tree.childFrames ?? []).some(visit))
284
286
  );
285
- return (
287
+ if (
286
288
  read.ok &&
287
289
  (spec.frame
288
290
  ? (read.result.frameTree.childFrames ?? []).some(visit)
289
291
  : visit(read.result.frameTree))
292
+ )
293
+ return true;
294
+ return spec.frame ? Boolean(await this.blockedFrameTarget(urls, targetId)) : false;
295
+ } finally {
296
+ await outcome(
297
+ this.reader.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
290
298
  );
299
+ }
300
+ }
301
+
302
+ async frameIds(targetId) {
303
+ const attached = await outcome(
304
+ this.reader.send('Target.attachToTarget', { targetId, flatten: true })
305
+ );
306
+ if (!attached.ok) return [];
307
+ try {
308
+ const read = await outcome(
309
+ this.reader.send('Page.getFrameTree', {}, attached.result.sessionId)
310
+ );
311
+ const ids = (tree) => [tree.frame.id, ...(tree.childFrames ?? []).flatMap(ids)];
312
+ return read.ok ? ids(read.result.frameTree) : [];
313
+ } finally {
314
+ await outcome(
315
+ this.reader.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
316
+ );
317
+ }
318
+ }
319
+
320
+ async blockedFrameTarget(urls, parentTarget) {
321
+ const parents = await this.frameIds(parentTarget);
322
+ const listed = await outcome(this.reader.send('Target.getTargets', {}));
323
+ if (!listed.ok) return undefined;
324
+ for (const info of listed.result.targetInfos) {
325
+ if (info.type !== 'iframe' || !parents.includes(info.parentFrameId)) continue;
326
+ const attached = await outcome(
327
+ this.reader.send('Target.attachToTarget', { targetId: info.targetId, flatten: true })
328
+ );
329
+ if (!attached.ok) continue;
330
+ try {
331
+ const read = await outcome(
332
+ this.reader.send('Page.getFrameTree', {}, attached.result.sessionId)
333
+ );
334
+ const frame = read.result?.frameTree?.frame;
335
+ if (frame?.url.startsWith('chrome-error://') && urls.includes(frame.unreachableUrl))
336
+ return info.targetId;
337
+ } finally {
338
+ await outcome(
339
+ this.reader.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
340
+ );
341
+ }
342
+ }
343
+ return undefined;
344
+ }
345
+
346
+ async frameBlocked(spec) {
347
+ const urls = (spec.anyOf ?? [spec.url]).map(address);
348
+ const targetId = spec.session === 'child' ? this.targets.at(-1) : this.pageTarget;
349
+ if (!targetId) return false;
350
+ const frame = await this.blockedFrameTarget(urls, targetId);
351
+ if (!frame) return false;
352
+ const attached = await outcome(
353
+ this.client.send('Target.attachToTarget', { targetId: frame, flatten: true })
354
+ );
355
+ if (attached.ok)
356
+ await outcome(
357
+ this.client.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
358
+ );
359
+ return !attached.ok;
360
+ }
361
+
362
+ async history(spec) {
363
+ const urls = (spec.anyOf ?? [spec.url]).map(address);
364
+ const targetId = spec.session === 'child' ? this.targets.at(-1) : this.pageTarget;
365
+ if (!targetId) return false;
366
+ const pre = await Promise.all(urls.map((url) => this.attemptCheck({ pre: url })));
367
+ if (!pre.some(Boolean)) return false;
368
+ const loaded = await Promise.all(urls.map((url) => this.attemptCheck({ loaded: url })));
369
+ const unloaded = urls.filter((_, index) => !loaded[index]);
370
+ if (!unloaded.length) return false;
371
+ const attached = await outcome(
372
+ this.reader.send('Target.attachToTarget', { targetId, flatten: true })
373
+ );
374
+ if (!attached.ok) return false;
375
+ try {
376
+ const read = await outcome(
377
+ this.reader.send('Page.getNavigationHistory', {}, attached.result.sessionId)
378
+ );
379
+ return read.ok && read.result.entries.some((entry) => unloaded.includes(entry.url));
291
380
  } finally {
292
381
  await outcome(
293
382
  this.reader.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
@@ -348,34 +437,34 @@ class Execution {
348
437
  continue;
349
438
  this.sessions.child = event.params.sessionId;
350
439
  if (info?.targetId) this.targets.push(info.targetId);
351
- this.pending.push(
352
- (async () => {
353
- const reload = this.childSteps.findIndex((step) => step.cdp === 'Page.reload');
354
- if (reload < 0) await this.execute(this.childSteps, event.params.sessionId);
355
- else {
356
- await this.execute(this.childSteps.slice(0, reload), event.params.sessionId);
357
- const start = this.client.events.length;
358
- await outcome(
359
- this.client.send('Runtime.runIfWaitingForDebugger', {}, event.params.sessionId)
360
- );
361
- await this.poll(
362
- () =>
363
- this.client.events
364
- .slice(start)
365
- .some(
366
- (e) =>
367
- e.method === 'Page.loadEventFired' &&
368
- e.sessionId === event.params.sessionId
369
- ),
370
- 1500
371
- );
372
- await this.execute(this.childSteps.slice(reload), event.params.sessionId);
373
- }
440
+ const child = event.params.sessionId;
441
+ const started = (async () => {
442
+ const reload = this.childSteps.findIndex((step) => step.cdp === 'Page.reload');
443
+ if (reload < 0) await this.execute(this.childSteps, event.params.sessionId);
444
+ else {
445
+ await this.execute(this.childSteps.slice(0, reload), event.params.sessionId);
446
+ const start = this.client.events.length;
374
447
  await outcome(
375
448
  this.client.send('Runtime.runIfWaitingForDebugger', {}, event.params.sessionId)
376
449
  );
377
- })().catch((error) => this.errors.push(String(error.message ?? error)))
378
- );
450
+ await this.poll(
451
+ () =>
452
+ this.client.events
453
+ .slice(start)
454
+ .some(
455
+ (e) =>
456
+ e.method === 'Page.loadEventFired' && e.sessionId === event.params.sessionId
457
+ ),
458
+ 1500
459
+ );
460
+ await this.execute(this.childSteps.slice(reload), event.params.sessionId);
461
+ }
462
+ await outcome(
463
+ this.client.send('Runtime.runIfWaitingForDebugger', {}, event.params.sessionId)
464
+ );
465
+ })().catch((error) => this.errors.push(String(error.message ?? error)));
466
+ this.childRuns.set(child, started);
467
+ this.pending.push(started);
379
468
  }
380
469
  }
381
470
  await sleep(10);
@@ -409,23 +498,40 @@ class Execution {
409
498
  this.calls[`answer.${p.requestId}.${event.method}`] = out;
410
499
  }
411
500
 
501
+ async childrenSettled() {
502
+ await Promise.all([...this.childRuns.values()]);
503
+ }
504
+
505
+ detached(session) {
506
+ return this.client.events
507
+ .slice(this.eventStart)
508
+ .some((e) => e.method === 'Target.detachedFromTarget' && e.params?.sessionId === session);
509
+ }
510
+
412
511
  async execute(list, childSession, phase = 'child') {
512
+ if (phase === 'trigger') await this.childrenSettled();
413
513
  for (const raw of list) {
414
514
  const step = expand(raw, this.vars);
415
515
  if (!supported(step.requires, this.options.capabilities) || step.text) continue;
516
+ if (!childSession && step.session === 'child') await this.childrenSettled();
416
517
  const session = childSession ?? this.session(step.session);
417
518
  const out = await this.operation(step, session, phase);
418
519
  if (!out) continue;
520
+ const gone = Boolean(childSession) && !out.ok && this.detached(childSession);
521
+ const refused =
522
+ !gone &&
523
+ (step.refusable
524
+ ? isRefusal(out, step)
525
+ : /refused/.test(String(out.error?.message ?? out.error)));
419
526
  this.trace.push({
420
527
  phase,
421
528
  declared: Boolean(step.refusable),
422
529
  session,
423
530
  out,
424
- refused: step.refusable
425
- ? isRefusal(out, step)
426
- : /refused/.test(String(out.error?.message ?? out.error)),
531
+ refused,
532
+ ...(gone ? { detached: true } : {}),
427
533
  });
428
- if (step.refusable) this.refused[step.refusal.name] = isRefusal(out, step);
534
+ if (step.refusable) this.refused[step.refusal.name] = refused;
429
535
  if (step.as && !out.result?.pending) this.calls[step.as] = out;
430
536
  if (step.cdp) (this.calls.$cdp ??= []).push({ method: step.cdp, ...out });
431
537
  this.options.debug?.({ step, out, side: this.side });
@@ -624,6 +730,7 @@ class Execution {
624
730
  );
625
731
  if (c.refused || c.reached) return this.callCheck(c);
626
732
  if (c.detached) {
733
+ const events = this.client.events.slice(this.eventStart, this.cleanupEventStart);
627
734
  const session = this.sessions[c.detached];
628
735
  return (
629
736
  Boolean(session) &&
@@ -669,6 +776,8 @@ class Execution {
669
776
  );
670
777
  if (c.state) return this.readState(c.state);
671
778
  if (c.blocked) return this.blocked(c.blocked, c.observer);
779
+ if (c.history) return this.history(c.history);
780
+ if (c.frameBlocked) return this.frameBlocked(c.frameBlocked);
672
781
  if (c.pre || c.loaded) return this.attemptCheck(c);
673
782
  }
674
783
 
@@ -784,6 +893,8 @@ class Execution {
784
893
  if (!supported(observation.requires, this.options.capabilities)) continue;
785
894
  if ((observation.dependsOn ?? []).some((dep) => this.refused[dep])) continue;
786
895
  observed[name] = await this.check(observation.check);
896
+ if (observation.eitherWhen && this.side === 'guarded')
897
+ observed[`${name}.either`] = await this.check(observation.eitherWhen);
787
898
  }
788
899
  this.options.debug?.({
789
900
  events: this.client.events.slice(this.eventStart),
@@ -824,7 +935,9 @@ const observerRequired = (check) => {
824
935
  const needsObserver = (vector) =>
825
936
  vector.control.via === 'unguarded' ||
826
937
  observerRequired(vector.liveCheck) ||
827
- Object.values(vector.observe ?? {}).some((observation) => observerRequired(observation.check));
938
+ Object.values(vector.observe ?? {}).some(
939
+ (observation) => observerRequired(observation.check) || observerRequired(observation.eitherWhen)
940
+ );
828
941
 
829
942
  const inactive = (vector, target, capabilities) =>
830
943
  vector.skip?.target === target ||
@@ -938,6 +1051,28 @@ async function executeRow(job, opts, clients, set, slot, index) {
938
1051
  return row;
939
1052
  }
940
1053
 
1054
+ const invalidBlocked = (check) =>
1055
+ Boolean(
1056
+ check?.blocked &&
1057
+ ((check.blocked.url === undefined) === (check.blocked.anyOf === undefined) ||
1058
+ (check.blocked.anyOf !== undefined &&
1059
+ (!Array.isArray(check.blocked.anyOf) || check.blocked.anyOf.length === 0)))
1060
+ ) ||
1061
+ [...(check?.all ?? []), ...(check?.any ?? []), ...(check?.not ? [check.not] : [])].some(
1062
+ invalidBlocked
1063
+ );
1064
+
1065
+ function validateBlocked(vectors) {
1066
+ for (const vector of vectors)
1067
+ if (
1068
+ [
1069
+ vector.liveCheck,
1070
+ ...Object.values(vector.observe ?? {}).flatMap((o) => [o.check, o.eitherWhen]),
1071
+ ].some(invalidBlocked)
1072
+ )
1073
+ throw new TypeError(`${vector.id}: blocked needs exactly one url or nonempty anyOf`);
1074
+ }
1075
+
941
1076
  function validate(set, opts) {
942
1077
  if (!opts.fixture?.log) throw new TypeError('fixture.log is required');
943
1078
  for (const vector of set.vectors)
@@ -954,6 +1089,7 @@ function validate(set, opts) {
954
1089
  for (const vector of set.vectors)
955
1090
  if (vector.kind === 'cdp' && invalidLive(vector.liveCheck))
956
1091
  throw new TypeError(`${vector.id}: liveCheck needs guarded attempt evidence`);
1092
+ validateBlocked(set.vectors);
957
1093
  const variants = Array.isArray(opts.variant) ? opts.variant : [opts.variant];
958
1094
  for (const name of variants)
959
1095
  if (!(name in set.variants)) throw new TypeError(`Unknown variant: ${name}`);