scenescout 3.11.1 → 3.13.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/CHANGELOG.md +31 -0
- package/README.md +29 -3
- package/dist/check-run.js +4 -2
- package/dist/ci-run.js +366 -0
- package/dist/cli.js +69 -0
- package/dist/engine/bench.js +147 -14
- package/dist/engine/browser.js +63 -34
- package/dist/engine/calibration.js +23 -6
- package/dist/engine/check.js +89 -15
- package/dist/engine/ci.js +633 -0
- package/dist/engine/dedup.js +237 -0
- package/dist/engine/design.js +15 -1
- package/dist/engine/fingerprint.js +57 -0
- package/dist/engine/lane.js +57 -3
- package/dist/engine/memory.js +115 -8
- package/dist/engine/policy.js +42 -0
- package/dist/engine/provider.js +193 -0
- package/dist/engine/report.js +37 -2
- package/dist/engine/verify.js +4 -3
- package/dist/mcp-server.js +23 -9
- package/package.json +6 -3
- package/skills/scenescout/SKILL.md +4 -3
package/dist/engine/bench.js
CHANGED
|
@@ -31,6 +31,9 @@
|
|
|
31
31
|
import { createHash } from "node:crypto";
|
|
32
32
|
import { z } from "zod";
|
|
33
33
|
import { BUCKET_EDGES, bucketLabel, bucketOf } from "./calibration.js";
|
|
34
|
+
import { laneRoutePaths, stripRouteQuery } from "./fingerprint.js";
|
|
35
|
+
import { isWorthALook } from "./memory.js";
|
|
36
|
+
export { laneRoutePaths };
|
|
34
37
|
export const LEVELS = ["minimal", "medium", "extensive"];
|
|
35
38
|
const SEVERITIES = ["high", "medium", "low"];
|
|
36
39
|
const Pattern = z.string().refine((p) => {
|
|
@@ -42,10 +45,24 @@ const Pattern = z.string().refine((p) => {
|
|
|
42
45
|
return false;
|
|
43
46
|
}
|
|
44
47
|
}, { message: "is not a valid regular expression" });
|
|
48
|
+
/**
|
|
49
|
+
* Other pages the same defect shows on, beyond `route`. A lane that owns any
|
|
50
|
+
* of them owns the defect, so its "not a defect" is a verdict, not a remark
|
|
51
|
+
* about another lane's page.
|
|
52
|
+
*/
|
|
53
|
+
const AlsoOn = z.array(z.string().startsWith("/")).min(1).optional();
|
|
54
|
+
/**
|
|
55
|
+
* Every lane can reach the defect: it is in chrome every page carries (a
|
|
56
|
+
* shared nav, header or footer), or in an endpoint any lane can call. No lane
|
|
57
|
+
* can call it another lane's, so a verdict on it is always scored.
|
|
58
|
+
*/
|
|
59
|
+
const EveryPage = z.boolean().optional();
|
|
45
60
|
const Entry = z
|
|
46
61
|
.object({
|
|
47
62
|
id: z.string().min(1),
|
|
48
63
|
route: z.string().startsWith("/"),
|
|
64
|
+
alsoOn: AlsoOn,
|
|
65
|
+
everyPage: EveryPage,
|
|
49
66
|
title: z.string().min(1),
|
|
50
67
|
/** The category a finding for it should carry. Reported, not enforced: two categories can both be defensible. */
|
|
51
68
|
category: z.string().min(1),
|
|
@@ -83,6 +100,8 @@ const Contextual = z
|
|
|
83
100
|
.object({
|
|
84
101
|
id: z.string().min(1),
|
|
85
102
|
route: z.string().startsWith("/"),
|
|
103
|
+
alsoOn: AlsoOn,
|
|
104
|
+
everyPage: EveryPage,
|
|
86
105
|
title: z.string().min(1),
|
|
87
106
|
category: z.string().min(1),
|
|
88
107
|
/** The convention that decides it: where it would be a defect, and why the run cannot tell whether it holds here. */
|
|
@@ -130,6 +149,14 @@ export function parseKey(raw) {
|
|
|
130
149
|
const dup = ids.find((id, i) => ids.indexOf(id) !== i);
|
|
131
150
|
if (dup)
|
|
132
151
|
throw new Error(`The answer key uses the id ${JSON.stringify(dup)} twice.`);
|
|
152
|
+
for (const e of [...parsed.data.defects, ...parsed.data.alsoReal, ...parsed.data.contextual]) {
|
|
153
|
+
if (e.alsoOn && e.everyPage)
|
|
154
|
+
throw new Error(`The entry ${JSON.stringify(e.id)} has both alsoOn and everyPage; an entry on every page names no other pages.`);
|
|
155
|
+
const pages = [e.route, ...(e.alsoOn ?? [])];
|
|
156
|
+
const dup = pages.find((p, i) => pages.indexOf(p) !== i);
|
|
157
|
+
if (dup)
|
|
158
|
+
throw new Error(`The entry ${JSON.stringify(e.id)} names the page ${JSON.stringify(dup)} twice in route and alsoOn.`);
|
|
159
|
+
}
|
|
133
160
|
const real = new Set([...parsed.data.defects, ...parsed.data.alsoReal].map((e) => e.id));
|
|
134
161
|
for (const nd of parsed.data.nonDefects) {
|
|
135
162
|
const unknown = nd.overrides.find((id) => !real.has(id));
|
|
@@ -241,14 +268,14 @@ export function lintKey(key) {
|
|
|
241
268
|
*
|
|
242
269
|
* "defect" on a planted or also-real defect is right, and on a known
|
|
243
270
|
* non-defect is wrong. "not_a_defect" is the reverse. An "unsure" verdict, a
|
|
244
|
-
* dismissal as another lane's, a decision the key does not
|
|
245
|
-
* names ambiguously, and one about a contextual entry are not scored — the same rule the in-product calibration
|
|
271
|
+
* "worth_a_look", a dismissal as another lane's, a decision the key does not
|
|
272
|
+
* name, one it names ambiguously, and one about a contextual entry are not scored — the same rule the in-product calibration
|
|
246
273
|
* follows, for the same reason.
|
|
247
274
|
*/
|
|
248
|
-
export function judgeDecision(d, key) {
|
|
249
|
-
if (d.verdict === "unsure")
|
|
275
|
+
export function judgeDecision(d, key, laneRoutes) {
|
|
276
|
+
if (d.verdict === "unsure" || d.verdict === "worth_a_look")
|
|
250
277
|
return null;
|
|
251
|
-
if (
|
|
278
|
+
if (isOwnershipRemark(d, key, laneRoutes))
|
|
252
279
|
return null;
|
|
253
280
|
const m = classify(decisionText(d), key);
|
|
254
281
|
if (!m || m.kind === "ambiguous" || m.kind === "contextual")
|
|
@@ -258,13 +285,79 @@ export function judgeDecision(d, key) {
|
|
|
258
285
|
}
|
|
259
286
|
/** How a lane says "not mine": the wording lanes actually used, none of it about a particular app. */
|
|
260
287
|
const SCOPE_DISMISSAL_RE = /out.of.lane.scope|out-of-scope|out of (my|this) (lane|scope)|belongs?.to.{0,20}\b(lane|route|page)\b|belongs-to-|(handled|owned) by (the |another )?[\w-]* ?lane|lane owns it|not (in |on |from |part of )?(my|this) (lane|assigned routes|routes?|pages?)\b|outside (my|this) (lane|routes?|pages?)|not part of my (assigned )?routes/i;
|
|
261
|
-
/**
|
|
288
|
+
/**
|
|
289
|
+
* A not-a-defect verdict whose stated reason is that the thing is another
|
|
290
|
+
* lane's. The WORDING rule: all a run archived before lane routes were kept
|
|
291
|
+
* can be judged by, and still what decides for a lane whose routes are not
|
|
292
|
+
* known. It cannot tell a lane's own page from another's, which is why it is
|
|
293
|
+
* not widened (see `isOwnershipRemark`).
|
|
294
|
+
*/
|
|
262
295
|
export function isScopeDismissal(d) {
|
|
263
296
|
return d.verdict === "not_a_defect" && SCOPE_DISMISSAL_RE.test(decisionText(d));
|
|
264
297
|
}
|
|
265
|
-
|
|
298
|
+
/** The pages a key entry is on, in the same form as `laneRoutePaths`. */
|
|
299
|
+
function entryPaths(e) {
|
|
300
|
+
return [e.route, ...(e.alsoOn ?? [])].flatMap(laneRoutePaths);
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* A lane's routes as a set of paths, or undefined when they are not known
|
|
304
|
+
* well enough to decide by: none were recorded, or ANY of them names no path
|
|
305
|
+
* ("the orders area"). A route that could not be read may be the very page a
|
|
306
|
+
* verdict is about, and deciding without it would set that verdict aside.
|
|
307
|
+
*/
|
|
308
|
+
function lanePaths(lane, laneRoutes) {
|
|
309
|
+
const routes = laneRoutes && Object.hasOwn(laneRoutes, lane) ? laneRoutes[lane] : undefined;
|
|
310
|
+
if (!routes || routes.length === 0)
|
|
311
|
+
return undefined;
|
|
312
|
+
const paths = new Set();
|
|
313
|
+
for (const r of routes) {
|
|
314
|
+
const named = laneRoutePaths(r);
|
|
315
|
+
if (named.length === 0)
|
|
316
|
+
return undefined;
|
|
317
|
+
for (const p of named)
|
|
318
|
+
paths.add(p);
|
|
319
|
+
}
|
|
320
|
+
return paths;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* A not-a-defect that is a remark about who owns the thing, not a verdict on
|
|
324
|
+
* whether it is broken.
|
|
325
|
+
*
|
|
326
|
+
* Where the lane's routes are known, decided by WHERE the thing is: the
|
|
327
|
+
* verdict matches a planted or also-real defect whose pages are all outside
|
|
328
|
+
* the lane's routes. The wording no longer matters either way — "raised on /
|
|
329
|
+
* before navigating" from the orders lane is a remark about the dashboard's
|
|
330
|
+
* defect, and "handled by another lane" from the lane that owns the page is
|
|
331
|
+
* its own wrong verdict. A defect in chrome every page carries (`everyPage`)
|
|
332
|
+
* is every lane's, so it is never another lane's. A verdict that matches a
|
|
333
|
+
* known non-defect, a contextual entry, several entries or none is not about
|
|
334
|
+
* a defect with a page to own, and is judged as it would be otherwise.
|
|
335
|
+
*
|
|
336
|
+
* Where they are not known — every archive made before routes were kept, and
|
|
337
|
+
* any lane that reported no route — the wording rule decides, unchanged, so
|
|
338
|
+
* those runs score as they always have.
|
|
339
|
+
*/
|
|
340
|
+
export function ownershipRemark(d, key, laneRoutes) {
|
|
341
|
+
if (d.verdict !== "not_a_defect")
|
|
342
|
+
return null;
|
|
343
|
+
const own = lanePaths(d.lane, laneRoutes);
|
|
344
|
+
if (!own)
|
|
345
|
+
return isScopeDismissal(d) ? { by: "wording" } : null;
|
|
346
|
+
const m = classify(decisionText(d), key);
|
|
347
|
+
if (!m || (m.kind !== "defect" && m.kind !== "alsoReal"))
|
|
348
|
+
return null;
|
|
349
|
+
if (m.entry.everyPage)
|
|
350
|
+
return null;
|
|
351
|
+
return entryPaths(m.entry).some((p) => own.has(p)) ? null : { by: "routes", id: m.entry.id };
|
|
352
|
+
}
|
|
353
|
+
export function isOwnershipRemark(d, key, laneRoutes) {
|
|
354
|
+
return ownershipRemark(d, key, laneRoutes) !== null;
|
|
355
|
+
}
|
|
356
|
+
export function calibrateAgainstKey(decisions, key, laneRoutes) {
|
|
266
357
|
if (decisions.length === 0)
|
|
267
358
|
return null;
|
|
359
|
+
const lanes = [...new Set(decisions.map((d) => d.lane))];
|
|
360
|
+
const known = lanes.filter((l) => lanePaths(l, laneRoutes) !== undefined).length;
|
|
268
361
|
const buckets = BUCKET_EDGES.map(() => ({ n: 0, conf: 0, right: 0 }));
|
|
269
362
|
const out = {
|
|
270
363
|
judged: 0,
|
|
@@ -274,7 +367,10 @@ export function calibrateAgainstKey(decisions, key) {
|
|
|
274
367
|
badConfidence: 0,
|
|
275
368
|
unsure: 0,
|
|
276
369
|
outOfScope: 0,
|
|
370
|
+
ownership: known === 0 ? "wording" : known === lanes.length ? "routes" : "mixed",
|
|
371
|
+
setAsideByRoute: [],
|
|
277
372
|
contextual: 0,
|
|
373
|
+
worthALook: 0,
|
|
278
374
|
buckets: [],
|
|
279
375
|
ece: 0,
|
|
280
376
|
brier: 0,
|
|
@@ -284,8 +380,15 @@ export function calibrateAgainstKey(decisions, key) {
|
|
|
284
380
|
out.unsure += 1;
|
|
285
381
|
continue;
|
|
286
382
|
}
|
|
287
|
-
if (
|
|
383
|
+
if (d.verdict === "worth_a_look") {
|
|
384
|
+
out.worthALook += 1;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
const remark = ownershipRemark(d, key, laneRoutes);
|
|
388
|
+
if (remark) {
|
|
288
389
|
out.outOfScope += 1;
|
|
390
|
+
if (remark.by === "routes")
|
|
391
|
+
out.setAsideByRoute.push({ lane: d.lane, id: remark.id, confidence: d.confidence });
|
|
289
392
|
continue;
|
|
290
393
|
}
|
|
291
394
|
const c = d.confidence;
|
|
@@ -334,14 +437,19 @@ export function calibrateAgainstKey(decisions, key) {
|
|
|
334
437
|
* across runs, and scoring an accumulated one credits a run with what an
|
|
335
438
|
* earlier run found. Point the scorer at a fresh project directory per run.
|
|
336
439
|
*/
|
|
337
|
-
export function score(key, findings, decisions, level = "medium") {
|
|
440
|
+
export function score(key, findings, decisions, level = "medium", laneRoutes) {
|
|
338
441
|
const hitsByDefect = new Map();
|
|
339
442
|
const falsePositives = [];
|
|
340
443
|
const unknown = [];
|
|
341
444
|
const ambiguous = [];
|
|
342
445
|
const contextual = [];
|
|
446
|
+
const worthALook = [];
|
|
343
447
|
let correct = 0;
|
|
344
448
|
for (const f of findings) {
|
|
449
|
+
if (isWorthALook(f)) {
|
|
450
|
+
worthALook.push({ title: f.title, convention: f.convention ?? "" });
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
345
453
|
const m = classify(findingText(f), key);
|
|
346
454
|
if (!m) {
|
|
347
455
|
unknown.push({ title: f.title, severity: f.severity, evidence: f.evidence ?? "" });
|
|
@@ -410,11 +518,12 @@ export function score(key, findings, decisions, level = "medium") {
|
|
|
410
518
|
correct,
|
|
411
519
|
falsePositives,
|
|
412
520
|
contextual,
|
|
521
|
+
worthALook,
|
|
413
522
|
unknown,
|
|
414
523
|
ambiguous,
|
|
415
524
|
duplicates,
|
|
416
525
|
severity,
|
|
417
|
-
calibration: calibrateAgainstKey(decisions, key),
|
|
526
|
+
calibration: calibrateAgainstKey(decisions, key, laneRoutes),
|
|
418
527
|
};
|
|
419
528
|
}
|
|
420
529
|
const pct = (n, d) => (d === 0 ? "—" : `${Math.round((n / d) * 100)}%`);
|
|
@@ -424,13 +533,18 @@ const pct = (n, d) => (d === 0 ? "—" : `${Math.round((n / d) * 100)}%`);
|
|
|
424
533
|
* false claims nobody has judged yet. The bounds can: the lower one counts
|
|
425
534
|
* every open finding as wrong, the upper one as right. Findings set aside as
|
|
426
535
|
* contextual are in neither: they are not claims the run could get right.
|
|
536
|
+
* Nor are findings the run filed as worth a look: it did not claim them.
|
|
427
537
|
*/
|
|
428
538
|
export function precisionBounds(c) {
|
|
429
539
|
const labelled = c.correct + c.falsePositives.length;
|
|
430
540
|
const open = c.unknown.length + c.ambiguous.length;
|
|
431
|
-
const scored = c.findings - c
|
|
541
|
+
const scored = c.findings - setAside(c);
|
|
432
542
|
return { labelled: `${c.correct}/${labelled} (${pct(c.correct, labelled)})`, low: pct(c.correct, scored), high: pct(c.correct + open, scored) };
|
|
433
543
|
}
|
|
544
|
+
/** Findings in neither precision count: contextual ones, and the run's own worth-a-looks. */
|
|
545
|
+
function setAside(c) {
|
|
546
|
+
return c.contextual.length + (c.worthALook?.length ?? 0);
|
|
547
|
+
}
|
|
434
548
|
/** The scorecard as a person reads it. Leads with the two numbers, then says what each is made of. */
|
|
435
549
|
export function formatScorecard(c) {
|
|
436
550
|
const p = precisionBounds(c);
|
|
@@ -441,11 +555,13 @@ export function formatScorecard(c) {
|
|
|
441
555
|
`Recall ${c.found.length}/${c.expected} (${pct(c.found.length, c.expected)}) of the planted defects expected at this level`,
|
|
442
556
|
`Precision ${p.labelled} of the findings the key can label` +
|
|
443
557
|
(open
|
|
444
|
-
? ` — ${open} of ${c.findings - c
|
|
558
|
+
? ` — ${open} of ${c.findings - setAside(c)} unlabelled, so between ${p.low} and ${p.high} of ${setAside(c) ? "the findings not set aside" : "all findings"}`
|
|
445
559
|
: ""),
|
|
446
560
|
];
|
|
447
561
|
if (c.contextual.length)
|
|
448
562
|
lines.push(`Set aside ${c.contextual.length} of ${c.findings} finding(s): defects only under a convention the run cannot see, so in neither count`);
|
|
563
|
+
if (c.worthALook.length)
|
|
564
|
+
lines.push(`Set aside ${c.worthALook.length} of ${c.findings} finding(s) the run filed as worth a look: not claimed as defects, so in neither recall nor precision`);
|
|
449
565
|
lines.push(``);
|
|
450
566
|
if (c.missed.length)
|
|
451
567
|
lines.push(`Missed: ${c.missed.join(", ")}`);
|
|
@@ -463,6 +579,11 @@ export function formatScorecard(c) {
|
|
|
463
579
|
for (const x of c.contextual)
|
|
464
580
|
lines.push(` ${x.title} (${x.id})`);
|
|
465
581
|
}
|
|
582
|
+
if (c.worthALook.length) {
|
|
583
|
+
lines.push(``, `Filed as worth a look (${c.worthALook.length}):`);
|
|
584
|
+
for (const x of c.worthALook)
|
|
585
|
+
lines.push(` ${x.title}${x.convention ? ` — a defect only if the project uses ${x.convention}` : ""}`);
|
|
586
|
+
}
|
|
466
587
|
if (c.ambiguous.length) {
|
|
467
588
|
lines.push(``, `Ambiguous (${c.ambiguous.length}) — the key claims each twice; sharpen it:`);
|
|
468
589
|
for (const a of c.ambiguous)
|
|
@@ -490,8 +611,10 @@ export function formatScorecard(c) {
|
|
|
490
611
|
k.ambiguous && `${k.ambiguous} the key names ambiguously`,
|
|
491
612
|
k.badConfidence && `${k.badConfidence} with an unusable confidence`,
|
|
492
613
|
k.unsure && `${k.unsure} unsure`,
|
|
493
|
-
k.outOfScope &&
|
|
614
|
+
k.outOfScope &&
|
|
615
|
+
`${k.outOfScope} dismissed as another lane's (${k.ownership === "routes" ? "by the lanes' routes" : k.ownership === "wording" ? "by wording: no lane routes archived" : "by the lanes' routes where archived, else by wording"})`,
|
|
494
616
|
k.contextual && `${k.contextual} about things that are defects only under a convention the run cannot see`,
|
|
617
|
+
k.worthALook && `${k.worthALook} the lane marked worth a look`,
|
|
495
618
|
].filter(Boolean);
|
|
496
619
|
lines.push(``, k.judged === 0
|
|
497
620
|
? `Lane calibration against the key: nothing the key could judge` + (skipped.length ? ` (${skipped.join(", ")})` : "")
|
|
@@ -499,6 +622,11 @@ export function formatScorecard(c) {
|
|
|
499
622
|
(skipped.length ? ` — not scored: ${skipped.join(", ")}` : ""));
|
|
500
623
|
for (const b of k.buckets)
|
|
501
624
|
lines.push(` stated ${b.label}: ${b.decisions} decision(s), said ${b.stated.toFixed(2)}, right ${Math.round(b.correct * 100)}%`);
|
|
625
|
+
if (k.setAsideByRoute.length) {
|
|
626
|
+
lines.push(` Set aside as another lane's, by the lanes' routes (${k.setAsideByRoute.length}):`);
|
|
627
|
+
for (const r of k.setAsideByRoute)
|
|
628
|
+
lines.push(` ${r.lane} on ${r.id}, stated ${Number.isFinite(r.confidence) ? r.confidence.toFixed(2) : "?"}`);
|
|
629
|
+
}
|
|
502
630
|
}
|
|
503
631
|
return lines.join("\n");
|
|
504
632
|
}
|
|
@@ -543,17 +671,22 @@ const LOCAL_PATH_RE = /(?:\/Users\/[^/\s"']+|\/home\/[^/\s"']+|\/private\/tmp|\/
|
|
|
543
671
|
export function sanitize(text) {
|
|
544
672
|
return text.replace(LOCAL_PATH_RE, "<path>");
|
|
545
673
|
}
|
|
546
|
-
export function toArchive(run, date, note, findings, decisions, app) {
|
|
674
|
+
export function toArchive(run, date, note, findings, decisions, app, laneRoutes = {}) {
|
|
675
|
+
// No query string or fragment reaches an archive: a lane copies routes from
|
|
676
|
+
// the address bar, and an address can carry a token.
|
|
677
|
+
const routes = Object.fromEntries(Object.entries(laneRoutes).map(([lane, list]) => [lane, list.map((r) => sanitize(stripRouteQuery(r)))]));
|
|
547
678
|
return {
|
|
548
679
|
app,
|
|
549
680
|
run,
|
|
550
681
|
date,
|
|
551
682
|
note,
|
|
683
|
+
...(Object.keys(routes).length ? { laneRoutes: routes } : {}),
|
|
552
684
|
findings: findings.map((f) => ({
|
|
553
685
|
title: sanitize(f.title),
|
|
554
686
|
severity: f.severity,
|
|
555
687
|
...(f.category ? { category: f.category } : {}),
|
|
556
688
|
...(f.evidence ? { evidence: sanitize(f.evidence) } : {}),
|
|
689
|
+
...(isWorthALook(f) ? { tier: "worth_a_look", ...(f.convention ? { convention: sanitize(f.convention) } : {}) } : {}),
|
|
557
690
|
})),
|
|
558
691
|
decisions: decisions.map((d) => ({ ...d, observation: sanitize(d.observation), evidence: d.evidence === null ? null : sanitize(d.evidence) })),
|
|
559
692
|
};
|
package/dist/engine/browser.js
CHANGED
|
@@ -22,7 +22,7 @@ import { explainLaunchFailure, isMissingBrowser } from "./launch.js";
|
|
|
22
22
|
import { ACTION_TIMEOUT_MS, performScroll, probeFocusIndicators, probeOverlays, scrollContainer } from "./probes.js";
|
|
23
23
|
import { BROWSER_MARKER, reapOrphanBrowsers } from "./reaper.js";
|
|
24
24
|
import { planUploadOptions, resolveDiskUpload } from "./uploads.js";
|
|
25
|
-
import { answersWithRefusal, destructiveRefusal, isDestructive, isDestructiveWire, allowsWrite, policyRefusal, foreignFrameOrigin, foreignWrite, withForeignFrameSandbox, offAppPageWrite, embedOfRequest, hostileForEmbed, trustedEmbedOrigins, MAX_TRUSTED_EMBEDS, trustsForeignWrite, embedProbeRefusal, EmbedMoveTracker, sandboxedRedirectPage, allowsForeignWriteOnSignIn, isAuthExempt, } from "./policy.js";
|
|
25
|
+
import { answersWithRefusal, destructiveRefusal, isDestructive, isDestructiveWire, allowsWrite, policyRefusal, foreignFrameOrigin, foreignWrite, withForeignFrameSandbox, offAppPageWrite, embedOfRequest, hostileForEmbed, trustedEmbedOrigins, MAX_TRUSTED_EMBEDS, trustsForeignWrite, embedProbeRefusal, EmbedMoveTracker, sandboxedRedirectPage, allowsForeignWriteOnSignIn, isAuthExempt, WriteRule, } from "./policy.js";
|
|
26
26
|
import { scanProject } from "../scan.js";
|
|
27
27
|
import { analyzeDesign, DESIGN_COLLECT_SCRIPT } from "./design.js";
|
|
28
28
|
import { acceptMatches, generatedUpload } from "./fixtures.js";
|
|
@@ -201,7 +201,13 @@ export class BrowserEngine {
|
|
|
201
201
|
/** Set when the project's real path could not be resolved — named in fence refusals, which it may then cause. */
|
|
202
202
|
projectDirNote = "";
|
|
203
203
|
memory = null;
|
|
204
|
-
|
|
204
|
+
currentMode = "read-only";
|
|
205
|
+
/** The rule the write policy judges by: `mode`, and for a moment after a flow hands back, the flow's stricter one. */
|
|
206
|
+
writeRule = new WriteRule("read-only");
|
|
207
|
+
/** The session's write mode. Read-only from outside: it changes only through `setMode`, which keeps `writeRule` in step. */
|
|
208
|
+
get mode() {
|
|
209
|
+
return this.currentMode;
|
|
210
|
+
}
|
|
205
211
|
/** Origins named as trusted embeds (policy.ts trustsEmbedWrite decides when that counts). */
|
|
206
212
|
trustedEmbeds = new Set();
|
|
207
213
|
trustNotice = "";
|
|
@@ -665,7 +671,8 @@ export class BrowserEngine {
|
|
|
665
671
|
if (opts.storageStatePath && !fs.existsSync(opts.storageStatePath)) {
|
|
666
672
|
throw new Error(`storageStatePath does not exist: ${opts.storageStatePath}`);
|
|
667
673
|
}
|
|
668
|
-
this.
|
|
674
|
+
this.currentMode = opts.mode ?? "read-only";
|
|
675
|
+
this.writeRule = new WriteRule(this.currentMode);
|
|
669
676
|
const trust = trustedEmbedOrigins(opts.trustedEmbeds);
|
|
670
677
|
this.trustedEmbeds = new Set(trust.origins);
|
|
671
678
|
this.trustNotice =
|
|
@@ -815,16 +822,17 @@ export class BrowserEngine {
|
|
|
815
822
|
// that was never submitted reading as tested, in read-only mode where by
|
|
816
823
|
// definition nothing is.
|
|
817
824
|
// Same test the route handler uses: in observe only an exempt auth request goes out.
|
|
818
|
-
|
|
825
|
+
const rule = this.writeRule.at();
|
|
826
|
+
if (rule === "observe" && !isAuthExempt(rule, method, pathnameOf(req.url()), isDestructiveWire(pathnameOf(req.url()), req.postData())))
|
|
819
827
|
return;
|
|
820
|
-
if (
|
|
828
|
+
if ((rule === "read-only" || rule === "observe") && isDestructiveWire(pathnameOf(req.url()), req.postData()))
|
|
821
829
|
return;
|
|
822
830
|
// A foreign frame's write is refused in every mode the route handler runs in.
|
|
823
|
-
if (
|
|
831
|
+
if (rule !== "destructive" && this.foreignWriteOf(req))
|
|
824
832
|
return;
|
|
825
|
-
if (
|
|
833
|
+
if (rule !== "destructive" &&
|
|
826
834
|
offAppPageWrite(this.baseUrl, this.page?.url(), req.url(), this.embedMoves.movedTo) &&
|
|
827
|
-
!isAuthExempt(
|
|
835
|
+
!isAuthExempt(rule, method, pathnameOf(req.url()), isDestructiveWire(pathnameOf(req.url()), req.postData())))
|
|
828
836
|
return;
|
|
829
837
|
const pageUrl = this.page?.url();
|
|
830
838
|
if (pageUrl && this.memory) {
|
|
@@ -841,6 +849,8 @@ export class BrowserEngine {
|
|
|
841
849
|
await this.context.route("**/*", async (route) => {
|
|
842
850
|
const req = route.request();
|
|
843
851
|
const method = req.method();
|
|
852
|
+
// Read once, as the request arrives: the rule the page sent it under (policy.ts WriteRule), not whatever holds after an await below.
|
|
853
|
+
const rule = this.writeRule.at();
|
|
844
854
|
// A redirect is followed by the browser without asking, so its target
|
|
845
855
|
// would load unsandboxed: a frame's redirect is answered with a
|
|
846
856
|
// sandboxed page that navigates there itself, and the next hop comes
|
|
@@ -932,7 +942,7 @@ export class BrowserEngine {
|
|
|
932
942
|
// of one actually runs; a navigation is dropped (policy.ts says why).
|
|
933
943
|
// Caught: a request the page has already cancelled rejects these, and an unhandled rejection ends the process.
|
|
934
944
|
if (answered)
|
|
935
|
-
return route.fulfill(policyRefusal(
|
|
945
|
+
return route.fulfill(policyRefusal(rule, method, pathname, req.headers()["origin"], why)).catch(() => { });
|
|
936
946
|
return route.abort("blockedbyclient").catch(() => { });
|
|
937
947
|
};
|
|
938
948
|
// An embedded widget from another site writes to that site, not to the
|
|
@@ -944,12 +954,12 @@ export class BrowserEngine {
|
|
|
944
954
|
const destructiveWire = isDestructiveWire(pathname, req.postData());
|
|
945
955
|
// An embed moved the session's page off the app: its writes out are not the app's, a sign-in excepted.
|
|
946
956
|
const offApp = offAppPageWrite(this.baseUrl, this.page?.url(), url, this.embedMoves.movedTo);
|
|
947
|
-
if (offApp && !isAuthExempt(
|
|
957
|
+
if (offApp && !isAuthExempt(rule, method, pathname, destructiveWire))
|
|
948
958
|
return refuse(`sent from a page of ${offApp}, outside the app`);
|
|
949
959
|
// Auth/session flows must work in every mode — but never a destructive
|
|
950
960
|
// one, and in observe only the requests a login itself needs.
|
|
951
|
-
if (isAuthExempt(
|
|
952
|
-
this.routedWrites.note(
|
|
961
|
+
if (isAuthExempt(rule, method, pathname, destructiveWire)) {
|
|
962
|
+
this.routedWrites.note(rule, method, url, bodyDigest(req.postDataBuffer()));
|
|
953
963
|
return route.continue();
|
|
954
964
|
}
|
|
955
965
|
let owned = this.isOwnedResource(pathname);
|
|
@@ -962,13 +972,13 @@ export class BrowserEngine {
|
|
|
962
972
|
// guaranteed visible — give it a bounded moment to land before the
|
|
963
973
|
// mutation is judged not-owned, otherwise the session's own,
|
|
964
974
|
// just-created resource gets wrongly blocked by a timing accident.
|
|
965
|
-
if (!owned &&
|
|
975
|
+
if (!owned && rule === "safe-write" && method !== "POST" && this.pendingCreations.size > 0) {
|
|
966
976
|
await BrowserEngine.settleWithin(Promise.allSettled([...this.pendingCreations]), 1500);
|
|
967
977
|
owned = this.isOwnedResource(pathname);
|
|
968
978
|
}
|
|
969
979
|
// POST: creation/RPC passes unless it smells destructive and isn't ours.
|
|
970
980
|
// PUT/PATCH/DELETE: only in safe-write, only on our own resources.
|
|
971
|
-
const allow = allowsWrite(
|
|
981
|
+
const allow = allowsWrite(rule, method, destructiveWire, owned);
|
|
972
982
|
if (allow) {
|
|
973
983
|
// Ownership tracking (safe-write): register the creation-tracking
|
|
974
984
|
// task BEFORE the POST goes out. Registering from a context
|
|
@@ -977,7 +987,7 @@ export class BrowserEngine {
|
|
|
977
987
|
// chained directly off the POST's json() could be judged before
|
|
978
988
|
// the listener ever ran. Here the registration is synchronous with
|
|
979
989
|
// request dispatch, which closes that window completely.
|
|
980
|
-
if (
|
|
990
|
+
if (rule === "safe-write" && method === "POST" && !BENIGN_MUTATION_RE.test(url)) {
|
|
981
991
|
const task = req
|
|
982
992
|
.response()
|
|
983
993
|
.then((res) => (res && res.ok() ? this.recordCreation(res) : undefined))
|
|
@@ -985,7 +995,7 @@ export class BrowserEngine {
|
|
|
985
995
|
.finally(() => this.pendingCreations.delete(task));
|
|
986
996
|
this.pendingCreations.add(task);
|
|
987
997
|
}
|
|
988
|
-
this.routedWrites.note(
|
|
998
|
+
this.routedWrites.note(rule, method, url, bodyDigest(req.postDataBuffer()));
|
|
989
999
|
return route.continue();
|
|
990
1000
|
}
|
|
991
1001
|
return refuse();
|
|
@@ -1806,7 +1816,7 @@ export class BrowserEngine {
|
|
|
1806
1816
|
}
|
|
1807
1817
|
if (frame === frame.page().mainFrame())
|
|
1808
1818
|
return null;
|
|
1809
|
-
if (allowsForeignWriteOnSignIn(this.
|
|
1819
|
+
if (allowsForeignWriteOnSignIn(this.writeRule.at(), this.page?.url() ?? "", this.baseUrl))
|
|
1810
1820
|
return null;
|
|
1811
1821
|
let url;
|
|
1812
1822
|
try {
|
|
@@ -1839,10 +1849,10 @@ export class BrowserEngine {
|
|
|
1839
1849
|
const source = requestSource(req);
|
|
1840
1850
|
const originHeader = req.headers()["origin"];
|
|
1841
1851
|
const foreign = foreignWrite(this.baseUrl, { url: req.url(), originHeader, unadoptedPageUrl, pageHasForeignFrame, ...source });
|
|
1842
|
-
if (foreign && allowsForeignWriteOnSignIn(this.
|
|
1852
|
+
if (foreign && allowsForeignWriteOnSignIn(this.writeRule.at(), this.page?.url() ?? "", this.baseUrl))
|
|
1843
1853
|
return null;
|
|
1844
1854
|
// Frames of origins the user named as trusted, in safe-write, every one involved: the ordinary rules.
|
|
1845
|
-
if (foreign && trustsForeignWrite(this.
|
|
1855
|
+
if (foreign && trustsForeignWrite(this.writeRule.at(), this.trustedEmbeds, this.baseUrl, { frameChain: source.frameChain, originHeader, unadoptedPageUrl }))
|
|
1846
1856
|
return null;
|
|
1847
1857
|
return foreign;
|
|
1848
1858
|
}
|
|
@@ -1870,11 +1880,13 @@ export class BrowserEngine {
|
|
|
1870
1880
|
});
|
|
1871
1881
|
};
|
|
1872
1882
|
try {
|
|
1883
|
+
// A request paused here was sent a moment ago, possibly by a page a flow has just left: WriteRule still holds the flow's rule.
|
|
1884
|
+
const rule = this.writeRule.at();
|
|
1873
1885
|
// Reads, and everything in destructive, go on. A later hop of a redirect is judged like any write: the route handler never sees one.
|
|
1874
|
-
if (isReadMethod(method) ||
|
|
1886
|
+
if (isReadMethod(method) || rule === "destructive")
|
|
1875
1887
|
return await answer(true);
|
|
1876
1888
|
const bytes = pausedRequestBytes(event.request);
|
|
1877
|
-
if (this.routedWrites.claim(
|
|
1889
|
+
if (this.routedWrites.claim(rule, method, url, bodyDigest(bytes)))
|
|
1878
1890
|
return await answer(true);
|
|
1879
1891
|
let verdict;
|
|
1880
1892
|
let pathname = url;
|
|
@@ -1882,7 +1894,7 @@ export class BrowserEngine {
|
|
|
1882
1894
|
pathname = pathnameOf(url);
|
|
1883
1895
|
const { foreign, offApp } = unseenWriteSource({
|
|
1884
1896
|
appUrl: this.baseUrl,
|
|
1885
|
-
mode:
|
|
1897
|
+
mode: rule,
|
|
1886
1898
|
url,
|
|
1887
1899
|
originHeader: headerOf(headers, "origin"),
|
|
1888
1900
|
referer: headerOf(headers, "referer"),
|
|
@@ -1891,7 +1903,7 @@ export class BrowserEngine {
|
|
|
1891
1903
|
movedByEmbed: this.embedMoves.movedTo,
|
|
1892
1904
|
});
|
|
1893
1905
|
verdict = judgeUnseenWrite({
|
|
1894
|
-
mode:
|
|
1906
|
+
mode: rule,
|
|
1895
1907
|
method,
|
|
1896
1908
|
pathname,
|
|
1897
1909
|
destructiveWire: isDestructiveWire(pathname, bytes?.toString("utf8")),
|
|
@@ -1976,7 +1988,9 @@ export class BrowserEngine {
|
|
|
1976
1988
|
const escaped = reasons.delete(ESCAPE_REFUSAL);
|
|
1977
1989
|
const foreign = [...reasons];
|
|
1978
1990
|
this.blockedRequests = [];
|
|
1979
|
-
|
|
1991
|
+
// The rule that judged them, which just after a flow hands back is still the flow's (WriteRule).
|
|
1992
|
+
const rule = this.writeRule.at();
|
|
1993
|
+
return (`\n🛡 WRITE-POLICY blocked (${rule}): ${list}${extra}. ` +
|
|
1980
1994
|
`This is the tester's safety policy, NOT an app bug — do not file a finding for the resulting error UI. ` +
|
|
1981
1995
|
(foreign.length > 0
|
|
1982
1996
|
? `Refused because it was ${foreign.join("; ")}: it would reach a site embedded in the page rather than the app, which no mode but destructive allows. `
|
|
@@ -1987,9 +2001,9 @@ export class BrowserEngine {
|
|
|
1987
2001
|
(answered
|
|
1988
2002
|
? `The page's own requests were answered with a 403 in the server's place, so the page's handling of a refusal is real: an error message is correct, and a success message is a false_success violation. `
|
|
1989
2003
|
: "") +
|
|
1990
|
-
(
|
|
2004
|
+
(rule === "observe"
|
|
1991
2005
|
? `observe mode blocks every request that is not a GET, so no form submission reaches the server. Re-attach with mode="read-only" ONLY if the user confirms that ordinary form submissions are acceptable on this target.`
|
|
1992
|
-
:
|
|
2006
|
+
: rule === "read-only"
|
|
1993
2007
|
? `Re-attach with mode="safe-write" to test create/edit flows, or "destructive" (user-approved disposable env only).`
|
|
1994
2008
|
: `In safe-write, updates/deletes are only allowed on resources this session created (${this.createdResources.length} so far).`));
|
|
1995
2009
|
}
|
|
@@ -3318,9 +3332,9 @@ export class BrowserEngine {
|
|
|
3318
3332
|
*/
|
|
3319
3333
|
async replayFlow(steps, mode = this.mode === "observe" ? "observe" : "read-only") {
|
|
3320
3334
|
const page = this.requirePage();
|
|
3321
|
-
// The write policy
|
|
3335
|
+
// The write policy judges by writeRule, so the flow's rule holds from here until the flow has handed back (finally).
|
|
3322
3336
|
const crawlMode = this.mode;
|
|
3323
|
-
this.mode
|
|
3337
|
+
this.setMode(mode);
|
|
3324
3338
|
const context = this.context;
|
|
3325
3339
|
const violations = [];
|
|
3326
3340
|
const here = () => {
|
|
@@ -3472,7 +3486,7 @@ export class BrowserEngine {
|
|
|
3472
3486
|
// What the write policy refused of the app's own requests while this step ran is the step's, whatever caused it.
|
|
3473
3487
|
const blocked = ownRefusals();
|
|
3474
3488
|
if (!refusal && blocked.length > 0)
|
|
3475
|
-
refusal = `the ${this.
|
|
3489
|
+
refusal = `the ${this.writeRule.at()} write policy refused ${blocked.join(", ")}`;
|
|
3476
3490
|
collect();
|
|
3477
3491
|
if (refusal)
|
|
3478
3492
|
return done({ status: "refused", step: n, did, reason: refusal.split("\n")[0], path: here() });
|
|
@@ -3495,18 +3509,24 @@ export class BrowserEngine {
|
|
|
3495
3509
|
status: "refused",
|
|
3496
3510
|
step: steps.length,
|
|
3497
3511
|
did: describeStep(last),
|
|
3498
|
-
reason: `after the last step, the ${this.
|
|
3512
|
+
reason: `after the last step, the ${this.writeRule.at()} write policy refused ${late.join(", ")}`,
|
|
3499
3513
|
path: here(),
|
|
3500
3514
|
});
|
|
3501
3515
|
}
|
|
3502
3516
|
return done({ status: "passed" });
|
|
3503
3517
|
}
|
|
3504
3518
|
finally {
|
|
3505
|
-
//
|
|
3506
|
-
|
|
3519
|
+
// The hand-back, in order. Every page in the context is left while the flow's rule holds, the one a step opened
|
|
3520
|
+
// a tab from included (a popup adopted as the session's page leaves it running), so nothing a page the flow used
|
|
3521
|
+
// does runs on under the crawl's rule. Only then is the crawl's rule restored, and WriteRule keeps the flow's for
|
|
3522
|
+
// a moment longer, for a write a page sent as it was left that the engine hears of late.
|
|
3523
|
+
await BrowserEngine.settleWithin(Promise.allSettled(context.pages().map((p) => BrowserEngine.leave(p))), 5000);
|
|
3507
3524
|
this.blockedRequests = [];
|
|
3508
3525
|
this.oracles.drain(false);
|
|
3509
|
-
this.
|
|
3526
|
+
this.setMode(crawlMode);
|
|
3527
|
+
// Pages the session no longer drives are closed only now, at about:blank and under the held rule: in Firefox a
|
|
3528
|
+
// page closed straight after it is left sent its leaving beacon unjudged about one time in forty.
|
|
3529
|
+
await BrowserEngine.settleWithin(Promise.allSettled(context.pages().map((p) => (p === this.page ? undefined : p.close().catch(() => { })))), 5000);
|
|
3510
3530
|
page.off("websocket", onSocket);
|
|
3511
3531
|
context.off("response", onResponse);
|
|
3512
3532
|
this.refs.clear();
|
|
@@ -3514,6 +3534,11 @@ export class BrowserEngine {
|
|
|
3514
3534
|
this.snapshotUrl = "";
|
|
3515
3535
|
}
|
|
3516
3536
|
}
|
|
3537
|
+
/** Change the write mode; a looser one takes over the judging only after WriteRule's hold (policy.ts). */
|
|
3538
|
+
setMode(mode) {
|
|
3539
|
+
this.currentMode = mode;
|
|
3540
|
+
this.writeRule.set(mode);
|
|
3541
|
+
}
|
|
3517
3542
|
/** Computed-style design audit of the current page — visual judgment material without pixels. */
|
|
3518
3543
|
async designAudit() {
|
|
3519
3544
|
const { url, report } = await this.auditPage();
|
|
@@ -3769,7 +3794,11 @@ export class BrowserEngine {
|
|
|
3769
3794
|
return;
|
|
3770
3795
|
await page.goto("about:blank", { timeout: 3000 }).catch(() => { });
|
|
3771
3796
|
}
|
|
3772
|
-
/**
|
|
3797
|
+
/**
|
|
3798
|
+
* Close a page the engine will not drive, after leaving it (`leave`), so what it sends on its way out meets the policy.
|
|
3799
|
+
* Known limit: in Firefox, a close straight after the leave has let the page's leaving beacon out unjudged (seen 2 times
|
|
3800
|
+
* in 80 under load). replayFlow's hand-back closes pages only once every page is at about:blank; this and `close` do not yet.
|
|
3801
|
+
*/
|
|
3773
3802
|
static async leaveAndClose(page) {
|
|
3774
3803
|
await BrowserEngine.leave(page);
|
|
3775
3804
|
await page.close().catch(() => { });
|