@ai-ecoverse/slicc-browsers 1.7.2 → 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
@@ -309,7 +309,20 @@ heartbeat; alternatively a pre-action `/live?tag=...&step=pre&to=...` request an
309
309
  an exact-URL block/error from the client or unguarded frame tree. An error for a
310
310
  different URL is not evidence. `blocked` accepts either `{ url }` for one exact URL
311
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`. Redirect
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
313
326
  vectors list every nested hop URL and the final destination because Chromium
314
327
  reports the URL intercepted by the guard. A matching block still requires the
315
328
  pre-action beacon alongside it; an unrelated error cannot certify the chain.
@@ -491,8 +504,9 @@ semantic-release cuts one version from `main` for all three packages (`.github/w
491
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.
492
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.
493
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.
494
508
 
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.
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.
496
510
 
497
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.
498
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;
@@ -283,12 +284,99 @@ class Execution {
283
284
  tree.frame.url.startsWith('chrome-error://')) ||
284
285
  (tree.childFrames ?? []).some(visit))
285
286
  );
286
- return (
287
+ if (
287
288
  read.ok &&
288
289
  (spec.frame
289
290
  ? (read.result.frameTree.childFrames ?? []).some(visit)
290
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 })
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)
291
378
  );
379
+ return read.ok && read.result.entries.some((entry) => unloaded.includes(entry.url));
292
380
  } finally {
293
381
  await outcome(
294
382
  this.reader.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
@@ -349,34 +437,34 @@ class Execution {
349
437
  continue;
350
438
  this.sessions.child = event.params.sessionId;
351
439
  if (info?.targetId) this.targets.push(info.targetId);
352
- this.pending.push(
353
- (async () => {
354
- const reload = this.childSteps.findIndex((step) => step.cdp === 'Page.reload');
355
- if (reload < 0) await this.execute(this.childSteps, event.params.sessionId);
356
- else {
357
- await this.execute(this.childSteps.slice(0, reload), event.params.sessionId);
358
- const start = this.client.events.length;
359
- await outcome(
360
- this.client.send('Runtime.runIfWaitingForDebugger', {}, event.params.sessionId)
361
- );
362
- await this.poll(
363
- () =>
364
- this.client.events
365
- .slice(start)
366
- .some(
367
- (e) =>
368
- e.method === 'Page.loadEventFired' &&
369
- e.sessionId === event.params.sessionId
370
- ),
371
- 1500
372
- );
373
- await this.execute(this.childSteps.slice(reload), event.params.sessionId);
374
- }
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;
375
447
  await outcome(
376
448
  this.client.send('Runtime.runIfWaitingForDebugger', {}, event.params.sessionId)
377
449
  );
378
- })().catch((error) => this.errors.push(String(error.message ?? error)))
379
- );
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);
380
468
  }
381
469
  }
382
470
  await sleep(10);
@@ -410,23 +498,40 @@ class Execution {
410
498
  this.calls[`answer.${p.requestId}.${event.method}`] = out;
411
499
  }
412
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
+
413
511
  async execute(list, childSession, phase = 'child') {
512
+ if (phase === 'trigger') await this.childrenSettled();
414
513
  for (const raw of list) {
415
514
  const step = expand(raw, this.vars);
416
515
  if (!supported(step.requires, this.options.capabilities) || step.text) continue;
516
+ if (!childSession && step.session === 'child') await this.childrenSettled();
417
517
  const session = childSession ?? this.session(step.session);
418
518
  const out = await this.operation(step, session, phase);
419
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)));
420
526
  this.trace.push({
421
527
  phase,
422
528
  declared: Boolean(step.refusable),
423
529
  session,
424
530
  out,
425
- refused: step.refusable
426
- ? isRefusal(out, step)
427
- : /refused/.test(String(out.error?.message ?? out.error)),
531
+ refused,
532
+ ...(gone ? { detached: true } : {}),
428
533
  });
429
- if (step.refusable) this.refused[step.refusal.name] = isRefusal(out, step);
534
+ if (step.refusable) this.refused[step.refusal.name] = refused;
430
535
  if (step.as && !out.result?.pending) this.calls[step.as] = out;
431
536
  if (step.cdp) (this.calls.$cdp ??= []).push({ method: step.cdp, ...out });
432
537
  this.options.debug?.({ step, out, side: this.side });
@@ -671,6 +776,8 @@ class Execution {
671
776
  );
672
777
  if (c.state) return this.readState(c.state);
673
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);
674
781
  if (c.pre || c.loaded) return this.attemptCheck(c);
675
782
  }
676
783
 
@@ -786,6 +893,8 @@ class Execution {
786
893
  if (!supported(observation.requires, this.options.capabilities)) continue;
787
894
  if ((observation.dependsOn ?? []).some((dep) => this.refused[dep])) continue;
788
895
  observed[name] = await this.check(observation.check);
896
+ if (observation.eitherWhen && this.side === 'guarded')
897
+ observed[`${name}.either`] = await this.check(observation.eitherWhen);
789
898
  }
790
899
  this.options.debug?.({
791
900
  events: this.client.events.slice(this.eventStart),
@@ -826,7 +935,9 @@ const observerRequired = (check) => {
826
935
  const needsObserver = (vector) =>
827
936
  vector.control.via === 'unguarded' ||
828
937
  observerRequired(vector.liveCheck) ||
829
- Object.values(vector.observe ?? {}).some((observation) => observerRequired(observation.check));
938
+ Object.values(vector.observe ?? {}).some(
939
+ (observation) => observerRequired(observation.check) || observerRequired(observation.eitherWhen)
940
+ );
830
941
 
831
942
  const inactive = (vector, target, capabilities) =>
832
943
  vector.skip?.target === target ||
@@ -954,9 +1065,10 @@ const invalidBlocked = (check) =>
954
1065
  function validateBlocked(vectors) {
955
1066
  for (const vector of vectors)
956
1067
  if (
957
- [vector.liveCheck, ...Object.values(vector.observe ?? {}).map((o) => o.check)].some(
958
- invalidBlocked
959
- )
1068
+ [
1069
+ vector.liveCheck,
1070
+ ...Object.values(vector.observe ?? {}).flatMap((o) => [o.check, o.eitherWhen]),
1071
+ ].some(invalidBlocked)
960
1072
  )
961
1073
  throw new TypeError(`${vector.id}: blocked needs exactly one url or nonempty anyOf`);
962
1074
  }