scenescout 3.14.1 → 3.16.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +90 -18
  3. package/dist/browsers.js +28 -0
  4. package/dist/check-run.js +211 -24
  5. package/dist/ci-run.js +374 -62
  6. package/dist/cli.js +221 -47
  7. package/dist/commands.js +42 -4
  8. package/dist/engine/baseline.js +377 -0
  9. package/dist/engine/brief.js +16 -7
  10. package/dist/engine/browser.js +1660 -386
  11. package/dist/engine/calibration.js +61 -30
  12. package/dist/engine/capture.js +164 -0
  13. package/dist/engine/check.js +258 -68
  14. package/dist/engine/ci-lanes.js +215 -0
  15. package/dist/engine/ci.js +233 -18
  16. package/dist/engine/claims.js +234 -12
  17. package/dist/engine/collector.js +851 -78
  18. package/dist/engine/crawl.js +49 -0
  19. package/dist/engine/dedup.js +329 -9
  20. package/dist/engine/design.js +355 -43
  21. package/dist/engine/export.js +877 -0
  22. package/dist/engine/fingerprint.js +92 -4
  23. package/dist/engine/flow.js +18 -6
  24. package/dist/engine/forms.js +198 -18
  25. package/dist/engine/journey.js +29 -1
  26. package/dist/engine/lane.js +13 -3
  27. package/dist/engine/launch.js +45 -6
  28. package/dist/engine/limits.js +7 -0
  29. package/dist/engine/live-page.js +49 -2
  30. package/dist/engine/live.js +4 -1
  31. package/dist/engine/memory.js +668 -70
  32. package/dist/engine/open.js +118 -0
  33. package/dist/engine/oracles.js +167 -2
  34. package/dist/engine/plain.js +268 -0
  35. package/dist/engine/png.js +127 -0
  36. package/dist/engine/policy.js +379 -9
  37. package/dist/engine/probes.js +3 -2
  38. package/dist/engine/profiles.js +45 -9
  39. package/dist/engine/project-folder.js +191 -0
  40. package/dist/engine/provider.js +4 -3
  41. package/dist/engine/refresh.js +338 -25
  42. package/dist/engine/replay.js +63 -10
  43. package/dist/engine/report.js +248 -40
  44. package/dist/engine/request.js +317 -23
  45. package/dist/engine/sarif.js +120 -0
  46. package/dist/engine/scripted-login.js +342 -62
  47. package/dist/engine/settle.js +67 -0
  48. package/dist/engine/signed-in.js +256 -0
  49. package/dist/engine/tickets.js +671 -0
  50. package/dist/engine/unload.js +3 -2
  51. package/dist/export-run.js +633 -0
  52. package/dist/first-run.js +581 -0
  53. package/dist/installer.js +267 -3
  54. package/dist/intake.js +104 -0
  55. package/dist/login-run.js +386 -71
  56. package/dist/mcp-server.js +637 -68
  57. package/dist/playbook.js +5 -0
  58. package/package.json +6 -3
  59. package/skills/scenescout/SKILL.md +51 -18
@@ -236,3 +236,130 @@ export function diffImages(before, after, threshold = DIFF_THRESHOLD) {
236
236
  image: { width, height, data },
237
237
  };
238
238
  }
