@ai-ecoverse/slicc-browsers 1.12.0 → 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -67,6 +67,8 @@ Every feature builds for `wasm32-wasip1`.
67
67
 
68
68
  `protected.mjs` is the reference matcher for [protected origins](#protected-origins): `import { protectedOrigins } from '@ai-ecoverse/slicc-browsers/protected.mjs'`, then `protectedOrigins(catalog.protectedOrigins, extraOrigins)` returns `{ url(text), target({ url, previous, opener, type, documentURL, unreachableURL, navigating }), cookieDomain(domain), storageKey(key), unparseable(text) }`, each a boolean. `url(text)` uses the load/loaded-URL fail-closed rule; `url(text, { readOnly: true })` preserves the old parse-failure result for read-only keys and value redaction, and `storageKey(key)` uses that mode internally. `unparseable(text)` identifies parse or unwrap failures without changing whether a parsed origin is protected. It needs only the WHATWG `URL`, so it runs in Node and in a browser. `protected.mjs` and `seven.mjs` import nothing and re-export nothing, so an extension or service worker can ship either file alone; `npm run validate` (through `scripts/self-contained.mjs`) evaluates each as a module in a fresh `node:vm` context that has only `URL`, with no linker resolution allowed.
69
69
 
70
+ The complete static import graph of `guard-runner.mjs` is browser-safe too: it uses ordinary browser globals (`URL`, timers, `fetch`, `atob`/`btoa`, `Uint8Array`, `TextEncoder` and `TextDecoder`), never `Buffer`, `process`, `require` or `node:` imports. Base64 bodies and postData are decoded with `atob` and UTF-8 bytes rather than a Node shim. `npm run validate` loads and executes that graph in a fresh `node:vm` browser-like context, including an encoded-body exercise and a drivable page row. The separate host-side `./fixture` server intentionally uses Node HTTP/TLS APIs and is not imported by the browser runner.
71
+
70
72
  ## `catalog.json`
71
73
 
72
74
  `"schema": 1`. A minor release only adds fields; a field that changes meaning or goes away needs schema 2 and a major release. Relative paths are arrays of path segments, joined with the platform's separator. Top-level fields:
@@ -169,6 +171,8 @@ A same-site frame shares its parent's process and has no target of its own. Ever
169
171
 
170
172
  **Storage keys** (`Storage`, `IndexedDB`, `CacheStorage` and `FileSystem` in CDP): a key is protected when its origin (up to the first `^`) or its top-level site (a part after `^0`) is a protected URL.
171
173
 
174
+ **Redirect secrets.** An allowed response can name a protected destination without becoming a protected document itself. Every protected URL in a nested delivered string or object key is redacted in place to its origin, including embedded `Location`, `Content-Location`, `Refresh` and `Link` values, raw `headersText` lines, `redirectResponse`, Page URL/history/error fields, console/exception text and Audits issues. Percent-encoded representations must not retain the tail. A separate `urlFragment` alongside a protected `url` is cleared too. The event remains delivered so Network ExtraInfo pairing survives; dropping an ExtraInfo event fails these vectors. A request whose response names a protected URL has its response body refused (`slicc: refused:`), through both Fetch and Network, even though the request started at an ordinary URL. Body reads, replay and URL overrides must not follow an ordinary redirect into protected content or send its cookies. Ordinary redirect headers and bodies stay readable.
175
+
172
176
  **Refusals** carry the text in `refusal`, `slicc: refused: target is on seven's origin`.
173
177
 
174
178
  **`protected.json`** (schema `schema/protected.schema.json`): `errorPage` (the committed-error previous-decision and origin-redaction rule), `unparseable` (`protected`) and `unparseableRefusal`, in step with the catalog; `origins`, the extra origins every vector runs with; `urls` (`url`, `protected`); `targets` (`url`, `documentURL?` and `unreachableURL?` for validated main-frame error-commit evidence, `navigating?` for a main-frame navigation in flight; `type?`, the target's `TargetInfo.type`; `previous?`, whether the target was protected before; `opener?`, `protected`, `unprotected` or `unknown`, and no `opener` for a target without one; `protected`); `cookieDomains` (`domain`, `protected`); `storageKeys` (`key`, `protected`). `npm run validate` runs every vector through `protected.mjs`, and no vector uses a port from 9222 to 9229.
@@ -186,6 +190,10 @@ The `C9` foreign extension isolated-world family requires `extensionWorld`: a br
186
190
 
187
191
  The integration launcher loads shared-web 1.25.2’s harness extension exactly once through `Extensions.loadUnpacked` (`ankmbodmgimjemnojmeddfcdlbocolob`), whose content script runs on HTTP(S) pages. shared-web 1.25.2 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.
188
192
 
193
+ `C2.redirect-secret` probes ordinary fixture 302s to seven’s `/auth/callback?code=…&state=…` and vault’s `/oauth/mcp-callback?code=…`, plus `#access_token=…` variants of both. It navigates with Page and scripted `location.href`, fetches a subresource and sends an XHR. The 302 carries `Location`, `Content-Location`, `Refresh: 0;url=…` and `Link: <…>; rel=preconnect`. Page, Network (including ExtraInfo), Runtime, Log, Audits and both Fetch stages are enabled. Every received raw message is scanned, including replies, across an eight-second tail, for raw, percent-encoded, JSON-escaped and base64 marker representations. Both response-body APIs are attempted on each hop; guarded bodies need matched refusals. Page frame trees, resource trees and navigation history are read before recovery to catch error-page leftovers. A separate structural check requires every protected `Network.Request.urlFragment` and `Page.Frame.urlFragment` to be empty or absent, even if its value contains no marker; the ordinary `#access_token` control independently requires fragment evidence in request events, frame events, frame-tree replies and resource-tree replies. The ordinary session must survive. The unguarded ordinary redirect control preserves all four headers in `redirectResponse`, ExtraInfo’s headers and `headersText`, and Fetch’s response headers. `C2.redirect-secret-mix` repeats that matrix with 301/303/307/308. The protected leg requires in-place ExtraInfo delivery with origin-only header values, not merely the absence of a marker.
194
+
195
+ `C2.redirect-body` calls `Network.loadNetworkResource` on an ordinary redirect with `includeCredentials: true`, reads any stream with `IO.read`, replays a redirected XHR, requests Page resource content, and captures Page/MHTML and DOM snapshots after a main-frame redirect. It also captures an ordinary parent containing an iframe redirected to vault, including its error document, and overrides a paused ordinary Fetch request to the allowed redirect URL. No protected marker may appear in any message or body, and no request from those operations may send the independently seeded seven cookie. The ordinary control must return the body, resource content and snapshots, and accept the URL override. Each probe cleans up its redirect registrations, observer session and cookie. The focused Chrome tests use HTTPS with SPKI-pinned fixture trust and default-port protected URLs; host resolver rules route seven and vault to the fixture rather than the public service.
196
+
189
197
  `C8.unparseable` probes both raw and canonical space/U+200D/invalid-punycode URLs through creation and navigation. Every guarded destination needs a matched refusal. Its unguarded controls record `document` or `native-rejection` outcomes, including Chrome builds that reject raw U+200D instead of committing it. Both raw and encoded-space forms must commit real fixture documents, because that is the proven cookie leak. An independent observer loads the encoded-space document and confirms it shares a uniquely seeded `.sliccy.ai` cookie; the guarded client must neither list nor attach to it, read its cookie, query that cookie by URL, nor cause a cookie-bearing destination request. The guard does not erase the observer’s private browser state. The row runs on both browser-session and page-session-only transports, uses the default context, and cleans up its own targets and cookie. Cookie seeding always uses the unguarded observer’s browser session, never the guarded client. Only guarded browser-session target listing requires `browserSession`; without it, `listed` records `not-supported` explicitly in probe and observation evidence, and the result lists that field in `notSupported`. Creation, navigation, attachment and `Network.getCookies` with `urls` still require matched refusals; filtering cookies without refusing fails `cookieRefused`. Fixture URLs omit the colon when the port is empty, and reported URLs are compared after canonicalisation, including Chrome’s WHATWG-unparseable encoded-space hosts. Launchers must resolve the raw/canonical space, U+200D/punycode and invalid-punycode `.sliccy.ai` fixture hosts to the fixture server, including for default-port HTTP or HTTPS origins. The HTTP fixture accepts Chrome’s encoded-space Host header without reparsing it as a WHATWG origin.
190
198
 
191
199
  `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.
@@ -203,7 +211,7 @@ A client may register `Fetch.enable` patterns naming a protected origin in eithe
203
211
  - `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.
204
212
  - `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.
205
213
  - `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.
206
- - `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`.
214
+ - `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; `discovery` means session-less `Target.setDiscoverTargets` and `Target.targetCreated`/`Target.targetInfoChanged` delivery; `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`.
207
215
  - `placeholders`: every `{name}` used in the vectors, with what it stands for. `{client}` is the client page's origin, which the runner maps.
208
216
 
209
217
  **A vector** has:
@@ -253,7 +261,13 @@ Popups are observed by `Target.targetCreated` with the opener's `openerId`, or b
253
261
 
254
262
  `{ "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. Discovery matches the expected fixture extension’s exact origin, never the first extension world: `run()`’s optional `extensionWorldId` defaults to shared-web’s harness id `ankmbodmgimjemnojmeddfcdlbocolob`. Multiple unrelated content-script worlds are ignored for fixture selection, while guards must still protect foreign worlds. A custom fixture must provide the same content-script marker contract. 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 ids from the control are also replayed alongside fresh observer-discovered selectors, but numeric ids are local to a tab: a control id may name the next tab’s exposed default context, which must work and return no extension marker. The same-tab hidden world’s actual numeric id and every extension unique-id guess still require a matched refusal. Unknown numeric ids and stale object handles (`not exposed`) need explicit refusals rather than Chrome’s invalid-id errors. `guessesRefused` aggregates only required refusals; `guessesReached` must be false on the guarded leg, and `exposedContextsWork` must remain true. Its result contains boolean evidence consumed by ordinary `result` checks. The unguarded leg validates those selectors against Chrome itself before guarded evidence can pass.
255
263
 
256
- `{ "errorPage": "recover", "secret"?: "all" | "query" | "fragment", "as": "errorPage" }` (also modes `restore` and `sticky`; `secret` defaults to `all`) runs the protected-error-page compound probe with independent commit evidence, discovery redaction and recovery or sticky-lock checks. The unguarded observer seeds the protected history and failed commit, so the rows run on page-session-only transports too. The guarded client uses only `Target.getTargetInfo` with a `targetId`, `Target.getTargets`, `Target.setDiscoverTargets`, `Target.attachToTarget`, `Target.detachFromTarget` and `Target.closeTarget` without a session, which page-session transports answer themselves, and everything else on its page session; the rows require no capability. The runner leaves this probe’s independent Fetch sessions to the probe rather than answering their pauses itself.
264
+ `{ "errorPage": "recover", "secret"?: "all" | "query" | "fragment", "as": "errorPage" }` (also modes `restore` and `sticky`; `secret` defaults to `all`) runs the protected-error-page compound probe with independent commit evidence, discovery redaction and recovery or sticky-lock checks. The unguarded observer seeds the protected history and failed commit, so the rows run on page-session-only transports too. The guarded client uses only `Target.getTargetInfo` with a `targetId`, `Target.getTargets`, `Target.setDiscoverTargets`, `Target.attachToTarget`, `Target.detachFromTarget` and `Target.closeTarget` without a session, which page-session transports answer themselves, and everything else on its page session; the rows require no whole-vector capability. Only `eventsRedacted` requires `discovery`: without it the probe does not call `Target.setDiscoverTargets`, that one field is `not-supported` in both legs, and the notes say `not-supported:discovery`; info, list, attach, recovery and lock checks still run. Discovery absence is structural: a guarded transport delivering either `Target.targetCreated` or `Target.targetInfoChanged` during such a row without declaring `discovery` fails the row, even when the event names an ordinary target. `Target.attachedToTarget` remains independently checked by `attachRedacted`. The runner leaves this probe’s independent Fetch sessions to the probe rather than answering their pauses itself.
265
+
266
+ `{ "redirectSecret": "headers" | "mix" | "body", "as": "redirect" }` runs a C2 compound probe and owns its Fetch pauses. The client must expose `messages`, an append-only array of every raw received CDP text frame, recorded before parsing; an events-only or reply-only tap fails `rawRecorded`. Whenever a run includes a `C2.redirect-*` row or a `redirectSecret` step, `run()` retains an explicit `fixture.redirectSecret` hook or automatically installs a fetch-only `POST /redirect` bridge. Its base is `fixtureBase`, then `fixture.origin`, then `targets.ordinary`; a browser-page alias or reverse proxy must forward that endpoint to the bundled fixture. The bundled `fixtureServer()` always supplies both its direct hook and HTTP control endpoint, so no custom registration method is needed. Missing, unreachable or non-successful registration is an explicit row/summary error containing `redirectSecret fixture registration required`, never a pass or a skipped observation. Fixture logs must include headers for the cookie check. The guard’s events and raw messages must represent the same delivered stream.
267
+
268
+ **Host-harness requirements for every guard.** Host Node processes may use `fixtureServer()` from `./fixture`; browser pages must import only the browser-safe runner graph, served with JavaScript MIME types, and need no Node-global shim. All guards supply a guarded client, an independent unguarded observer, mapped fixture origins and the relevant advertised capabilities. C2 clients additionally record every received raw CDP frame in `client.messages` before parsing. Route protected seven/vault hosts to the fixture with `--host-resolver-rules`, including default-port callback URLs; a live protected-body negative needs HTTPS fixture trust (the reference harness pins its SPKI).
269
+
270
+ For remote/page harnesses, `fixtureClient(base)` is exported by `./guard-runner` and `./fixture-client`, or `run(set, { fixtureBase: base, ... })` constructs it. The bundled server serves CORS-safe `GET /log?since=N` as `{ from: N, entries: [...] }`, with an append-only history unaffected by `fixture.reset()`, and `POST /redirect` with a JSON-encoded string body (a simple text/plain request, without a preflight). Control requests do not appear in the log. Registration descriptors are `{ path, host?, search?, to?, code?, body, remove? }`: `path` starts with `/`, `host` selects the callback hostname, `search` distinguishes query-bearing callbacks, `to` creates a redirect with the four Location/Content-Location/Refresh/Link headers, `code` defaults to 302, an omitted `to` serves the callback body, and `remove: true` unregisters it. The bundled endpoint returns 204; custom servers must acknowledge with a successful status and actually serve the registered routes. Custom fixtures may instead supply an async `fixture.redirectSecret` hook directly; a separate log/control server can implement the same `GET /log` and `POST /redirect` contract. A Node harness using browser-only DNS aliases should pass a reachable `fixtureBase` or its direct hook.
257
271
 
258
272
  `{ "unparseable": true, "as": "unparseable" }` runs the C8 malformed-URL compound probe on the current ordinary page: independent browser ground truth, raw/canonical destination commands, discovery/attach and cookie checks. Boolean evidence is consumed by ordinary `result` checks, with native Chrome outcomes retained in its diagnostics.
259
273
 
@@ -366,6 +380,8 @@ every alternative of an `any`, or a required (`all`, `not` or plain) check, need
366
380
  selects one variant or an array; omission selects all. `parallel` defaults to 1.
367
381
  With the `browserContexts` capability each leg runs in its own browser context. A `Target.createBrowserContext` that fails with a timeout is retried once, and the row's `notes` records it; any other failure fails the row.
368
382
 
383
+ Every target created by the runner’s own unguarded observer uses `Target.createTarget` with `newWindow: true`, then verifies through `Target.getTargetInfo` that `openerId` is absent. A supplied opener or missing target evidence fails and cleans up that target, rather than inheriting the active client tab (which could be seven). This includes ordinary control setup, storage canaries, protected-error history seeds and the malformed-URL observer. The row notes record `newWindow=true, openerId absent`. Intentional guarded-client creation commands and popup/opener tests keep their original parameters.
384
+
369
385
  `settleScale` (default 1) multiplies the bounds of waits for events the leg expects to happen: settles (beacon, served, title and load) on the control leg and `required` settles on either leg, `paused` on the control leg, page and child loads, the wait for a background step's `as` result (`<as> did not complete within <ms>ms`), the wait for the pre-action beacons, the C9 control's `Debugger.paused`, and the wait for an observation the leg expects to be `true`. Adopters on slow hosts can set 3 or 4. Every wait polls and returns as soon as its event arrives, so a larger bound only costs time when something is really missing, and each timeout error names the bound it used. An observation read from the fixture (`observer: "fixture"`, such as a beacon or a served request) that the leg expects to be `true` is polled for up to 5000 ms (scaled) instead of read once. One that the leg expects to be `false` is still read once.
370
386
 
371
387
  `refusalScale` (default 1) multiplies the bounds of waits for what the guarded leg expects to be absent, the beacon, request, title or navigation that the guard should block: settles that are not `required`, and `paused`, on the guarded leg. Such a wait runs to its full bound whenever the guard works, so `settleScale` leaves it alone. The trade-off: a larger `refusalScale` gives more sensitivity to a leak that arrives late on a slow host, because the observations after the wait see it, at the cost of that much more time per guarded row. Adopters should keep `refusalScale` at 1 or higher, never below 1; the runner refuses a `refusalScale` below 1 with `refusalScale must be a number of at least 1`.
@@ -491,6 +507,7 @@ const capabilities = {
491
507
  browserSession: true,
492
508
  browserContexts: true,
493
509
  chromeUrlsAttach: true,
510
+ discovery: true,
494
511
  };
495
512
  const execution = await run(set, {
496
513
  clientFactory: (metadata) => connectGuardAndObserver(metadata),
@@ -544,7 +561,7 @@ A six endpoint has tier `six`, whatever its seven tabs. **A program never choose
544
561
 
545
562
  ## Output schema 1
546
563
 
547
- `schema/browsers.schema.json` describes `slicc browsers --json`, which `playwright-cli list --json` also prints. [slicc-cli's README](https://github.com/ai-ecoverse/slicc-cli#slicc-browsers---json---cdp-url---no-bundle-scan) is the authoritative spec for it; this schema is a copy and follows it. Fields can be added; `source` is one of `devtools-active-port`, `flag`, `probe` and `process`. `tier` is 1 to 6, and `six: true` marks an endpoint in SLICC six's profile (tier 6). An endpoint may carry `tabs`: every page target except those on protected origins. Seven's own tabs are protected, so they are in `sevenTabs` only, never in `tabs`. `playwright-cli list --json` prints it, and a corpus case that checks it needs `tabs`.
564
+ `schema/browsers.schema.json` describes `slicc browsers --json`, which `playwright-cli list --json` also prints. [slicc-cli's README](https://github.com/ai-ecoverse/slicc-cli#slicc-browsers---json---cdp-url---no-bundle-scan) is the authoritative spec for it; this schema is a copy and follows it. Fields can be added; `source` is one of `devtools-active-port`, `flag`, `probe` and `process`. `tier` is 1 to 6, and `six: true` marks an endpoint in SLICC six's profile (tier 6). An endpoint may carry `tabs`: every page target except those on protected origins. Seven's own tabs are protected, so they are in `sevenTabs` only, never in `tabs`. In `sevenTabs`, `url` and `title` are the origin only, never a path, query, fragment or document title, including configured extra seven origins and default-port normalization. `playwright-cli list --json` prints it, and a corpus case that checks it needs `tabs`.
548
565
 
549
566
  ## The corpus
550
567
 
package/corpus.json CHANGED
@@ -761,8 +761,8 @@
761
761
  "sevenTabs": [
762
762
  {
763
763
  "id": "P1",
764
- "url": "https://lars.sliccy.ai/",
765
- "title": "seven"
764
+ "url": "https://lars.sliccy.ai",
765
+ "title": "https://lars.sliccy.ai"
766
766
  }
767
767
  ],
768
768
  "tabs": [
@@ -854,13 +854,13 @@
854
854
  "sevenTabs": [
855
855
  {
856
856
  "id": "O1",
857
- "url": "https://dev.example.test/app",
858
- "title": "dev"
857
+ "url": "https://dev.example.test",
858
+ "title": "https://dev.example.test"
859
859
  },
860
860
  {
861
861
  "id": "O3",
862
- "url": "https://dev.example.test:443/other",
863
- "title": "dev again"
862
+ "url": "https://dev.example.test",
863
+ "title": "https://dev.example.test"
864
864
  }
865
865
  ]
866
866
  }
@@ -1298,8 +1298,8 @@
1298
1298
  "sevenTabs": [
1299
1299
  {
1300
1300
  "id": "P1",
1301
- "url": "https://lars.sliccy.ai/",
1302
- "title": "seven"
1301
+ "url": "https://lars.sliccy.ai",
1302
+ "title": "https://lars.sliccy.ai"
1303
1303
  }
1304
1304
  ]
1305
1305
  }
@@ -1713,8 +1713,8 @@
1713
1713
  "sevenTabs": [
1714
1714
  {
1715
1715
  "id": "S1",
1716
- "url": "https://b.sliccy.ai/",
1717
- "title": "seven b"
1716
+ "url": "https://b.sliccy.ai",
1717
+ "title": "https://b.sliccy.ai"
1718
1718
  }
1719
1719
  ]
1720
1720
  }
@@ -1737,8 +1737,8 @@
1737
1737
  "sevenTabs": [
1738
1738
  {
1739
1739
  "id": "S2",
1740
- "url": "https://a.sliccy.ai/",
1741
- "title": "seven a"
1740
+ "url": "https://a.sliccy.ai",
1741
+ "title": "https://a.sliccy.ai"
1742
1742
  }
1743
1743
  ]
1744
1744
  }
@@ -2012,8 +2012,8 @@
2012
2012
  "sevenTabs": [
2013
2013
  {
2014
2014
  "id": "D1",
2015
- "url": "https://seven.sliccy.ai:443/",
2016
- "title": "seven"
2015
+ "url": "https://seven.sliccy.ai",
2016
+ "title": "https://seven.sliccy.ai"
2017
2017
  }
2018
2018
  ]
2019
2019
  }
@@ -2039,6 +2039,69 @@
2039
2039
  ]
2040
2040
  }
2041
2041
  },
