scenescout 3.8.0 → 3.10.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.
@@ -135,7 +135,12 @@ export function parseLaneReport(text, expectedLane) {
135
135
  const trailing = /after JSON|Unexpected non-whitespace/i.test(message);
136
136
  return { ok: false, reason: trailing ? "the reply must be one JSON object, with no text after it" : `not valid JSON: ${message}` };
137
137
  }
138
- const result = LaneReport.safeParse(raw);
138
+ // Decoded before the schema, so every cap measures the text the lane wrote.
139
+ // Only a reply that looks relay-escaped is decoded: one holding a literal
140
+ // `<` or `>` was not escaped on the way, and its `&amp;` is what the lane
141
+ // meant (a page that double-escapes its text is a defect a lane can report).
142
+ const decoded = /[<>]/.test(text) ? { value: raw, count: 0 } : decodeStringValues(raw);
143
+ const result = LaneReport.safeParse(decoded.value);
139
144
  if (!result.success) {
140
145
  const issue = result.error.issues[0];
141
146
  const at = issue.path.length ? ` at ${issue.path.join(".")}` : "";
@@ -144,7 +149,77 @@ export function parseLaneReport(text, expectedLane) {
144
149
  if (expectedLane !== undefined && result.data.lane !== expectedLane) {
145
150
  return { ok: false, reason: `the report names lane "${result.data.lane}", but this reply was asked of lane "${expectedLane}"` };
146
151
  }
147
- return { ok: true, report: result.data, aroundIgnored };
152
+ return { ok: true, report: result.data, aroundIgnored, entitiesDecoded: decoded.count };
153
+ }
154
+ /**
155
+ * The HTML character references a relay adds when it escapes text: the five
156
+ * named ones an escaper emits, and decimal or hex numeric ones. Anything else
157
+ * that looks like a reference (`&nbsp;`, `&#0;`, a bare `&`) is left as it is.
158
+ */
159
+ const CHAR_REFERENCE = /&(?:(lt|gt|amp|quot|apos)|#(\d{1,7})|#[xX]([0-9a-fA-F]{1,6}));/g;
160
+ const NAMED_REFERENCES = { lt: "<", gt: ">", amp: "&", quot: '"', apos: "'" };
161
+ /**
162
+ * One pass over the text, so each reference is decoded exactly once:
163
+ * `&amp;lt;` becomes `&lt;`, not `<`, because the `&` a match produces is
164
+ * never scanned again.
165
+ */
166
+ function decodeReferences(text) {
167
+ let count = 0;
168
+ const out = text.replace(CHAR_REFERENCE, (whole, name, dec, hex) => {
169
+ if (name) {
170
+ count += 1;
171
+ return NAMED_REFERENCES[name];
172
+ }
173
+ const code = hex !== undefined ? parseInt(hex, 16) : Number(dec);
174
+ // NUL, surrogates and anything past the last code point are not characters a relay escaped.
175
+ if (code === 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff))
176
+ return whole;
177
+ count += 1;
178
+ return String.fromCodePoint(code);
179
+ });
180
+ return { text: out, count };
181
+ }
182
+ /**
183
+ * A planner relaying a lane's reply often HTML-escapes it, so `->` arrives as
184
+ * `-&gt;` and no longer matches the finding filed with the same evidence.
185
+ * Decoded in string VALUES only, after JSON.parse: JSON's own syntax contains
186
+ * no `<`, `>` or `&`, so an escaper can only have touched it by turning its
187
+ * quotes into `&quot;`, and that reply does not parse at all; it is refused
188
+ * as invalid JSON rather than repaired. Keys are a fixed set of plain words
189
+ * and are left alone.
190
+ */
191
+ function decodeStringValues(value) {
192
+ if (typeof value === "string") {
193
+ const d = decodeReferences(value);
194
+ return { value: d.text, count: d.count };
195
+ }
196
+ if (Array.isArray(value)) {
197
+ let count = 0;
198
+ const out = value.map((v) => {
199
+ const d = decodeStringValues(v);
200
+ count += d.count;
201
+ return d.value;
202
+ });
203
+ return { value: out, count };
204
+ }
205
+ if (value !== null && typeof value === "object") {
206
+ let count = 0;
207
+ const out = {};
208
+ for (const [k, v] of Object.entries(value)) {
209
+ const d = decodeStringValues(v);
210
+ count += d.count;
211
+ out[k] = d.value;
212
+ }
213
+ return { value: out, count };
214
+ }
215
+ return { value, count: 0 };
216
+ }
217
+ /** The line the fold adds when the parse decoded references, so a reader knows the tool changed the text; empty when it did not. */
218
+ export function decodedEntitiesNote(count) {
219
+ if (count === 0)
220
+ return "";
221
+ const what = count === 1 ? "1 HTML character reference" : `${count} HTML character references`;
222
+ return `\n(${what} in the report ${count === 1 ? "was" : "were"} decoded once, as a relay that escapes text would have added ${count === 1 ? "it" : "them"}.)`;
148
223
  }
149
224
  /**
150
225
  * The paragraph the planner puts in every lane's prompt. It is generated from
@@ -196,3 +271,105 @@ export function summarizeLaneReport(r) {
196
271
  parts.push(`blocked by ${r.blocked_by}`);
197
272
  return `${r.lane}: ${parts.join(", ")}`;
198
273
  }
274
+ /**
275
+ * Which sessions are lanes of a parallel run, and whether each one's report
276
+ * has been folded, so a lane's session is not closed before its decisions
277
+ * have somewhere to go.
278
+ *
279
+ * Folding a report writes its decisions to the lane's own session's project
280
+ * memory. Closing that session first leaves nothing to write to: a planner
281
+ * that closes a lane whose fold was just refused (a cap exceeded, say) and
282
+ * then re-sends the corrected object loses every decision in it.
283
+ *
284
+ * A session counts as a lane only when something in this run named it as
285
+ * one: scout_lane_brief listed it, scout_lane_report issued its instruction,
286
+ * or a reply was folded (accepted or refused) under its name while that
287
+ * session was attached. Nothing is inferred from other lanes' activity: the
288
+ * planner's own session is not a lane, and a single-session run never names
289
+ * one, so neither is ever refused a close by this. The ledger belongs to one
290
+ * run: the server clears it when the last session closes, because lane names
291
+ * are module names and a later run may well attach a role with one of them.
292
+ */
293
+ export class LaneLedger {
294
+ named = new Set();
295
+ refused = new Set();
296
+ folded = new Set();
297
+ /** An instruction named this session as a lane. */
298
+ name(lane) {
299
+ this.named.add(lane);
300
+ }
301
+ /**
302
+ * A brief's lanes. One whose name is a session already live is skipped:
303
+ * that session was attached before the split existed, so it is the
304
+ * planner's own (or another role's), not a lane waiting to report.
305
+ */
306
+ nameBriefed(lanes, isLive) {
307
+ for (const lane of lanes)
308
+ if (!isLive(lane))
309
+ this.named.add(lane);
310
+ }
311
+ /**
312
+ * A reply for this lane was refused: it is a lane, and nothing it decided is
313
+ * kept yet. Only while its session is attached: a reply for a session that
314
+ * is gone says nothing about a later session given the same name.
315
+ */
316
+ refuse(lane, attached) {
317
+ if (!attached)
318
+ return;
319
+ this.named.add(lane);
320
+ if (!this.folded.has(lane))
321
+ this.refused.add(lane);
322
+ }
323
+ /** A reply for this lane was accepted. Only while its session is attached, for the same reason. */
324
+ fold(lane, attached) {
325
+ if (!attached)
326
+ return;
327
+ this.named.add(lane);
328
+ this.folded.add(lane);
329
+ this.refused.delete(lane);
330
+ }
331
+ /** The session closed: a later session with the same name is a fresh lane. */
332
+ forget(lane) {
333
+ this.named.delete(lane);
334
+ this.refused.delete(lane);
335
+ this.folded.delete(lane);
336
+ }
337
+ clear() {
338
+ this.named.clear();
339
+ this.refused.clear();
340
+ this.folded.clear();
341
+ }
342
+ state(lane) {
343
+ if (!this.named.has(lane))
344
+ return "not-a-lane";
345
+ if (this.folded.has(lane))
346
+ return "folded";
347
+ return this.refused.has(lane) ? "refused" : "unfolded";
348
+ }
349
+ }
350
+ /**
351
+ * Whether scout_close may close these sessions. Refused only when one of them
352
+ * is a lane whose report has not been accepted; `force` closes anyway. The
353
+ * message names every such lane and the two ways on.
354
+ */
355
+ export function laneCloseGuard(sessions, ledger, force = false) {
356
+ if (force)
357
+ return { ok: true };
358
+ const pending = sessions.filter((s) => ledger.state(s) === "refused" || ledger.state(s) === "unfolded");
359
+ if (pending.length === 0)
360
+ return { ok: true };
361
+ const one = pending.length === 1;
362
+ const lines = pending.map((s) => ledger.state(s) === "refused"
363
+ ? ` · ${JSON.stringify(s)}: its last report was REFUSED and no corrected one has been accepted`
364
+ : ` · ${JSON.stringify(s)}: no report from it has been accepted yet`);
365
+ const others = sessions.filter((s) => !pending.includes(s));
366
+ const message = `Not closed: ${one ? "this session is a lane" : `${pending.length} sessions are lanes`} of a parallel run whose report has not been folded.\n` +
367
+ lines.join("\n") +
368
+ `\nFolding keeps a lane's decisions in its own session's project memory and checks its defects were filed; closing the session first loses them. ` +
369
+ `Fold with scout_lane_report { lane, reply } (for a refused report, the lane's corrected object), then close. ` +
370
+ `To close anyway and lose ${one ? "that lane's" : "those lanes'"} decisions, pass force: true.` +
371
+ (others.length > 0
372
+ ? `\nThe other session(s) can be closed one by one by name meanwhile: ${others.map((s) => `scout_close { session: ${JSON.stringify(s)} }`).join(", ")}.`
373
+ : "");
374
+ return { ok: false, unfolded: pending, message };
375
+ }
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { normalizePath, shortHash } from "./fingerprint.js";
4
+ import { isFormBookkeeping } from "./forms.js";
4
5
  /**
5
6
  * The kinds a finding can be. One list: scout_finding's input schema, the lane
6
7
  * report a parallel agent hands back, and the skill text all read it from
@@ -282,6 +283,14 @@ function decisionKey(d) {
282
283
  * project would otherwise accumulate every decision ever made and re-serialise
283
284
  * them on each save, which is what made an old history slow to open.
284
285
  */
286
+ /**
287
+ * Whether a coverage key belongs to a control inside another site's frame:
288
+ * those keys carry the frame's origin (collector.ts frameElementKey), where
289
+ * the app's own frames carry a path. Not the app's to cover.
290
+ */
291
+ export function isEmbedKey(key) {
292
+ return /^frame:https?:\/\//.test(key);
293
+ }
285
294
  export const MAX_LANE_DECISIONS = 1000;
286
295
  /**
287
296
  * Most options a dropdown may have and still be tracked for unchosen options.
@@ -605,7 +614,7 @@ function liveOwnerPid(dir, isAlive) {
605
614
  * Best-effort and idempotent — a read-only checkout must never break attach,
606
615
  * and an existing file is left exactly as the user left it.
607
616
  */
608
- function writeSelfIgnore(dir) {
617
+ export function writeSelfIgnore(dir) {
609
618
  try {
610
619
  const ignorePath = path.join(dir, ".gitignore");
611
620
  if (fs.existsSync(ignorePath))
@@ -682,6 +691,7 @@ export class MemoryStore {
682
691
  this.injectionsReported.clear();
683
692
  this.auditsThisRun = 0;
684
693
  this.selectChoices.clear();
694
+ this.emptySubmits.clear();
685
695
  }
686
696
  /**
687
697
  * Each dropdown's options and the ones chosen in THIS run, by any session,
@@ -720,6 +730,55 @@ export class MemoryStore {
720
730
  }
721
731
  return out;
722
732
  }
733
+ /**
734
+ * Each form a session saw this run, by route and identity (forms.ts
735
+ * formIdentity: its own attributes, else its first submit control's
736
+ * coverage key), and whether any session has submitted it with every
737
+ * text field blank (forms.ts has the definition). Per run and shared across
738
+ * sessions, like the dropdown choices: an earlier run's empty submit says
739
+ * nothing about this build, and in a parallel run any lane may try it.
740
+ */
741
+ emptySubmits = new Map();
742
+ /**
743
+ * A form seen on a visited state. Recording it again changes nothing, except
744
+ * that `guarded` (every submit control disabled while its fields were blank)
745
+ * takes it off the list for good: the page refuses the empty submit itself,
746
+ * and no one could clear an entry whose submit cannot be pressed.
747
+ */
748
+ recordForm(fingerprint, key, guarded = false) {
749
+ const entry = this.formEntry(fingerprint, key);
750
+ if (entry && guarded)
751
+ entry.guarded = true;
752
+ }
753
+ /**
754
+ * A submit of a form already seen; `empty` when every text field was blank
755
+ * at the moment it went. It only marks: a submit never puts a form on the
756
+ * list, since a key read at submit time is the one most likely to be stale.
757
+ * Returns whether the form was on the list, so a miss can be logged.
758
+ */
759
+ recordFormSubmit(fingerprint, key, empty) {
760
+ const entry = this.formEntry(fingerprint, key, false);
761
+ if (entry && empty)
762
+ entry.triedEmpty = true;
763
+ return entry !== null;
764
+ }
765
+ formEntry(fingerprint, key, create = true) {
766
+ // Another site's frame is not the app's form to probe.
767
+ if (isEmbedKey(key))
768
+ return null;
769
+ const route = fingerprint.split("#")[0];
770
+ const id = `${route}\u0000${key}`;
771
+ let entry = this.emptySubmits.get(id) ?? null;
772
+ if (!entry && create) {
773
+ entry = { route, key, triedEmpty: false };
774
+ this.emptySubmits.set(id, entry);
775
+ }
776
+ return entry;
777
+ }
778
+ /** Forms seen this run that no session has submitted empty, in the order they were first seen. */
779
+ formsNeverSubmittedEmpty() {
780
+ return [...this.emptySubmits.values()].filter((f) => !f.triedEmpty && !f.guarded).map(({ route, key }) => ({ route, key }));
781
+ }
723
782
  constructor(projectDir) {
724
783
  this.dir = path.join(projectDir, MEMORY_DIRNAME);
725
784
  this.legacyDirNote = adoptLegacyMemoryDir(projectDir);
@@ -1339,7 +1398,8 @@ export class MemoryStore {
1339
1398
  // The planner's fold marker is not a step anyone took on a page. Left in,
1340
1399
  // its empty URL read as a route change and cut the trace of a finding filed
1341
1400
  // right after a fold — which is when the fold asks lanes to file.
1342
- const log = this.actionLog.filter((a) => a.action !== "lane-report");
1401
+ // Nor is the forms bookkeeping (a read that failed, a submit it could not match).
1402
+ const log = this.actionLog.filter((a) => a.action !== "lane-report" && !isFormBookkeeping(a.action));
1343
1403
  let start = Math.max(0, log.length - 12);
1344
1404
  for (let i = log.length - 1; i >= 0 && i >= log.length - 12; i--) {
1345
1405
  if (routeOf(log[i].url) !== routeOf(f.url)) {
@@ -1388,6 +1448,7 @@ export class MemoryStore {
1388
1448
  let elementsTotal = 0;
1389
1449
  let elementsExercised = 0;
1390
1450
  const unexercised = [];
1451
+ const embeds = { total: 0, exercised: 0 };
1391
1452
  for (const [route, elements] of byRoute) {
1392
1453
  const own = [];
1393
1454
  // The route's OWN element count — deduped across states and with shared
@@ -1398,6 +1459,12 @@ export class MemoryStore {
1398
1459
  // untouched route silently drops out of the gap ledger.
1399
1460
  let ownTotal = 0;
1400
1461
  for (const [key, done] of elements) {
1462
+ if (isEmbedKey(key)) {
1463
+ embeds.total += 1;
1464
+ if (done)
1465
+ embeds.exercised += 1;
1466
+ continue;
1467
+ }
1401
1468
  if (isChrome(key)) {
1402
1469
  chrome.set(key, (chrome.get(key) ?? false) || done);
1403
1470
  continue;
@@ -1424,7 +1491,7 @@ export class MemoryStore {
1424
1491
  if (chromeLeft.length > 0) {
1425
1492
  unexercised.push({ state: SHARED_CHROME_ROUTE, keys: chromeLeft, total: chrome.size });
1426
1493
  }
1427
- return { states: Object.keys(this.data.states).length, elementsTotal, elementsExercised, unexercised };
1494
+ return { states: Object.keys(this.data.states).length, elementsTotal, elementsExercised, unexercised, embeds };
1428
1495
  }
1429
1496
  /**
1430
1497
  * Remember which styled-element signatures the design audit saw on a route.
@@ -1,4 +1,13 @@
1
1
  import { redactSecrets } from "./memory.js";
2
+ /** How an HTTP error violation states its request. One formatter, so a reader of the detail (the check) cannot drift from its writer. */
3
+ export function httpErrorDetail(method, url, status) {
4
+ return `${method} ${url.slice(0, 200)} → HTTP ${status}`;
5
+ }
6
+ /** The status an http_error detail names, or null when the detail is not one. */
7
+ export function httpStatusOf(detail) {
8
+ const m = /→ HTTP (\d{3})$/.exec(detail.replace(/ \[\d+ secrets? redacted\]$/, ""));
9
+ return m ? Number(m[1]) : null;
10
+ }
2
11
  /** URLs whose failures are noise, not findings (favicons, source maps). */
3
12
  const BENIGN_URL_RE = /favicon|\.map($|\?)/i;
4
13
  /**
@@ -35,6 +44,68 @@ export function isPolicyInduced(v, msSincePolicyBlock) {
35
44
  return false;
36
45
  return /ERR_BLOCKED_BY_CLIENT|Failed to fetch|NetworkError when attempting to fetch|Load failed|the server responded with a status of 403\b/i.test(v.detail);
37
46
  }
47
+ /**
48
+ * The request a console message is the browser's own echo of, or null when it
49
+ * is not one.
50
+ *
51
+ * Chromium and WebKit print "Failed to load resource: …" for a subresource
52
+ * that answered with an error status or got no answer, and give the
53
+ * resource's address as the message's location (Firefox prints nothing; see
54
+ * `echoesFailedLoads` in browsers.ts). The line carries no address in its
55
+ * text, so without this it could only be charged to the page. Returned in the
56
+ * form `requestKey` gives a request's URL, so the two can be matched.
57
+ */
58
+ export function failedLoadEchoOf(text, locationUrl, pageUrl) {
59
+ if (!/^Failed to load resource: /.test(text) || !locationUrl)
60
+ return null;
61
+ try {
62
+ return requestKey(new URL(locationUrl, pageUrl).href);
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ /** A request's address as the echo lookup keys it: absolute, and without its fragment, which is never sent. */
69
+ export function requestKey(url) {
70
+ try {
71
+ const u = new URL(url);
72
+ u.hash = "";
73
+ return u.href;
74
+ }
75
+ catch {
76
+ return url;
77
+ }
78
+ }
79
+ /**
80
+ * Which recent requests an embed sent, by address, so the browser's echo of
81
+ * one that failed is charged where the request was.
82
+ *
83
+ * Only addresses an embed requested are kept, and the latest request to an
84
+ * address decides: when the app then requests the same address, its echo is
85
+ * the app's again. Bounded, dropping the oldest, so a long run does not hold
86
+ * every address it saw.
87
+ */
88
+ export class EmbedRequestLog {
89
+ cap;
90
+ byKey = new Map();
91
+ constructor(cap = 500) {
92
+ this.cap = cap;
93
+ }
94
+ note(url, embed) {
95
+ const key = requestKey(url);
96
+ this.byKey.delete(key);
97
+ if (!embed)
98
+ return;
99
+ this.byKey.set(key, embed);
100
+ if (this.byKey.size > this.cap)
101
+ this.byKey.delete(this.byKey.keys().next().value);
102
+ }
103
+ /** The embed whose request a console message echoes, or null when it echoes none of theirs. */
104
+ embedOfEcho(text, locationUrl, pageUrl) {
105
+ const key = failedLoadEchoOf(text, locationUrl, pageUrl);
106
+ return key === null ? null : (this.byKey.get(key) ?? null);
107
+ }
108
+ }
38
109
  /**
39
110
  * Invariant oracles: passive listeners that record violations regardless of
40
111
  * what the agent is doing. The engine drains the buffer after every action and
@@ -58,8 +129,12 @@ export class OracleMonitor {
58
129
  severity: "high",
59
130
  detail: text.slice(0, 500),
60
131
  url: page.url(),
132
+ // The one console line that names its request: the browser's echo of an embed's failed load is the embed's too.
133
+ embed: this.embedRequests.embedOfEcho(text, msg.location().url, page.url()) ?? undefined,
61
134
  });
62
135
  });
136
+ // Noted as each request starts, which is always before the browser can echo its failure.
137
+ page.on("request", (req) => this.embedRequests.note(req.url(), this.embedOfRequest(req)));
63
138
  page.on("pageerror", (err) => {
64
139
  this.record({
65
140
  kind: "page_error",
@@ -84,6 +159,7 @@ export class OracleMonitor {
84
159
  severity: "medium",
85
160
  detail: `${req.method()} ${req.url().slice(0, 200)} → ${failure}`,
86
161
  url: page.url(),
162
+ embed: this.embedOfRequest(req) ?? undefined,
87
163
  });
88
164
  });
89
165
  page.on("response", (res) => {
@@ -104,8 +180,9 @@ export class OracleMonitor {
104
180
  this.record({
105
181
  kind: "http_error",
106
182
  severity: status >= 500 ? "high" : "medium",
107
- detail: `${res.request().method()} ${res.url().slice(0, 200)} → HTTP ${status}`,
183
+ detail: httpErrorDetail(res.request().method(), res.url(), status),
108
184
  url: page.url(),
185
+ embed: this.embedOfRequest(res.request()) ?? undefined,
109
186
  });
110
187
  });
111
188
  }
@@ -128,6 +205,19 @@ export class OracleMonitor {
128
205
  * errors look the same.
129
206
  */
130
207
  policyAttributed = 0;
208
+ embedOfRequest = () => null;
209
+ embedRequests = new EmbedRequestLog();
210
+ /**
211
+ * The engine knows which frame a request came from; a failing request is
212
+ * attributed to an embed through this. Console and page errors are not
213
+ * attributed: a console message says where its script was served from, not
214
+ * which frame ran it, so an SDK the app's page loads from the embed's own
215
+ * site would be taken for the embed. The exception is the browser's echo of
216
+ * a failed load, which names its request and goes where the request went.
217
+ */
218
+ setEmbedAttribution(ofRequest) {
219
+ this.embedOfRequest = ofRequest;
220
+ }
131
221
  /** The engine knows exactly which requests its policy stopped; failed requests and stand-in refusals are matched against that, not against wording. */
132
222
  setPolicyRefusalCheck(check) {
133
223
  this.refusedByPolicy = check;
@@ -158,7 +248,11 @@ export class OracleMonitor {
158
248
  this.policyAttributed += 1;
159
249
  return;
160
250
  }
161
- const violation = { ...redactViolation(v), at: new Date().toISOString() };
251
+ // Another site's frame: its behaviour, reported, but never as the app's high-severity defect.
252
+ const attributed = v.embed ? { ...v, severity: "medium" } : v;
253
+ if (!attributed.embed)
254
+ delete attributed.embed;
255
+ const violation = { ...redactViolation(attributed), at: new Date().toISOString() };
162
256
  this.buffer.push(violation);
163
257
  this.all.push(violation);
164
258
  }
@@ -179,7 +273,8 @@ export class OracleMonitor {
179
273
  for (const v of out) {
180
274
  // Same normalization as the report rollup, so "the same violation"
181
275
  // means the same thing in tool output and in the final report.
182
- const sig = `${v.kind}: ${v.detail
276
+ // An embed's violation is not the app's: its signature says whose it is.
277
+ const sig = `${v.embed ? `[${v.embed}] ` : ""}${v.kind}: ${v.detail
183
278
  .replace(/\b\d+\b/g, ":n")
184
279
  .replace(/[0-9a-f]{8,}/gi, ":h")
185
280
  .slice(0, 140)}`;
@@ -205,7 +300,9 @@ export function formatViolations(violations) {
205
300
  if (fresh.length === 0) {
206
301
  return `\nORACLE: ${repeats} repeat violation(s) of previously reported signatures — nothing new.`;
207
302
  }
208
- const lines = fresh.slice(0, 10).map((v) => ` ⚠ [${v.severity}] ${v.kind}: ${v.detail}`);
303
+ const lines = fresh
304
+ .slice(0, 10)
305
+ .map((v) => ` ⚠ [${v.severity}] ${v.kind}${v.embed ? ` (in an embed of ${v.embed}: its behaviour, not the app's)` : ""}: ${v.detail}`);
209
306
  const more = fresh.length > 10 ? `\n … and ${fresh.length - 10} more` : "";
210
307
  return `\nORACLE VIOLATIONS since last action (${fresh.length} new):\n${lines.join("\n")}${more}${repeatLine}`;
211
308
  }
@@ -1,10 +1,23 @@
1
+ import { FORMS_READ_FAILED, FORMS_SUBMIT_UNMATCHED } from "./forms.js";
1
2
  /**
2
3
  * Log entries that are not actions: a stated task, an attach, the note that a
3
4
  * resource was created. They carry no browser work and take no time, so
4
5
  * counting them inflates the action count and drags the median gap toward
5
6
  * zero. One real run logged 325 entries of which 107 were stated tasks.
6
7
  */
7
- const MARKER_ACTIONS = new Set(["task", "attach", "close", "lane-report", "created-resource", "journey:start", "journey:end", "record:full", "record:failed"]);
8
+ const MARKER_ACTIONS = new Set([
9
+ "task",
10
+ "attach",
11
+ "close",
12
+ "lane-report",
13
+ "created-resource",
14
+ "journey:start",
15
+ "journey:end",
16
+ "record:full",
17
+ "record:failed",
18
+ FORMS_READ_FAILED,
19
+ FORMS_SUBMIT_UNMATCHED,
20
+ ]);
8
21
  /** Whether a log entry represents work the browser actually did. */
9
22
  export function isActing(action) {
10
23
  return !MARKER_ACTIONS.has(action);
@@ -394,6 +394,124 @@ export function allowsForeignWriteOnSignIn(mode, topPageUrl, appUrl) {
394
394
  const last = (segments[segments.length - 1] ?? "").replace(/\.[a-z0-9]+$/i, "").replace(/_/g, "-");
395
395
  return SIGN_IN_SEGMENT_RE.test(last);
396
396
  }
397
+ /**
398
+ * The embed a failing request is attributed to: the other site whose frame
399
+ * sent it (`frameSite`, judged on the frame chain), unless the request went to
400
+ * the app itself — a 500 from the app is the app's to answer, whoever called.
401
+ */
402
+ export function embedOfRequest(appUrl, requestUrl, frameSite) {
403
+ if (!frameSite)
404
+ return null;
405
+ return foreignFrameOrigin(appUrl, [requestUrl]) === null ? null : frameSite;
406
+ }
407
+ /** The most origins a session may trust with its embeds' writes. */
408
+ export const MAX_TRUSTED_EMBEDS = 10;
409
+ /**
410
+ * The origins a session was told to trust, normalised, and what was given
411
+ * that is not one. An entry must be a plain http(s) origin — scheme, host and
412
+ * port, nothing after — so a path or a wildcard cannot widen it by accident.
413
+ */
414
+ export function trustedEmbedOrigins(list) {
415
+ const origins = [];
416
+ const rejected = [];
417
+ const overflow = [];
418
+ for (const raw of list ?? []) {
419
+ let u;
420
+ try {
421
+ u = new URL(raw.trim());
422
+ }
423
+ catch {
424
+ rejected.push(raw);
425
+ continue;
426
+ }
427
+ const bare = u.pathname === "/" && !u.search && !u.hash && !u.username && !u.password;
428
+ if ((u.protocol !== "http:" && u.protocol !== "https:") || !bare || raw.includes("*")) {
429
+ rejected.push(raw);
430
+ continue;
431
+ }
432
+ // "pay.example.com." is "pay.example.com": one origin, one slot.
433
+ const origin = u.origin.replace(/\.(?=(:\d+)?$)/, "");
434
+ if (origins.includes(origin))
435
+ continue;
436
+ if (origins.length < MAX_TRUSTED_EMBEDS)
437
+ origins.push(origin);
438
+ else
439
+ overflow.push(raw);
440
+ }
441
+ return { origins, rejected, overflow };
442
+ }
443
+ /**
444
+ * Whether the writes a frame of `origin` sends outside the app may go out
445
+ * after all: only for an origin the user named as trusted (a provider in test
446
+ * mode, say), and only in safe-write — read-only and observe keep their
447
+ * promise, and destructive allows everything already.
448
+ */
449
+ export function trustsEmbedWrite(mode, trusted, origin) {
450
+ return mode === "safe-write" && trusted.has(origin);
451
+ }
452
+ /**
453
+ * Whether a foreign write may go out because of trust: every other site
454
+ * involved — each http(s) frame from the sender up to the page, and the
455
+ * Origin header when it names one — must be trusted, so an untrusted embed
456
+ * cannot borrow a trusted one it wraps. A page the session never adopted (a
457
+ * popup) is not a frame, and trust does not reach it.
458
+ */
459
+ export function trustsForeignWrite(mode, trusted, appUrl, req) {
460
+ if (mode !== "safe-write" || trusted.size === 0 || req.unadoptedPageUrl)
461
+ return false;
462
+ const originOf = (url) => {
463
+ if (!url)
464
+ return null;
465
+ try {
466
+ const u = new URL(url);
467
+ return u.protocol === "http:" || u.protocol === "https:" ? u.origin : null;
468
+ }
469
+ catch {
470
+ return null;
471
+ }
472
+ };
473
+ const app = originOf(appUrl);
474
+ const involved = new Set();
475
+ for (const url of req.frameChain) {
476
+ // A blank or srcdoc frame is its parent's, and the parent is judged next.
477
+ // Any other frame with no web address (blob:, data:) could be an untrusted
478
+ // site that moved itself there to wrap a trusted one: no trust through it.
479
+ if (/^about:(blank|srcdoc)/i.test(url))
480
+ continue;
481
+ const o = originOf(url);
482
+ if (!o)
483
+ return false;
484
+ if (o !== app)
485
+ involved.add(o);
486
+ }
487
+ const header = originOf(req.originHeader);
488
+ if (header && header !== app)
489
+ involved.add(header);
490
+ return involved.size > 0 && [...involved].every((o) => trustsEmbedWrite(mode, trusted, o));
491
+ }
492
+ /** The longest text typed into another site's frame; past it, a value is a fuzzing probe, not a user's input. */
493
+ export const MAX_EMBED_TEXT = 200;
494
+ /**
495
+ * Why a value must not be typed into another site's frame, or null when it
496
+ * may be. The tester is authorised to test the app, not the embeds of
497
+ * others: markup, fuzzing lengths and control characters typed there would
498
+ * be probing a third party's system without its leave.
499
+ */
500
+ export function hostileForEmbed(text) {
501
+ if (/<\s*[a-z!/?]/i.test(text) || /javascript:/i.test(text))
502
+ return "it is markup";
503
+ if (text.length > MAX_EMBED_TEXT)
504
+ return `it is longer than ${MAX_EMBED_TEXT} characters`;
505
+ if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(text))
506
+ return "it holds control characters";
507
+ return null;
508
+ }
509
+ /** The refusal for a probe aimed at another site's frame. */
510
+ export function embedProbeRefusal(what, origin) {
511
+ return (`REFUSED: ${what} inside a frame of ${origin}, another site embedded in the page. The tester is authorised to test the app, ` +
512
+ `not the embeds of others, so hostile input, repeated-click probes and file uploads are never sent into one, in any mode. ` +
513
+ `Ordinary clicks and typing there are allowed; the embed's writes out of the app are refused by the write policy.`);
514
+ }
397
515
  /**
398
516
  * The sandbox given to every document a frame of another origin loads, outside
399
517
  * destructive mode: scripts, forms and its own origin keep working, and no