239
+ /**
240
+ * Shrinks a picture to `width` × `height` by averaging the source pixels each
241
+ * output pixel covers, so text and thin lines fade rather than vanish the way
242
+ * picking one pixel in every few would make them. Never enlarges: a size
243
+ * larger than the source in either direction is the source's in it.
244
+ */
245
+ export function shrinkImage(img, width, height) {
246
+ const w = Math.max(1, Math.min(img.width, Math.floor(width)));
247
+ const h = Math.max(1, Math.min(img.height, Math.floor(height)));
248
+ if (w === img.width && h === img.height)
249
+ return img;
250
+ const data = new Uint8Array(w * h * 4);
251
+ const sx = img.width / w;
252
+ const sy = img.height / h;
253
+ for (let y = 0; y < h; y++) {
254
+ const y0 = Math.floor(y * sy);
255
+ const y1 = Math.max(y0 + 1, Math.floor((y + 1) * sy));
256
+ for (let x = 0; x < w; x++) {
257
+ const x0 = Math.floor(x * sx);
258
+ const x1 = Math.max(x0 + 1, Math.floor((x + 1) * sx));
259
+ let r = 0;
260
+ let g = 0;
261
+ let b = 0;
262
+ let a = 0;
263
+ for (let yy = y0; yy < y1; yy++) {
264
+ for (let xx = x0; xx < x1; xx++) {
265
+ const i = (yy * img.width + xx) * 4;
266
+ r += img.data[i];
267
+ g += img.data[i + 1];
268
+ b += img.data[i + 2];
269
+ a += img.data[i + 3];
270
+ }
271
+ }
272
+ const n = (y1 - y0) * (x1 - x0);
273
+ const o = (y * w + x) * 4;
274
+ data[o] = Math.round(r / n);
275
+ data[o + 1] = Math.round(g / n);
276
+ data[o + 2] = Math.round(b / n);
277
+ data[o + 3] = Math.round(a / n);
278
+ }
279
+ }
280
+ return { width: w, height: h, data };
281
+ }
282
+ /**
283
+ * Writes a picture as a PNG as small as this encoder makes one: three
284
+ * channels when every pixel is opaque (a screenshot always is), and each row
285
+ * filtered by whichever of the five PNG filters leaves the smallest sum, the
286
+ * usual heuristic. encodePng stays as it is, since the diff is written by it.
287
+ */
288
+ export function encodePngCompact(img) {
289
+ if (img.data.length !== img.width * img.height * 4)
290
+ throw new Error("the pixel data is not width × height × 4 bytes");
291
+ let opaque = true;
292
+ for (let i = 3; i < img.data.length; i += 4)
293
+ if (img.data[i] !== 255) {
294
+ opaque = false;
295
+ break;
296
+ }
297
+ const channels = opaque ? 3 : 4;
298
+ const stride = img.width * channels;
299
+ const rows = new Uint8Array(img.height * stride);
300
+ if (opaque) {
301
+ for (let i = 0, j = 0; i < img.data.length; i += 4, j += 3) {
302
+ rows[j] = img.data[i];
303
+ rows[j + 1] = img.data[i + 1];
304
+ rows[j + 2] = img.data[i + 2];
305
+ }
306
+ }
307
+ else
308
+ rows.set(img.data);
309
+ const raw = Buffer.alloc(img.height * (stride + 1));
310
+ const trial = new Uint8Array(stride);
311
+ for (let y = 0; y < img.height; y++) {
312
+ const row = y * stride;
313
+ let best = Infinity;
314
+ const out = y * (stride + 1);
315
+ for (let filter = 0; filter <= 4; filter++) {
316
+ let sum = 0;
317
+ for (let x = 0; x < stride; x++) {
318
+ const left = x >= channels ? rows[row + x - channels] : 0;
319
+ const up = y > 0 ? rows[row - stride + x] : 0;
320
+ const upLeft = y > 0 && x >= channels ? rows[row - stride + x - channels] : 0;
321
+ const v = rows[row + x];
322
+ const p = filter === 0 ? 0 : filter === 1 ? left : filter === 2 ? up : filter === 3 ? (left + up) >> 1 : paeth(left, up, upLeft);
323
+ const f = (v - p) & 0xff;
324
+ trial[x] = f;
325
+ sum += f < 128 ? f : 256 - f;
326
+ }
327
+ if (sum < best) {
328
+ best = sum;
329
+ raw[out] = filter;
330
+ raw.set(trial, out + 1);
331
+ }
332
+ }
333
+ }
334
+ const header = Buffer.alloc(13);
335
+ header.writeUInt32BE(img.width, 0);
336
+ header.writeUInt32BE(img.height, 4);
337
+ header[8] = 8;
338
+ header[9] = opaque ? 2 : 6;
339
+ return Buffer.concat([SIGNATURE, chunk("IHDR", header), chunk("IDAT", deflateSync(raw, { level: 9 })), chunk("IEND", new Uint8Array(0))]);
340
+ }
341
+ /** The most a picture is shrunk to fit its bytes: below this on its longer side it is no longer worth showing. */
342
+ export const FIT_MIN_SIDE = 120;
343
+ /**
344
+ * A picture within `maxSide` pixels on its longer side and `maxBytes` as a
345
+ * PNG: shrunk to the side first, then, while the file is still too large,
346
+ * shrunk again in proportion to how far over it is. Null when even
347
+ * FIT_MIN_SIDE on its longer side is over the bytes: nothing worth looking at
348
+ * fits, and the caller says so rather than keeping a smudge.
349
+ */
350
+ export function fitPicture(img, maxSide, maxBytes) {
351
+ const longer = Math.max(img.width, img.height);
352
+ let scale = Math.min(1, maxSide / longer);
353
+ for (let attempt = 0; attempt < 8; attempt++) {
354
+ const width = Math.max(1, Math.round(img.width * scale));
355
+ const height = Math.max(1, Math.round(img.height * scale));
356
+ const png = encodePngCompact(shrinkImage(img, width, height));
357
+ if (png.length <= maxBytes)
358
+ return { png, width, height, shrunk: width !== img.width || height !== img.height };
359
+ if (Math.max(width, height) <= FIT_MIN_SIDE)
360
+ return null;
361
+ // Bytes go roughly with area, so the side goes with the square root; a little under, so one more pass usually fits.
362
+ scale = Math.max(FIT_MIN_SIDE / longer, scale * Math.sqrt(maxBytes / png.length) * 0.9);
363
+ }
364
+ return null;
365
+ }
@@ -5,7 +5,12 @@
5
5
  */
