scenescout 3.8.0 → 3.9.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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # scenescout
2
2
 
3
+ ## 3.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e2cf5bb: Snapshots list the controls inside the page's frames, each marked with its frame, and every action works on them by ref. In a frame of another site, container text is masked and typed markup, fuzzing-length values, repeated-click probes and uploads are refused in every mode; ordinary clicks and typing are allowed, and the write policy still refuses the writes such a frame sends outside the app.
8
+ - 5a48f6c: What happens inside another site's frame is attributed to it: failing requests and error responses it sent outside the app are labelled with its origin, kept at medium severity at most and grouped in their own report section (a request it sent to the app stays the app's), and its controls are counted apart from the app's coverage and gap ledger.
9
+ - fa71eb8: `scout_attach` takes `trustedEmbeds`, a list of origins the user trusts (a provider in test mode, say): in safe-write mode only, the writes their frames send outside the app go out, provided every other site involved (each frame up to the page, and the Origin header) is trusted. Anything that is not a plain http(s) origin is refused at attach, trust is ignored in the other modes, and the report names the list.
10
+
3
11
  ## 3.8.0
4
12
 
5
13
  ### Minor Changes
@@ -7,7 +7,7 @@ import { normalizeTask } from "./task.js";
7
7
  import { CLAIM_SCAN_SCRIPT, findContradictions } from "./claims.js";
8
8
  import { describeInjection, newInjections, probeQueries, probeScript, probeShape, rememberProbe } from "./injection.js";
9
9
  import { AuthLossTracker } from "./authloss.js";
10
- import { COLLECT_INTERACTABLES_SCRIPT, VISIBLE_SRC, geometryIssues, BROKEN_IMAGES_SCRIPT, brokenImageIssues, frameLines, hasVisibleFrame, displayName, missingName, } from "./collector.js";
10
+ import { COLLECT_INTERACTABLES_SCRIPT, VISIBLE_SRC, geometryIssues, BROKEN_IMAGES_SCRIPT, brokenImageIssues, frameLines, hasVisibleFrame, frameElementKey, frameLabel, masksForeignName, MASKED_NAME, capForeignName, stripForeignHref, frameToPageRect, displayName, missingName, } from "./collector.js";
11
11
  import { OracleMonitor, formatViolations } from "./oracles.js";
12
12
  import { extractCreatedIds, isOwnedResource, normalizeId } from "./ownership.js";
13
13
  import { formatJourney, measureJourney } from "./journey.js";
@@ -20,7 +20,7 @@ import { explainLaunchFailure, isMissingBrowser } from "./launch.js";
20
20
  import { ACTION_TIMEOUT_MS, performScroll, probeFocusIndicators, probeOverlays, scrollContainer } from "./probes.js";
21
21
  import { BROWSER_MARKER, reapOrphanBrowsers } from "./reaper.js";
22
22
  import { planUploadOptions, resolveDiskUpload } from "./uploads.js";
23
- import { answersWithRefusal, destructiveRefusal, isDestructive, isDestructiveWire, allowsWrite, policyRefusal, foreignFrameOrigin, foreignWrite, withForeignFrameSandbox, offAppPageWrite, EmbedMoveTracker, sandboxedRedirectPage, allowsForeignWriteOnSignIn, isAuthExempt, } from "./policy.js";
23
+ 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";
24
24
  import { scanProject } from "../scan.js";
25
25
  import { analyzeDesign, DESIGN_COLLECT_SCRIPT } from "./design.js";
26
26
  import { acceptMatches, generatedUpload } from "./fixtures.js";
@@ -77,6 +77,8 @@ function requestSource(req) {
77
77
  }
78
78
  /** The `why` of a top-window navigation refused as a possible frame escape; the notice words it on its own. */
79
79
  const ESCAPE_REFUSAL = "a possible frame escape";
80
+ /** The most frames a snapshot reads controls from. */
81
+ const MAX_READ_FRAMES = 10;
80
82
  /** How long a snapshot waits for its frames' elements to answer. */
81
83
  const FRAME_READ_MS = 1500;
82
84
  /** In-page XPath lookup fragment for string-expression evaluates. */
@@ -180,6 +182,9 @@ export class BrowserEngine {
180
182
  projectDirNote = "";
181
183
  memory = null;
182
184
  mode = "read-only";
185
+ /** Origins named as trusted embeds (policy.ts trustsEmbedWrite decides when that counts). */
186
+ trustedEmbeds = new Set();
187
+ trustNotice = "";
183
188
  /** Human label for the auth identity driving this session (the server sets it from the storage-state filename). */
184
189
  role = "anonymous";
185
190
  /** Named-session id (the server sets it; every engine shares one MemoryStore, so
@@ -628,6 +633,18 @@ export class BrowserEngine {
628
633
  throw new Error(`storageStatePath does not exist: ${opts.storageStatePath}`);
629
634
  }
630
635
  this.mode = opts.mode ?? "read-only";
636
+ const trust = trustedEmbedOrigins(opts.trustedEmbeds);
637
+ this.trustedEmbeds = new Set(trust.origins);
638
+ this.trustNotice =
639
+ (trust.origins.length > 0
640
+ ? this.mode === "safe-write"
641
+ ? ` Trusted embeds: ${trust.origins.join(", ")} — writes their frames send outside the app go out in this mode; hostile input, repeated-click probes and uploads stay refused.`
642
+ : this.mode === "destructive"
643
+ ? ` Trusted embeds (${trust.origins.join(", ")}) are not needed in destructive mode, which lets every embed's writes out.`
644
+ : ` Trusted embeds (${trust.origins.join(", ")}) are ignored in ${this.mode} mode: they apply in safe-write only.`
645
+ : "") +
646
+ (trust.rejected.length > 0 ? ` Not a plain http(s) origin, so not trusted: ${trust.rejected.join(", ")}.` : "") +
647
+ (trust.overflow.length > 0 ? ` More than ${MAX_TRUSTED_EMBEDS} trusted embeds; not trusted: ${trust.overflow.join(", ")}.` : "");
631
648
  this.sessionObjective = (opts.objective ?? "").trim().replace(/\s+/g, " ").slice(0, 300);
632
649
  // An agent-supplied task counts as stated; the placeholder does not.
633
650
  this.setTask(opts.task ?? "Attaching and taking stock", opts.task !== undefined);
@@ -665,6 +682,18 @@ export class BrowserEngine {
665
682
  }
666
683
  this.oracles = new OracleMonitor();
667
684
  this.oracles.setPolicyRefusalCheck((req) => this.refusedByPolicy.has(req));
685
+ // A request an embed sends to the app is the app's to answer: only one headed outside the app is the embed's.
686
+ this.oracles.setEmbedAttribution((req) => {
687
+ let site = null;
688
+ try {
689
+ const frame = req.frame();
690
+ site = frame === frame.page().mainFrame() ? null : this.foreignOriginOf(frame);
691
+ }
692
+ catch {
693
+ /* no frame: a service worker's or a new window's first request */
694
+ }
695
+ return embedOfRequest(this.baseUrl, req.url(), site);
696
+ });
668
697
  this.lastSnap = null;
669
698
  this.designAuditCount = 0;
670
699
  // The engine learns the route list itself so completion is an objective,
@@ -996,7 +1025,7 @@ export class BrowserEngine {
996
1025
  `Memory: ${this.memory.dir}.${this.memory.loadWarning ? ` WARNING: ${this.memory.loadWarning}` : ""}` +
997
1026
  (this.memory.prunedStates > 0 ? ` Trimmed ${this.memory.prunedStates} old page state(s) from the history; coverage is unchanged.` : "") +
998
1027
  `${this.memory.legacyDirNote ? ` ${this.memory.legacyDirNote}` : ""}` +
999
- `${this.memory.gitIgnoreNote ? ` ${this.memory.gitIgnoreNote}` : ""} Call scout_snapshot to see the current state.` +
1028
+ `${this.memory.gitIgnoreNote ? ` ${this.memory.gitIgnoreNote}` : ""}${this.trustNotice} Call scout_snapshot to see the current state.` +
1000
1029
  authWarning);