2042
+ {
2043
+ "name": "seven-tab-origin-redaction",
2044
+ "case": {
2045
+ "description": "sevenTabs URL and title reveal only the origin, never a secret path, query, fragment or document title",
2046
+ "needs": [
2047
+ "flag"
2048
+ ],
2049
+ "cdp": [
2050
+ "127.0.0.1:9531"
2051
+ ],
2052
+ "http": {
2053
+ "127.0.0.1:9531": {
2054
+ "version": {
2055
+ "Browser": "Chrome/154.0.8037.98",
2056
+ "Protocol-Version": "1.3",
2057
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.8037.98 Safari/537.36",
2058
+ "V8-Version": "15.4.79.1",
2059
+ "WebKit-Version": "537.36 (@a1b2c3)",
2060
+ "webSocketDebuggerUrl": "ws://127.0.0.1:9531/devtools/browser/d1"
2061
+ },
2062
+ "list": [
2063
+ {
2064
+ "id": "D1",
2065
+ "type": "page",
2066
+ "url": "https://seven.sliccy.ai/auth/secret-path-marker?code=secret-query-marker#access_token=secret-fragment-marker",
2067
+ "title": "secret-title-marker",
2068
+ "webSocketDebuggerUrl": "ws://127.0.0.1/devtools/page/D1"
2069
+ }
2070
+ ]
2071
+ }
2072
+ }
2073
+ },
2074
+ "expected": {
2075
+ "schema": 1,
2076
+ "minChromium": 116,
2077
+ "entries": [
2078
+ {
2079
+ "id": "cdp:127.0.0.1:9531",
2080
+ "kind": "endpoint",
2081
+ "tier": 1,
2082
+ "name": "Chrome",
2083
+ "version": "154.0.8037.98",
2084
+ "endpoint": {
2085
+ "cdp": "http://127.0.0.1:9531",
2086
+ "webSocketDebuggerUrl": "ws://127.0.0.1:9531/devtools/browser/d1",
2087
+ "name": "Chrome",
2088
+ "version": "154.0.8037.98",
2089
+ "product": "Chrome/154.0.8037.98",
2090
+ "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.8037.98 Safari/537.36",
2091
+ "source": "flag",
2092
+ "sevenOpen": true,
2093
+ "sevenTabs": [
2094
+ {
2095
+ "id": "D1",
2096
+ "url": "https://seven.sliccy.ai",
2097
+ "title": "https://seven.sliccy.ai"
2098
+ }
2099
+ ]
2100
+ }
2101
+ }
2102
+ ]
2103
+ }
2104
+ },
2042
2105
  {
2043
2106
  "name": "seven-tabs",
2044
2107
  "case": {
@@ -2189,13 +2252,13 @@
2189
2252
  "sevenTabs": [
2190
2253
  {
2191
2254
  "id": "T1",
2192
- "url": "https://lars.sliccy.ai/",
2193
- "title": "seven"
2255
+ "url": "https://lars.sliccy.ai",
2256
+ "title": "https://lars.sliccy.ai"
2194
2257
  },
2195
2258
  {
2196
2259
  "id": "T6",
2197
- "url": "https://seven.sliccy.ai/?x=1#top",
2198
- "title": "seven too"
2260
+ "url": "https://seven.sliccy.ai",
2261
+ "title": "https://seven.sliccy.ai"
2199
2262
  }
2200
2263
  ]
2201
2264
  }
@@ -2218,8 +2281,8 @@
2218
2281
  "sevenTabs": [
2219
2282
  {
2220
2283
  "id": "V1",
2221
- "url": "https://team-7.sliccy.ai/workspace",
2222
- "title": "seven"
2284
+ "url": "https://team-7.sliccy.ai",
2285
+ "title": "https://team-7.sliccy.ai"
2223
2286
  }
2224
2287
  ]
2225
2288
  }
