@ai-ecoverse/slicc-browsers 1.20.2 → 1.21.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 +20 -16
- package/extension-frame.mjs +69 -21
- package/fixture-server.mjs +2 -0
- package/guard-runner.mjs +292 -52
- package/guard-vectors.json +3784 -2
- package/package.json +3 -3
- package/schema/guard-vectors.schema.json +249 -28
package/README.md
CHANGED
|
@@ -183,17 +183,21 @@ It also offers `storageKey(key)` and `unparseable(text)`. A Go or Rust implement
|
|
|
183
183
|
|
|
184
184
|
## Guard vectors
|
|
185
185
|
|
|
186
|
-
A guard sits between a CDP client, such as an agent's browser tool, and the browser, and keeps the client away from protected origins. `guard-vectors.json` holds more than a hundred attacks on that promise, and every guard must pass them all. They
|
|
186
|
+
A guard sits between a CDP client, such as an agent's browser tool, and the browser, and keeps the client away from protected origins. `guard-vectors.json` holds more than a hundred attacks on that promise, and every guard must pass them all. They cover navigation, redirect chains, popups, frames, request interception, cookies, storage, privileged and error pages, foreign extension worlds, debugger tricks and session overrides that would leak into a protected page.
|
|
187
187
|
|
|
188
188
|
Each vector runs twice. The **control** runs it against an ordinary origin, where it must work. The **guarded** leg runs it through the guard against a protected target, where it must be refused or have no effect. A **liveness** check, made outside the guard, proves the protected target was really there. A row passes only when all three do, so a guard can't pass by breaking everything, and a test can't pass by never reaching the target.
|
|
189
189
|
|
|
190
|
-
When a vector compares ordinary and protected fixture markers, those markers do not contain one another. Protected-marker checks still examine complete probe replies, including errors, and the vector's client events and messages, including encoded values.
|
|
191
|
-
|
|
192
190
|
A guard may refuse every client navigation to an `about:` URL that inherits an origin, such as `about:blank`. Refusing an ordinary navigation away from a protected page still fails.
|
|
193
191
|
|
|
194
|
-
|
|
192
|
+
### What a guard must let through
|
|
193
|
+
|
|
194
|
+
For stock Playwright, Puppeteer and raw CDP clients, a guard that declares `stockSetup` admits on ordinary pages:
|
|
195
|
+
- `Page.addScriptToEvaluateOnNewDocument` whose `source` matches `^(?:[ \t\n]|//[^\n\r\u2028\u2029]*)*$` on the whole string without the multiline flag (no code), with any `worldName` or `runImmediately`, and its removal;
|
|
196
|
+
- exactly `Emulation.setFocusEmulationEnabled`, `setEmulatedMedia`, `setDeviceMetricsOverride` and `setTouchEmulationEnabled`;
|
|
197
|
+
- `Page.createIsolatedWorld` on a frame the guard already classifies as ordinary: strip `grantUniveralAccess` and `contentSecurityPolicy`, return no `executionContextId` for any other frame; `Runtime.addBinding` scoped to such a world by a non-empty `executionContextName` or its `executionContextId`, with `Runtime.bindingCalled` only from those worlds;
|
|
198
|
+
- `Target.setAutoAttach`: accept the call but narrow the filter to tab targets, hold pages under the tab like pages (same URL redaction in `targetInfo`); a transport that synthesizes it declares `syntheticAutoAttach`.
|
|
195
199
|
|
|
196
|
-
|
|
200
|
+
The strip holds whether or not a given Chrome lets that world reach another origin. Code init scripts, main-world bindings and other overrides stay refused or harmless. Admitted display overrides and no-code init scripts must not remain in effect on a protected document — refuse that navigation, or clear them before it runs. Undeclared, they skip, never pass.
|
|
197
201
|
|
|
198
202
|
### Running them
|
|
199
203
|
|
|
@@ -225,20 +229,20 @@ You supply:
|
|
|
225
229
|
| capability | means the transport can |
|
|
226
230
|
| --- | --- |
|
|
227
231
|
| `autoAttach` | auto-attach to child targets such as popups and iframes |
|
|
228
|
-
| `browserSession` | send commands
|
|
232
|
+
| `browserSession` | send commands on the browser session, not only a page |
|
|
229
233
|
| `browserContexts` | create isolated browser contexts |
|
|
230
|
-
| `discovery` | report target
|
|
231
|
-
| `chromeUrlsAttach` | attach to `chrome://` pages (
|
|
232
|
-
| `extensionWorld` | host a foreign extension
|
|
233
|
-
| `extensionFrame` | host the harness extension
|
|
234
|
-
| `clientExit` | close the guarded client
|
|
235
|
-
| `siteIsolationOff`, `inboundDelay`, `forwardAudit` |
|
|
236
|
-
|
|
237
|
-
|
|
234
|
+
| `discovery` | report target create/change without a session |
|
|
235
|
+
| `chromeUrlsAttach` | attach to `chrome://` pages (DevTools-port CDP; not `chrome.debugger`) |
|
|
236
|
+
| `extensionWorld` | host a foreign extension content script on the test page |
|
|
237
|
+
| `extensionFrame` | host the harness extension frame an ordinary test page embeds |
|
|
238
|
+
| `clientExit` | close the guarded client mid-row |
|
|
239
|
+
| `siteIsolationOff`, `inboundDelay`, `forwardAudit` | no site isolation; hold inbound; audit forwarded commands |
|
|
240
|
+
| `secureOrigin` | HTTPS origins (service workers) |
|
|
241
|
+
| `stockSetup`, `syntheticAutoAttach` | admit stock clients' setup calls; synthesize auto-attach |
|
|
238
242
|
|
|
239
243
|
### Reading the result
|
|
240
244
|
|
|
241
|
-
Each row reports `control`, `guarded` and `live` as `pass`, `fail`, `skip`, `incomplete` (
|
|
245
|
+
Each row reports `control`, `guarded` and `live` as `pass`, `fail`, `skip`, `incomplete` (evidence missing) or `not-run` (words only). `summary()` makes one line:
|
|
242
246
|
|
|
243
247
|
```text
|
|
244
248
|
GUARD-PASS my-guard@1a2b3c4 (vectors @ai-ecoverse/slicc-browsers@1.18.5)
|
|
@@ -266,7 +270,7 @@ A new discovery behaviour gets a corpus case, and a new guard behaviour gets a g
|
|
|
266
270
|
|
|
267
271
|
CI is [slicc-shared-web's pipeline](https://github.com/ai-ecoverse/slicc-shared-web#pipeline), called from `.github/workflows/ci.yml`. A pull request gets lint, then `npm test` split over shards by timing, then a Claude and a Codex review; the merge queue runs lint and the tests again. Tests run on Linux only.
|
|
268
272
|
|
|
269
|
-
Every merge to `main` that warrants it is released by semantic-release
|
|
273
|
+
Every merge to `main` that warrants it is released by semantic-release as one version for all three packages. Pushed tags are never moved or deleted: a bad release is fixed by the next one.
|
|
270
274
|
|
|
271
275
|
## License
|
|
272
276
|
|
package/extension-frame.mjs
CHANGED
|
@@ -938,7 +938,48 @@ async function driveCast(client, page, frameMs) {
|
|
|
938
938
|
};
|
|
939
939
|
}
|
|
940
940
|
|
|
941
|
-
|
|
941
|
+
function classifyRevealOverlap(
|
|
942
|
+
captureStartedAt,
|
|
943
|
+
revealAt,
|
|
944
|
+
pendingWhenRevealAcknowledged,
|
|
945
|
+
captureSettledAt
|
|
946
|
+
) {
|
|
947
|
+
if (typeof captureStartedAt !== 'number' || typeof revealAt !== 'number') return 'unknown';
|
|
948
|
+
if (revealAt <= captureStartedAt) return 'before';
|
|
949
|
+
if (pendingWhenRevealAcknowledged) return 'during';
|
|
950
|
+
if (typeof captureSettledAt === 'number' && captureSettledAt < revealAt) return 'after';
|
|
951
|
+
return 'unknown';
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function revealRaceVerdict(overlap, side, ok, shownRefused) {
|
|
955
|
+
if (overlap === 'during' || overlap === 'before') return side === 'control' ? ok : shownRefused;
|
|
956
|
+
if (overlap === 'after') return ok || shownRefused;
|
|
957
|
+
return false;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
async function finishReveal(
|
|
961
|
+
client,
|
|
962
|
+
reader,
|
|
963
|
+
page,
|
|
964
|
+
observer,
|
|
965
|
+
frameMs,
|
|
966
|
+
{ ok, text, overlap, revealRacePass }
|
|
967
|
+
) {
|
|
968
|
+
const revealedPress = await click(client, reader, page, observer);
|
|
969
|
+
await style(reader, observer, 'hidden', frameMs);
|
|
970
|
+
const rehidden = await shot(client, page);
|
|
971
|
+
return {
|
|
972
|
+
revealedShot: ok,
|
|
973
|
+
revealedRefusal: text,
|
|
974
|
+
revealOverlap: overlap,
|
|
975
|
+
revealRacePass,
|
|
976
|
+
revealedClick: revealedPress.ok,
|
|
977
|
+
revealedClickRefusal: refusal(revealedPress.out),
|
|
978
|
+
rehiddenShot: rehidden.ok,
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
async function attemptRevealCapture(client, reader, page, observer, side) {
|
|
942
983
|
const captureStartedAt = await observe(reader, observer, 'performance.now()');
|
|
943
984
|
let captureSettled = false;
|
|
944
985
|
let captureSettledAt;
|
|
@@ -963,31 +1004,38 @@ export async function driveReveal(client, reader, page, observer, frameMs, missi
|
|
|
963
1004
|
const out = await pending;
|
|
964
1005
|
const ok = out.ok && typeof out.result?.data === 'string' && out.result.data.length > 0;
|
|
965
1006
|
const text = refusal(out);
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
1007
|
+
const overlap = classifyRevealOverlap(
|
|
1008
|
+
captureStartedAt,
|
|
1009
|
+
revealAt,
|
|
1010
|
+
pendingWhenRevealAcknowledged,
|
|
1011
|
+
captureSettledAt
|
|
1012
|
+
);
|
|
972
1013
|
const shownRefused = Boolean(text && (text === SHOWN || /^slicc: refused/.test(text)));
|
|
973
|
-
let revealRacePass = false;
|
|
974
|
-
if (overlap === 'during' || overlap === 'before')
|
|
975
|
-
revealRacePass = side === 'control' ? ok : shownRefused;
|
|
976
|
-
else if (overlap === 'after') revealRacePass = ok || shownRefused;
|
|
977
|
-
const revealedPress = await click(client, reader, page, observer);
|
|
978
|
-
await style(reader, observer, 'hidden', frameMs);
|
|
979
|
-
const rehidden = await shot(client, page);
|
|
980
1014
|
return {
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
revealRacePass,
|
|
985
|
-
revealedClick: revealedPress.ok,
|
|
986
|
-
revealedClickRefusal: refusal(revealedPress.out),
|
|
987
|
-
rehiddenShot: rehidden.ok,
|
|
1015
|
+
ok,
|
|
1016
|
+
text,
|
|
1017
|
+
overlap,
|
|
1018
|
+
revealRacePass: revealRaceVerdict(overlap, side, ok, shownRefused),
|
|
988
1019
|
};
|
|
989
1020
|
}
|
|
990
1021
|
|
|
1022
|
+
export async function driveReveal(client, reader, page, observer, frameMs, missing, side) {
|
|
1023
|
+
const maxAttempts = 3;
|
|
1024
|
+
let last = null;
|
|
1025
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
1026
|
+
last = await attemptRevealCapture(client, reader, page, observer, side);
|
|
1027
|
+
if (last.overlap !== 'unknown')
|
|
1028
|
+
return finishReveal(client, reader, page, observer, frameMs, last);
|
|
1029
|
+
if (attempt < maxAttempts) await style(reader, observer, 'hidden', frameMs);
|
|
1030
|
+
}
|
|
1031
|
+
missing.push(`reveal/capture overlap unproven (${maxAttempts} attempts)`);
|
|
1032
|
+
return finishReveal(client, reader, page, observer, frameMs, {
|
|
1033
|
+
...last,
|
|
1034
|
+
overlap: 'unknown',
|
|
1035
|
+
revealRacePass: false,
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
|
|
991
1039
|
function frameObservations({ pageAttached, origin, read, leaks, picture, input, captures }) {
|
|
992
1040
|
return {
|
|
993
1041
|
exists: true,
|
package/fixture-server.mjs
CHANGED
|
@@ -103,6 +103,8 @@ function route(req, url, send, html, redirects) {
|
|
|
103
103
|
},
|
|
104
104
|
'/doc': () => document(url, html),
|
|
105
105
|
'/csp': () => document(url, html),
|
|
106
|
+
'/sw.js': () =>
|
|
107
|
+
send(200, "self.addEventListener('install',()=>self.skipWaiting())", 'text/javascript'),
|
|
106
108
|
};
|
|
107
109
|
if (req.method === 'OPTIONS') return send(204, '');
|
|
108
110
|
if (url.pathname.startsWith('/auth')) {
|