1001
1030
  }
1002
1031
  /** Whether the page was moved off the app by one of its embeds (policy.ts EmbedMoveTracker). */
@@ -1154,31 +1183,156 @@ export class BrowserEngine {
1154
1183
  if (stable)
1155
1184
  break;
1156
1185
  }
1186
+ const mainCount = rawElements.length;
1187
+ // Then each frame's own controls, read once, each within its own limit.
1188
+ const framed = await this.collectFrames(page);
1157
1189
  // Element identity is the coverage key (testid or role+name, ordinal-
1158
1190
  // disambiguated). When a key persists across snapshots of the same route,
1159
1191
  // its ref is REUSED — diffs stay meaningful and the agent's mental model
1160
- // (and previously issued refs) survive re-snapshots.
1192
+ // (and previously issued refs) survive re-snapshots. An element inside a
1193
+ // frame carries the frame in its key (collector.ts frameElementKey).
1161
1194
  const route = normalizePath(page.url());
1162
1195
  const prevByKey = this.lastSnap?.route === route ? this.lastSnap.byKey : null;
1163
1196
  this.refs.clear();
1197
+ this.refFrames.clear();
1164
1198
  const keyCounts = new Map();
1165
- const elements = rawElements.map((el) => {
1166
- const baseKey = elementKey(el);
1199
+ const all = [
1200
+ ...rawElements.map((raw) => ({ raw })),
1201
+ ...framed.flatMap((g) => g.raws.map((raw) => ({ raw: raw, frame: g.frame, tag: g.tag }))),
1202
+ ];
1203
+ const elements = all.map(({ raw: el, frame, tag }) => {
1204
+ const baseKey = frameElementKey(elementKey(el), tag);
1167
1205
  const count = keyCounts.get(baseKey) ?? 0;
1168
1206
  keyCounts.set(baseKey, count + 1);
1169
1207
  const key = count === 0 ? baseKey : `${baseKey}~${count}`;
1170
1208
  const ref = prevByKey?.get(key)?.ref ?? `e${++this.refCounter}`;
1171
1209
  const full = {
1172
1210
  ...el,
1211
+ ...(tag ? { frame: tag } : {}),
1212
+ // Judged on the real label, then masked: the policy must see what a click would press.
1213
+ destructive: isDestructive(el.name, el.testid),
1214
+ ...(tag?.foreign ? { name: masksForeignName(el.tag, el.role) ? MASKED_NAME : capForeignName(el.name) } : {}),
1215
+ ...(tag?.foreign && el.href ? { href: stripForeignHref(el.href) } : {}),
1173
1216
  ref,
1174
1217
  key,
1175
- destructive: isDestructive(el.name, el.testid),
1176
1218
  };
1177
1219
  this.refs.set(ref, full);
1220
+ if (frame)
1221
+ this.refFrames.set(ref, frame);
1178
1222
  return full;
1179
1223
  });
1180
- this.harvestRoutes(elements);
1181
- return { elements, truncated: rawElements.length >= 150 };
1224
+ this.harvestRoutes(elements.filter((el) => !el.frame?.foreign));
1225
+ this.framesRead = new Set(framed.map((g) => g.frame));
1226
+ return { elements, truncated: mainCount >= 150 };
1227
+ }
1228
+ /**
1229
+ * The controls inside the page's frames, up to MAX_READ_FRAMES visible ones,
1230
+ * each read within FRAME_READ_MS so one busy frame cannot stall a snapshot.
1231
+ * Positions are moved into the page's coordinates (the frame element's box
1232
+ * on screen, the frame's own scroll, the page's scroll), so geometry and
1233
+ * the design audit can place them.
1234
+ */
1235
+ async collectFrames(page) {
1236
+ const top = page.mainFrame();
1237
+ const frames = page.frames().filter((f) => f !== top && !f.isDetached());
1238
+ if (frames.length === 0)
1239
+ return [];
1240
+ const pageScroll = (await page.evaluate("({ x: window.scrollX, y: window.scrollY })").catch(() => null)) ?? {
1241
+ x: 0,
1242
+ y: 0,
1243
+ };
1244
+ const read = async (frame) => {
1245
+ let timer;
1246
+ const work = (async () => {
1247
+ const el = await frame.frameElement();
1248
+ const box = await el.boundingBox();
1249
+ if (!box || box.width < 2 || box.height < 2)
1250
+ return null;
1251
+ const title = ((await el.getAttribute("title")) || (await el.getAttribute("name")) || "").trim();
1252
+ const got = (await frame.evaluate(`(() => ({ els: ${COLLECT_INTERACTABLES_SCRIPT}, sx: window.scrollX, sy: window.scrollY }))()`));
1253
+ const raws = got.els.map((r) => ({ ...r, rect: frameToPageRect(r.rect, box, { x: got.sx, y: got.sy }, pageScroll) }));
1254
+ let origin = "";
1255
+ try {
1256
+ const u = new URL(frame.url());
1257
+ if (u.protocol === "http:" || u.protocol === "https:")
1258
+ origin = u.origin;
1259
+ }
1260
+ catch {
1261
+ /* no web address */
1262
+ }
1263
+ const foreignOrigin = this.foreignOriginOf(frame);
1264
+ return { frame, tag: { url: frame.url(), origin: foreignOrigin ?? origin, title, foreign: foreignOrigin !== null }, raws };
1265
+ })().catch(() => null);
1266
+ const limit = new Promise((resolve) => {
1267
+ timer = setTimeout(() => resolve(null), FRAME_READ_MS);
1268
+ });
1269
+ return Promise.race([work, limit]).finally(() => clearTimeout(timer));
1270
+ };
1271
+ // Visible frames first: a page's hidden plumbing frames must not use up the budget.
1272
+ const boxes = await Promise.all(frames.map((f) => this.withinFrameLimit(f.frameElement().then((el) => el.boundingBox()))));
1273
+ const visible = frames.filter((_, i) => {
1274
+ const b = boxes[i];
1275
+ return !!b && b.width >= 2 && b.height >= 2;
1276
+ });
1277
+ const got = await Promise.all(visible.slice(0, MAX_READ_FRAMES).map(read));
1278
+ return got.filter((g) => g !== null);
1279
+ }
1280
+ /** The origin of another site's frame an element is in, or null for the page and the app's own frames. */
1281
+ foreignEmbedOf(el) {
1282
+ const frame = this.refFrames.get(el.ref);
1283
+ const now = frame ? this.foreignOriginOf(frame) : null;
1284
+ if (now)
1285
+ return now;
1286
+ return el.frame?.foreign ? el.frame.origin || el.frame.url : null;
1287
+ }
1288
+ /**
1289
+ * The other site a frame belongs to, judged on the whole chain up to the
1290
+ * page: a blank or srcdoc frame inside another site's frame is that site's,
1291
+ * and so is a frame that once held its document (the foreign-frame record).
1292
+ */
1293
+ foreignOriginOf(frame) {
1294
+ const top = frame.page().mainFrame();
1295
+ const chain = [];
1296
+ for (let f = frame; f && f !== top; f = f.parentFrame()) {
1297
+ const recorded = this.foreignFrames.get(f);
1298
+ if (recorded)
1299
+ return recorded;
1300
+ chain.push(f.url());
1301
+ }
1302
+ return foreignFrameOrigin(this.baseUrl, chain);
1303
+ }
1304
+ /** A frame read that gives up after FRAME_READ_MS, answering null. */
1305
+ async withinFrameLimit(work) {
1306
+ let timer;
1307
+ const limit = new Promise((resolve) => {
1308
+ timer = setTimeout(() => resolve(null), FRAME_READ_MS);
1309
+ });
1310
+ return Promise.race([work.catch(() => null), limit]).finally(() => clearTimeout(timer));
1311
+ }
1312
+ /** Frames not directly under the page, less those whose controls were read anyway. */
1313
+ unreadNestedFrames(page, nested) {
1314
+ const top = page.mainFrame();
1315
+ const readNested = [...this.framesRead].filter((f) => !f.isDetached() && f.parentFrame() !== top).length;
1316
+ return Math.max(0, nested - readNested);
1317
+ }
1318
+ /** The frames whose controls the last snapshot listed. */
1319
+ framesRead = new Set();
1320
+ /** The frame each ref of the last snapshot lives in; refs of the page itself are absent. */
1321
+ refFrames = new Map();
1322
+ /**
1323
+ * Where to look for a ref's element: its frame, or the page. A frame that has
1324
+ * gone makes the ref stale, like a page that has navigated.
1325
+ */
1326
+ scopeOf(el) {
1327
+ const frame = this.refFrames.get(el.ref);
1328
+ if (!frame)
1329
+ return this.requirePage();
1330
+ if (frame.isDetached())
1331
+ throw new Error(`The frame holding ${el.ref} has gone — take a new scout_snapshot.`);
1332
+ // A frame that navigated holds a new document: the ref, and what it knew of the frame's site, are stale.
1333
+ if (el.frame && frame.url() !== el.frame.url)
1334
+ throw new Error(`The frame holding ${el.ref} has navigated — take a new scout_snapshot.`);
1335
+ return frame;
1182
1336
  }
1183
1337
  /**
1184
1338
  * Collect the current page and REGISTER it as a visited state, returning the
@@ -1285,7 +1439,7 @@ export class BrowserEngine {
1285
1439
  memory.wasExercised(fp, el.key) ? "done" : null,
1286
1440
  el.href ? `href=${el.href.slice(0, 60)}` : null,
1287
1441
  ].filter(Boolean);
1288
- return `${el.ref} ${el.role} "${displayName(el)}"${flags.length ? ` [${flags.join(", ")}]` : ""}`;
1442
+ return `${el.ref} ${el.role} "${displayName(el)}"${flags.length ? ` [${flags.join(", ")}]` : ""}${el.frame ? ` ⟨in ${frameLabel(el.frame)}⟩` : ""}`;
1289
1443
  };
1290
1444
  // Diff mode: when re-snapshotting the same route, report only what
1291
1445
  // changed — same idea as UI reconciliation, applied to agent context.
@@ -1334,7 +1488,15 @@ export class BrowserEngine {
1334
1488
  `Interactables (${elements.length}${truncated ? "+ — TRUNCATED at 150, dense page" : ""}${missingTestids ? `, ${missingTestids} missing data-testid` : ""}):\n` +
1335
1489
  elements.map(line).join("\n");
1336
1490
  }
1337
- const geometry = geometryIssues(elements, page.viewportSize() ?? { width: 1280, height: 900 });
1491
+ // Per document: the page's own controls together, and each frame's apart —
1492
+ // an embed's controls are not laid out against the page's.
1493
+ const viewport = page.viewportSize() ?? { width: 1280, height: 900 };
1494
+ const byDocument = new Map();
1495
+ for (const el of elements) {
1496
+ const doc = el.frame ? el.key.slice(0, el.key.indexOf("|") + 1) : "";
1497
+ byDocument.set(doc, [...(byDocument.get(doc) ?? []), el]);
1498
+ }
1499
+ const geometry = [...byDocument.values()].flatMap((group) => geometryIssues(group, viewport));
1338
1500
  geometry.push(...(await probeOverlays(page)));
1339
1501
  const hiddenFileInputs = await this.hiddenFileInputs(page);
1340
1502
  const brokenImages = brokenImageIssues((await page.evaluate(BROKEN_IMAGES_SCRIPT).catch(() => null)) ?? { images: [], total: 0 }, url);
@@ -1350,7 +1512,13 @@ export class BrowserEngine {
1350
1512
  (geometry.length > 0 ? `\nGEOMETRY issues:\n` + geometry.map((g) => ` ⚠ ${g}`).join("\n") : "") +
1351
1513
  (brokenImages.length > 0 ? `\nBROKEN IMAGES:\n` + brokenImages.map((b) => ` ⚠ ${b}`).join("\n") : "") +
1352
1514
  (frames.length > 0 || nestedFrames > 0
1353
- ? `\n` + frameLines(this.baseUrl, frames, { nested: nestedFrames, writesRefused: this.mode !== "destructive" }).join("\n")
1515
+ ? `\n` +
1516
+ frameLines(this.baseUrl, frames, {
1517
+ nested: this.unreadNestedFrames(page, nestedFrames),
1518
+ writesRefused: this.mode !== "destructive",
1519
+ trustedWrites: this.mode === "safe-write" ? this.trustedEmbeds : undefined,
1520
+ read: new Set([...this.framesRead].map((f) => f.url())),
1521
+ }).join("\n")
1354
1522
  : "") +
1355
1523
  (hiddenFileInputs.length > 0
1356
1524
  ? `\nFILE INPUTS not listed above (hidden behind a styled control — a user never sees the input itself): ${hiddenFileInputs.join("; ")}. ` +
@@ -1384,7 +1552,7 @@ export class BrowserEngine {
1384
1552
  }
1385
1553
  // String EXPRESSION via page.evaluate (locator.evaluate treats a string as
1386
1554
  // an expression, not a function — the element arg never binds).
1387
- const live = (await page
1555
+ const live = (await this.scopeOf(el)
1388
1556
  .evaluate(`(() => { const node = ${xpathLookup(el.xpath)}; if (!node) return null; ` +
1389
1557
  `return { testid: node.getAttribute('data-testid'), label: (node.getAttribute('aria-label') || node.innerText || node.textContent || node.getAttribute('placeholder') || '').trim().slice(0, 120) }; })()`)
1390
1558
  .catch(() => null));
@@ -1605,15 +1773,14 @@ export class BrowserEngine {
1605
1773
  }
1606
1774
  // Frames still attached that hold, or held, another site: one that moved itself to data: is no longer foreign by its URL.
1607
1775
  const pageHasForeignFrame = this.embeddedSites().size > 0;
1608
- const foreign = foreignWrite(this.baseUrl, {
1609
- url: req.url(),
1610
- originHeader: req.headers()["origin"],
1611
- unadoptedPageUrl,
1612
- pageHasForeignFrame,
1613
- ...requestSource(req),
1614
- });
1776
+ const source = requestSource(req);
1777
+ const originHeader = req.headers()["origin"];
1778
+ const foreign = foreignWrite(this.baseUrl, { url: req.url(), originHeader, unadoptedPageUrl, pageHasForeignFrame, ...source });
1615
1779
  if (foreign && allowsForeignWriteOnSignIn(this.mode, this.page?.url() ?? "", this.baseUrl))
1616
1780
  return null;
1781
+ // Frames of origins the user named as trusted, in safe-write, every one involved: the ordinary rules.
1782
+ if (foreign && trustsForeignWrite(this.mode, this.trustedEmbeds, this.baseUrl, { frameChain: source.frameChain, originHeader, unadoptedPageUrl }))
1783
+ return null;
1617
1784
  return foreign;
1618
1785
  }
1619
1786
  /** Report (and clear) write-policy blocks since the last action. */
@@ -1740,11 +1907,16 @@ export class BrowserEngine {
1740
1907
  this.logAction({ action: "click:refused", target: liveLabel || el.name, url: page.url() });
1741
1908
  return refusal;
1742
1909
  }
1910
+ const embed = this.foreignEmbedOf(el);
1911
+ if (embed && clicks > 1) {
1912
+ this.logAction({ action: "click:refused", target: `${clicks} clicks in a frame of ${embed}`, url: page.url() });
1913
+ return embedProbeRefusal(`a ${clicks}-click probe`, embed);
1914
+ }
1743
1915
  // Submit-shaped clicks that fire zero network requests are a smell
1744
1916
  // (silent no-op forms): capture the count before to compare after.
1745
1917
  const xhrBefore = this.xhrCount;
1746
1918
  const submitLike = el.role === "button" && /submit|send|save|create|apply|subscribe|register|sign|post|add\b/i.test(el.name + " " + (el.testid ?? ""));
1747
- const { forced } = await this.resilientClick(page.locator(`xpath=${el.xpath}`), ACTION_TIMEOUT_MS, clicks);
1919
+ const { forced } = await this.resilientClick(this.scopeOf(el).locator(`xpath=${el.xpath}`), ACTION_TIMEOUT_MS, clicks);
1748
1920
  this.memory.markExercised(this.currentFingerprint, el.key, clicks > 1 ? `click×${clicks}` : "click");
1749
1921
  const result = await this.afterAction(clicks > 1 ? `click×${clicks}` : "click", `${el.role} "${el.name}"`);
1750
1922
  // Impatient-user probe: a rapid multi-click that fires the SAME
@@ -1854,15 +2026,28 @@ export class BrowserEngine {
1854
2026
  const refusal = this.actionPolicyCheck(el, liveLabel);
1855
2027
  if (refusal)
1856
2028
  return refusal;
1857
- const locator = page.locator(`xpath=${el.xpath}`);
1858
- // Before the fill: a page that reflects input as it is typed already holds the element afterwards.
1859
- await this.noteProbe(text, `${el.role} "${el.name}"`);
1860
- const fillNote = await this.fillOrAppend(locator, text, replace);
2029
+ const embed = this.foreignEmbedOf(el);
2030
+ if (embed) {
2031
+ const hostile = hostileForEmbed(text);
2032
+ if (hostile) {
2033
+ this.logAction({ action: "type:refused", target: `${el.role} in a frame of ${embed} (${hostile})`, url: page.url() });
2034
+ return embedProbeRefusal(`typing this value (${hostile})`, embed);
2035
+ }
2036
+ }
2037
+ const locator = this.scopeOf(el).locator(`xpath=${el.xpath}`);
2038
+ // Before the fill: a page that reflects input as it is typed already holds
2039
+ // the element afterwards. Never for another site's frame: no probe is placed there.
2040
+ if (!embed)
2041
+ await this.noteProbe(text, `${el.role} "${el.name}"`);
2042
+ let fillNote = await this.fillOrAppend(locator, text, replace);
2043
+ // What another site's field already held is its business, not the report's.
2044
+ if (embed)
2045
+ fillNote = fillNote.replace(/existing content "(?:[^"\\]|\\.)*"/g, "existing content (masked: another site's frame)");
1861
2046
  if (pressEnter) {
1862
2047
  // Enter inside a form submits it — check the form's submit target, or
1863
2048
  // pressEnter becomes a read-only bypass for destructive submits.
1864
2049
  if (this.readOnly) {
1865
- const submitLabel = (await page
2050
+ const submitLabel = (await this.scopeOf(el)
1866
2051
  .evaluate(`(() => { const node = ${xpathLookup(el.xpath)}; if (!node) return ''; ` +
1867
2052
  `const f = node.form || node.closest('form'); if (!f) return ''; ` +
1868
2053
  `const s = f.querySelector('[type="submit"], button:not([type="button"]):not([type="reset"])'); ` +
@@ -1897,7 +2082,10 @@ export class BrowserEngine {
1897
2082
  const refusal = this.actionPolicyCheck(el, resolved.liveLabel);
1898
2083
  if (refusal)
1899
2084
  return refusal;
1900
- locator = page.locator(`xpath=${el.xpath}`);
2085
+ const embed = this.foreignEmbedOf(el);
2086
+ if (embed)
2087
+ return embedProbeRefusal("a file upload", embed);
2088
+ locator = this.scopeOf(el).locator(`xpath=${el.xpath}`);
1901
2089
  }
1902
2090
  const outcome = await this.performUpload(locator, opts);
1903
2091
  if (outcome.refused)
@@ -2094,7 +2282,7 @@ export class BrowserEngine {
2094
2282
  const page = this.requirePage();
2095
2283
  const { el } = await this.resolveForAction(ref);
2096
2284
  const { before, bodyBefore, churning } = await this.hoverBaselines();
2097
- const locator = page.locator(`xpath=${el.xpath}`);
2285
+ const locator = this.scopeOf(el).locator(`xpath=${el.xpath}`);
2098
2286
  await locator.hover({ timeout: ACTION_TIMEOUT_MS });
2099
2287
  // Wiggle inside the element: pointer-tracking libraries distinguish real
2100
2288
  // movement from a single synthetic hover event.
@@ -2104,7 +2292,7 @@ export class BrowserEngine {
2104
2292
  await page.mouse.move(box.x + box.width / 2 - 2, box.y + box.height / 2 - 1);
2105
2293
  }
2106
2294
  const { revealed, fallbackUsed } = await this.detectHoverReveal(before, bodyBefore, churning);
2107
- const attrTexts = (await page
2295
+ const attrTexts = (await this.scopeOf(el)
2108
2296
  .evaluate(`(() => { const node = ${xpathLookup(el.xpath)}; if (!node) return []; const out = []; ` +
2109
2297
  `const t = node.getAttribute('title'); if (t) out.push('title: ' + t.slice(0, 300)); ` +
2110
2298
  `const d = node.getAttribute('aria-describedby'); if (d) { for (const id of d.split(/\\s+/)) { ` +
@@ -2136,7 +2324,7 @@ export class BrowserEngine {
2136
2324
  return refusal;
2137
2325
  if (this.readOnly) {
2138
2326
  // Bulk-action dropdowns fire on change — vet the chosen option itself.
2139
- const optionLabel = (await page
2327
+ const optionLabel = (await this.scopeOf(el)
2140
2328
  .evaluate(`(() => { const node = ${xpathLookup(el.xpath)}; if (!node) return ''; const v = ${JSON.stringify(value)}; ` +
2141
2329
  `const opts = Array.from(node.options || []); ` +
2142
2330
  `const o = opts.find(o => o.value === v || o.label === v || (o.textContent || '').trim() === v); ` +
@@ -2147,7 +2335,7 @@ export class BrowserEngine {
2147
2335
  return destructiveRefusal(optionLabel || value, this.mode);
2148
2336
  }
2149
2337
  }
2150
- const loc = page.locator(`xpath=${el.xpath}`);
2338
+ const loc = this.scopeOf(el).locator(`xpath=${el.xpath}`);
2151
2339
  const options = el.tag === "select" ? await readSelectOptions(loc) : null;
2152
2340
  const picked = await loc.selectOption(value, { timeout: ACTION_TIMEOUT_MS });
2153
2341
  this.memory.markExercised(this.currentFingerprint, el.key, "select");
@@ -2160,11 +2348,37 @@ export class BrowserEngine {
2160
2348
  * policy as click, or the keyboard becomes a read-only bypass. Shared by
2161
2349
  * scout_press and plan press steps; returns a refusal message or null.
2162
2350
  */
2351
+ /**
2352
+ * The frame that holds keyboard focus: the page, or — when the page's
2353
+ * focused element is a frame — that frame, followed down as far as focus goes.
2354
+ */
2355
+ async focusedFrame(page) {
2356
+ let scope = page;
2357
+ let current = page.mainFrame();
2358
+ for (let depth = 0; depth < 5; depth++) {
2359
+ let next = null;
2360
+ for (const child of current.childFrames()) {
2361
+ // Within the frame limit: a frame that never answers must not hold up a key press.
2362
+ const holds = await this.withinFrameLimit(child.frameElement().then((el) => el.evaluate((node) => node === node.ownerDocument?.activeElement)));
2363
+ if (holds) {
2364
+ next = child;
2365
+ break;
2366
+ }
2367
+ }
2368
+ if (!next)
2369
+ break;
2370
+ scope = next;
2371
+ current = next;
2372
+ }
2373
+ return scope;
2374
+ }
2163
2375
  async vetFocusedActivation(key) {
2164
2376
  if (!(this.readOnly && /^(Enter|NumpadEnter|Space| )$/i.test(key)))
2165
2377
  return null;
2166
2378
  const page = this.requirePage();
2167
- const focusedLabel = await page
2379
+ // Focus inside a frame reads, from the page, as the <iframe> itself: follow it down.
2380
+ const focused = await this.focusedFrame(page);
2381
+ const focusedLabel = await focused
2168
2382
  .evaluate(`(() => { const el = document.activeElement; if (!el) return ""; ` +
2169
2383
  `return (el.getAttribute("aria-label") || el.getAttribute("data-testid") || el.innerText || el.textContent || "").trim().slice(0, 120); })()`)
2170
2384
  .catch(() => "");
@@ -402,13 +402,89 @@ export const BROKEN_IMAGES_SCRIPT = `(() => {
402
402
  }
403
403
  return { images, total };
404
404
  })()`;
405
+ /**
406
+ * An element's coverage key inside a frame: the frame's origin (another site)
407
+ * or path (the app's own) before the element's own key, so a "Submit" in an
408
+ * embed is not the page's "Submit". Elements of the page itself keep their key
409
+ * unchanged, so pages without frames keep the coverage they had.
410
+ */
411
+ export function frameElementKey(baseKey, frame) {
412
+ if (!frame)
413
+ return baseKey;
414
+ let where = frame.origin || frame.url;
415
+ if (!frame.foreign) {
416
+ try {
417
+ const u = new URL(frame.url);
418
+ // A frame with no web address (srcdoc, about:blank) is told apart by its title.
419
+ where = u.protocol === "http:" || u.protocol === "https:" ? u.pathname : `${frame.url}#${frame.title}`;
420
+ }
421
+ catch {
422
+ where = `${frame.url}#${frame.title}`;
423
+ }
424
+ }
425
+ return `frame:${where}|${baseKey}`;
426
+ }
427
+ /** The longest name kept for a control in another site's frame: a chat that renders messages as buttons would otherwise print them whole. */
428
+ export const MAX_FOREIGN_NAME = 40;
429
+ /** A control's name from another site's frame, cut to MAX_FOREIGN_NAME. */
430
+ export function capForeignName(name) {
431
+ return name.length > MAX_FOREIGN_NAME ? `${name.slice(0, MAX_FOREIGN_NAME)}…` : name;
432
+ }
433
+ /** A link's address from another site's frame without its query and fragment, where tokens and addresses travel. */
434
+ export function stripForeignHref(href) {
435
+ try {
436
+ const u = new URL(href);
437
+ return u.origin + u.pathname;
438
+ }
439
+ catch {
440
+ return href.split(/[?#]/)[0];
441
+ }
442
+ }
443
+ /**
444
+ * A rect read inside a frame, in the page's document coordinates: the frame
445
+ * element's box on screen, less the frame's own scroll, plus the page's.
446
+ */
447
+ export function frameToPageRect(rect, box, frameScroll, pageScroll) {
448
+ return { ...rect, x: rect.x + box.x + pageScroll.x - frameScroll.x, y: rect.y + box.y + pageScroll.y - frameScroll.y };
449
+ }
450
+ /** How a snapshot line names the frame an element is in. */
451
+ export function frameLabel(frame) {
452
+ let where = frame.url;
453
+ if (!frame.foreign) {
454
+ try {
455
+ const u = new URL(frame.url);
456
+ where = u.pathname + u.search;
457
+ }
458
+ catch {
459
+ /* shown as it is */
460
+ }
461
+ }
462
+ else if (frame.origin)
463
+ where = frame.origin;
464
+ return `${frame.foreign ? "cross-origin" : "same-origin"} frame ${where.slice(0, 80)}${frame.title ? ` "${frame.title.slice(0, 40)}"` : ""}`;
465
+ }
466
+ /**
467
+ * Whether an element's name, read from another site's frame, is masked. A
468
+ * name taken from a container's text — a select's options, a textarea's
469
+ * contents, a tagged <div> — can carry other people's data (a support chat,
470
+ * a customer record) into the snapshot, the transcript and the report. The
471
+ * label of a link, a button or a field is the interface itself and is kept:
472
+ * the agent needs it to act.
473
+ */
474
+ export function masksForeignName(tag, role) {
475
+ if (tag === "a" || tag === "button" || tag === "input")
476
+ return false;
477
+ return !/^(button|link|tab|menuitem|checkbox|switch|radio)$/.test(role);
478
+ }
479
+ /** The name shown for a masked element. */
480
+ export const MASKED_NAME = "(content masked: another site's frame)";
405
481
  /** A frame smaller than this in both directions is plumbing (a tracking pixel, a messaging bridge), not something a user sees. */
406
482
  const VISIBLE_FRAME_PX = 2;
407
483
  /**
408
- * The snapshot's account of the page's frames. Nothing inside a frame is
409
- * collected or can be acted on yet, and a page that shows its form in an
410
- * embed used to look like a page with no form at all: say what is there,
411
- * where it comes from, and that it was not looked inside. `nested` counts
484
+ * The snapshot's account of the page's frames: what is embedded, where it
485
+ * comes from, and whether its controls were read (`read`, by frame URL) —
486
+ * a page that shows its form in an embed used to look like a page with no
487
+ * form at all. Without `read`, nothing was looked inside. `nested` counts
412
488
  * frames that are not read: nested inside others, or past the first 30; `writesRefused` is false only in
413
489
  * destructive mode, where a foreign frame's writes do go out.
414
490
  */
@@ -436,12 +512,22 @@ export function frameLines(appUrl, frames, opts = {}) {
436
512
  /* about:blank, srcdoc: shown as they are */
437
513
  }
438
514
  const label = f.title ? ` "${f.title.slice(0, 60)}"` : "";
515
+ let frameOrigin = "";
516
+ try {
517
+ frameOrigin = new URL(f.url).origin;
518
+ }
519
+ catch {
520
+ /* no origin */
521
+ }
439
522
  const writes = f.foreign
440
523
  ? opts.writesRefused === false
441
524
  ? " — its writes go out (destructive mode)"
442
- : " — writes it sends outside the app are refused"
525
+ : opts.trustedWrites?.has(frameOrigin)
526
+ ? " — trusted embed: its writes go out (safe-write)"
527
+ : " — writes it sends outside the app are refused"
443
528
  : "";
444
- return ` ${f.foreign ? "cross-origin" : "same-origin"} ${where.slice(0, 120)}${label} ${f.width}×${f.height}${writes}`;
529
+ const read = opts.read ? (opts.read.has(f.url) ? " — controls listed above" : " — not read") : "";
530
+ return ` ${f.foreign ? "cross-origin" : "same-origin"} ${where.slice(0, 120)}${label} ${f.width}×${f.height}${read}${writes}`;
445
531
  });
446
532
  if (visible.length > 10)
447
533
  lines.push(` … +${visible.length - 10} more`);
@@ -449,7 +535,14 @@ export function frameLines(appUrl, frames, opts = {}) {
449
535
  lines.push(` (+${hidden} hidden frame${hidden === 1 ? "" : "s"})`);
450
536
  if (nested > 0)
451
537
  lines.push(` (+${nested} more frame${nested === 1 ? "" : "s"}, nested inside those or past the first 30, not read)`);
452
- return [`FRAMES not explored — their controls are not listed above and cannot be acted on:`, ...lines];
538
+ const header = opts.read && opts.read.size > 0
539
+ ? "FRAMES — the controls of each frame read are listed above, marked ⟨in … frame⟩, and can be acted on by ref" +
540
+ (visible.some((f) => f.foreign)
541
+ ? "; in another site's frame, content is masked and hostile input, repeated-click probes and uploads are refused"
542
+ : "") +
543
+ ":"
544
+ : "FRAMES not explored — their controls are not listed above and cannot be acted on:";
545
+ return [header, ...lines];
453
546
  }
454
547
  /** Whether any frame on the page is one a user can see. */
455
548
  export function hasVisibleFrame(frames) {
@@ -282,6 +282,14 @@ function decisionKey(d) {
282
282
  * project would otherwise accumulate every decision ever made and re-serialise
283
283
  * them on each save, which is what made an old history slow to open.
284
284
  */
285
+ /**
286
+ * Whether a coverage key belongs to a control inside another site's frame:
287
+ * those keys carry the frame's origin (collector.ts frameElementKey), where
288
+ * the app's own frames carry a path. Not the app's to cover.
289
+ */
290
+ export function isEmbedKey(key) {
291
+ return /^frame:https?:\/\//.test(key);
292
+ }
285
293
  export const MAX_LANE_DECISIONS = 1000;
286
294
  /**
287
295
  * Most options a dropdown may have and still be tracked for unchosen options.
@@ -1388,6 +1396,7 @@ export class MemoryStore {
1388
1396
  let elementsTotal = 0;
1389
1397
  let elementsExercised = 0;
1390
1398
  const unexercised = [];
1399
+ const embeds = { total: 0, exercised: 0 };
1391
1400
  for (const [route, elements] of byRoute) {
1392
1401
  const own = [];
1393
1402
  // The route's OWN element count — deduped across states and with shared
@@ -1398,6 +1407,12 @@ export class MemoryStore {
1398
1407
  // untouched route silently drops out of the gap ledger.
1399
1408
  let ownTotal = 0;
1400
1409
  for (const [key, done] of elements) {
1410
+ if (isEmbedKey(key)) {
1411
+ embeds.total += 1;
1412
+ if (done)
1413
+ embeds.exercised += 1;
1414
+ continue;
1415
+ }
1401
1416
  if (isChrome(key)) {
1402
1417
  chrome.set(key, (chrome.get(key) ?? false) || done);
1403
1418
  continue;
@@ -1424,7 +1439,7 @@ export class MemoryStore {
1424
1439
  if (chromeLeft.length > 0) {
1425
1440
  unexercised.push({ state: SHARED_CHROME_ROUTE, keys: chromeLeft, total: chrome.size });
1426
1441
  }
1427
- return { states: Object.keys(this.data.states).length, elementsTotal, elementsExercised, unexercised };
1442
+ return { states: Object.keys(this.data.states).length, elementsTotal, elementsExercised, unexercised, embeds };
1428
1443
  }
1429
1444
  /**
1430
1445
  * Remember which styled-element signatures the design audit saw on a route.
@@ -84,6 +84,7 @@ export class OracleMonitor {
84
84
  severity: "medium",
85
85
  detail: `${req.method()} ${req.url().slice(0, 200)} → ${failure}`,
86
86
  url: page.url(),
87
+ embed: this.embedOfRequest(req) ?? undefined,
87
88
  });
88
89
  });
89
90
  page.on("response", (res) => {
@@ -106,6 +107,7 @@ export class OracleMonitor {
106
107
  severity: status >= 500 ? "high" : "medium",
107
108
  detail: `${res.request().method()} ${res.url().slice(0, 200)} → HTTP ${status}`,
108
109
  url: page.url(),
110
+ embed: this.embedOfRequest(res.request()) ?? undefined,
109
111
  });
110
112
  });
111
113
  }
@@ -128,6 +130,17 @@ export class OracleMonitor {
128
130
  * errors look the same.
129
131
  */
130
132
  policyAttributed = 0;
133
+ embedOfRequest = () => null;
134
+ /**
135
+ * The engine knows which frame a request came from; a failing request is
136
+ * attributed to an embed through this. Console and page errors are not
137
+ * attributed: a console message says where its script was served from, not
138
+ * which frame ran it, so an SDK the app's page loads from the embed's own
139
+ * site would be taken for the embed.
140
+ */
141
+ setEmbedAttribution(ofRequest) {
142
+ this.embedOfRequest = ofRequest;
143
+ }
131
144
  /** The engine knows exactly which requests its policy stopped; failed requests and stand-in refusals are matched against that, not against wording. */
132
145
  setPolicyRefusalCheck(check) {
133
146
  this.refusedByPolicy = check;
@@ -158,7 +171,11 @@ export class OracleMonitor {
158
171
  this.policyAttributed += 1;
159
172
  return;
160
173
  }
161
- const violation = { ...redactViolation(v), at: new Date().toISOString() };
174
+ // Another site's frame: its behaviour, reported, but never as the app's high-severity defect.
175
+ const attributed = v.embed ? { ...v, severity: "medium" } : v;
176
+ if (!attributed.embed)
177
+ delete attributed.embed;
178
+ const violation = { ...redactViolation(attributed), at: new Date().toISOString() };
162
179
  this.buffer.push(violation);
163
180
  this.all.push(violation);
164
181
  }
@@ -179,7 +196,8 @@ export class OracleMonitor {
179
196
  for (const v of out) {
180
197
  // Same normalization as the report rollup, so "the same violation"
181
198
  // means the same thing in tool output and in the final report.
182
- const sig = `${v.kind}: ${v.detail
199
+ // An embed's violation is not the app's: its signature says whose it is.
200
+ const sig = `${v.embed ? `[${v.embed}] ` : ""}${v.kind}: ${v.detail
183
201
  .replace(/\b\d+\b/g, ":n")
184
202
  .replace(/[0-9a-f]{8,}/gi, ":h")
185
203
  .slice(0, 140)}`;
@@ -205,7 +223,9 @@ export function formatViolations(violations) {
205
223
  if (fresh.length === 0) {
206
224
  return `\nORACLE: ${repeats} repeat violation(s) of previously reported signatures — nothing new.`;
207
225
  }
208
- const lines = fresh.slice(0, 10).map((v) => ` ⚠ [${v.severity}] ${v.kind}: ${v.detail}`);
226
+ const lines = fresh
227
+ .slice(0, 10)
228
+ .map((v) => ` ⚠ [${v.severity}] ${v.kind}${v.embed ? ` (in an embed of ${v.embed}: its behaviour, not the app's)` : ""}: ${v.detail}`);
209
229
  const more = fresh.length > 10 ? `\n … and ${fresh.length - 10} more` : "";
210
230
  return `\nORACLE VIOLATIONS since last action (${fresh.length} new):\n${lines.join("\n")}${more}${repeatLine}`;
211
231
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { SHARED_CHROME_ROUTE } from "./memory.js";
3
+ import { SHARED_CHROME_ROUTE, isEmbedKey } from "./memory.js";
4
4
  import { sayVerification } from "./verify.js";
5
5
  import { feedForSession } from "./live.js";
6
6
  import { buildReplayHtml, evidenceFor } from "./replay.js";
@@ -59,6 +59,38 @@ function violationRollup(oracleLog) {
59
59
  ``,
60
60
  ];
61
61
  }
62
+ /**
63
+ * What came from other sites' frames: their controls, and the violations
64
+ * they caused, by origin. Kept apart from the app's coverage and rollup —
65
+ * an embed's failing request is the embed's behaviour, and its controls are
66
+ * not the app's to cover — but listed, since the app chose to embed them.
67
+ */
68
+ export function embedSection(violations, coverage) {
69
+ if (violations.length === 0 && coverage.total === 0)
70
+ return [];
71
+ const lines = [`## Embeds (other sites' frames)`, ``];
72
+ if (coverage.total > 0) {
73
+ lines.push(`${coverage.exercised}/${coverage.total} of their controls exercised; not counted in the app's coverage or its gap ledger.`, ``);
74
+ }
75
+ if (violations.length > 0) {
76
+ const byOrigin = new Map();
77
+ for (const v of violations) {
78
+ const origin = v.embed ?? "";
79
+ const sig = `${v.kind}: ${v.detail.replace(/\b\d+\b/g, ":n").slice(0, 120)}`;
80
+ const sigs = byOrigin.get(origin) ?? new Map();
81
+ sigs.set(sig, (sigs.get(sig) ?? 0) + 1);
82
+ byOrigin.set(origin, sigs);
83
+ }
84
+ lines.push(`Violations inside them (${violations.length}), reported as the embed's behaviour, not the app's:`, ``, `| Embed | Count | Signature |`, `|---|---|---|`);
85
+ for (const [origin, sigs] of byOrigin) {
86
+ for (const [sig, count] of [...sigs.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8)) {
87
+ lines.push(`| \`${escapeTableCell(origin)}\` | ${count} | \`${escapeTableCell(sig)}\` |`);
88
+ }
89
+ }
90
+ lines.push(``);
91
+ }
92
+ return lines;
93
+ }
62
94
  const SEVERITY_ORDER = { high: 0, medium: 1, low: 2 };
63
95
  const SEVERITY_ICON = { high: "🔴", medium: "🟠", low: "🟡" };
64
96
  /**
@@ -253,13 +285,15 @@ export function classifyFilledStates(memory, facts) {
253
285
  const unsubmitted = new Set();
254
286
  const noSubmitControl = new Set();
255
287
  for (const st of Object.values(memory.states)) {
256
- const keys = Object.keys(st.elements);
288
+ // Another site's frame is not the app's form: typing into an embed's chat leaves no app form unsubmitted.
289
+ const own = Object.fromEntries(Object.entries(st.elements).filter(([key]) => !isEmbedKey(key)));
290
+ const keys = Object.keys(own);
257
291
  const filledForReal = keys.some((key) => st.elements[key].exercised && /^(type|select|upload|plan:(type|select|upload))/.test(st.elements[key].lastAction ?? "") && !isFilterKey(key));
258
292
  if (!filledForReal)
259
293
  continue;
260
294
  if (facts[st.route]?.mutated || mutatedSiblingStep(st.route, facts))
261
295
  continue;
262
- if (offersSubmit(st.elements) || keys.length >= COLLECTOR_CAP)
296
+ if (offersSubmit(own) || keys.length >= COLLECTOR_CAP)
263
297
  unsubmitted.add(st.route);
264
298
  else
265
299
  noSubmitControl.add(st.route);
@@ -431,7 +465,8 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
431
465
  lines.push(`| States explored | ${cov.states} |`);
432
466
  if (extras)
433
467
  lines.push(`| Design audits this run (all sessions) | ${extras.designAudits} |`);
434
- lines.push(`| Oracle violations this session | ${oracleLog.length} |`);
468
+ const fromEmbeds = oracleLog.filter((v) => v.embed).length;
469
+ lines.push(`| Oracle violations this session | ${oracleLog.length - fromEmbeds}${fromEmbeds > 0 ? ` (plus ${fromEmbeds} inside other sites' frames)` : ""} |`);
435
470
  if (extras?.policyAttributed) {
436
471
  lines.push(`| Errors caused by the tester's own write-policy blocks (not counted above) | ${extras.policyAttributed} |`);
437
472
  }
@@ -614,7 +649,21 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
614
649
  lines.push(`- \`${r}\``);
615
650
  lines.push(``);
616
651
  }
617
- lines.push(...violationRollup(oracleLog));
652
+ if (extras?.trustedEmbeds && extras.trustedEmbeds.length > 0) {
653
+ lines.push(`## Trusted embeds`);
654
+ lines.push(``);
655
+ lines.push(extras.mode === "safe-write"
656
+ ? `Writes that frames of these origins sent outside the app were allowed, as the user asked; anything they created lives with that provider, and SceneScout cannot list it:`
657
+ : extras.mode === "destructive"
658
+ ? `Named as trusted, and not needed: destructive mode let every embed's writes out:`
659
+ : `Named as trusted, but not applied: trust only counts in safe-write mode, and this run was ${extras.mode ?? "read-only"}:`);
660
+ lines.push(``);
661
+ for (const o of extras.trustedEmbeds)
662
+ lines.push(`- \`${o}\``);
663
+ lines.push(``);
664
+ }
665
+ lines.push(...violationRollup(oracleLog.filter((v) => !v.embed)));
666
+ lines.push(...embedSection(oracleLog.filter((v) => v.embed), cov.embeds));
618
667
  if (cov.unexercised.length > 0) {
619
668
  lines.push(`## Unexplored surface (for the next run)`);
620
669
  lines.push(``);
@@ -243,6 +243,7 @@ function reportExtras(eng) {
243
243
  createdResources: eng.createdResources,
244
244
  unvisitedRoutes: unvisited,
245
245
  mode: eng.mode,
246
+ trustedEmbeds: [...eng.trustedEmbeds],
246
247
  policyAttributed: eng.oracleLog.policyAttributed,
247
248
  version: PKG_VERSION,
248
249
  attachedSessions: [...engines.keys()],
@@ -628,6 +629,12 @@ server.registerTool("scout_attach", {
628
629
  .max(60000)
629
630
  .optional()
630
631
  .describe("A floor between actions, in milliseconds, for when a person is watching and needs to keep up — following a flow, taking notes, demonstrating. Default 0: as fast as the page allows, which is what a run wants otherwise. Changeable mid-run with scout_session {paceMs}."),
632
+ trustedEmbeds: z
633
+ .array(z.string().max(200))
634
+ .max(10)
635
+ .optional()
636
+ .describe('Origins of embedded frames (e.g. "https://pay.example.com") whose writes out of the app may go out — ONLY when the user named them, typically a provider in test mode, and only in safe-write mode. ' +
637
+ "Never add one yourself. Hostile input, repeated-click probes and uploads stay refused in them."),
631
638
  record: z
632
639
  .boolean()
633
640
  .default(false)
@@ -639,7 +646,7 @@ server.registerTool("scout_attach", {
639
646
  .optional()
640
647
  .describe("Session name for multi-role runs (e.g. 'admin', 'qa'). Creates/replaces that session's browser and makes it the default. Default: 'default'."),
641
648
  },
642
- }, serializedControl(async ({ url, projectPath, storageStatePath, mode, headed, browser, viewportWidth, viewportHeight, objective, task, record, paceMs, session, }) => {
649
+ }, serializedControl(async ({ url, projectPath, storageStatePath, mode, headed, browser, viewportWidth, viewportHeight, objective, task, record, trustedEmbeds, paceMs, session, }) => {
643
650
  try {
644
651
  const target = session ?? activeName;
645
652
  if (session) {
@@ -722,6 +729,7 @@ server.registerTool("scout_attach", {
722
729
  paceMs,
723
730
  task: objective ? task : undefined,
724
731
  record,
732
+ trustedEmbeds,
725
733
  memoryStore: store,
726
734
  });
727
735
  eng.role = storageStatePath ? path.basename(storageStatePath).replace(/\.json$/i, "") : "anonymous";
@@ -1169,7 +1177,7 @@ server.registerTool("scout_coverage", {
1169
1177
  `⚠ MEMORY WRITE FAILING: ${eng.memory.lastSaveError} — coverage/findings since the last successful write are NOT persisted to disk. If this doesn't clear on its own, check the project directory still exists and is writable.`,
1170
1178
  ]
1171
1179
  : []),
1172
- `States known: ${cov.states} · Elements exercised: ${cov.elementsExercised}/${cov.elementsTotal}`,
1180
+ `States known: ${cov.states} · Elements exercised: ${cov.elementsExercised}/${cov.elementsTotal}${cov.embeds.total > 0 ? ` (plus ${cov.embeds.exercised}/${cov.embeds.total} inside other sites' frames, not counted)` : ""}`,
1173
1181
  formatRouteCoverage(eng.allKnownRoutes(), unvisited),
1174
1182
  `Unexercised elements by route:`,
1175
1183
  ...cov.unexercised.slice(0, 25).map((u) => ` ${u.state}: ${u.keys.slice(0, 6).join(", ")}${u.keys.length > 6 ? ` … +${u.keys.length - 6}` : ""}`),
@@ -1254,6 +1262,7 @@ server.registerTool("scout_report", {
1254
1262
  createdResources: eng.createdResources,
1255
1263
  unvisitedRoutes: unvisited,
1256
1264
  mode: eng.mode,
1265
+ trustedEmbeds: [...eng.trustedEmbeds],
1257
1266
  policyAttributed: eng.oracleLog.policyAttributed,
1258
1267
  // Which sessions are still open decides whether a quiet one is holding a browser, and how long its trailing idle runs.
1259
1268
  attachedSessions: [...engines.keys()],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenescout",
3
- "version": "3.8.0",
3
+ "version": "3.9.0",
4
4
  "description": "SceneScout — exploratory UI testing for AI coding agents. An MCP server that gives any agent (Claude Code, Cursor, VS Code Copilot, Codex, Gemini CLI and others) a structured view of a running web app, always-on oracles, a network-level write policy, memory across runs and a gap-checked report.",
5
5
  "license": "MIT",
6
6
  "author": "brunoboto96",
@@ -34,7 +34,7 @@ You are the brain of an exploratory UI tester. The SceneScout MCP server gives y
34
34
  1. **`scout_crawl` first, always.** One call visits every known route (pass `paths` to sweep a specific subset instead), records coverage, and returns per-route health. This is the whole breadth pass — do not visit routes one-by-one with navigate+snapshot.
35
35
  2. **Investigate what the crawl flagged.** For each problem route (violations, dead-ends, auth-redirects): navigate there, `scout_snapshot`, reproduce, then `scout_finding`.
36
36
  3. **Run journeys with `scout_run_plan {steps}`.** Mechanical sequences (fill form → submit → check) go in ONE plan call — `steps` is an ordered list of `{action, target, value}` — with `testid=`/`text=`/`label=` targets — not one LLM turn per click. The plan aborts at the first violation and tells you where; that's your cue to investigate interactively.
37
- 4. **Snapshot economics:** `scout_snapshot` after landing somewhere new; re-snapshots of the same route return *diffs* with stable refs — "No element changes" costs you almost nothing. `scout_screenshot` ONLY for suspected pixel-native issues (a canvas, a rendering glitch); geometry problems (overlap, off-screen, a covered control) are already in the snapshot as GEOMETRY issues, and images that failed to load are listed under BROKEN IMAGES — file those, quoting the line. A FRAMES line means part of the page is an embed (`<iframe>`) whose controls are not listed and cannot be acted on yet: say in your summary that its contents were not explored, and never file its absence as a missing feature. Writes a cross-origin frame sends outside the app are refused in every mode but destructive, because they reach the third party serving it.
37
+ 4. **Snapshot economics:** `scout_snapshot` after landing somewhere new; re-snapshots of the same route return *diffs* with stable refs — "No element changes" costs you almost nothing. `scout_screenshot` ONLY for suspected pixel-native issues (a canvas, a rendering glitch); geometry problems (overlap, off-screen, a covered control) are already in the snapshot as GEOMETRY issues, and images that failed to load are listed under BROKEN IMAGES — file those, quoting the line. Controls inside embeds (`<iframe>`) are listed with the page's, each marked `⟨in … frame⟩`, and act by ref like any other. A frame of the app's own origin is the app: test it fully. A frame of another site (`cross-origin`) is someone else's system that the user has not authorised you to test: click and type there as a user would, to see the embed render and respond, but never send it hostile input — the engine refuses markup, values over 200 characters, control characters, repeated-click probes and uploads there, and you must not try SQL, template or other injection shapes either — and file what you find there as the embed's behaviour, naming its origin, not as the app's bug. A failing request such a frame sent outside the app says so (`in an embed of …`) and is kept at medium; console errors cannot be told apart by frame and stay the app's; its controls are counted apart and never enter the app's coverage or gap ledger. Its container text is masked, and the writes it sends outside the app are refused in every mode but destructive — unless the user names that origin as a trusted embed (a provider in test mode, say): pass it in `scout_attach {trustedEmbeds: ["https://…"]}` and, in safe-write only, its writes go out. Never add an origin the user did not name. The FRAMES line names frames that were not read; say in your summary that their contents were not explored.
38
38
  5. **Native-user behaviours.** `scout_type {ref, textValue}` (or its alias `value`, matching `scout_select` and a plan step) APPENDS when a field already has content (menu clicks often insert @-mention chips or commands into composers — appending preserves them; the result reports what was already there); pass `replace=true` only to deliberately clear, and `pressEnter=true` to submit from the field the way a user would. Before concluding a badge, icon, or "N errors" indicator *does nothing*, `scout_hover` it — tooltips and hover cards are invisible to snapshots and clicks, and hover output includes what appeared. In HEADED mode (`scout_attach {headed:true}`, which the user asks for when they want to watch) the user's physical mouse competes with the synthetic pointer: if a hover reveals nothing and the finding matters, ask the user to move their mouse off the browser window and retry before filing. **Scroll long pages with `scout_scroll`** — the design audit and snapshot measure at the current scroll position, so judge deep sections by scrolling then re-auditing; it refuses to scroll where a real user couldn't and reports SCROLL LOCKED (the leaked modal scroll-lock that silently amputates everything below the fold — snapshots also flag it passively as an OVERLAY line), and scrolling triggers lazy-loaded content whose failures surface as fresh oracle violations. Elements fully clipped inside an overflow-hidden container are flagged UNREACHABLE in GEOMETRY issues — no amount of scrolling reveals them; that's a high-value layout bug, distinct from merely below-the-fold content. **A page can hold SEVERAL independent scroll regions** and plain `scout_scroll` moves the largest one, so a sidebar nav beside a taller main pane never budges: pass `scout_scroll {target:"testid=…"}` to scroll one region. Never report a nav item, tab or list row as missing/truncated until you have scrolled ITS container — content scrolled out of a secondary pane looks exactly like content that was cut off.
39
39
  6. **The rest of the input vocabulary.** `scout_select` sets a `<select>` option by value or visible label — use it rather than clicking a native dropdown open, which does not render as page DOM. `scout_press` sends a real key to the focused element (`Escape` to dismiss a modal, `Tab` to walk focus order, `Enter` to submit from a field); it is also how the keyboard-only pass at `extensive` is performed, and it vets the focused control first so a destructive action cannot be triggered blind in read-only mode. **`scout_upload {ref}` attaches a file the way a user does** — `ref` is a visible `<input type=file>` (snapshots list these with role `file`; `scout_type` on one redirects here) OR the button/label/dropzone that opens the file chooser (the chooser is intercepted and answered — that is how the hidden input behind a styled "Choose file" control is reached); omit `ref` when the page has exactly one file input, hidden or not (snapshots disclose hidden ones on a FILE INPUTS line). Nothing needs to exist on disk: a small VALID fixture is generated in memory, its kind inferred from the input's `accept` attribute or chosen with `fixture` (`pdf`, `png`, `txt`, `csv`, `json`); `filePath` uploads a real file but must live inside the attached project (fenced, like navigation is fenced to the origin); `name` overrides the filename. The result flags a file that violates `accept` (a mismatch the app then ACCEPTS is a validation finding), warns when the app cleared the input after selection, and says whether a state-changing request fired on selection — if none did, either click the form's submit or read the next snapshot for a client-side rejection. Plans take `{action:"upload", target, value:"pdf"}` steps (`target` required). When the input or its trigger was addressed by `ref`, the gap ledger counts an attached-but-unsent file as filled-never-submitted; the ref-less path has no listed element to mark.
40
40
  7. **Say what you are doing: `task` is required before a tool acts.** A session shows two lines to whoever is watching. Its **objective** is the whole remit you were given, set once at `scout_attach {objective}` ("Admin lane: §2 registers, §7 plan gating", "Approve and reject orders as a manager"). Its **task** is what you are doing *right now*, and every tool that changes the app or the page — `scout_navigate`, `scout_back`, `scout_click`, `scout_type`, `scout_select`, `scout_press`, `scout_upload`, `scout_run_plan` — takes it: a few words for the batch in front of you ("Filtering the documents register by status", "Filling the deviation form with invalid dates", "Signing in as QA_Team"). Say what you are DOING, not what you are checking — "§2.4 filtering narrows the set and is reflected in the URL" is the acceptance criteria, which is the result you will judge, not the batch you are running; naming the item is fine ("§2.4: filtering the documents register"). The task STAYS SET until you pass a different one, so a batch costs a few words, not one per call — pass a fresh one whenever you move on. Acting with none standing is refused: the person watching would otherwise see a session clicking through their app with nothing to say why. `scout_journey {action:"start", goal:…}` sets the task too while it runs — use a journey when you are MEASURING a whole user task, the parameter for everything else.