@@ -2400,8 +2463,8 @@
2400
2463
  "sevenTabs": [
2401
2464
  {
2402
2465
  "id": "S1",
2403
- "url": "https://lars.sliccy.ai/",
2404
- "title": "seven"
2466
+ "url": "https://lars.sliccy.ai",
2467
+ "title": "https://lars.sliccy.ai"
2405
2468
  }
2406
2469
  ]
2407
2470
  }
@@ -2522,8 +2585,8 @@
2522
2585
  "sevenTabs": [
2523
2586
  {
2524
2587
  "id": "S1",
2525
- "url": "https://lars.sliccy.ai/",
2526
- "title": "seven"
2588
+ "url": "https://lars.sliccy.ai",
2589
+ "title": "https://lars.sliccy.ai"
2527
2590
  }
2528
2591
  ]
2529
2592
  }
@@ -2674,8 +2737,8 @@
2674
2737
  "sevenTabs": [
2675
2738
  {
2676
2739
  "id": "S1",
2677
- "url": "https://lars.sliccy.ai/",
2678
- "title": "seven"
2740
+ "url": "https://lars.sliccy.ai",
2741
+ "title": "https://lars.sliccy.ai"
2679
2742
  }
2680
2743
  ]
2681
2744
  }
@@ -2775,8 +2838,8 @@
2775
2838
  "sevenTabs": [
2776
2839
  {
2777
2840
  "id": "S1",
2778
- "url": "https://lars.sliccy.ai/",
2779
- "title": "seven"
2841
+ "url": "https://lars.sliccy.ai",
2842
+ "title": "https://lars.sliccy.ai"
2780
2843
  }
2781
2844
  ]