6
6
  const DESTRUCTIVE_PATTERNS = [
7
7
  /\bdelete\b/i,
8
- /\bremove\b/i,
8
+ // "Remove" a filter chip drops a condition from the view: "Remove Status:
9
+ // Open filter" re-queries the list, the same sense the reset rule exempts.
10
+ // A label whose object, at its end, is a filter or a chip, or a test id with
11
+ // the chip before the verb ("filter-remove"), is exempt; "Remove member",
12
+ // and "Remove member. Use the filter to find others.", still count.
13
+ /(?<!\b(?:filters?|chips?)[\s-]{1,3})\bremove\b(?![^\n]{0,60}?\b(?:filters?|chips?)\W*$)/i,
9
14
  /\brevoke\b/i,
10
15
  /\bdestroy\b/i,
11
16
  /\bpurge\b/i,
@@ -20,13 +25,23 @@ const DESTRUCTIVE_PATTERNS = [
20
25
  // view/form senses; everything else ("Reset workspace", "Factory reset",
21
26
  // "Reset all data") still counts.
22
27
  /\breset\b(?!\s+(filters?|zoom|search|view|sort|order|form|password|layout|columns?|selection|preferences?|defaults?))/i,
23
- /\bdiscard\b/i,
28
+ // "Discard" names two different things. "Discard changes", "Discard your
29
+ // edits" and "Discard unsaved changes" drop what the tester typed and has not
30
+ // sent: no record exists to lose, and refusing it left a session stuck on a
31
+ // dirty form behind the app's own leave-confirmation. Those senses are
32
+ // exempt, written as words or as a test id ("discard-changes"); "Discard
33
+ // draft", "Discard record" and a bare "Discard" still count.
34
+ // If the confirm does send a write, the wire policy judges that request as it
35
+ // judges any other, and `discard` is a destructive verb in a path there.
36
+ /\bdiscard\b(?![\s_-]+(?:(?:your|my|all|the|any)[\s_-]+)?(?:unsaved[\s_-]+|pending[\s_-]+|local[\s_-]+|draft[\s_-]+)?(?:changes|edits)\b)/i,
24
37
  /\bcancel subscription\b/i,
25
- // The verb: a control that signs the user off, alone or after another verb
26
- // ("Save and sign off"). The noun is how approval apps label things, and it
27
- // is told by the word before it ("Needs sign-off", "Awaiting sign-off",
28
- // "Send for sign-off"); those destroy nothing. Same shape as the reset rule.
29
- /(?<!\b(?:needs?|awaiting|awaits|pending|requires?|required|for|before|after|without|of|the|a|an|its|their|your|my)\s)\bsign(?: |-)?off\b/i,
38
+ // The verb: a control that signs the user off, starting the label ("Sign
39
+ // off", "(Sign off)", a test id "sign-off-button") or joined to another verb
40
+ // ("Save and sign off", "Save & sign off"). Anywhere else it is the noun
41
+ // approval apps label things with ("Needs sign-off", "Manager sign-off",
42
+ // "Final sign-off recorded"), which destroys nothing. Told by position, not
43
+ // by a list of the words that may come before it: any modifier can.
44
+ /(?:^[^\p{L}\p{N}]*|\b(?:and|then)\s+|&\s*)sign(?: |-)?off\b/iu,
30
45
  // The tool is generic — destructive labels come in many languages.
31
46
  /\b(eliminar|borrar|suprimir)\b/i, // es
32
47
  /\b(excluir|apagar|remover)\b/i, // pt
@@ -55,7 +70,7 @@ const DESTRUCTIVE_PATTERNS = [
55
70
  * destructive. So the body is matched only for STRUCTURED destructive intent.
56
71
  */
57
72
  /** A destructive verb occupying a URL PATH segment. Bare keywords are meaningful here — a path is not prose. */
58
- const DESTRUCTIVE_URL_RE = /(\/|\b|_)(delete|remove|purge|destroy|archive|revoke|deactivate|wipe|bulk[-_]?delete|force[-_]?delete)(\/|\b|_)/i;
73
+ const DESTRUCTIVE_URL_RE = /(\/|\b|_)(delete|remove|purge|destroy|archive|revoke|deactivate|wipe|discard|bulk[-_]?delete|force[-_]?delete)(\/|\b|_)/i;
59
74
  /**
60
75
  * Structured destructive intent inside a body — never a bare keyword.
61
76
  * Two shapes: (a) a GraphQL destructive mutation (the 200-char window after
@@ -72,7 +87,7 @@ const DESTRUCTIVE_URL_RE = /(\/|\b|_)(delete|remove|purge|destroy|archive|revoke
72
87
  * and `verb` are not in the key list at all — they read as prose far more often
73
88
  * than as command fields.
74
89
  */
75
- const DESTRUCTIVE_BODY_RE = /\bmutation\b[\s\S]{0,200}?\b(?:delete|remove|archive|destroy|purge|revoke)[A-Za-z_]|(?:^|[{,[\s]*["']|[?&])\s*(?:action|operation|op|method|_method|command|cmd)["']?\s*[:=]\s*["']?(?:delete|remove|purge|destroy|archive|revoke|deactivate|wipe)\b/i;
90
+ const DESTRUCTIVE_BODY_RE = /\bmutation\b[\s\S]{0,200}?\b(?:delete|remove|archive|destroy|purge|revoke)[A-Za-z_]|(?:^|[{,[\s]*["']|[?&])\s*(?:action|operation|op|method|_method|command|cmd)["']?\s*[:=]\s*["']?(?:delete|remove|purge|destroy|archive|revoke|deactivate|wipe|discard)\b/i;
76
91
  export function isDestructiveWire(url, body) {
77
92
  return DESTRUCTIVE_URL_RE.test(url) || (!!body && DESTRUCTIVE_BODY_RE.test(body.slice(0, 2000)));
78
93
  }
@@ -128,11 +143,226 @@ export function isDestructive(...labels) {
128
143
  });
129
144
  });
130
145
  }
146
+ /** The most words a click target's own text may have and still be read as its command. */
147
+ export const COMMAND_TEXT_WORDS = 4;
148
+ /** A short verb phrase, or null: at most COMMAND_TEXT_WORDS words and no sentence. */
149
+ function commandText(text) {
150
+ const words = text.trim().split(/\s+/).filter(Boolean);
151
+ return words.length > 0 && words.length <= COMMAND_TEXT_WORDS && !SENTENCE_RE.test(text) ? text : null;
152
+ }
153
+ /** Roles whose accessible name is the label of the one thing a click does. */
154
+ const LABELLED_CONTROL_ROLES = new Set([
155
+ "button",
156
+ "link",
157
+ "menuitem",
158
+ "menuitemcheckbox",
159
+ "menuitemradio",
160
+ "tab",
161
+ "option",
162
+ "checkbox",
163
+ "radio",
164
+ "switch",
165
+ "treeitem",
166
+ "image",
167
+ ]);
168
+ /**
169
+ * The label that makes a listed element destructive, or null. A control is
170
+ * judged by its OWN name, never by what it merely contains:
171
+ *
172
+ * - A button, link, menu item, tab or option (by tag or by role) is named
173
+ * by what it does, and that name is judged whole, however long: a button
174
+ * "Delete all my saved data" is refused. The word cap below is only for
175
+ * containers, whose text is the record they show.
176
+ * - A dropdown (a `<select>`, a combobox, a listbox) is named by its options,
177
+ * all of them, so a filter offering "All, Create, Update, Delete" read as a
178
+ * delete control and choosing "Create" was refused. Choosing is judged where
179
+ * the value is known: scout_select vets the chosen option, and an option
180
+ * clicked in a custom list is a control of its own. Only its test id, and a
181
+ * control covering its centre, are judged here.
182
+ * - Anything else (a row, a card, a heading, a panel listed for its test id
183
+ * or a click handler) is judged by its test id and by the control covering
184
+ * its centre, where a click on it lands: a row whose middle IS a delete
185
+ * button is still refused, and the buttons inside it are listed, and
186
+ * refused, on their own. Its text is the record it shows ("Archive Test
187
+ * Widget", "Final sign-off recorded"), not a command, so the text counts
188
+ * only for a click target whose own text is a short verb phrase
189
+ * (COMMAND_TEXT_WORDS words, no sentence): a clickable div saying "Delete".
190
+ * A heading is never judged by its text.
191
+ *
192
+ * When the page could not say what the own text is, the whole name stands in
193
+ * and is judged whole, so a reading that failed can only refuse more.
194
+ */
195
+ export function destructiveLabelOf(c) {
196
+ const first = (...labels) => labels.find((l) => typeof l === "string" && l.length > 0 && isDestructive(l)) ?? null;
197
+ const centre = c.centre ?? [];
198
+ if (c.tag === "select" || c.role === "combobox" || c.role === "listbox")
199
+ return first(c.testid, ...centre);
200
+ if (LABELLED_CONTROL_ROLES.has(c.role) || c.tag === "button" || c.tag === "a")
201
+ return first(c.name, c.testid);
202
+ if (c.role === "heading" || /^h[1-6]$/i.test(c.tag))
203
+ return first(c.testid, ...centre);
204
+ const own = c.ownText == null ? c.name : c.interactive === false ? null : commandText(c.ownText);
205
+ return first(own, c.testid, ...centre);
206
+ }
207
+ /**
208
+ * A dropdown pick, judged by the value asked for, the dropdown's test id and
209
+ * the label of the option it matches. The dropdown's own name is not judged
210
+ * (destructiveLabelOf), so this is the check, and a label that could not be
211
+ * read (null) refuses: an unvetted pick could be "Delete".
212
+ */
213
+ export function pickIsDestructive(value, testid, optionLabel) {
214
+ return optionLabel === null || isDestructive(value, testid, optionLabel);
215
+ }
131
216
  export function destructiveRefusal(label, mode = "read-only") {
132
217
  return (`REFUSED by ${mode} policy: "${label}" matches a destructive-action pattern. ` +
133
218
  `This run is ${mode}; do not attempt this element again. If destructive flows must be tested, ` +
134
219
  `the user has to re-attach with mode="destructive" against a disposable/seeded environment.`);
135
220
  }
221
+ /** The `why` of a page move refused as a possible escape from a foreign frame's sandbox. */
222
+ export const ESCAPE_REFUSAL = "a possible frame escape";
223
+ /** New blocked endpoints listed by name in one notice; the rest are counted. */
224
+ export const BLOCK_NOTICE_LIST = 5;
225
+ /** What a blocked write is remembered by: its method and URL, without query or fragment. */
226
+ export function blockSignature(sig) {
227
+ return sig.replace(/[?#].*$/, "");
228
+ }
229
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
230
+ /**
231
+ * The write-policy block notice, said once. A page that beacons to a
232
+ * monitoring endpoint on every load had every tool result open with the same
233
+ * blocked request five times over and the whole explanation after it, which
234
+ * buried the action's own result and the blocks that mattered.
235
+ *
236
+ * So a session remembers what it has been told. Each endpoint (by
237
+ * blockSignature, per write rule) is named in full the first time it is
238
+ * blocked; after that it is counted on one line. The explanation is given in
239
+ * full once per rule, and later notices for a new endpoint refer back to it.
240
+ * Nothing about the blocking changes: every request is still refused, and
241
+ * every refusal is still reported, if only as a count.
242
+ */
243
+ export class BlockNotices {
244
+ seen = new Set();
245
+ explained = new Set();
246
+ /** Forget everything said: a re-attached session starts a new conversation. */
247
+ reset() {
248
+ this.seen.clear();
249
+ this.explained.clear();
250
+ }
251
+ /**
252
+ * The notice for the writes refused since the last action, or "" when there
253
+ * were none. `createdCount` is how many records this run has created, which
254
+ * the safe-write advice names.
255
+ */
256
+ notice(rule, blocked, createdCount = 0) {
257
+ if (blocked.length === 0)
258
+ return "";
259
+ const groups = new Map();
260
+ for (const b of blocked) {
261
+ const key = blockSignature(b.sig);
262
+ const g = groups.get(key);
263
+ if (g) {
264
+ g.count += 1;
265
+ g.allLate &&= b.late;
266
+ g.answered ||= !!b.answered;
267
+ if (b.why)
268
+ g.whys.add(b.why);
269
+ }
270
+ else
271
+ groups.set(key, { first: b, count: 1, allLate: b.late, answered: !!b.answered, whys: new Set(b.why ? [b.why] : []) });
272
+ }
273
+ const fresh = [];
274
+ const repeats = [];
275
+ for (const [key, g] of groups) {
276
+ const memo = `${rule} ${key}`;
277
+ if (this.seen.has(memo))
278
+ repeats.push([key, g]);
279
+ else {
280
+ this.seen.add(memo);
281
+ fresh.push([key, g]);
282
+ }
283
+ }
284
+ const late = (g) => (g.allLate ? " (late — likely from a previous action or background traffic)" : "");
285
+ const times = (g) => (g.count > 1 ? ` ×${g.count}` : "");
286
+ const repeatCount = repeats.reduce((n, [, g]) => n + g.count, 0);
287
+ const repeatLine = repeats.length === 0
288
+ ? ""
289
+ : `${plural(repeatCount, "repeat block")} of ${plural(repeats.length, "known endpoint")} (` +
290
+ repeats
291
+ .slice(0, 3)
292
+ .map(([key, g]) => `${key} ×${g.count}${g.allLate ? ", background" : ""}`)
293
+ .join("; ") +
294
+ (repeats.length > 3 ? `; +${repeats.length - 3} more` : "") +
295
+ `)`;
296
+ const head = `\n🛡 WRITE-POLICY blocked (${rule}): `;
297
+ if (fresh.length === 0)
298
+ return `${head}${repeatLine}, refused as before. The tester's safety policy, not an app bug.`;
299
+ const list = fresh
300
+ .slice(0, BLOCK_NOTICE_LIST)
301
+ .map(([, g]) => `${g.first.sig}${times(g)}${late(g)}`)
302
+ .join("; ");
303
+ const more = fresh.length > BLOCK_NOTICE_LIST ? ` (+${fresh.length - BLOCK_NOTICE_LIST} more new)` : "";
304
+ const also = repeatLine ? `; also ${repeatLine}` : "";
305
+ const whys = new Set(fresh.flatMap(([, g]) => [...g.whys]));
306
+ const escaped = whys.delete(ESCAPE_REFUSAL);
307
+ const foreign = [...whys];
308
+ const answered = fresh.some(([, g]) => g.answered);
309
+ const reasons = (foreign.length > 0
310
+ ? `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. `
311
+ : "") +
312
+ (escaped
313
+ ? `A move of the whole page off the app, with no Referer, was refused: a frame that held another site now sits on a data: or blob: URL, where WebKit drops the frame's sandbox, so the move may be that frame's. No mode but destructive allows it. `
314
+ : "");
315
+ if (this.explained.has(rule)) {
316
+ return (`${head}${list}${more}${also}. The tester's safety policy, not an app bug (explained in full earlier in this session). ` +
317
+ reasons +
318
+ (answered ? `The page's request was answered with a 403 in the server's place, as before. ` : "")).trimEnd();
319
+ }
320
+ this.explained.add(rule);
321
+ return (`${head}${list}${more}${also}. ` +
322
+ `This is the tester's safety policy, NOT an app bug — do not file a finding for the resulting error UI. ` +
323
+ reasons +
324
+ (answered
325
+ ? `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. `
326
+ : "") +
327
+ (rule === "observe"
328
+ ? `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. If a refused POST only reads (a search or query sent as POST), the user can name it in readPosts instead; never add one yourself.`
329
+ : rule === "read-only"
330
+ ? `Re-attach with mode="safe-write" to test create/edit flows, or "destructive" (user-approved disposable env only).`
331
+ : `In safe-write, updates/deletes are only allowed on resources this session created (${createdCount} so far).`));
332
+ }
333
+ }
334
+ /**
335
+ * The answer to a native dialog. alert, confirm and prompt are dismissed in
336
+ * the modes that protect data (observe, read-only) and accepted otherwise, as
337
+ * before: a confirm can stand between a click and a delete.
338
+ *
339
+ * `beforeunload` is the page asking whether to leave while it holds unsent
340
+ * input. Leaving sends no write of its own, and whatever the page sends as it
341
+ * goes is judged by the wire policy like any other request, but leaving does
342
+ * throw away what the tester typed. So it is the caller's choice: `leave: true`
343
+ * leaves, `leave: false` stays, and with no choice the mode decides, staying
344
+ * in observe and read-only. Either way the result says what happened (see
345
+ * dialogNote), because a dismissed one surfaced only as a bare ERR_ABORTED.
346
+ */
347
+ export function dialogResponse(type, readOnly, leave) {
348
+ if (type === "beforeunload")
349
+ return (leave ?? !readOnly) ? "accept" : "dismiss";
350
+ return readOnly ? "dismiss" : "accept";
351
+ }
352
+ /** The line an action's result carries for a native dialog the page opened during it. */
353
+ export function dialogNote(d) {
354
+ const message = d.message.trim().replace(/\s+/g, " ").slice(0, 120);
355
+ if (d.type === "beforeunload") {
356
+ return d.response === "dismiss"
357
+ ? `\n⚠ LEAVE CONFIRMATION: the page asked to confirm leaving (it holds input that was never sent), and the engine answered "stay"` +
358
+ `${d.leave === false ? " as asked (leave: false)" : ""}, so the navigation was cancelled and the page is unchanged. ` +
359
+ `This is not an app bug, and nothing was sent. To leave and discard that unsent input, repeat the action with leave: true.`
360
+ : `\nℹ LEAVE CONFIRMATION: the page asked to confirm leaving (it held input that was never sent), and the engine answered "leave"` +
361
+ `${d.leave === true ? " as asked (leave: true)" : ""}, so that unsent input is discarded. ` +
362
+ `Any request the page sends as it is left is judged by the write policy like any other.`;
363
+ }
364
+ return `\nℹ DIALOG (${d.type})${message ? `: "${message}"` : ""} — ${d.response === "accept" ? "accepted" : "dismissed"} by the engine.`;
365
+ }
136
366
  export const WRITE_MODES = ["observe", "read-only", "safe-write", "destructive"];
137
367
  /** The stricter of two modes: WRITE_MODES runs from the strictest to the loosest. */
138
368
  export function stricterMode(a, b) {
@@ -572,6 +802,146 @@ export const FOREIGN_FRAME_SANDBOX = "sandbox allow-scripts allow-forms allow-sa
572
802
  export function withForeignFrameSandbox(existing) {
573
803
  return existing && existing.trim() ? `${existing}, ${FOREIGN_FRAME_SANDBOX}` : FOREIGN_FRAME_SANDBOX;
574
804
  }
805
+ /** The most POST endpoints a session may name as reads. */
806
+ export const MAX_READ_POSTS = 20;
807
+ /** The environment variable naming POST endpoints that only read, for every surface that attaches. A `readPosts` option wins over it. */
808
+ export const READ_POSTS_ENV = "SCENESCOUT_READ_POSTS";
809
+ /**
810
+ * The POST endpoints a session was told only read, from the `readPosts`
811
+ * option, else the environment variable (entries separated by commas or new
812
+ * lines). Nothing by default: observe refuses every POST until the user names
813
+ * one, and the agent must never add one itself.
814
+ */
815
+ export function readPostsSetting(option, env) {
816
+ if (option !== undefined)
817
+ return [...option];
818
+ return (env ?? "")
819
+ .split(/[,\n]/)
820
+ .map((e) => e.trim())
821
+ .filter(Boolean);
822
+ }
823
+ /**
824
+ * The entries parsed, and what was given that is not one. An entry is
825
+ * `POST <path>` for the app's own origin, or `POST <http(s) URL>` for an API
826
+ * on another origin: the method must be POST, the path must start with `/`
827
+ * and carry no query or fragment, and `*` may stand only for one whole path
828
+ * segment, such as an id. Exact otherwise, so nothing is widened by accident;
829
+ * a trailing slash is ignored.
830
+ */
831
+ export function readPostEntries(list) {
832
+ const entries = [];
833
+ const rejected = [];
834
+ const overflow = [];
835
+ for (const raw of list) {
836
+ const m = /^\s*POST\s+(\S+)\s*$/i.exec(raw);
837
+ if (!m) {
838
+ rejected.push(raw);
839
+ continue;
840
+ }
841
+ let origin = null;
842
+ let pathname = m[1];
843
+ if (/^https?:\/\//i.test(pathname)) {
844
+ let u;
845
+ try {
846
+ u = new URL(pathname);
847
+ }
848
+ catch {
849
+ rejected.push(raw);
850
+ continue;
851
+ }
852
+ if (u.search || u.hash || u.username || u.password || /[?#]/.test(pathname)) {
853
+ rejected.push(raw);
854
+ continue;
855
+ }
856
+ origin = u.origin;
857
+ // The path as written: URL would percent-encode a `*`.
858
+ const slash = pathname.indexOf("/", pathname.indexOf("//") + 2);
859
+ pathname = slash === -1 ? "/" : pathname.slice(slash);
860
+ }
861
+ const segments = pathname.split("/").filter(Boolean);
862
+ if (!pathname.startsWith("/") || /[?#\s]/.test(pathname) || segments.some((seg) => seg.includes("*") && seg !== "*")) {
863
+ rejected.push(raw);
864
+ continue;
865
+ }
866
+ const entry = `POST ${origin ?? ""}${"/" + segments.join("/")}`;
867
+ if (entries.some((e) => e.entry === entry))
868
+ continue;
869
+ if (entries.length < MAX_READ_POSTS)
870
+ entries.push({ entry, origin, segments });
871
+ else
872
+ overflow.push(raw);
873
+ }
874
+ return { entries, rejected, overflow };
875
+ }
876
+ /** The entry a POST to `url` matches, or null. An entry with no origin matches the app's own origin only. */
877
+ export function matchReadPost(entries, appUrl, url) {
878
+ let target;
879
+ let app;
880
+ try {
881
+ target = new URL(url);
882
+ app = new URL(appUrl).origin;
883
+ }
884
+ catch {
885
+ return null;
886
+ }
887
+ const segments = target.pathname.split("/").filter(Boolean);
888
+ return (entries.find((e) => (e.origin ?? app) === target.origin && e.segments.length === segments.length && e.segments.every((seg, i) => seg === "*" || seg === segments[i])) ?? null);
889
+ }
890
+ /** The longest body read for a GraphQL operation; a longer one cannot be vetted and is refused. */
891
+ export const MAX_READ_POST_BODY = 100_000;
892
+ /** A GraphQL operation that writes: `mutation` or `subscription`, then an optional name, then its variables, directives or selection. */
893
+ const GRAPHQL_WRITE_OP_RE = /(?:^|[^A-Za-z0-9_$])(?:mutation|subscription)\s*(?:[A-Za-z_][A-Za-z0-9_]*\s*)?[({@]/;
894
+ /**
895
+ * Whether a body carries a GraphQL operation that is not a query, or one that
896
+ * cannot be read. Read from the body as sent, from every `query` string in a
897
+ * JSON body (or a batch of them), so an escaped newline cannot hide the
898
+ * keyword, and from a form-encoded `query` field. A persisted query (a hash
899
+ * and no text) counts, since its operation is unknown. A search box's text
900
+ * ("mutation testing") is not an operation and does not match.
901
+ */
902
+ export function graphqlWriteOperation(body) {
903
+ if (GRAPHQL_WRITE_OP_RE.test(body))
904
+ return true;
905
+ let parsed;
906
+ try {
907
+ parsed = JSON.parse(body);
908
+ }
909
+ catch {
910
+ // A form-encoded body: its `query` field, decoded.
911
+ const query = /(?:^|&)query=/.test(body) ? new URLSearchParams(body).get("query") : null;
912
+ return query !== null && GRAPHQL_WRITE_OP_RE.test(query);
913
+ }
914
+ const items = Array.isArray(parsed) ? parsed : [parsed];
915
+ return items.some((item) => {
916
+ if (typeof item !== "object" || item === null)
917
+ return false;
918
+ const { query, extensions } = item;
919
+ if (typeof query === "string")
920
+ return GRAPHQL_WRITE_OP_RE.test(query);
921
+ // A persisted query names its operation by hash alone, so what it runs cannot be read: refused.
922
+ return typeof extensions === "object" && extensions !== null && extensions.persistedQuery !== undefined;
923
+ });
924
+ }
925
+ /**
926
+ * The entry that lets this POST out of observe, or null. Only in observe:
927
+ * read-only and safe-write already let a POST out unless it looks
928
+ * destructive, and destructive lets everything out. Even a listed endpoint is
929
+ * refused when its path or body looks destructive (the whole body is read),
930
+ * when its body is a GraphQL mutation, subscription or persisted query, or
931
+ * when the body is too long to vet.
932
+ */
933
+ export function readPostAllowed(input) {
934
+ if (input.mode !== "observe" || input.method !== "POST" || input.destructiveWire || input.entries.length === 0)
935
+ return null;
936
+ const entry = matchReadPost(input.entries, input.appUrl, input.url);
937
+ if (!entry)
938
+ return null;
939
+ const body = input.body ?? "";
940
+ // The whole body, not the first 2000 characters isDestructiveWire reads: a command after a long filter still counts.
941
+ if (body.length > MAX_READ_POST_BODY || DESTRUCTIVE_BODY_RE.test(body) || graphqlWriteOperation(body))
942
+ return null;
943
+ return entry;
944
+ }
575
945
  export function allowsWrite(mode, method, destructiveWire, owned) {
576
946
  if (mode === "destructive")
577
947
  return true;
@@ -1,4 +1,4 @@
1
- import { DIALOG_LIKE_SEL } from "./collector.js";
1
+ import { ACCESSIBLE_NAME_SRC, DIALOG_LIKE_SEL } from "./collector.js";
2
2
  import { focusAdvanceKey, isBrowserEngine } from "../browsers.js";
3
3
  /**
4
4
  * Scroll ONE named region rather than the page. The page-level heuristic
@@ -267,7 +267,8 @@ export async function probeFocusIndicators(page) {
267
267
  el.setAttribute("data-scout-focus-probe", "${i}");
268
268
  const s = getComputedStyle(el);
269
269
  const tid = el.getAttribute("data-testid");
270
- const name = ((el.textContent || el.getAttribute("aria-label") || "").trim().replace(/\\s+/g, " ").slice(0, 30));
270
+ // Named as the snapshot names it, so an icon button reads by its aria-label here too.
271
+ const name = (${ACCESSIBLE_NAME_SRC})(el).slice(0, 30);
271
272
  return { label: tid ? "[" + tid + "]" : "<" + el.tagName.toLowerCase() + "> " + JSON.stringify(name), focused: ${styleSig} };
272
273
  })()`));
273
274
  if (info === null || info === "wrapped")