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 +8 -0
- package/dist/engine/browser.js +246 -32
- package/dist/engine/collector.js +100 -7
- package/dist/engine/memory.js +16 -1
- package/dist/engine/oracles.js +23 -3
- package/dist/engine/policy.js +118 -0
- package/dist/engine/report.js +54 -5
- package/dist/mcp-server.js +11 -2
- package/package.json +1 -1
- package/skills/scenescout/SKILL.md +1 -1
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
|
package/dist/engine/browser.js
CHANGED
|
@@ -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
|
|
1166
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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` +
|
|
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
|
|
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
|
|
1609
|
-
|
|
1610
|
-
|
|
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(
|
|
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
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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(() => "");
|
package/dist/engine/collector.js
CHANGED
|
@@ -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
|
|
409
|
-
*
|
|
410
|
-
* embed used to look like a page with no
|
|
411
|
-
*
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/engine/memory.js
CHANGED
|
@@ -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.
|
package/dist/engine/oracles.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/dist/engine/policy.js
CHANGED
|
@@ -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
|
package/dist/engine/report.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(``);
|
package/dist/mcp-server.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|