2782
2845
  }
@@ -0,0 +1,51 @@
1
+ export function fixtureClient(base, { fetch: request = globalThis.fetch } = {}) {
2
+ const entries = [];
3
+ let pending;
4
+ return {
5
+ log: async () => {
6
+ if (!pending)
7
+ pending = (async () => {
8
+ const response = await request(new URL(`/log?since=${entries.length}`, base));
9
+ if (!response.ok) throw new Error(`fixture.log: HTTP ${response.status}`);
10
+ const batch = await response.json();
11
+ if (batch.from !== entries.length || !Array.isArray(batch.entries))
12
+ throw new Error('fixture.log: invalid append-only log response');
13
+ entries.push(...batch.entries);
14
+ return [...entries];
15
+ })().finally(() => {
16
+ pending = undefined;
17
+ });
18
+ return pending;
19
+ },
20
+ redirectSecret: async (registration) => {
21
+ try {
22
+ const response = await request(new URL('/redirect', base), {
23
+ method: 'POST',
24
+ body: JSON.stringify(registration),
25
+ });
26
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
27
+ } catch (error) {
28
+ throw new Error(
29
+ `redirectSecret fixture registration required: POST /redirect failed: ${error.message}`
30
+ );
31
+ }
32
+ },
33
+ };
34
+ }
35
+
36
+ export function prepareFixture(set, options) {
37
+ const fixture =
38
+ options.fixture ?? (options.fixtureBase ? fixtureClient(options.fixtureBase) : undefined);
39
+ const needsRedirect = set.vectors.some(
40
+ (vector) =>
41
+ vector.id.startsWith('C2.redirect') || vector.trigger?.some((step) => step.redirectSecret)
42
+ );
43
+ if (!needsRedirect || typeof fixture?.redirectSecret === 'function' || !fixture?.log)
44
+ return fixture;
45
+ const control = fixtureClient(options.fixtureBase ?? fixture.origin ?? options.targets?.ordinary);
46
+ return {
47
+ ...fixture,
48
+ log: (...args) => fixture.log(...args),
49
+ redirectSecret: control.redirectSecret,
50
+ };
51
+ }
@@ -26,7 +26,20 @@ function trigger(p) {
26
26
  return `<p>trigger</p><button onclick="go()">Go</button>${frame}<script>const to=${JSON.stringify(to)};function go(){const k=${JSON.stringify(kind)};if(k==='href'||k==='deferred')location.href=to;if(k==='replace')location.replace(to);if(k==='popup')window.open(to);if(k==='form'){const u=new URL(to);const f=document.createElement('form');f.action=u.origin+u.pathname;for(const [name,value] of u.searchParams){const i=document.createElement('input');i.type='hidden';i.name=name;i.value=value;f.append(i);}document.body.append(f);f.submit();}return 'went';}${kind === 'deferred' ? `setTimeout(go,${Number(p.get('defer') ?? 3000)});` : ''}</script>`;
27
27
  }
