slicc-browsers
The browser catalog, JSON schemas and conformance corpus shared by the two programs that find and launch browsers for SLICC:
slicc browsersandslicc launchin slicc-cli (Go,CGO_ENABLED=0);playwright-cli listandopenin slicc-cdp (Rust, also built forwasm32-wasip1).
It also holds the protected origins: the origins browser control must never reach, which slicc-agent checks too. It also holds the guard vectors, which every CDP guard runs: bios, slicc-cdp, slicc-extension and slicc-cli.
They can't share code, so they share data. Three packages wrap the same files, at one version:
| package | what it gives you |
|---|---|
Go module github.com/ai-ecoverse/slicc-browsers (package sliccbrowsers) |
the files embedded, typed structs, and corpus helpers |
Rust crate slicc-browsers (lib slicc_browsers) |
the files as &str constants, serde types behind features |
npm package @ai-ecoverse/slicc-browsers |
the JSON files and the schemas |
| file | what it is |
|---|---|
catalog.json |
the browsers, Electron apps, ranking, discovery, seven flags and prefs, profile and platform paths |
schema/catalog.schema.json |
JSON Schema (2020-12) for catalog.json |
schema/browsers.schema.json |
JSON Schema for slicc browsers --json and playwright-cli list --json, schema 1 |
schema/corpus.schema.json |
JSON Schema for corpus.json |
corpus/<case>/case.json, corpus/<case>/expected.json |
the conformance cases |
corpus.json |
every case in one file, generated from corpus/ by npm run corpus |
protected.json, schema/protected.schema.json |
the protected-origin test vectors and their schema |
protected.mjs |
the reference protected-origin matcher, in JavaScript (npm only) |
guard-vectors.json, schema/guard-vectors.schema.json |
the guard vectors and their schema |
guard-driver.mjs |
the guard vectors' beacon and canary helpers, in JavaScript (npm only) |
Go
import sliccbrowsers "github.com/ai-ecoverse/slicc-browsers"
c := sliccbrowsers.Default()
b, ok := c.Browser("chrome")
Default() *Catalogparses the embeddedcatalog.jsononce and panics if it doesn't parse (CI makes sure it does).Load() (*Catalog, error)is the same without the panic. Treat the result as read-only.Parse([]byte)parses another copy and rejects aschemaother than 1.(*Catalog).Browser(id)and(*Catalog).Priority(id), the index inbrowsers(its length for an unknown id).CatalogJSON(),CatalogSchemaJSON(),BrowsersSchemaJSON(),CorpusJSON(),ProtectedJSON()andProtectedSchemaJSON()return copies of the embedded files.(*Catalog).ProtectedOriginsiscatalog.json'sprotectedOrigins.Protected() (*ProtectedVectors, error)parsesprotected.json(Origins,URLs,Targets,CookieDomains,StorageKeys; a target'sOpeneris"",OpenerProtected,OpenerUnprotectedorOpenerUnknown).Guard() (*GuardVectors, error)parsesguard-vectors.json;GuardVectorsJSON()andGuardVectorsSchemaJSON()return copies of the files.Guard()parses it intoSchemaVersion,SetVersion,Targets,Variants,Capabilities,PlaceholdersandVectors. AGuardExpectedisChecks(names to booleans) orText, and aGuardObservationisMeans,DependsOnandRequires. TheGuardTarget*,GuardOrdinary,GuardSameSite,GuardKind*,GuardVia*,GuardScopeAny,GuardControl,GuardGuarded,GuardCap*andGuardSession*constants name the values.Corpus() ([]Case, error)returns the cases. See Running the corpus for the helpers.
Rust
[]
= "1"
[]
= { = "1", = ["corpus"] }
- Always:
CATALOG,CATALOG_SCHEMA,BROWSERS_SCHEMA,CORPUS_SCHEMA,PROTECTED_SCHEMAandGUARD_VECTORS_SCHEMA, each the file's text viainclude_str!, andSCHEMA(1). No dependencies. - Feature
serde:Catalogand the other types (protected_originsis aProtectedOrigins),Catalog::parse(&str),catalog()(parsesCATALOG),Catalog::browserandCatalog::priority. It pulls inserdeandserde_json. - Feature
corpus(impliesserde):CORPUS,PROTECTEDandGUARD_VECTORS, the text ofcorpus.json,protected.jsonandguard-vectors.json; the modulecorpuswithcorpus(),Case,Layoutand the helpers below; the moduleprotectedwithvectors(),Vectorsand theOPENER_*constants; and the moduleguardwithvectors(),Vectors,Vector,Step,Observation,Expected(ChecksorText), and theTARGET_*,ORDINARY*,KIND_*,VIA_*,SCOPE_ANY,CONTROL,GUARDED,CAP_*andSESSION_*constants. Use it from[dev-dependencies]only; with resolver 2 it stays out of normal builds.
Every feature builds for wasm32-wasip1.
npm
import catalog from '@ai-ecoverse/slicc-browsers' with { type: 'json' }. The exports are . (catalog.json), ./catalog.json, ./corpus.json, ./protected.json, ./protected.mjs, ./guard-vectors.json, ./guard-driver.mjs, ./schema/catalog.schema.json, ./schema/browsers.schema.json, ./schema/corpus.schema.json, ./schema/protected.schema.json and ./schema/guard-vectors.schema.json.
guard-driver.mjs is described under Guard vectors.
protected.mjs is the reference matcher for protected origins: import { protectedOrigins } from '@ai-ecoverse/slicc-browsers/protected.mjs', then protectedOrigins(catalog.protectedOrigins, extraOrigins) returns { url(text), target({ url, previous, opener }), cookieDomain(domain), storageKey(key) }, each a boolean. It needs only the WHATWG URL, so it runs in Node and in a browser.
catalog.json
"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:
minChromiumMajor(116): endpoints whose user agent'sChrome/<major>(discovery.chromeMajorPattern) is lower are left out, and so are installed browsers withchromiumVersion: truewhose version's leading number is lower. A version that doesn't start with a number is kept.browsers: every known Chromium browser, in rank priority order. Each has:id,name,channel(stable,beta,dev,canary,nightly,snapshot,testing),engine(chromium), andchromiumVersion: whether its version is the Chromium version (Chrome, Edge, Chromium) or its own (Brave, Opera, Vivaldi, Arc).macos:bundleId,app(the bundle's file name),executable(underContents/MacOS), anddataDir?, the default user-data dir under~/Library/Application Support.windows?:roots(names fromplatforms.windows.roots, searched in order) andexecutables(each tried under each root);dataRootanddataDir, the default user-data dir;startMenuInternet?, the browser's key names under theStartMenuInternetregistry keys.linux?:bins(names onPATH),desktopIds(.desktopfile names without the suffix),flatpak?(the app id),snap?(the snap name),snapDataDir?(the user-data dir under~/snap/<snap>/common),configDir(the user-data dir under the config dir, also under~/.var/app/<flatpak>/configfor a flatpak), anddebs(dpkg package names for the version).
electron:macosApps(known Electron apps by bundle id, with display names),debugBlocked(app names that block remote debugging; they're listed withdebugSupport: "disabled"and never launched), andidPrefix(electron:, followed by the slug of the name: lower case, runs of other characters as one-).rank:tiers, 1 to 6:sevenOpen(an endpoint with a seven tab),endpoint(any other endpoint),chrome(installedchromeId),chromium(other installed Chromium browsers),electron(installed Electron apps),six(an endpoint in SLICC six's profile, with or without a seven tab; see SLICC six's profiles).discovery:activePortFile(DevToolsActivePort): its first line is the port (1 to 65535) and its second the browser's websocket path, which starts with/. A file without both is ignored. A file is live only when its port answers/json/versionwith awebSocketDebuggerUrlwhose path is that websocket path; otherwise it's stale and ignored. When the port accepts connections but serves no/json/version, the endpoint is listed aswsOnly, withws://127.0.0.1:<port><path>;probePorts(9222 to 9229, probed byslicconly);- every endpoint's
cdpis<scheme>://<host>:<port>, with the host in lower case andlocalhostand::1written as127.0.0.1, whatever form the URL was found in. Endpoints with the same host and port, written that way, are one endpoint. Aws://--cdpURL that is listed aswsOnlykeeps its websocket URL as given; sourceRank: when two sources find the same host and port, the lower rank wins, and on a tie the first found;names: an endpoint's name when it isn't from a known browser's user-data dir:electronwhen the user agent containselectronUserAgent, elseproducts[<product>]for the product before the/in/json/version'sBrowser, else that product, elsefallback;process: the process scan, see The process source.
seven:originSchemeandoriginHostPattern: a seven origin ishttps://<host>with a host matching the pattern, except for theexcludedLabels(www,vault,vault-preview,get,mcpcat) and theexcludedLabelPrefixes(vault-, which excludesvault-feature-xand any other vault branch preview). The bare apex (sliccy.ai, i.e. an empty label) never matches. An origin is compared without the scheme's default port (443 forhttps, 80 forhttp), sohttps://seven.sliccy.ai:443is seven andhttps://seven.sliccy.ai:8443is not. The same goes for extra seven origins;sitePattern([*.]sliccy.ai);switchesanddisabledFeatures: the seven flags, all--disable-featuresvalues merged into one switch;prefs: values merged into a profile'sDefault/Preferencesat thepathof keys;discardExceptions: where the site pattern and the extra origins' hosts go, as alistof strings or as achrome-time-map, a map from site to the time it was added (microseconds since 1601-01-01 UTC, as a decimal string);cleanExit: values that mark the last exit clean;formsays where a key comes from:six(SLICC six's keys) orchrome-154(what Chrome 154 reads).
protectedOrigins: what browser control must never reach, see Protected origins.domains(sliccy.ai),hosts(vault.sliccy.ai),schemes(https,http),innerSchemes(blob,filesystem,view-source),anySchemes(devtools),inheriting(about:blank,about:srcdoc), andrefusal, the text of a refusal.profiles: SLICC profiles live in<config dir>/<root>/<name>, and a snap's in~/snap/<snap>/common/<snapRoot>/<name>.legacyRootis the old root, never scanned; on macOS's case-insensitive volume it is SLICC six's root too.sixProfilePatternmatches six's profile names, case-insensitively.namePatternis a valid profile name.sixdescribes SLICC six's Chrome profiles (see SLICC six's profiles):roots: six's profile root per OS (macos,linux,windows), abaselocation followed by thepathsegments. The base is itsenvwhen that is set and not empty (any value, as six takes it), elsehomeunder the home dir.defaultName(browser-coding-agent-chrome),defaultPort(5710) andportSuffix(-{port}): six's profile for its serve port<port>is<root>/<defaultName>, withportSuffixadded for any port other thandefaultPort.userDataDirEnv(SLICC_USER_DATA_DIR): when set, its value, trimmed of whitespace, is six's profile for every port, unless it is empty.
launch:chromiumArgs(with{profile}for the profile dir), then the seven flags, thenheadlesswith--headless. Electron apps getelectron.portFlagwith the first free port fromelectron.minPortup that isn't inelectron.reservedPorts.platforms: the config dir per OS, the Windows roots, and the Windows registry keys. A location is the environment variableenvwhen it's set (on Linux only an absolute path counts), elseabsolute, elsehomeunder the home dir.registry: under each ofhives, every subkey of eachstartMenuInternetkey, with itsopenCommanddefault value, and the default value ofappPaths\<name>for each ofappPathNames.
Protected origins
Browser control must never navigate to, open or attach to a target on seven's own origin, or read or write its cookies or storage: not slicc-cli's cdp relay, not slicc-cdp's playwright-cli, not slicc-agent. protectedOrigins in catalog.json says what is protected, and every implementation runs the vectors in protected.json.
URLs. A URL is parsed the way the WHATWG URL Standard (and so the browser) parses it, so https:\\seven.sliccy.ai, a tab or newline inside the URL, %2E, 。 and full-width letters all reach seven. In Go and Rust that takes a WHATWG parser, not net/url. A URL that doesn't parse isn't protected; the browser doesn't load it either. Then:
- While its scheme is one of
innerSchemes, the rest of the URL after<scheme>:takes its place, at most 8 times:blob:https://seven.sliccy.ai/<uuid>ishttps://seven.sliccy.ai/<uuid>. - A scheme in
anySchemesis protected whatever follows: a DevTools front end (devtools://) can be inspecting seven. - A scheme in
schemesis protected when its host is one ofhosts, or one ofdomainsor under one, at any depth and on any port. The host is lower-cased, and one trailing dot is dropped. - Any other URL is protected when its origin is one of the program's extra origins: the seven origins it accepts besides
sliccy.ai(slicc-cli's--originand--pageorigins), compared asscheme://host[:port]without the scheme's default port, with the host as in 3.
This is wider than the rule for finding seven (seven.originHostPattern, which leaves out the excludedLabels and excludedLabelPrefixes—www, vault, vault-preview, get, mcpcat, 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.
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.
Targets. 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.
A same-site frame shares its parent's process and has no target of its own. Every sliccy.ai host is protected, and site isolation gives a seven frame inside any other site its own target, so this only matters for an extra origin whose site has other origins (another port of 127.0.0.1, say).
Cookies. A cookie domain, lower-cased and without a leading or trailing dot, is protected when it is one of domains or under one, or when it is one of hosts, the host of a protected domain or an extra origin, or a parent of one: a cookie on .example.test reaches https://seven.example.test.
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.
Refusals carry the text in refusal, slicc: refused: target is on seven's origin.
protected.json (schema schema/protected.schema.json): origins, the extra origins every vector runs with; urls (url, protected); targets (url, 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.
Guard vectors
Every CDP guard runs the vectors in guard-vectors.json (schema schema/guard-vectors.schema.json): bios, slicc-cdp, slicc-extension and slicc-cli. The sources are listed in sources:
- slicc-cli's
protectVectorsat2d5d981(test/integration/testdata/link/protect.js), with the ids kept; - slicc-cdp's
test/integration/guard-vectors.jsonat4907a45, where itsE1,E2,E3,E5andB2are renamedE1.onload,E2.binding,E3.bypass-csp,E5.consoleandB2.headers; - the cert lane's E and B rows;
C5.continue-requestandC5.continue-response, added next toC5.document.
Top level.
schemaVersion(1): guards pin it.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 reads0.0.0; Go code gets the version from the module.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}),ordinaryandordinary-same-site. Each has adescriptionand, for the protected ones, a defaulthost. Nothing in the file is a URL a runner must use.variants: the detach variants in ms.promptandlaggingare documented; a runner may add others, such aslagging-8s, and reports the name it ran.capabilities: what a transport may lack.autoAttachcoversTarget.setAutoAttach,childrensteps and child sessions;browserSessioncovers calls on the browser session;browserContextscoversTarget.createBrowserContextandTarget.disposeBrowserContext.placeholders: every{name}used in the vectors, with what it stands for.{client}is the client page's origin, which the runner maps.
A vector has:
id, andfamily(C,EorB, the id's first letter);kind:cdp(runnable steps) ortext(rows in words, which runners report asnot-run, neverpass);scope:any, or the one guard the vector is for;setupandtrigger, both lists of steps;targets: the protected targets it runs against;requires?: the capabilities it needs, the union of its steps' and observations'requires. A runner whose transport lacks one reports the vector asskipwith the reasonnot-supported:<capability>. A row with apage-or-browsercookie step that would carry abrowserContextIdreportsnot-supported:browserSessionwithout a browser session, because that parameter is only allowed on the browser target;observe?: each observed name, as{ "means", "dependsOn"?, "requires"?, "eitherWhen"? }.dependsOnlists the refusals the observation depends on (see Refusals).eitherWhenis a check: when it holds on the guarded leg, that observation's expectation becomes"either"there (see Design-dependent outcomes);live: the liveness check;control(origin: "ordinary",via,expected) andexpected: an object of names and booleans, or text;skip?(target,reason):reasonstarts with a code, as innot-applicable: ….
Steps are declarative and transport-neutral. Any step can take on (control or guarded), which runs it on that leg only.
Any cdp, page or children step can take requires, the capabilities it needs: autoAttach for Target.setAutoAttach, children and session: "child", browserSession for session: "browser", and browserContexts for Target.createBrowserContext and Target.disposeBrowserContext.
{ "cdp": method, "params"?, "session"?, "as"?, "refusable"?, "refusal"? }: a CDP call. It runs on the page session by default, on the browser withbrowser, on either withpage-or-browser(the driver uses the page session), or on the current auto-attached target withchild.asnames its outcome.refusable: truewithrefusal: { "match" }(a regular expression for the error message or code) marks a call that a guard may refuse at registration.{ "page": action, … }:tab: a new tab aturl, incontext, which becomes the page.backgrounddefaults totrue;navigate: go tourland wait for the load;href,replace,form,post: navigate from the page, 50 ms later;open:window.openwith a user gesture;iframe: add an iframe;framed: load the/framedpage fromfrom, withurlin an iframe;fetch,xhr,image: loadurland name the body (width Nfor an image, or<status> <body>withstatus);backgrounddoesn't wait;eval: evaluateexpression.hopsroutesurlthrough/hopon eachorigin, with itscode.
{ "answer": { "url", "stage"?, "cdp"?, "params"?, "hold"? } }: answer each paused request or auth challenge forurlwith that call;holdleaves it paused. Every other paused request continues unchanged.{ "children": steps, "where"? }: run these steps on each target auto-attached from then on, then resume it.wherescopes them to the row's own target:{ "openerId": "page" }(opened by the row's page) or{ "urlPrefix": url }; other attached targets are resumed and otherwise left alone.{ "settle": { "beacon"?, "title"?, "ms"?, "variant"?, "session"? } }: wait for a beacon frombeacon, for the pagetitle, or for the session to detach. It waits up toms(10000 by default), then another 600 ms.variantwaits the run's detach variant instead.{ "wait": ms }.{ "guess": { "between", "cdp", "params" }, "as" }: the call for each request id between the ids paused for the two URLs.{ "text": … }: a step in words.
Popups are observed by Target.targetCreated with the opener's openerId, or by the child's Target.attachedToTarget, never by the URL: a popup waiting for the debugger can still be about:blank.
The stand-in serves these routes on every origin. It never listens on 9222 to 9229.
/doc?n=: an HTML page with thedata-canarymarker;csp=1addsContent-Security-Policy: script-src 'none'and an inline script that sends a beacon taggedinline-<n>to the log, which the CSP blocks unless it is bypassed;/hop?code=&to=and/framed?src=;/data?n=:real-<n>, with CORS;/img: a 1×1 PNG;/auth: Basicu:p, thenauthed;/rewritten(titlerewritten);/plant: sets the canary;/beacon, and/log?n=, which returns{ hits, beacons }.
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.
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. A guard that allows the step must pass its control through the guard.
Liveness. A runner checks live through its own CDP, never through the guard path: either the protected document loaded (title, marker or URL), or the guard returned its own refusal for a refusable step. A failing live fails the id.
Design-dependent outcomes. A key listed in designDependent may expect "either", which true and false both match: a guard that refuses a setup step lets the real document load, and one that blocks before commit doesn't. detached is design-dependent on the rows whose guarded leg proves the block (the pre-action beacon plus an exact-URL block or the history form): its eitherWhen holds the same proof, so a guard that blocks without detaching passes, and one that neither blocks nor detaches still fails. The hard negatives stay required.
Observability. observable.own: false marks a vector the guard host's own origin can't show (headers, auth, redirects); a runner whose own is a static server reports it as skip with not-observable.
Applies to. appliesTo lists the triggers a vector runs with (href, iframe, popup, redirect). Chrome doesn't apply some session overrides inside a cross-site iframe, so those E6 rows list href only.
Variants. variants gives the delay in milliseconds before the client's Target.detachFromTarget: prompt 0, lagging 8000. A runner may add its own variant names.
Reports. There is one row per id, target, variant and transport. control, guarded and live are each pass, fail, skip (with the skip's reason) or not-run (for text rows):
guard-driver.mjs has no dependencies and no imports. It uses no Node APIs, so it runs in a plain browser page as well as in Node. It drives a client of this shape:
send(method, params, sessionId): returns a promise of the result, and rejects with an error carryingcdp, the CDP error;waitEvent(predicate, ms): resolves with the first matching event{ method, params, sessionId };events: every event received so far.
It exports:
beacon(log, tag)andprobe(log, tag): the sources for{beacon}and{probe};canary.plant(value),canary.read()andcanary.untouched(value, read): the canary inlocalStorage, IndexedDB and the stand-in's marker;hop,expand(value, vars), andsteps(vector, side, vars), a leg's expanded steps, withrefusal.nameon each refusable step andbackgroundset on eachtabstep;refusals(vector): the vector's refusal names;outcome,call(client, step, sessionId)andisRefusal(outcome, step);instrument(client, sessionId, log, tag),load(client, sessionId, url)andpopup(client, openerId);judge(vector, side, observed):{ pass, refused, skipped, mismatches }, whererefusedlists the leg's refusals andskippedthe observations that depend on them;result(vector, run): a report row from{ target, variant, transport, via?, capabilities?, control, guarded, live }.capabilitiesis the transport's; without it, the transport has them all;summary({ guard, sha, set, results }):setis the parsedguard-vectors.json. It returns{ set, schemaVersion, guard, sha, variants, targets, transports, results, skipped, notRun, missing, pass, line }.missinglists each{ id, target, variant, transport }without a row, for every vector whosescopeisanyor the guard, every target it lists, every variant invariantsorresults, and every transport inresults;passneeds it empty, wherelineisGUARD-PASS <guard>@<sha> (vectors @ai-ecoverse/slicc-browsers@X.Y.Z)orGUARD-FAIL ….
npm run validate checks that:
- the file matches the schema, and ids are unique;
- every vector has a control and an
expected, text rows included; - each id is in its family;
- a
cdpvector has acdporpagestep, and atextvector has neither; - every CDP method on the guarded leg also runs on the control;
- a skip names one of the vector's targets;
observedefines every expected name;- in a vector with a refusable step, every observation, except an
asof a step before the first refusable one, hasdependsOn, and each name in it is a refusable step's refusal name; - every
Target.setAutoAttach,children, child-session, browser-session and browser-context step has the matchingrequires, and a vector'srequiresis the union of its steps' and observations'; - every
{name}is inplaceholders; - no URL uses a port from 9222 to 9229.
Reference runner
guard-runner.mjs is dependency-free browser ESM. run(set, options) returns
{ results, summary(metadata) }; the module also exports summary. Results have
id, target, variant, control, guarded, live, boolean observations and
errors. Statuses are pass, fail, skip and not-run. Text vectors are reported
as not-run; they are not silently certified. A missing observation check is an
error before execution.
Supply a CDP client with send(method, params, sessionId): Promise<object>,
waitEvent(predicate, milliseconds): Promise<event> and an append-only events
array of { method, params, sessionId }. Rejections should expose the protocol
error as error.cdp, or an error message. client carries the guarded leg;
unguarded, when supplied, carries unguarded controls and observation reads.
There is no fallback from observer reads to the guarded client. A bare client
means guarded transport only; rows requiring an observer report
skip with not-supported:unguarded-observer, counted in summary().observerSkipped. The run's summary() also reports
skips, the number of skipped rows per reason, and appends
skips <n> (<reason>=<count>, ...) to line whenever any row was skipped, so a
certification line never reads skipped rows as coverage.
clientFactory({ variant, detachDelay, vector, target, slot }) is called per row
and may return either a client or { client, unguarded, close, transportVariant }.
The factory can configure the guard's own transport delay from this metadata;
the runner never reaches into the guard. transportVariant: true acknowledges
that configuration, and every result records that boolean (false by default).
slot allows pooling browsers while keeping each row's clients independent.
The optional close releases the row's clients; release pooled browsers after
run completes. Each parallel slot needs an independent browser for
browser-wide settings. Without a factory execution is serial. Cookie and storage
rows use fresh browser contexts when capabilities.browserContexts is true;
otherwise they run serially. capabilities is an object of booleans or an array
of supported names. capabilities.scope selects owner-specific rows.
Map ordinary, ordinary-same-site, seven, vault, vault-x, apex, own
and client to origins. Optionally map plain, cross and log; their defaults
are ordinary, client and ordinary. Origins must preserve the site
relationships of the data. Map an unavailable target to null, or leave it out; a
missing or undefined origin is treated exactly like null: every
row needing that origin is reported as skip for control, guarded and live, with
reason not-applicable:no-<target>-origin; no substitute origin is used and the
row never passes. A guard whose whole site is protected, such as the extension
where every sliccy.ai host is protected, passes ordinary-same-site: null.
This also applies to origins referenced in setup and triggers, not only the row
target. In observations and liveness checks, an any alternative that references
a null origin is dropped and the rest are evaluated; the row skips only when
every alternative of an any, or a required (all, not or plain) check, needs it. Tags and cookie names are unique per leg. variant
selects one variant or an array; omission selects all. parallel defaults to 1.
log(row) receives completed rows. A variant supplies the wait before an
explicit Target.detachFromTarget step and for a settle.variant step.
Every runnable vector has a declarative liveCheck; every observation has a
declarative check. Each check declares its observer: fixture reads request
logs, client reads already-delivered transport data, and unguarded requires
an independent CDP observer. all needs the observers for every operand; any
can use an available fixture or client alternative without an unguarded observer.
Unavailable optional observer alternatives are false, never substituted with guarded reads.
Checks include: beacon, served, header with
on, event with where, result with path and equals, cookie with from,
compare, refused, reached, attached, delivered, title, expression,
hops (each exact nested redirect URL), detached, loaded, pre, blocked,
history, frameBlocked, registration, state and filtered. The diagnostic executed and live
checks are not valid liveness proofs.
Compose checks with all, any or not. Predicates support nested partial
objects, $contains, $regex, $some, nonempty $every, and $encoded with
{ value, prefix } for a base64-encoded value with an optional prefix. Reads of titles and
expressions use the unguarded observer. A refused check can declare refusal.match to match protocol errors. Transport timeouts, timeout sentinel values and evaluation exceptions fail result,
comparison and observer-read
observations; they are never comparable values. Registration refusals must match the
step's declared expression; dependent observations are omitted and reported by
the existing result evaluator.
liveCheck applies only to the guarded leg. Controls are judged by their positive
observations: a missing or failing effect fails the control. Guarded liveness
requires the exact target URL in the fixture log or a target-origin /live
heartbeat; alternatively a pre-action /live?tag=...&step=pre&to=... request and
an exact-URL block/error from the client or unguarded frame tree. An error for a
different URL is not evidence. blocked accepts either { url } for one exact URL
or { anyOf: [url, ...] } for a nonempty list of exact URLs; each URL can be a
string or { hops, to, index? }. Both forms allow session and frame. With frame,
the unguarded observer also reads the page's out-of-process iframe targets (matched by
their parent frame): a site-isolated iframe isn't in the parent's frame tree, so its
chrome-error main frame with the exact unreachableUrl is the block. history (unguarded,
same URL forms) is for guards that replace the error page, such as with about:blank#blocked: it
needs the pre-action beacon, an entry with the exact URL in the observer's
Page.getNavigationHistory of the page or child, and no fixture request for that URL.
frameBlocked (unguarded) is that out-of-process chrome-error iframe target, and the
client must be unable to attach to it; the cross-site C4 rows accept it as errorFrame.
A step that the guard refuses in every context, such as Network.clearBrowserCookies
in C6.clear-cookies, is refusable, so refusing it on both legs passes and allowing it
is judged on its effect. A child step that fails after its child session detached is
recorded as detached, not as a refusal, and the runner awaits a child's started
children steps before any later session: "child" step and before the trigger. A page
navigation waits for the load of its own loaderId (its Page.frameNavigated and then a
load event), and a settle waits for a main-frame commit followed by its load, so a stale
load event never ends a wait. After a child's children steps that include Page.enable,
the runner waits, bounded at 5 s, for the child's first document to commit and load before
any later child step. { "settle": { "beacon", "required": true, "dependsOn"? } } waits
for that beacon at the fixture and fails the row with settle: no beacon from <origin> within <ms>ms when it never arrives, unless a refusal it dependsOn was recorded; the rows
whose allowedBeacon comes from an earlier document settle on it before their trigger.
Beacons use navigator.sendBeacon, with a fetch(…, { keepalive: true }) fallback. An observation that reads a background step's as result
waits for that step, up to 10 s, and fails with <as> did not complete within … past it. Redirect
vectors list every nested hop URL and the final destination because Chromium
reports the URL intercepted by the guard. A matching block still requires the
pre-action beacon alongside it; an unrelated error cannot certify the chain.
A declared setup refusal can supply liveness only
when its refusal.match matched. Transport connectivity and command completion
alone never prove liveness. Cookie and storage rows seed a canary outside the
guard and read it back outside the guard; refused writes require unchanged
canary state and a refusal or filtered call response. Their control can clear or
modify state as required by its positive observations. C5.auth and
C5.guessed-id.continue-with-auth use fixture Authorization logs and refusal of
candidate auth ids; C5.auth also checks delivered auth events. Their liveness
requires an auth request at the fixture, not a completed fetch. Neither uses a
plain-tab baseline: headless Chromium can leave a credentials-including fetch
to a Basic 401 pending even without an attached client.
Beacon settlement uses only requests recorded after the page action starts. For redirect chains it also requires the exact final destination request in the fixture log. A setup beacon on the same origin and tag cannot settle an unfinished redirect chain. This does not change the observations used to judge either leg.
The separately exported Node module @ai-ecoverse/slicc-browsers/fixture exposes
fixtureServer({ host, port, tls, latency }), defaulting to a random loopback port. latency(url) returns milliseconds to hold that response, for timing tests.
Pass tls: { key, cert } to serve HTTPS using Node TLS key and certificate
values. The returned origin, request logs, redirect URLs and document heartbeats
use https://<Host> with TLS, so served, hops and liveCheck compare against
the mapped HTTPS origins, including an extension client at https://*.sliccy.ai.
Without tls, the server and logged URLs remain HTTP. It returns
origin, port, log(), reset() and close(). Log entries contain url,
method, headers and time. Mirror that API when using another fixture.
The server serves document, frame, redirect, CSP, authentication, data, image,
canary and beacon paths. Configure host resolution in your test browser, not in
the runner. npm run test:guard exercises Chromium twice with no guard, one target per
invocation: controls must pass and guarded expectations must fail. For a single
target use GUARD_TARGET=seven npm run test:guard-target. The fixture also serves
/live image requests from stand-in documents, independently of injected code.
import set from '@ai-ecoverse/slicc-browsers/guard-vectors.json' with ;
import from '@ai-ecoverse/slicc-browsers/guard-runner';
import from '@ai-ecoverse/slicc-browsers/fixture';
const fixture = await ;
const targets = await ;
const capabilities = ;
const execution = await ;
console.log;
await fixture.;
The process source
The process scan finds browsers started with a debugging port by something else:
- Process list. On Unix it's
discovery.process.unix(ps -ax -o pid= -o command=): one process a line, the pid, whitespace, and the command line. On Windows it'sdiscovery.process.windows, which prints a JSON array (or one object) ofProcessIdandCommandLine; entries with no command line are skipped. - What's skipped: pid 0, the scanning process itself, and commands whose first token is a wrapper. The first token is the quoted string when the command starts with a quote. A wrapper is a token whose base name, without
.exe,.cmdor.bat, is one ofwrappers, case-insensitively. A command with no executable before its first-token is skipped too. - The port comes from the first
--remote-debugging-portfollowed by=or whitespace and then digits. A port above 65535 drops the process. - The dir is the value of
--user-data-dirat the start of a token, followed by=or by whitespace (which is skipped). A value that starts with one ofvalueQuotes("or') runs to the matching quote. Any other value runs up to the first whitespace that is followed byvalueStop(--), or to the end of the command, without trailing whitespace. So--user-data-dir=/Users/me/Library/Application Support/x --no-first-rungives/Users/me/Library/Application Support/x. A value that would start with--is empty. - Port 0 means the port is in the dir's
DevToolsActivePort, checked like any other. With no dir, or a dir under the legacy profile root (asliccsegment followed byprofiles, case-insensitively) that isn't one of six's profile dirs, the process is skipped. - Any other port is probed on
127.0.0.1. An endpoint from the process scan hassource: "process"anduserDataDirset to the dir when there is one. - Tests must fake the process list: never scan the real one in a test.
SLICC six's profiles
SLICC six launches Chrome in a profile of its own, and slicc-cli can use that profile in place (slicc --profile six). Discovery never chooses it: its endpoints are listed, marked and ranked last.
| macOS | Linux | Windows | |
|---|---|---|---|
| six's root | ~/Library/Application Support/Slicc/profiles |
$XDG_STATE_HOME/slicc/profiles, else ~/.local/state/slicc/profiles |
%LOCALAPPDATA%\Slicc\profiles, else ~\AppData\Local\Slicc\profiles |
Taken from ai-ecoverse/slicc at 65c5a79b4 (v6.247.17-8):
| what | where |
|---|---|
| the root per OS | packages/node-server/src/chrome-launch.ts:119-136 (resolveProfilesDir); packages/swift-server/Sources/Browser/ChromeLauncher.swift:314-322 (macOS only) |
browser-coding-agent-chrome |
chrome-launch.ts:27; ChromeLauncher.swift:5 |
the -<port> suffix for a serve port other than 5710 |
chrome-launch.ts:147; ChromeLauncher.swift:6, :323 |
SLICC_USER_DATA_DIR, trimmed |
chrome-launch.ts:143-146 (node-server only) |
six's own --remote-debugging-port (9222 by default) |
chrome-launch.ts:283, packages/node-server/src/runtime-flags.ts:47; ChromeLauncher.swift:213 |
Six's profile dirs are the dirs in six's root whose name matches sixProfilePattern case-insensitively, and the SLICC_USER_DATA_DIR dir. A dir found anywhere is six's when it is one of them: the paths are compared case-insensitively, with / and \ alike, and after resolving symlinks where the program can (so a symlink to six's profile, or a symlinked root, is six's too).
Discovery marks an endpoint six: true when one of its sources found it in six's profile:
DevToolsActivePortin each of six's profile dirs, checked like any other. No other dir in six's root is read.- The process list: a process whose
--user-data-diris six's. With port 0 itsDevToolsActivePortis read, which the legacy-root rule would otherwise skip. - Merging: an endpoint is six's when any source that found it, and whose checks passed, is six's. So a port probe hit or a
--cdpURL that the process list maps to six's profile is six's, while a staleDevToolsActivePortin six's profile marks nothing. ItsuserDataDiris six's dir when the source that wins has none.
A six endpoint has tier six, whatever its seven tabs. A program never chooses it by itself: a default choice, an "attach to the best" rule, or a non-interactive mode skips it. Only an explicit request for six's profile unlocks it, such as slicc --profile six or picking it in a picker.
Output schema 1
schema/browsers.schema.json describes slicc browsers --json, which playwright-cli list --json also prints. slicc-cli's README 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).
The corpus
Each case is a fake machine: a home tree, DevToolsActivePort files, fake /json/version and /json/list answers, a fake process list, --cdp URLs, and installed browsers, with the ranked list it must produce in expected.json. corpus.json holds every case as { "name", "case", "expected" }.
case.json:
description, andos?: the OSes the case runs on (darwin,linux,windows; default all).needs: what a program must support to run the case:user-data-dirs(DevToolsActivePortin browsers' default user-data dirs),seven-profiles(in the profile roots),flag(--cdpURLs),process,probe(port probes,slicconly),origins(extra seven origins),installed(ranking installed browsers),six(finding and marking SLICC six's profiles). A program skips a case that needs something it doesn't do.env?: environment variables for discovery, which sees nothing else from the real environment.files?: file contents by path. Every path starts with a token.cdp?,origins?,probePorts?:--cdpURLs, extra seven origins, and the ports to probe.processes?:{ "pid", "command" }, or{ "self": true, "command" }for the scanning process, whose pid the harness fills in.http?: fake servers byhost:port.localhostand[::1]are127.0.0.1. A listed port accepts TCP connections./json/versionanswers 200 withversion,/json/list(and/json) withlist. A missing body, or any other path, answers 404. An unlisted port refuses connections.installed?: installed browsers in the shape of output schema 1'sinstalled, ranked along with the endpoints.auto: exactly in the cases that needsix, the id of the first entry inexpected.jsonthat isn't six's, ornullwhen every entry is. A program that chooses without being asked must choose no entry before it, and never a six entry.
Tokens. {home} (the fake home), {config} (the config dir), {sevenProfiles} (the profile root), {sixProfiles} (SLICC six's root) and {userData:<id>} (the browser's first default user-data dir on this OS) stand for directories. In files, env, cdp, process commands and expected.json, a token and the path characters after it (up to whitespace, ", ' or ,) become a path on the OS under test, with \ on Windows. The token values come from catalog.json, with the case's env.
Never 9222 to 9229. A developer's own Chrome listens there. The corpus schema refuses those ports, npm run validate refuses them anywhere in the corpus, and the fakes never serve them.
Running the corpus
A harness, per case it runs:
- makes a temporary home and writes each of
files; - points discovery at that home and the case's
envonly, so no real user-data dir, profile or process is ever read; - feeds the fake process list (Unix:
PSOutput, Windows:CIMOutput) and routes every HTTP request and TCP dial to the fakes, so discovery never touches the network; - discovers endpoints, ranks them with
installed, and compares the JSON withexpected.jsonafter token expansion, as parsed JSON.
Go (slicc-cli does this in test/integration/corpus_test.go):
cases, _ := sliccbrowsers.Corpus()
for _, c := range cases
Rust, with the corpus feature:
use ;
let cat = catalog;
let os = match OS ;
for case in corpus.cases
Layout also gives Config, SevenProfiles, SixProfiles, UserDataDirs(id), Token(name) and Expand(text) (Rust: config, seven_profiles, six_profiles, user_data_dirs, token, expand). Their results for a few homes are pinned in test/integration/layout.json, which the Go and Rust tests both check.
Development
Go 1.26, Rust stable with wasm32-wasip1, Node ≥ 24.
npm run corpusregeneratescorpus.jsonfromcorpus/; the pre-commit hook and CI fail when it's stale.npm run validatecheckscatalog.json,corpus.json,protected.jsonandguard-vectors.jsonagainst the schemas, runs the protected-origin vectors throughprotected.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,sixandautoin eachexpected.json, and no port from 9222 to 9229.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 --checkand clippy.npm test:validate, the Go tests intest/integration/, andcargo test --all-features.npm run build: the Go module, the crate (also forwasm32-wasip1), andnpm pack --dry-run.
Changing a table in slicc-cli/internal/browser means changing catalog.json here, releasing, and bumping the dependency; slicc-cli has no tables of its own. A new behaviour gets a corpus case.
Releases
semantic-release cuts one version from main for all three packages (.github/workflows/release.yml):
- The tag
vX.Y.Zand a GitHub release are the Go module's release.scripts/major-guard.mjsfails the release when the major doesn't fit the module path ingo.mod(no suffix allows v0 and v1,/vNonly vN). The workflow then asksproxy.golang.orgfor the new version andpkg.go.devto fetch it; both only warn. - npm: with the repository variable
SLICC_BROWSERS_NPM_PUBLISHset totrue, the package is stamped with the version and published through npm trusted publishing (OIDC, with provenance). A version that is already on npm is skipped. - crates.io: with the repository variable
SLICC_BROWSERS_CRATES_PUBLISHset totrue,Cargo.tomlis stamped with the version andcargo publishruns with a short-lived token from crates.io trusted publishing (rust-lang/crates-io-auth-action). A version that is already on crates.io is skipped. - Cert line: after the npm publish, the workflow runs
npm pack @ai-ecoverse/slicc-browsers@X.Y.Zagainst the registry and appendscert: @ai-ecoverse/slicc-browsers@X.Y.Z sha256=<hex> gitHead=<sha>to thevX.Y.ZGitHub release and the job summary, once; the cert lane copies it from there.
A run started by hand publishes what's missing without a new release: gh workflow run release.yml --ref main when main's head has its tag, or gh workflow run release.yml --ref main -f tag=vX.Y.Z for any existing tag. gh workflow run release.yml --ref main -f cert-only=true only adds the cert line for the latest npm version.
Go module versions. A pushed tag is the release: proxy.golang.org caches it and sum.golang.org records its hash for good. Never move or delete a pushed tag; retract a bad version in go.mod and release anew. A BREAKING CHANGE needs a /vN module path first.
First publish (by a maintainer with 2FA):
- npm:
npx fledgling add --new @ai-ecoverse/slicc-browsersclaims the name with a0.0.0placeholder and trustsai-ecoverse/slicc-browsers'release.yml. Then setSLICC_BROWSERS_NPM_PUBLISHtotrue. - crates.io: trusted publishing needs the crate to exist. Publish it once by hand from the latest tag:
git checkout vX.Y.Z && node scripts/set-crate-version.mjs X.Y.Z && cargo publish --allow-dirty, logged in withcargo login. Then, on crates.io, add a trusted publisher toslicc-browsers: GitHub, ownerai-ecoverse, repositoryslicc-browsers, workflowrelease.yml, no environment. Then setSLICC_BROWSERS_CRATES_PUBLISHtotrue.