@ai-ecoverse/slicc-browsers 1.8.4 → 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
@@ -94,7 +94,7 @@ Every feature builds for `wasm32-wasip1`.
94
94
  - `discardExceptions`: where the site pattern and the extra origins' hosts go, as a `list` of strings or as a `chrome-time-map`, a map from site to the time it was added (microseconds since 1601-01-01 UTC, as a decimal string);
95
95
  - `cleanExit`: values that mark the last exit clean;
96
96
  - `form` says where a key comes from: `six` (SLICC six's keys) or `chrome-154` (what Chrome 154 reads).
97
- - `protectedOrigins`: what browser control must never reach, see [Protected origins](#protected-origins). `domains` (`sliccy.ai`), `hosts` (`vault.sliccy.ai`), `schemes` (`https`, `http`), `innerSchemes` (`blob`, `filesystem`, `view-source`), `anySchemes` (`devtools`), `protectedSchemes` (`chrome`, `chrome-untrusted`, `chrome-extension`, `devtools`, `edge`, `brave`, `opera`, `vivaldi`, `arc`, `file`) with `schemeExceptions` (`chrome://newtab`, `chrome://new-tab-page`, `chrome-untrusted://new-tab-page`, `edge://newtab`, `brave://newtab`, `opera://startpage`), `aboutScheme` (`chrome`), `targetTypes` (`browser_ui`), `inheriting` (`about:blank`, `about:srcdoc`), and `refusal`, the text of a refusal.
97
+ - `protectedOrigins`: what browser control must never reach, see [Protected origins](#protected-origins). `domains` (`sliccy.ai`), `hosts` (`vault.sliccy.ai`), `schemes` (`https`, `http`), `innerSchemes` (`blob`, `filesystem`, `view-source`), `anySchemes` (`devtools`), `protectedSchemes` (`chrome`, `chrome-untrusted`, `chrome-search`, `chrome-native`, `chrome-distiller`, `chrome-experimental-site-token-provider`, `isolated-app`, `externalfile`, `content`, `chrome-extension`, `devtools`, `edge`, `brave`, `opera`, `vivaldi`, `arc`, `file`) with `schemeExceptions` (`chrome://newtab`, `chrome://new-tab-page`, `chrome-untrusted://new-tab-page`, `edge://newtab`, `brave://newtab`, `opera://startpage`), `aboutScheme` (`chrome`), `targetTypes` (`browser_ui`), `inheriting` (`about:blank`, `about:srcdoc`), and `refusal`, the text of a refusal.
98
98
  - `profiles`: SLICC profiles live in `<config dir>/<root>/<name>`, and a snap's in `~/snap/<snap>/common/<snapRoot>/<name>`. `legacyRoot` is the old root, never scanned; on macOS's case-insensitive volume it is SLICC six's root too. `sixProfilePattern` matches six's profile names, case-insensitively. `namePattern` is a valid profile name. `six` describes SLICC six's Chrome profiles (see [SLICC six's profiles](#slicc-sixs-profiles)):
99
99
  - `roots`: six's profile root per OS (`macos`, `linux`, `windows`), a `base` location followed by the `path` segments. The base is its `env` when that is set and not empty (any value, as six takes it), else `home` under the home dir.
100
100
  - `defaultName` (`browser-coding-agent-chrome`), `defaultPort` (5710) and `portSuffix` (`-{port}`): six's profile for its serve port `<port>` is `<root>/<defaultName>`, with `portSuffix` added for any port other than `defaultPort`.
@@ -120,8 +120,35 @@ Browser control must never navigate to, open or attach to a target on seven's ow
120
120
 
121
121
  This is wider than the rule for finding seven (`seven.originHostPattern`, which leaves out the `excludedLabels` and `excludedLabelPrefixes`—`www`, `vault`, `vault-preview`, `get`, `mcpcat`, `echo`, and any `vault-*` prefix). Every `sliccy.ai` host shares the `sliccy.ai` cookies, and cookies ignore the port and, without `Secure`, the scheme. SLICC six lives on `www.sliccy.ai` and the apex, and its cookies matter. `*.kernel.localhost` is not protected: each port is its own origin, and the agent's own servers live there. An extra origin is matched exactly, so `http://127.0.0.1:5710` doesn't protect `http://127.0.0.1:5711`.
122
122
 
123
+ **Chromium scheme audit.** Checked [content scheme registration](https://github.com/chromium/chromium/blob/d0bfaae35fa745d9dee525dc4ff6617b1551dbe7/content/common/url_schemes.cc) and [Chrome additional schemes](https://github.com/chromium/chromium/blob/d0bfaae35fa745d9dee525dc4ff6617b1551dbe7/chrome/common/chrome_content_client.cc), including platform-specific registrations:
124
+
125
+ | Scheme | Decision and reason |
126
+ | --- | --- |
127
+ | `chrome` | Protected: browser WebUI, with only the catalog's exact new-tab host exceptions. |
128
+ | `chrome-untrusted` | Protected: browser-owned isolated UI resources, except the exact `new-tab-page` host. |
129
+ | `chrome-search` | Protected: Instant/search-provider UI and profile resources, including `local-ntp`, `most-visited` and `remote-ntp`; hiding targets is not refusal. |
130
+ | `chrome-native` | Protected: browser UI rendered by platform-native widgets instead of HTML. |
131
+ | `chrome-distiller` | Protected: browser-generated reader-mode documents, which may contain content distilled from a protected document. |
132
+ | `chrome-error` | Not scheme-wide protected: generic network-error documents hold no protected document content; ordinary errors must remain readable and recoverable. |
133
+ | `isolated-app` | Protected: installed isolated web apps have app-specific storage and privileged capabilities outside ordinary web origins. |
134
+ | `devtools` | Protected already: a DevTools front end can inspect a protected target. |
135
+ | `chrome-extension` | Protected already: extension documents and workers can contain credentials and browser privileges. |
136
+ | `chrome-experimental-site-token-provider` | Protected: an internal data source issues site-specific tokens. |
137
+ | `externalfile` | Protected: ChromeOS local/external file access must not bypass the `file` fence. |
138
+ | `file` | Protected already: local files must not bypass the agent's file-system fence. |
139
+ | `content` | Protected: Android local content-provider access must not bypass the `file` fence. |
140
+ | `android-app` | Not scheme-wide protected: Android app/referrer identity, not a browser UI document; extra-origin rules still apply. |
141
+ | `googlechrome` | Not scheme-wide protected: an external launch alias, not registered as a privileged document scheme by these entry points. |
142
+ | `view-source`, `blob`, `filesystem` | Not blindly scheme-wide protected: existing recursive inner-origin rules protect what they wrap. |
143
+ | `about` | Not blindly scheme-wide protected: existing Chromium alias rewriting protects UI while retaining inheriting blank/srcdoc URLs. |
144
+ | `http`, `https`, `data`, `javascript`, `ws`, `wss`, `mailto`, `webcal` | Not scheme-wide protected: ordinary web, inline content, transport or external handlers, not privileged browser UI. |
145
+
146
+ A guard must refuse navigation, creation and attach on protected schemes, including protected frames of an otherwise ordinary tab, rather than merely hiding them from discovery. `chrome-error` is deliberately not a protected scheme: ordinary DNS, connection and certificate failures produce that same generic document, so scheme-wide refusal would prevent reattaching to an ordinary error tab. Error pages hold no secrets from the failed document. This does not permit opening or attaching to a protected origin: guards still fail protected navigations before commit and enforce protected-target provenance; C1–C5 observer-only `unreachableUrl`, history and network block evidence is unchanged.
147
+
123
148
  **New-tab checks (1.8.2).** In fresh default profiles on macOS, Chrome for Testing 156.0.8078.4 (Playwright build 1248) and Chrome 155.0.8059.82 commit `chrome://newtab/` to `chrome://new-tab-page/` and embed `chrome-untrusted://new-tab-page/one-google-bar?paramsencoded=` as an out-of-process frame. Direct `chrome://new-tab-page/` also commits as itself. Edge 155.0.4283.60 commits `edge://newtab/` to `https://ntp.msn.com/edge/ntp` with an `about:blank` child and no internal-scheme child observed; directly opening `edge://ntp/` returns `ERR_INVALID_URL`. These observations are for fresh default profiles, not every browser configuration. Brave, Opera and Vivaldi were not installed locally, and this repository's CI installs only Chromium, so their redirects and internal frames were not verified and no additional exceptions are inferred. Incognito Chrome and Edge new tabs omit the ordinary new tab's frame tree; the reachability vector therefore uses the default context.
124
149
 
150
+ **Privileged-scheme follow-up checks.** With the expanded catalog, fresh default-profile Chrome for Testing 156.0.8078.4 on macOS again committed both NTP entry points to `chrome://new-tab-page/`, with an `about:blank` background child and only `chrome-untrusted://new-tab-page/one-google-bar?paramsencoded=` as its internal-scheme OOPIF. `C8.newtab` passed both attach and ordinary-navigation legs; no `chrome-search` exceptions are added or inferred for other profile/search-provider configurations. Direct `Page.navigate` to `chrome-search://most-visited/` returned `net::ERR_INVALID_URL` and committed `chrome-error://chromewebdata/` with that exact `unreachableUrl`, confirming why `C8.chrome-search` distinguishes a browser response from a guard refusal rather than requiring a successful search-document commit. The catalog-adopting emulator passed every C8 protection row; plain clients failed them, and removing only `chrome-search` from the adopted catalog failed the new row. C1.href, C2.302, C3.popup-self-nav, all tested C4 iframe process layouts and C5.document passed their guarded and control legs with the expanded schemes; plain clients passed controls and failed protection expectations. Those HTTP fixtures deliberately protect only the seven host, leaving their same-site control host ordinary, while adopting every internal-scheme rule unchanged.
151
+
125
152
  **The vault.** `vault.sliccy.ai` is in `hosts` by name, and vault branch previews (`vault-preview.sliccy.ai`, `vault-*.sliccy.ai`) are covered by the `sliccy.ai` domain rule. `sliccy.ai` already covers them all, but vault must stay protected even if `domains` ever changes: the vault must never be a CDP target for agent tools. The one exception is slicc-cli's own pairing attach for the vault, which is the CLI's own connection and not browser control handed to the agent.
126
153
 
127
154
  **Targets.** A target whose `TargetInfo.type` is one of `targetTypes` (`browser_ui`, Chrome's own UI such as `chrome://omnibox-popup.top-chrome/`) is always protected, whatever its URL. Otherwise a target's effective origin is its URL's (`TargetInfo.url`), except when the URL inherits the origin of whoever made the document: the empty URL, and `inheriting` with any query or fragment. Such a target is protected when it was protected before this URL (a seven tab that went to `about:blank` keeps seven's origin), or when its opener (`openerId`, else `openerFrameId`) is protected or unknown, for an opener closed before the program saw it. The URL, `openerFrameId` and `browserContextId` alone can't tell: a blank popup that seven opens has seven's origin and the URL `about:blank`. A program that holds a session to the target also has the document's real origin: the main frame's `securityOrigin` in `Page.frameNavigated`, and the default context's `origin` in `Runtime.executionContextCreated`. When one of those is protected, the target is protected too, until a URL with an origin of its own replaces it.
@@ -143,18 +170,26 @@ Every CDP guard runs the vectors in `guard-vectors.json` (schema `schema/guard-v
143
170
  - slicc-cdp's `test/integration/guard-vectors.json` at `4907a45`, where its `E1`, `E2`, `E3`, `E5` and `B2` are renamed `E1.onload`, `E2.binding`, `E3.bypass-csp`, `E5.console` and `B2.headers`;
144
171
  - the cert lane's E and B rows;
145
172
  - `C5.continue-request` and `C5.continue-response`, added next to `C5.document`;
146
- - 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 four 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.
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
+
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.
147
176
 
148
- 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 other C8 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/`.
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
+
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.
180
+
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/`.
182
+
183
+ `C8.network-error` and `C8.iframe-error` are ordinary-error reachability controls: request the configured `network-error` URL (default `http://nonexistent.invalid/`) directly or in an ordinary iframe, observe a failed Document request, reattach to the settled tab, evaluate and snapshot its document, then navigate the same tab to an ordinary document. Both control and guarded legs must pass. The failing URL is unchanged on the control, rather than replaced with a healthy origin, and the rows require `browserSession` but not `chromeUrlsAttach`. With Chrome for Testing 156.0.8078.4, scheme-wide `chrome-error` protection made the direct-error row fail both legs because reattachment was refused; removing it passed both rows, with readable DOM and successful recovery. The old emulator still allowed the cross-process iframe parent because the iframe TargetInfo kept the failing HTTP URL, but that layout-specific behavior is not grounds for protecting the error scheme. Plain-client controls and reachability legs also passed both rows.
149
184
 
150
185
  A client may register `Fetch.enable` patterns naming a protected origin in either of two conformant ways: the guard allows registration but never pauses or forwards protected requests to the client and refuses continue, fulfil or fail calls for request ids it did not hand out; or the guard refuses registration with its declared refusal text. Setup calls explicitly naming a protected placeholder are refusable with `refusal.onlyProtected: true`, and interception-dependent observations depend on that refusal. Wildcard and ordinary-only patterns do not receive this exemption. The ordinary-origin control still runs through the guard and must succeed.
151
186
 
152
187
  **Top level.**
153
188
  - `schemaVersion` (1): guards pin it.
154
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.
155
- - `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-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-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.
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.
156
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.
157
- - `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`.
158
193
  - `placeholders`: every `{name}` used in the vectors, with what it stands for. `{client}` is the client page's origin, which the runner maps.
159
194
 
160
195
  **A vector** has:
@@ -162,7 +197,7 @@ A client may register `Fetch.enable` patterns naming a protected origin in eithe
162
197
  - `kind`: `cdp` (runnable steps) or `text` (rows in words, which runners report as `not-run`, never `pass`);
163
198
  - `scope`: `any`, or the one guard the vector is for;
164
199
  - `setup` and `trigger`, both lists of steps;
165
- - `targets`: the protected targets it runs against;
200
+ - `targets`: the protected or reachability targets it runs against;
166
201
  - `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>`. A row with a `page-or-browser` cookie step that would carry a `browserContextId` reports `not-supported:browserSession` without a browser session, because that parameter is only allowed on the browser target;
167
202
  - `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);
168
203
  - `live`: the liveness check;
@@ -202,6 +237,8 @@ Popups are observed by `Target.targetCreated` with the opener's `openerId`, or b
202
237
  - `/plant`: sets the canary;
203
238
  - `/beacon`, and `/log?n=`, which returns `{ hits, beacons }`.
204
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
+
205
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`.
206
243
 
207
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.
@@ -288,7 +325,7 @@ extension on `chrome.debugger` leaves it out, so `C8.newtab` is skipped with
288
325
  `not-supported:chromeUrlsAttach`. `capabilities.scope` selects owner-specific rows.
289
326
 
290
327
  Map `ordinary`, `ordinary-same-site`, `seven`, `vault`, `vault-x`, `apex`, `own`
291
- and `client` to origins. `chrome-newtab`, `chrome-new-tab-page`, `chrome-settings`, `chrome-passwords`, `chrome-extension-page` and `file-root` default to their `url`.
328
+ and `client` to origins. `chrome-newtab`, `chrome-new-tab-page`, `chrome-settings`, `chrome-passwords`, `chrome-search`, `network-error`, `chrome-extension-page` and `file-root` default to their `url`.
292
329
  `{browserUi}` is the id of one of Chrome's own `browser_ui` targets, which the runner looks up through
293
330
  its unguarded observer (or the client) before the row; a row that uses it fails when there is none.
294
331
  A vector with `"defaultContext": true` runs in the browser's default context even with the
@@ -540,7 +577,7 @@ for case in corpus().cases {
540
577
  Go 1.26, Rust stable with `wasm32-wasip1`, Node ≥ 24.
541
578
  - `npm run corpus` regenerates `corpus.json` from `corpus/`; the pre-commit hook and CI fail when it's stale.
542
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.
543
- - 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 11 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.
544
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.
545
582
  - `npm test`: `validate`, the Go tests in `test/integration/`, and `cargo test --all-features`.
546
583
  - `npm run build`: the Go module, the crate (also for `wasm32-wasip1`), and `npm pack --dry-run`.
package/catalog.json CHANGED
@@ -711,6 +711,13 @@
711
711
  "protectedSchemes": [
712
712
  "chrome",
713
713
  "chrome-untrusted",
714
+ "chrome-search",
715
+ "chrome-native",
716
+ "chrome-distiller",
717
+ "chrome-experimental-site-token-provider",
718
+ "isolated-app",
719
+ "externalfile",
720
+ "content",
714
721
  "chrome-extension",
715
722
  "devtools",
716
723
  "edge",
@@ -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);