28
28
 
29
- function route(req, url, send, html) {
29
+ function route(req, url, send, html, redirects) {
30
+ const secret =
31
+ redirects.get(`${url.hostname}${url.pathname}${url.search ?? ''}`) ??
32
+ redirects.get(`${url.hostname}${url.pathname}`) ??
33
+ redirects.get(url.pathname);
34
+ if (secret) {
35
+ if (!secret.to) return send(200, secret.body, 'text/plain');
36
+ return send(secret.code ?? 302, secret.body, 'text/plain', {
37
+ location: secret.to,
38
+ 'content-location': secret.to,
39
+ refresh: `0;url=${secret.to}`,
40
+ link: `<${secret.to}>; rel=preconnect`,
41
+ });
42
+ }
30
43
  const p = url.searchParams;
31
44
  const routes = {
32
45
  '/beacon': () => send(204, ''),
@@ -95,15 +108,22 @@ function requestURL(req, scheme) {
95
108
 
96
109
  export async function fixtureServer({ host = '127.0.0.1', port = 0, tls, latency } = {}) {
97
110
  let entries = [];
111
+ const history = [];
112
+ const redirects = new Map();
113
+ const redirectSecret = async ({ path, host = '', search = '', remove, ...value }) => {
114
+ if (
115
+ typeof path !== 'string' ||
116
+ !path.startsWith('/') ||
117
+ (!remove && typeof value.body !== 'string')
118
+ )
119
+ throw new Error('invalid redirect registration');
120
+ const key = `${host}${path}${search}`;
121
+ if (remove) redirects.delete(key);
122
+ else redirects.set(key, value);
123
+ };
98
124
  const scheme = tls ? 'https' : 'http';
99
- const handler = (req, res) => {
125
+ const handler = async (req, res) => {
100
126
  const url = requestURL(req, scheme);
101
- entries.push({
102
- url: url.href,
103
- method: req.method,
104
- headers: { ...req.headers },
105
- time: Date.now(),
106
- });
107
127
  const p = url.searchParams;
108
128
  const cors = {
109
129
  'access-control-allow-origin': req.headers.origin ?? '*',
@@ -122,9 +142,33 @@ export async function fixtureServer({ host = '127.0.0.1', port = 0, tls, latency
122
142
  'text/html',
123
143
  headers
124
144
  );
145
+ if (req.method === 'GET' && url.pathname === '/log') {
146
+ const from = Number(p.get('since') ?? 0);
147
+ if (!Number.isInteger(from) || from < 0 || from > history.length)
148
+ return send(400, 'invalid log cursor', 'text/plain');
149
+ return send(200, JSON.stringify({ from, entries: history.slice(from) }), 'application/json');
150
+ }
151
+ if (req.method === 'POST' && url.pathname === '/redirect') {
152
+ try {
153
+ const chunks = [];
154
+ for await (const chunk of req) chunks.push(chunk);
155
+ await redirectSecret(JSON.parse(Buffer.concat(chunks).toString('utf8')));
156
+ return send(204, '', 'text/plain');
157
+ } catch {
158
+ return send(400, 'invalid redirect registration', 'text/plain');
159
+ }
160
+ }
161
+ const entry = {
162
+ url: url.href,
163
+ method: req.method,
164
+ headers: { ...req.headers },
165
+ time: Date.now(),
166
+ };
167
+ entries.push(entry);
168
+ history.push(entry);
125
169
  const wait = latency?.(url) ?? 0;
126
- if (wait > 0) setTimeout(() => route(req, url, send, html), wait);
127
- else route(req, url, send, html);
170
+ if (wait > 0) setTimeout(() => route(req, url, send, html, redirects), wait);
171
+ else route(req, url, send, html, redirects);
128
172
  };
129
173
  const server = tls ? createSecureServer(tls, handler) : createServer(handler);
130
174
  await new Promise((resolve, reject) => {
@@ -133,6 +177,8 @@ export async function fixtureServer({ host = '127.0.0.1', port = 0, tls, latency
133
177
  });
134
178
  const address = server.address();
135
179
  return {
180
+ redirects,
181
+ redirectSecret,
136
182
  port: address.port,
137
183
  origin: `${scheme}://${host}:${address.port}`,
138
184
  log: () => structuredClone(entries),
package/guard-runner.mjs CHANGED
@@ -1,3 +1,7 @@
1
+ import { fixtureClient, prepareFixture } from './fixture-client.mjs';
2
+
3
+ export { fixtureClient };
4
+
1
5
  import { extensionWorld } from './extension-world.mjs';
2
6
  import {
3
7
  beacon,
@@ -13,7 +17,9 @@ import {
13
17
  result,
14
18
  steps,
15
19
  } from './guard-driver.mjs';
20
+ import { createObserverTarget, discoveryViolation } from './observer-target.mjs';
16
21
  import { protectedErrorPage } from './protected-error-page.mjs';
22
+ import { redirectSecret } from './redirect-secret.mjs';
17
23
  import { canonicalURL, unparseableURLs } from './unparseable-url.mjs';
18
24
 
19
25
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -152,11 +158,15 @@ class Execution {
152
158
  }
153
159
 
154
160
  async create(url, context = this.vars.context, client = this.client, background = true) {
155
- const { targetId } = await client.send('Target.createTarget', {
161
+ const params = {
156
162
  url: 'about:blank',
157
163
  background,
158
164
  ...(context ? { browserContextId: context } : {}),
159
- });
165
+ };
166
+ const { targetId } =
167
+ client === this.reader
168
+ ? await createObserverTarget(this.reader, params, this.notes)
169
+ : await client.send('Target.createTarget', params);
160
170
  this.targets.push(targetId);
161
171
  this.pageTarget = targetId;
162
172
  this.vars.pageTarget = targetId;
@@ -199,10 +209,14 @@ class Execution {
199
209
  ...(this.vars.context ? { browserContextId: this.vars.context } : {}),
200
210
  });
201
211
  if (!state.storage) return;
202
- const { targetId } = await this.reader.send('Target.createTarget', {
203
- url: 'about:blank',
204
- ...(this.vars.context ? { browserContextId: this.vars.context } : {}),
205
- });
212
+ const { targetId } = await createObserverTarget(
213
+ this.reader,
214
+ {
215
+ url: 'about:blank',
216
+ ...(this.vars.context ? { browserContextId: this.vars.context } : {}),
217
+ },
218
+ this.notes
219
+ );
206
220
  this.canaryTarget = targetId;
207
221
  this.targets.push(targetId);
208
222
  const { sessionId } = await this.reader.send('Target.attachToTarget', {
@@ -623,10 +637,10 @@ class Execution {
623
637
  );
624
638
  if (step.extensionWorld && out.result?.incomplete)
625
639
  this.errors.push(`extensionWorld ${step.extensionWorld}: ${out.result.incomplete}`);
626
- if ((step.extensionWorld || step.unparseable || step.errorPage) && !out.ok)
627
- this.errors.push(
628
- `${step.unparseable ? 'unparseable' : step.errorPage ? 'errorPage' : 'extensionWorld'}: ${out.error?.message ?? 'probe failed'}`
629
- );
640
+ const probe = ['redirectSecret', 'unparseable', 'errorPage', 'extensionWorld'].find(
641
+ (key) => step[key]
642
+ );
643
+ if (probe && !out.ok) this.errors.push(`${probe}: ${out.error?.message ?? 'probe failed'}`);
630
644
  if (step.as && !out.result?.pending) this.calls[step.as] = out;
631
645
  if (step.cdp) (this.calls.$cdp ??= []).push({ method: step.cdp, ...out });
632
646
  }
@@ -676,6 +690,26 @@ class Execution {
676
690
  await this.settle(step.settle);
677
691
  return;
678
692
  }
693
+ if (step.redirectSecret) {
694
+ this.internalSessions.add(session);
695
+ try {
696
+ return await outcome(
697
+ redirectSecret({
698
+ client: this.client,
699
+ reader: this.reader,
700
+ session,
701
+ targetId: this.pageTarget,
702
+ ordinary: this.vars.ordinary,
703
+ tag: this.vars.tag,
704
+ fixture: this.options.fixture,
705
+ mode: step.redirectSecret,
706
+ side: this.side,
707
+ })
708
+ );
709
+ } finally {
710
+ this.internalSessions.delete(session);
711
+ }
712
+ }
679
713
  if (step.errorPage)
680
714
  return outcome(
681
715
  protectedErrorPage({
@@ -690,6 +724,8 @@ class Execution {
690
724
  mode: step.errorPage,
691
725
  side: this.side,
692
726
  secret: step.secret,
727
+ discovery: supported(['discovery'], this.options.capabilities),
728
+ notes: this.notes,
693
729
  })
694
730
  );
695
731
  if (step.unparseable)
@@ -703,6 +739,7 @@ class Execution {
703
739
  tag: this.vars.tag,
704
740
  fixture: this.options.fixture,
705
741
  browserSession: supported(['browserSession'], this.options.capabilities),
742
+ notes: this.notes,
706
743
  })
707
744
  );
708
745
  if (step.extensionWorld)
@@ -1198,6 +1235,16 @@ class Execution {
1198
1235
  (this.reader ?? this.client).send('Target.disposeBrowserContext', { browserContextId })
1199
1236
  );
1200
1237
  await Promise.allSettled(this.pending);
1238
+ if (
1239
+ this.side === 'guarded' &&
1240
+ this.vector.observe?.eventsRedacted?.requires?.includes('discovery')
1241
+ ) {
1242
+ const violation = discoveryViolation(
1243
+ this.client.events.slice(this.eventStart),
1244
+ supported(['discovery'], this.options.capabilities)
1245
+ );
1246
+ if (violation) this.errors.push(violation);
1247
+ }
1201
1248
  }
1202
1249
  }
1203
1250
  }
@@ -1592,6 +1639,7 @@ export function run(set, options) {
1592
1639
 
1593
1640
  async function runAll(set, options, flights) {
1594
1641
  const opts = { capabilities: {}, variant: Object.keys(set.variants), parallel: 1, ...options };
1642
+ opts.fixture = prepareFixture(set, opts);
1595
1643
  opts.targets = {
1596
1644
  ...Object.fromEntries(
1597
1645
  Object.entries(set.targets ?? {})