@ai-ecoverse/slicc-browsers 1.8.5 → 1.9.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
@@ -172,6 +172,10 @@ Every CDP guard runs the vectors in `guard-vectors.json` (schema `schema/guard-v
172
172
  - `C5.continue-request` and `C5.continue-response`, added next to `C5.document`;
173
173
  - the `C8` rows for privileged pages: `C8.navigate` (a page navigates to `chrome://settings`, `chrome://password-manager`, an extension page or `file:///`; refused), `C8.view-source` (the same through `view-source:`; refused), `C8.create` (`Target.createTarget` straight on those pages; refused), `C8.browser-ui-listed` (`Target.getTargets` lists no `browser_ui` target) and `C8.browser-ui-attach` (`Target.attachToTarget` by a `browser_ui` target's id; refused). All five have an unguarded control, since the guard refuses them only on the privileged target. Two cases aren't rows, because Chrome itself blocks them, so an unguarded client can't tell a guard from none: a page's `window.open('chrome://settings')`, and an `about:` alias such as `about:settings`, which `Page.navigate` and `Target.createTarget` both turn into `about:blank#blocked`. The `about:` aliases are in `protected.json` instead.
174
174
 
175
+ The `C9` foreign extension isolated-world family requires `extensionWorld`: a browser hosting a foreign content-script extension on the ordinary fixture page. `C9.world-hidden` requires the page default context to remain visible after `Runtime.enable`, while the extension context and Runtime/Debugger identifiers remain hidden without cutting or detaching the session. `C9.world-select` probes numeric and unique context selectors, object and argument handles, promises, properties, named bindings and DOM node resolution; each guarded command must fail with the `slicc: refused:` prefix, never an ordinary browser error. `C9.world-debugger` pauses inside that content script: the control must reach its frame, while the guard must refuse or hide it. `C9.page-drivable` evaluates `self.origin`, captures a DOM snapshot and dispatches an Input click on the same ordinary page; cutting the entire session fails this regression row. All four use an unguarded control, the default browser context and observer discovery of the real extension world, not an invented isolated world. Protected default contexts remain session-cutting evidence; hiding a foreign isolated world does not weaken any seven-origin row.
176
+
177
+ The integration launcher loads shared-web 1.25.1’s harness extension exactly once through `Extensions.loadUnpacked` (`ankmbodmgimjemnojmeddfcdlbocolob`), whose content script runs on HTTP(S) pages. shared-web 1.25.1 also loads it by default in its own harness. A transport unable to host a foreign content-script extension omits `extensionWorld` and reports `not-supported:extensionWorld`; an advertised capability with no observed world fails, rather than silently skipping.
178
+
175
179
  `C8.chrome-search` requests `chrome-search://most-visited/` through `Page.navigate` and requires an explicit guard refusal. Its unguarded control requires a CDP response, not a successful document commit: Chrome can answer with `errorText` when that internal data source is unavailable outside an Instant process. Browser-side load failure is not a guard refusal, and merely hiding the target must fail this row.
176
180
 
177
181
  The `C8.newtab` reachability control attaches through the guard to a settled ordinary new tab, including its internal frames, then navigates that same tab to an ordinary document. Both legs must succeed, including without a guard. It requires `chromeUrlsAttach`, so an extension transport reports it as `skip` with `not-supported:chromeUrlsAttach`. The C8 protection rows expect a refusal on the guarded leg and run their controls unguarded, so they don't require it and still run on extension transports. It uses the default browser context, since an incognito new tab omits the ordinary new tab's internal frames. It covers `chrome://newtab/` and directly opened `chrome://new-tab-page/`.
@@ -185,7 +189,7 @@ A client may register `Fetch.enable` patterns naming a protected origin in eithe
185
189
  - `setVersion`: the package version. The release flow stamps it into the npm package and the crate (`scripts/set-crate-version.mjs`). In git, and so in the Go module, it reads `0.0.0`; Go code gets the version from the module.
186
190
  - `targets`: the names a runner maps to a scheme, host and port: `seven`, `vault`, `vault-x` (a vault branch preview), `apex`, `own` (the client page's origin, `{client}`), `ordinary`, `ordinary-same-site`, `chrome-newtab`, `chrome-new-tab-page`, `chrome-settings`, `chrome-passwords`, `chrome-search`, `network-error`, `chrome-extension-page` and `file-root`. Each has a `description` and, for the protected ones, a default `host`. `chrome-newtab` (`chrome://newtab/`), `chrome-new-tab-page` (`chrome://new-tab-page/`), `chrome-settings` (`chrome://settings/`), `chrome-passwords` (`chrome://password-manager/`), `chrome-search` (`chrome-search://most-visited/`), `network-error` (`http://nonexistent.invalid/`), `chrome-extension-page` (a page of Chrome's built-in PDF viewer extension) and `file-root` (`file:///`) carry a `url`, which the runner uses unless `targets` maps the name to something else. No other URL in the file is one a runner must use.
187
191
  - `variants`: the detach variants in ms. `prompt` and `lagging` are documented; a runner may add others, such as `lagging-8s`, and reports the name it ran.
188
- - `capabilities`: what a transport may lack. `autoAttach` covers `Target.setAutoAttach`, `children` steps and child sessions; `browserSession` covers calls on the browser session; `browserContexts` covers `Target.createBrowserContext` and `Target.disposeBrowserContext`; `chromeUrlsAttach` means the transport can attach to and drive `chrome://` pages. CDP over a DevTools port can; `chrome.debugger` can't, since Chrome refuses it every `chrome://` page, even with `--extensions-on-chrome-urls`.
192
+ - `capabilities`: what a transport may lack. `autoAttach` covers `Target.setAutoAttach`, `children` steps and child sessions; `browserSession` covers calls on the browser session; `browserContexts` covers `Target.createBrowserContext` and `Target.disposeBrowserContext`; `extensionWorld` means it can host a foreign content-script extension isolated world on the ordinary fixture; `chromeUrlsAttach` means the transport can attach to and drive `chrome://` pages. CDP over a DevTools port can; `chrome.debugger` can't, since Chrome refuses it every `chrome://` page, even with `--extensions-on-chrome-urls`.
189
193
  - `placeholders`: every `{name}` used in the vectors, with what it stands for. `{client}` is the client page's origin, which the runner maps.
190
194
 
191
195
  **A vector** has:
@@ -233,6 +237,8 @@ Popups are observed by `Target.targetCreated` with the opener's `openerId`, or b
233
237
  - `/plant`: sets the canary;
234
238
  - `/beacon`, and `/log?n=`, which returns `{ hits, beacons }`.
235
239
 
240
+ `{ "extensionWorld": "hidden" | "select" | "debugger" | "drivable", "as": "world", "requires": ["extensionWorld"] }` runs the C9 compound probe on the current ordinary page. The runner attaches an independent observer to discover actual context ids, unique ids and names, and sends probes through the client session. Object handles are minted and validated on the unguarded control session, then replayed as guesses on the guarded leg; Chrome object handles and DOM node ids are session-local, so observer handles must never be mistaken for client handles. Numeric and unique context ids from the control are also replayed alongside fresh observer-discovered selectors. Every guarded replay needs an explicit refusal, including a stale handle (`not exposed`), rather than Chrome’s invalid-id error. Its result contains boolean evidence consumed by ordinary `result` checks. The unguarded leg validates those selectors against Chrome itself before guarded evidence can pass.
241
+
236
242
  **The control rule.** Every vector has a control on an ordinary origin: the same setup and trigger against an unprotected origin must succeed. A missing or failing control fails the id. With `via: "guard"`, the control runs through the guard. With `via: "unguarded"`, the runner runs setup and trigger through its own CDP against the ordinary origin and against the protected stand-in, and both must show the effect. The runner reports the control's transport as `via`.
237
243
 
238
244
  **Refusals.** Each refusable step has a refusal name: its `as`, or `refused.<i>`, where `i` is its index in the vector's steps, setup then trigger, with each `children` step's steps right after it, counted from 0 before `on` is applied. When a guard refuses the step and the error matches `refusal.match`, the runner sets that name to `true` in the leg's observations. On that leg, an observation whose `dependsOn` lists the name is skipped; every other observation is checked as usual. A guard that refuses the step on every origin passes in one of two ways: through the guard, with the step refused on both legs, or with an unguarded control and the guard's matched refusal on the guarded leg. With `via: "guard"`, a refusal on one leg only fails that leg, unless the step declares `refusal.onlyProtected: true`: its matched refusal on the guarded leg alone is conformant, and its dependent observations are skipped there. Refusing such a step on the control always fails the control, even if both legs are refused. Without that flag, refusal symmetry is unchanged. The comparison only counts steps that ran on both legs. A step that ran on one leg only (for example a `children` step on the control's popup, when the guarded leg never gets that popup attached) doesn't fail the row by itself, and the row reports it in `ran`, as `{ "refused.5": "control-only" }` or `"guarded-only"`. Observations that depend on such a step follow their `dependsOn` on each leg. A refusable step that fails without a matching refusal, for example with an internal error or a timeout, is not a refusal: it fails the row with `<name> failed without a refusal: <message>`. A step skipped because an earlier step failed therefore never passes through `ran`, and only a matched refusal skips the observations that depend on it. A guard that allows the step must pass its control through the guard.
@@ -571,7 +577,7 @@ for case in corpus().cases {
571
577
  Go 1.26, Rust stable with `wasm32-wasip1`, Node ≥ 24.
572
578
  - `npm run corpus` regenerates `corpus.json` from `corpus/`; the pre-commit hook and CI fail when it's stale.
573
579
  - `npm run validate` checks `catalog.json`, `corpus.json`, `protected.json` and `guard-vectors.json` against the schemas, runs the protected-origin vectors through `protected.mjs`, and checks what a schema can't say: unique ids, bundle ids and names, known Windows roots, rising tiers, compiling patterns, known tokens, tier order, `sevenOpen`, `tabs` (which holds no entry of `sevenTabs`), `six` and `auto` in each `expected.json`, and no port from 9222 to 9229.
574
- - Every gate prints one verdict line when it passes: `validate: PASS …`, `corpus: PASS <n> cases` (`node scripts/corpus.mjs --check`), `major-guard: PASS v<version> fits <module>`, and `guard-sweep: PASS 13 targets` (`npm run test:guard-sweep`). CI runs each through `scripts/gate.mjs --expect <regex> -- <command>`, which fails when the command exits non-zero or prints no matching line, so a silent or early exit never reads as a pass. `gate.mjs --expect <regex> --in <file>` checks a log instead; the release job uses it on semantic-release's log for the major-guard line. `npm run test:gates` tests the gates, including runs through a symlinked checkout and a symlinked script.
580
+ - Every gate prints one verdict line when it passes: `validate: PASS …`, `corpus: PASS <n> cases` (`node scripts/corpus.mjs --check`), `major-guard: PASS v<version> fits <module>`, and `guard-sweep: PASS 14 targets` (`npm run test:guard-sweep`). CI runs each through `scripts/gate.mjs --expect <regex> -- <command>`, which fails when the command exits non-zero or prints no matching line, so a silent or early exit never reads as a pass. `gate.mjs --expect <regex> --in <file>` checks a log instead; the release job uses it on semantic-release's log for the major-guard line. `npm run test:gates` tests the gates, including runs through a symlinked checkout and a symlinked script.
575
581
  - `npm run lint`: the slicc lint tools (Biome, no comments, no unit tests in git, `AGENTS.md`), `gofmt`, `go mod tidy -diff`, `go vet`, golangci-lint, `cargo fmt --check` and clippy.
576
582
  - `npm test`: `validate`, the Go tests in `test/integration/`, and `cargo test --all-features`.
577
583
  - `npm run build`: the Go module, the crate (also for `wasm32-wasip1`), and `npm pack --dry-run`.
@@ -0,0 +1,316 @@
1
+ import { outcome } from './guard-driver.mjs';
2
+
3
+ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4
+ const extension = (context) =>
5
+ context?.origin?.startsWith('chrome-extension://') && !context.auxData?.isDefault;
6
+ const marker = 'typeof chrome.runtime?.id === "string"';
7
+ const refused = (out) => !out.ok && /^slicc: refused:/.test(out.error?.message ?? '');
8
+
9
+ async function discover(reader, session) {
10
+ const start = reader.events.length;
11
+ await reader.send('Runtime.enable', {}, session);
12
+ const deadline = Date.now() + 5000;
13
+ do {
14
+ const world = reader.events
15
+ .slice(start)
16
+ .find(
17
+ (event) =>
18
+ event.sessionId === session &&
19
+ event.method === 'Runtime.executionContextCreated' &&
20
+ extension(event.params.context)
21
+ )?.params.context;
22
+ if (world) return world;
23
+ await pause(25);
24
+ } while (Date.now() < deadline);
25
+ throw new Error('extensionWorld: no foreign content-script context');
26
+ }
27
+
28
+ async function drivable(client, session, live) {
29
+ const setup = await outcome(
30
+ client.send(
31
+ 'Runtime.evaluate',
32
+ {
33
+ expression:
34
+ 'document.body.innerHTML = `<button style="position:fixed;left:0;top:0;width:100px;height:100px" onclick="document.body.dataset.clicked=\'yes\'">C9 click</button>`',
35
+ returnByValue: true,
36
+ },
37
+ session
38
+ )
39
+ );
40
+ const snapshot = await outcome(
41
+ client.send('DOMSnapshot.captureSnapshot', { computedStyles: [] }, session)
42
+ );
43
+ const down = await outcome(
44
+ client.send(
45
+ 'Input.dispatchMouseEvent',
46
+ { type: 'mousePressed', x: 20, y: 20, button: 'left', clickCount: 1 },
47
+ session
48
+ )
49
+ );
50
+ const up = await outcome(
51
+ client.send(
52
+ 'Input.dispatchMouseEvent',
53
+ { type: 'mouseReleased', x: 20, y: 20, button: 'left', clickCount: 1 },
54
+ session
55
+ )
56
+ );
57
+ const click = await outcome(
58
+ client.send(
59
+ 'Runtime.evaluate',
60
+ { expression: 'document.body.dataset.clicked', returnByValue: true },
61
+ session
62
+ )
63
+ );
64
+ return {
65
+ exists: true,
66
+ alive: await live(),
67
+ snapshot: snapshot.ok && snapshot.result.documents?.length > 0,
68
+ click: setup.ok && down.ok && up.ok && click.ok && click.result.result?.value === 'yes',
69
+ };
70
+ }
71
+
72
+ async function debuggerWorld({ client, reader, session, observer, world, events, live }) {
73
+ const debug = await outcome(client.send('Debugger.enable', {}, session));
74
+ const probe = outcome(
75
+ reader.send(
76
+ 'Runtime.evaluate',
77
+ {
78
+ contextId: world.id,
79
+ expression:
80
+ 'setTimeout(() => { debugger; globalThis.c9DebuggerReached = chrome.runtime.id; }, 0)',
81
+ returnByValue: true,
82
+ },
83
+ observer
84
+ )
85
+ );
86
+ const deadline = Date.now() + 1500;
87
+ while (
88
+ debug.ok &&
89
+ !events().some((event) => event.method === 'Debugger.paused') &&
90
+ Date.now() < deadline
91
+ )
92
+ await pause(25);
93
+ const paused = events().find((event) => event.method === 'Debugger.paused');
94
+ let reached = false;
95
+ let frameRefused = false;
96
+ if (paused?.params.callFrames?.[0]) {
97
+ const out = await outcome(
98
+ client.send(
99
+ 'Debugger.evaluateOnCallFrame',
100
+ {
101
+ callFrameId: paused.params.callFrames[0].callFrameId,
102
+ expression: marker,
103
+ returnByValue: true,
104
+ },
105
+ session
106
+ )
107
+ );
108
+ reached = out.ok && out.result.result?.value === true;
109
+ frameRefused = refused(out);
110
+ }
111
+ await outcome(reader.send('Debugger.resume', {}, observer));
112
+ await outcome(client.send('Debugger.resume', {}, session));
113
+ const probeResult = await probe;
114
+ if (!probeResult.ok)
115
+ throw new Error(`extensionWorld debugger stimulus: ${probeResult.error?.message}`);
116
+ const exposed =
117
+ events().some(
118
+ (event) =>
119
+ event.method === 'Debugger.scriptParsed' && event.params.executionContextId === world.id
120
+ ) || Boolean(paused);
121
+ await outcome(client.send('Debugger.disable', {}, session));
122
+ return {
123
+ exists: true,
124
+ reachable: reached,
125
+ protected: refused(debug) || frameRefused || !exposed,
126
+ alive: await live(),
127
+ };
128
+ }
129
+
130
+ function commands(world, objectId, promiseObjectId, nodeId) {
131
+ return [
132
+ [
133
+ 'contextId',
134
+ 'Runtime.evaluate',
135
+ { contextId: world.id, expression: marker, returnByValue: true },
136
+ ],
137
+ [
138
+ 'uniqueContextId',
139
+ 'Runtime.evaluate',
140
+ { uniqueContextId: world.uniqueId, expression: marker, returnByValue: true },
141
+ ],
142
+ [
143
+ 'executionContextId',
144
+ 'Runtime.callFunctionOn',
145
+ {
146
+ executionContextId: world.id,
147
+ functionDeclaration: `function(){return ${marker}}`,
148
+ returnByValue: true,
149
+ },
150
+ ],
151
+ [
152
+ 'objectId',
153
+ 'Runtime.callFunctionOn',
154
+ {
155
+ objectId,
156
+ functionDeclaration: 'function(){return typeof this.chrome.runtime.id === "string"}',
157
+ returnByValue: true,
158
+ },
159
+ ],
160
+ [
161
+ 'argument',
162
+ 'Runtime.callFunctionOn',
163
+ {
164
+ objectId,
165
+ functionDeclaration: 'function(value){return typeof value.chrome.runtime.id === "string"}',
166
+ arguments: [{ objectId }],
167
+ returnByValue: true,
168
+ },
169
+ ],
170
+ ['promiseObjectId', 'Runtime.awaitPromise', { promiseObjectId, returnByValue: true }],
171
+ ['properties', 'Runtime.getProperties', { objectId, ownProperties: true }],
172
+ ['binding', 'Runtime.addBinding', { name: 'c9Binding', executionContextName: world.name }],
173
+ ['resolveNode', 'DOM.resolveNode', { nodeId, executionContextId: world.id }],
174
+ ];
175
+ }
176
+
177
+ async function selectWorld({ client, session, world, side, controls, live }) {
178
+ let handles = controls.select;
179
+ if (side === 'control') {
180
+ const object = await client.send(
181
+ 'Runtime.evaluate',
182
+ { contextId: world.id, expression: 'globalThis', returnByValue: false },
183
+ session
184
+ );
185
+ const promise = await client.send(
186
+ 'Runtime.evaluate',
187
+ {
188
+ contextId: world.id,
189
+ expression: 'Promise.resolve(chrome.runtime.id)',
190
+ returnByValue: false,
191
+ },
192
+ session
193
+ );
194
+ handles = { world, objectId: object.result.objectId, promiseObjectId: promise.result.objectId };
195
+ controls.select = handles;
196
+ }
197
+ if (!handles?.objectId || !handles?.promiseObjectId)
198
+ throw new Error('extensionWorld: control did not provide object handles');
199
+ const dom = await outcome(client.send('DOM.getDocument', {}, session));
200
+ const result = { exists: true };
201
+ for (const [name, method, params] of commands(
202
+ world,
203
+ handles.objectId,
204
+ handles.promiseObjectId,
205
+ dom.result?.root.nodeId ?? 1
206
+ )) {
207
+ const out = await outcome(client.send(method, params, session));
208
+ result[`${name}Refused`] = refused(out);
209
+ result[`${name}Reached`] =
210
+ out.ok &&
211
+ !out.result.exceptionDetails &&
212
+ (['contextId', 'uniqueContextId', 'executionContextId', 'objectId', 'argument'].includes(name)
213
+ ? out.result.result?.value === true
214
+ : name === 'promiseObjectId'
215
+ ? typeof out.result.result?.value === 'string'
216
+ : name === 'properties'
217
+ ? out.result.result?.some((property) => property.name === 'chrome')
218
+ : name === 'resolveNode'
219
+ ? Boolean(out.result.object?.objectId)
220
+ : true);
221
+ result[`${name}Error`] = out.error?.message ?? out.result?.exceptionDetails?.text;
222
+ }
223
+ const guesses = commands(
224
+ handles.world,
225
+ handles.objectId,
226
+ handles.promiseObjectId,
227
+ dom.result?.root.nodeId ?? 1
228
+ ).slice(0, 3);
229
+ result.guessesRefused = true;
230
+ result.guessesReached = true;
231
+ for (const [, method, params] of guesses) {
232
+ const out = await outcome(client.send(method, params, session));
233
+ result.guessesRefused &&= refused(out);
234
+ result.guessesReached &&= out.ok && out.result.result?.value === true;
235
+ }
236
+ result.alive = await live();
237
+ return result;
238
+ }
239
+
240
+ export async function extensionWorld({
241
+ client,
242
+ reader,
243
+ session,
244
+ targetId,
245
+ mode,
246
+ origin,
247
+ side,
248
+ controls,
249
+ }) {
250
+ const attached = await reader.send('Target.attachToTarget', { targetId, flatten: true });
251
+ const observer = attached.sessionId;
252
+ const seen = client.events.length;
253
+ try {
254
+ const world = await discover(reader, observer);
255
+ const enabled = await outcome(client.send('Runtime.enable', {}, session));
256
+ await pause(100);
257
+ const events = () => client.events.slice(seen).filter((event) => event.sessionId === session);
258
+ const live = async () => {
259
+ const out = await outcome(
260
+ client.send('Runtime.evaluate', { expression: 'self.origin', returnByValue: true }, session)
261
+ );
262
+ return (
263
+ out.ok &&
264
+ out.result.result?.value === origin &&
265
+ !out.result.exceptionDetails &&
266
+ !client.events
267
+ .slice(seen)
268
+ .some(
269
+ (event) =>
270
+ event.method === 'Target.detachedFromTarget' && event.params.sessionId === session
271
+ )
272
+ );
273
+ };
274
+ if (mode === 'hidden') {
275
+ await outcome(client.send('Debugger.enable', {}, session));
276
+ await reader.send(
277
+ 'Runtime.evaluate',
278
+ {
279
+ contextId: world.id,
280
+ expression: 'console.log("C9 isolated world", globalThis); globalThis',
281
+ returnByValue: false,
282
+ },
283
+ observer
284
+ );
285
+ await pause(100);
286
+ const leaked = events().filter((event) => /^(Runtime|Debugger)\./.test(event.method));
287
+ const leaks = leaked.some(
288
+ (event) =>
289
+ event.params.executionContextId === world.id ||
290
+ event.params.executionContextUniqueId === world.uniqueId ||
291
+ JSON.stringify(event.params).includes(world.uniqueId) ||
292
+ extension(event.params.context) ||
293
+ (event.method === 'Runtime.consoleAPICalled' &&
294
+ event.params.args?.some((arg) => arg.objectId))
295
+ );
296
+ const listed = leaked.some(
297
+ (event) =>
298
+ event.method === 'Runtime.executionContextCreated' && extension(event.params.context)
299
+ );
300
+ const defaultSeen = leaked.some(
301
+ (event) =>
302
+ event.method === 'Runtime.executionContextCreated' &&
303
+ event.params.context.auxData?.isDefault &&
304
+ event.params.context.origin === origin
305
+ );
306
+ await outcome(client.send('Debugger.disable', {}, session));
307
+ return { exists: true, listed, leaks, defaultSeen, alive: enabled.ok && (await live()) };
308
+ }
309
+ const input = { client, reader, session, observer, world, events, live, side, controls };
310
+ if (mode === 'drivable') return await drivable(client, session, live);
311
+ if (mode === 'debugger') return await debuggerWorld(input);
312
+ return await selectWorld(input);
313
+ } finally {
314
+ await outcome(reader.send('Target.detachFromTarget', { sessionId: observer }));
315
+ }
316
+ }
package/guard-runner.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { extensionWorld } from './extension-world.mjs';
1
2
  import {
2
3
  beacon,
3
4
  canary,
@@ -15,6 +16,7 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
15
16
  const namedResultMs = 10000;
16
17
  const observeMs = 5000;
17
18
  const q = JSON.stringify;
19
+
18
20
  const at = (value, path = '') =>
19
21
  path
20
22
  .split('.')
@@ -598,6 +600,8 @@ class Execution {
598
600
  this.errors.push(
599
601
  `${step.refusal.name} failed without a refusal: ${String(out.error?.message ?? out.error)}`
600
602
  );
603
+ if (step.extensionWorld && !out.ok)
604
+ this.errors.push(`extensionWorld: ${out.error?.message ?? 'probe failed'}`);
601
605
  if (step.as && !out.result?.pending) this.calls[step.as] = out;
602
606
  if (step.cdp) (this.calls.$cdp ??= []).push({ method: step.cdp, ...out });
603
607
  }
@@ -647,6 +651,19 @@ class Execution {
647
651
  await this.settle(step.settle);
648
652
  return;
649
653
  }
654
+ if (step.extensionWorld)
655
+ return outcome(
656
+ extensionWorld({
657
+ client: this.client,
658
+ reader: this.reader,
659
+ session,
660
+ targetId: this.pageTarget,
661
+ mode: step.extensionWorld,
662
+ origin: this.vars.ordinary,
663
+ side: this.side,
664
+ controls: (this.clients.extensionWorldControls ??= {}),
665
+ })
666
+ );
650
667
  if (step.guess) return this.guess(step.guess, session);
651
668
  if (step.cdp) return this.cdp(step, session);
652
669
  if (step.page) return this.page(step, session, phase);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "setVersion": "1.8.5",
3
+ "setVersion": "1.9.0",
4
4
  "sources": [
5
5
  {
6
6
  "repo": "ai-ecoverse/slicc-cli",
@@ -90,6 +90,7 @@
90
90
  "autoAttach": "Target.setAutoAttach, the children steps and child sessions",
91
91
  "browserSession": "CDP calls on the browser session",
92
92
  "browserContexts": "Target.createBrowserContext and Target.disposeBrowserContext",
93
+ "extensionWorld": "a browser hosting a foreign content-script extension isolated world on the ordinary fixture page",
93
94
  "chromeUrlsAttach": "attaching to and driving chrome:// pages, which CDP over a DevTools port can and chrome.debugger can't"
94
95
  },
95
96
  "placeholders": {
@@ -117,6 +118,537 @@
117
118
  "browserUi": "the id of one of Chrome's own browser_ui targets, which the runner looks up with its own CDP"
118
119
  },
119
120
  "vectors": [
121
+ {
122
+ "id": "C9.world-hidden",
123
+ "family": "C",
124
+ "kind": "cdp",
125
+ "scope": "any",
126
+ "defaultContext": true,
127
+ "setup": [
128
+ {
129
+ "page": "tab",
130
+ "url": "{ordinary}/blank"
131
+ }
132
+ ],
133
+ "trigger": [
134
+ {
135
+ "extensionWorld": "hidden",
136
+ "as": "world",
137
+ "requires": ["extensionWorld"]
138
+ }
139
+ ],
140
+ "targets": ["ordinary"],
141
+ "requires": ["extensionWorld"],
142
+ "observe": {
143
+ "exists": {
144
+ "means": "foreign extension world exists",
145
+ "check": {
146
+ "result": "world",
147
+ "path": "result.exists",
148
+ "equals": true,
149
+ "observer": "client"
150
+ }
151
+ },
152
+ "listed": {
153
+ "means": "foreign extension world listed",
154
+ "check": {
155
+ "result": "world",
156
+ "path": "result.listed",
157
+ "equals": true,
158
+ "observer": "client"
159
+ }
160
+ },
161
+ "leaks": {
162
+ "means": "foreign extension world leaks",
163
+ "check": {
164
+ "result": "world",
165
+ "path": "result.leaks",
166
+ "equals": true,
167
+ "observer": "client"
168
+ }
169
+ },
170
+ "defaultSeen": {
171
+ "means": "foreign extension world defaultSeen",
172
+ "check": {
173
+ "result": "world",
174
+ "path": "result.defaultSeen",
175
+ "equals": true,
176
+ "observer": "client"
177
+ }
178
+ },
179
+ "alive": {
180
+ "means": "foreign extension world alive",
181
+ "check": {
182
+ "result": "world",
183
+ "path": "result.alive",
184
+ "equals": true,
185
+ "observer": "client"
186
+ }
187
+ }
188
+ },
189
+ "live": "the observer confirms a foreign extension isolated world exists on an ordinary page",
190
+ "liveCheck": {
191
+ "result": "world",
192
+ "path": "result.exists",
193
+ "equals": true,
194
+ "observer": "client"
195
+ },
196
+ "control": {
197
+ "origin": "ordinary",
198
+ "via": "unguarded",
199
+ "expected": {
200
+ "exists": true,
201
+ "listed": true,
202
+ "leaks": true,
203
+ "defaultSeen": true,
204
+ "alive": true
205
+ }
206
+ },
207
+ "expected": {
208
+ "exists": true,
209
+ "listed": false,
210
+ "leaks": false,
211
+ "defaultSeen": true,
212
+ "alive": true
213
+ }
214
+ },
215
+ {
216
+ "id": "C9.world-select",
217
+ "family": "C",
218
+ "kind": "cdp",
219
+ "scope": "any",
220
+ "defaultContext": true,
221
+ "setup": [
222
+ {
223
+ "page": "tab",
224
+ "url": "{ordinary}/blank"
225
+ }
226
+ ],
227
+ "trigger": [
228
+ {
229
+ "extensionWorld": "select",
230
+ "as": "world",
231
+ "requires": ["extensionWorld"]
232
+ }
233
+ ],
234
+ "targets": ["ordinary"],
235
+ "requires": ["extensionWorld"],
236
+ "observe": {
237
+ "exists": {
238
+ "means": "foreign extension world exists",
239
+ "check": {
240
+ "result": "world",
241
+ "path": "result.exists",
242
+ "equals": true,
243
+ "observer": "client"
244
+ }
245
+ },
246
+ "alive": {
247
+ "means": "foreign extension world alive",
248
+ "check": {
249
+ "result": "world",
250
+ "path": "result.alive",
251
+ "equals": true,
252
+ "observer": "client"
253
+ }
254
+ },
255
+ "contextIdRefused": {
256
+ "means": "foreign extension world contextIdRefused",
257
+ "check": {
258
+ "result": "world",
259
+ "path": "result.contextIdRefused",
260
+ "equals": true,
261
+ "observer": "client"
262
+ }
263
+ },
264
+ "uniqueContextIdRefused": {
265
+ "means": "foreign extension world uniqueContextIdRefused",
266
+ "check": {
267
+ "result": "world",
268
+ "path": "result.uniqueContextIdRefused",
269
+ "equals": true,
270
+ "observer": "client"
271
+ }
272
+ },
273
+ "executionContextIdRefused": {
274
+ "means": "foreign extension world executionContextIdRefused",
275
+ "check": {
276
+ "result": "world",
277
+ "path": "result.executionContextIdRefused",
278
+ "equals": true,
279
+ "observer": "client"
280
+ }
281
+ },
282
+ "objectIdRefused": {
283
+ "means": "foreign extension world objectIdRefused",
284
+ "check": {
285
+ "result": "world",
286
+ "path": "result.objectIdRefused",
287
+ "equals": true,
288
+ "observer": "client"
289
+ }
290
+ },
291
+ "argumentRefused": {
292
+ "means": "foreign extension world argumentRefused",
293
+ "check": {
294
+ "result": "world",
295
+ "path": "result.argumentRefused",
296
+ "equals": true,
297
+ "observer": "client"
298
+ }
299
+ },
300
+ "promiseObjectIdRefused": {
301
+ "means": "foreign extension world promiseObjectIdRefused",
302
+ "check": {
303
+ "result": "world",
304
+ "path": "result.promiseObjectIdRefused",
305
+ "equals": true,
306
+ "observer": "client"
307
+ }
308
+ },
309
+ "propertiesRefused": {
310
+ "means": "foreign extension world propertiesRefused",
311
+ "check": {
312
+ "result": "world",
313
+ "path": "result.propertiesRefused",
314
+ "equals": true,
315
+ "observer": "client"
316
+ }
317
+ },
318
+ "bindingRefused": {
319
+ "means": "foreign extension world bindingRefused",
320
+ "check": {
321
+ "result": "world",
322
+ "path": "result.bindingRefused",
323
+ "equals": true,
324
+ "observer": "client"
325
+ }
326
+ },
327
+ "resolveNodeRefused": {
328
+ "means": "foreign extension world resolveNodeRefused",
329
+ "check": {
330
+ "result": "world",
331
+ "path": "result.resolveNodeRefused",
332
+ "equals": true,
333
+ "observer": "client"
334
+ }
335
+ },
336
+ "contextIdReached": {
337
+ "means": "foreign extension world contextIdReached",
338
+ "check": {
339
+ "result": "world",
340
+ "path": "result.contextIdReached",
341
+ "equals": true,
342
+ "observer": "client"
343
+ }
344
+ },
345
+ "uniqueContextIdReached": {
346
+ "means": "foreign extension world uniqueContextIdReached",
347
+ "check": {
348
+ "result": "world",
349
+ "path": "result.uniqueContextIdReached",
350
+ "equals": true,
351
+ "observer": "client"
352
+ }
353
+ },
354
+ "executionContextIdReached": {
355
+ "means": "foreign extension world executionContextIdReached",
356
+ "check": {
357
+ "result": "world",
358
+ "path": "result.executionContextIdReached",
359
+ "equals": true,
360
+ "observer": "client"
361
+ }
362
+ },
363
+ "objectIdReached": {
364
+ "means": "foreign extension world objectIdReached",
365
+ "check": {
366
+ "result": "world",
367
+ "path": "result.objectIdReached",
368
+ "equals": true,
369
+ "observer": "client"
370
+ }
371
+ },
372
+ "argumentReached": {
373
+ "means": "foreign extension world argumentReached",
374
+ "check": {
375
+ "result": "world",
376
+ "path": "result.argumentReached",
377
+ "equals": true,
378
+ "observer": "client"
379
+ }
380
+ },
381
+ "promiseObjectIdReached": {
382
+ "means": "foreign extension world promiseObjectIdReached",
383
+ "check": {
384
+ "result": "world",
385
+ "path": "result.promiseObjectIdReached",
386
+ "equals": true,
387
+ "observer": "client"
388
+ }
389
+ },
390
+ "propertiesReached": {
391
+ "means": "foreign extension world propertiesReached",
392
+ "check": {
393
+ "result": "world",
394
+ "path": "result.propertiesReached",
395
+ "equals": true,
396
+ "observer": "client"
397
+ }
398
+ },
399
+ "bindingReached": {
400
+ "means": "foreign extension world bindingReached",
401
+ "check": {
402
+ "result": "world",
403
+ "path": "result.bindingReached",
404
+ "equals": true,
405
+ "observer": "client"
406
+ }
407
+ },
408
+ "resolveNodeReached": {
409
+ "means": "foreign extension world resolveNodeReached",
410
+ "check": {
411
+ "result": "world",
412
+ "path": "result.resolveNodeReached",
413
+ "equals": true,
414
+ "observer": "client"
415
+ }
416
+ },
417
+ "guessesRefused": {
418
+ "means": "numeric and unique context ids guessed from the successful control leg",
419
+ "check": {
420
+ "result": "world",
421
+ "path": "result.guessesRefused",
422
+ "equals": true,
423
+ "observer": "client"
424
+ }
425
+ },
426
+ "guessesReached": {
427
+ "means": "numeric and unique context ids guessed from the successful control leg",
428
+ "check": {
429
+ "result": "world",
430
+ "path": "result.guessesReached",
431
+ "equals": true,
432
+ "observer": "client"
433
+ }
434
+ }
435
+ },
436
+ "live": "the observer confirms a foreign extension isolated world exists on an ordinary page",
437
+ "liveCheck": {
438
+ "result": "world",
439
+ "path": "result.exists",
440
+ "equals": true,
441
+ "observer": "client"
442
+ },
443
+ "control": {
444
+ "origin": "ordinary",
445
+ "via": "unguarded",
446
+ "expected": {
447
+ "exists": true,
448
+ "alive": true,
449
+ "contextIdRefused": false,
450
+ "contextIdReached": true,
451
+ "uniqueContextIdRefused": false,
452
+ "uniqueContextIdReached": true,
453
+ "executionContextIdRefused": false,
454
+ "executionContextIdReached": true,
455
+ "objectIdRefused": false,
456
+ "objectIdReached": true,
457
+ "argumentRefused": false,
458
+ "argumentReached": true,
459
+ "promiseObjectIdRefused": false,
460
+ "promiseObjectIdReached": true,
461
+ "propertiesRefused": false,
462
+ "propertiesReached": true,
463
+ "bindingRefused": false,
464
+ "bindingReached": true,
465
+ "resolveNodeRefused": false,
466
+ "resolveNodeReached": true,
467
+ "guessesRefused": false,
468
+ "guessesReached": true
469
+ }
470
+ },
471
+ "expected": {
472
+ "exists": true,
473
+ "alive": true,
474
+ "contextIdRefused": true,
475
+ "uniqueContextIdRefused": true,
476
+ "executionContextIdRefused": true,
477
+ "objectIdRefused": true,
478
+ "argumentRefused": true,
479
+ "promiseObjectIdRefused": true,
480
+ "propertiesRefused": true,
481
+ "bindingRefused": true,
482
+ "resolveNodeRefused": true,
483
+ "guessesRefused": true
484
+ }
485
+ },
486
+ {
487
+ "id": "C9.world-debugger",
488
+ "family": "C",
489
+ "kind": "cdp",
490
+ "scope": "any",
491
+ "defaultContext": true,
492
+ "setup": [
493
+ {
494
+ "page": "tab",
495
+ "url": "{ordinary}/blank"
496
+ }
497
+ ],
498
+ "trigger": [
499
+ {
500
+ "extensionWorld": "debugger",
501
+ "as": "world",
502
+ "requires": ["extensionWorld"]
503
+ }
504
+ ],
505
+ "targets": ["ordinary"],
506
+ "requires": ["extensionWorld"],
507
+ "observe": {
508
+ "exists": {
509
+ "means": "foreign extension world exists",
510
+ "check": {
511
+ "result": "world",
512
+ "path": "result.exists",
513
+ "equals": true,
514
+ "observer": "client"
515
+ }
516
+ },
517
+ "reachable": {
518
+ "means": "foreign extension world reachable",
519
+ "check": {
520
+ "result": "world",
521
+ "path": "result.reachable",
522
+ "equals": true,
523
+ "observer": "client"
524
+ }
525
+ },
526
+ "protected": {
527
+ "means": "foreign extension world protected",
528
+ "check": {
529
+ "result": "world",
530
+ "path": "result.protected",
531
+ "equals": true,
532
+ "observer": "client"
533
+ }
534
+ },
535
+ "alive": {
536
+ "means": "foreign extension world alive",
537
+ "check": {
538
+ "result": "world",
539
+ "path": "result.alive",
540
+ "equals": true,
541
+ "observer": "client"
542
+ }
543
+ }
544
+ },
545
+ "live": "the observer confirms a foreign extension isolated world exists on an ordinary page",
546
+ "liveCheck": {
547
+ "result": "world",
548
+ "path": "result.exists",
549
+ "equals": true,
550
+ "observer": "client"
551
+ },
552
+ "control": {
553
+ "origin": "ordinary",
554
+ "via": "unguarded",
555
+ "expected": {
556
+ "exists": true,
557
+ "reachable": true,
558
+ "protected": false,
559
+ "alive": true
560
+ }
561
+ },
562
+ "expected": {
563
+ "exists": true,
564
+ "reachable": false,
565
+ "protected": true,
566
+ "alive": true
567
+ }
568
+ },
569
+ {
570
+ "id": "C9.page-drivable",
571
+ "family": "C",
572
+ "kind": "cdp",
573
+ "scope": "any",
574
+ "defaultContext": true,
575
+ "setup": [
576
+ {
577
+ "page": "tab",
578
+ "url": "{ordinary}/blank"
579
+ }
580
+ ],
581
+ "trigger": [
582
+ {
583
+ "extensionWorld": "drivable",
584
+ "as": "world",
585
+ "requires": ["extensionWorld"]
586
+ }
587
+ ],
588
+ "targets": ["ordinary"],
589
+ "requires": ["extensionWorld"],
590
+ "observe": {
591
+ "exists": {
592
+ "means": "foreign extension world exists",
593
+ "check": {
594
+ "result": "world",
595
+ "path": "result.exists",
596
+ "equals": true,
597
+ "observer": "client"
598
+ }
599
+ },
600
+ "alive": {
601
+ "means": "foreign extension world alive",
602
+ "check": {
603
+ "result": "world",
604
+ "path": "result.alive",
605
+ "equals": true,
606
+ "observer": "client"
607
+ }
608
+ },
609
+ "snapshot": {
610
+ "means": "foreign extension world snapshot",
611
+ "check": {
612
+ "result": "world",
613
+ "path": "result.snapshot",
614
+ "equals": true,
615
+ "observer": "client"
616
+ }
617
+ },
618
+ "click": {
619
+ "means": "foreign extension world click",
620
+ "check": {
621
+ "result": "world",
622
+ "path": "result.click",
623
+ "equals": true,
624
+ "observer": "client"
625
+ }
626
+ }
627
+ },
628
+ "live": "the observer confirms a foreign extension isolated world exists on an ordinary page",
629
+ "liveCheck": {
630
+ "result": "world",
631
+ "path": "result.exists",
632
+ "equals": true,
633
+ "observer": "client"
634
+ },
635
+ "control": {
636
+ "origin": "ordinary",
637
+ "via": "unguarded",
638
+ "expected": {
639
+ "exists": true,
640
+ "alive": true,
641
+ "snapshot": true,
642
+ "click": true
643
+ }
644
+ },
645
+ "expected": {
646
+ "exists": true,
647
+ "alive": true,
648
+ "snapshot": true,
649
+ "click": true
650
+ }
651
+ },
120
652
  {
121
653
  "id": "C1.script-nav",
122
654
  "family": "C",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-ecoverse/slicc-browsers",
3
- "version": "1.8.5",
3
+ "version": "1.9.0",
4
4
  "description": "SLICC's shared browser catalog, JSON schemas and discovery conformance corpus",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -50,7 +50,8 @@
50
50
  "seven.mjs",
51
51
  "schema",
52
52
  "guard-runner.mjs",
53
- "fixture-server.mjs"
53
+ "fixture-server.mjs",
54
+ "extension-world.mjs"
54
55
  ],
55
56
  "scripts": {
56
57
  "prepare": "lefthook install",
@@ -65,19 +66,19 @@
65
66
  "trust:dry": "npx fledgling sync --dry-run -y --skip-publish",
66
67
  "release": "semantic-release",
67
68
  "test:guard": "npm run test:guard-integration && npm run test:guard-sweep",
68
- "test:guard-integration": "node --test test/integration/guard-fit.test.mjs test/integration/guard-chain.test.mjs test/integration/guard-validation.test.mjs test/integration/guard-liveness.test.mjs test/integration/guard-observations.test.mjs test/integration/guard-child-latency.test.mjs test/integration/guard-adoption.test.mjs test/integration/guard-scoping.test.mjs test/integration/guard-late-pause.test.mjs test/integration/guard-legs.test.mjs test/integration/guard-fetch-enable.test.mjs test/integration/guard-diagnostics.test.mjs test/integration/guard-chrome.test.mjs test/integration/guard-scales.test.mjs",
69
+ "test:guard-integration": "node --test --test-concurrency=2 test/integration/guard-fit.test.mjs test/integration/guard-chain.test.mjs test/integration/guard-validation.test.mjs test/integration/guard-liveness.test.mjs test/integration/guard-observations.test.mjs test/integration/guard-child-latency.test.mjs test/integration/guard-adoption.test.mjs test/integration/guard-scoping.test.mjs test/integration/guard-late-pause.test.mjs test/integration/guard-legs.test.mjs test/integration/guard-fetch-enable.test.mjs test/integration/guard-diagnostics.test.mjs test/integration/guard-chrome.test.mjs test/integration/guard-worlds.test.mjs test/integration/guard-scales.test.mjs",
69
70
  "test:guard-sweep": "node scripts/test-guards.mjs",
70
71
  "test:gates": "node --test test/integration/gates.test.mjs",
71
72
  "test:guard-target": "node --test test/integration/guard-runner.test.mjs",
72
73
  "test:unit:guard": "node --experimental-test-coverage --test --test-coverage-include=guard-runner.mjs --test-coverage-include=fixture-server.mjs --test-coverage-lines=100 test/unit/guard-*.test.mjs"
73
74
  },
74
75
  "devDependencies": {
75
- "@ai-ecoverse/slicc-shared-web": "1.24.2",
76
+ "@ai-ecoverse/slicc-shared-web": "1.25.1",
76
77
  "@biomejs/biome": "2.5.15",
77
78
  "ajv": "8.20.0",
78
79
  "ajv-formats": "3.0.1",
79
80
  "lefthook": "2.2.1",
80
- "semantic-release": "25.0.9",
81
- "playwright-core": "1.64.1"
81
+ "playwright-core": "1.64.1",
82
+ "semantic-release": "25.0.9"
82
83
  }
83
84
  }
@@ -81,7 +81,13 @@
81
81
  "capabilities": {
82
82
  "type": "object",
83
83
  "additionalProperties": false,
84
- "required": ["autoAttach", "browserSession", "browserContexts", "chromeUrlsAttach"],
84
+ "required": [
85
+ "autoAttach",
86
+ "browserSession",
87
+ "browserContexts",
88
+ "chromeUrlsAttach",
89
+ "extensionWorld"
90
+ ],
85
91
  "properties": {
86
92
  "autoAttach": {
87
93
  "type": "string",
@@ -95,6 +101,7 @@
95
101
  "type": "string",
96
102
  "minLength": 1
97
103
  },
104
+ "extensionWorld": { "type": "string", "minLength": 1 },
98
105
  "chromeUrlsAttach": {
99
106
  "type": "string",
100
107
  "minLength": 1
@@ -121,7 +128,13 @@
121
128
  },
122
129
  "$defs": {
123
130
  "capability": {
124
- "enum": ["autoAttach", "browserSession", "browserContexts", "chromeUrlsAttach"]
131
+ "enum": [
132
+ "autoAttach",
133
+ "browserSession",
134
+ "browserContexts",
135
+ "chromeUrlsAttach",
136
+ "extensionWorld"
137
+ ]
125
138
  },
126
139
  "requires": {
127
140
  "type": "array",
@@ -161,6 +174,7 @@
161
174
  },
162
175
  "target": {
163
176
  "enum": [
177
+ "ordinary",
164
178
  "seven",
165
179
  "vault",
166
180
  "vault-x",
@@ -413,6 +427,22 @@
413
427
  },
414
428
  "step": {
415
429
  "oneOf": [
430
+ {
431
+ "type": "object",
432
+ "additionalProperties": false,
433
+ "required": ["extensionWorld", "as", "requires"],
434
+ "properties": {
435
+ "extensionWorld": {
436
+ "enum": ["hidden", "select", "debugger", "drivable"]
437
+ },
438
+ "as": {
439
+ "$ref": "#/$defs/name"
440
+ },
441
+ "requires": {
442
+ "$ref": "#/$defs/requires"
443
+ }
444
+ }
445
+ },
416
446
  {
417
447
  "type": "object",
418
448
  "additionalProperties": false,