scenescout 1.3.0 → 1.4.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,18 @@
1
1
  # scenescout
2
2
 
3
+ ## 1.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 01fe452: The engine now notices when a value it typed comes back as markup. Any markup-shaped value the agent types — `<script>…</script>`, `<img src=x onerror=…>`, a `<b>` — is remembered by shape, and every page seen afterwards is checked for an element of that shape. When one is found, a `dom_injection` violation (severity high) names the field it was typed into, the page it was typed on, the page it rendered on and the element it became: whoever opens that page runs the input, which is a stored or reflected XSS. The oracle never chooses what to type; the method asks for markup in the fuzzing pass, and the rest is the agent's judgment.
8
+
9
+ ### Patch Changes
10
+
11
+ - 2deca2a: Two findings that name the same endpoint no longer merge unless both name a failure status for it. A double submit and an accepted bad value can both mention `POST /api/orders` and are two bugs; the second one filed used to be absorbed into the first without a trace.
12
+ - 4e10197: The live view's header keeps its two buttons together when it wraps on a narrow screen, numbers that tick every second (badges, feed times, the session counts) use tabular numerals so they no longer jitter, the feed's journey groups are marked by their tint with a hairline rather than a stripe, and the scrolling panels and text selection take the page's own palette.
13
+ - 978022a: Every record a session creates is now named in the result of the action that created it (`created: /api/things id=44`), including the second and later ones on an endpoint. The state-changing-request notice reports each endpoint once per session, which in safe-write mode hid every creation after the first, so the agent could not tell from the result that it had just made one.
14
+ - dd8927a: `scout_run_plan` now prints a type step's note about the field (such as "replaced existing content") on that step's own line. It used to appear under the previous step, so a reader concluded the wrong field was prefilled.
15
+
3
16
  ## 1.3.0
4
17
 
5
18
  ### Minor Changes
package/README.md CHANGED
@@ -506,6 +506,7 @@ src/
506
506
  probes.ts in-page scroll + overlay + focus probes (needs a browser too)
507
507
  fingerprint.ts route + element-set identity (state hashing)
508
508
  oracles.ts console/page/network/HTTP error detection
509
+ injection.ts the DOM-injection oracle's rules (what to watch for, how to find it)
509
510
  policy.ts the write-policy safety net
510
511
  ownership.ts safe-write: which records did this run create?
511
512
  uploads.ts disk uploads, fenced to the project by real path
@@ -545,7 +546,7 @@ Working on SceneScout itself is the only reason to clone it:
545
546
  git clone https://github.com/brunoboto96/SceneScout.git scenescout && cd scenescout
546
547
  npm install # installs dependencies and builds
547
548
  npm run setup # same as `scenescout install`, but registers THIS checkout (the skill is linked, so edits are live)
548
- npm test # build + 12 suites: scan, oracle, policy, fixture, dispatch, design,
549
+ npm test # build + 14 suites: scan, oracle, policy, fixture, dispatch, design,
549
550
  # contract, memory, install, hygiene, smoke, mcp-check
550
551
  npm run demo # regenerate examples/ from the demo app
551
552
  ```
@@ -566,7 +567,7 @@ Found a way past the write policy, or another security problem? Please report it
566
567
  - **Structured render-state, not pixels.** Element lists with geometry; screenshots reserved for pixel-native residue (canvas, rendering glitches). Images that failed to load are reported from the DOM, including ones whose URL answered 200 with something that is not an image.
567
568
  - **Diff snapshots with stable refs.** Re-snapshots return only what changed (10.7 kB → 0.7 kB on a 130-element page); old refs stay valid.
568
569
  - **Geometry oracles.** Overlap and off-screen defects computed from layout boxes.
569
- - **Oracles after every action.** Console errors, page errors, failed requests, HTTP 4xx/5xx drained into every tool result.
570
+ - **Oracles after every action.** Console errors, page errors, failed requests, HTTP 4xx/5xx drained into every tool result — and DOM injection: a markup-shaped value the agent typed that later renders as an element on any page (stored or reflected XSS).
570
571
  - **Multi-role, genuinely concurrent.** Commands to *different* sessions run in parallel; safe-write ownership is shared, so role A can create what role B approves. The report renders a role capability matrix.
571
572
  - **Task ease, not just correctness.** `scout_journey` measures interaction cost, distinct screens, path, and backtracks.
572
573
  - **Design audit with page scores.** Two tiers (⚠ measurable defects / → craft suggestions incl. AI-slop tells), per-page 0–100 score persisted per route, plus an automatic overlay/modal probe on every snapshot. Shared shell scored once, separately.
package/dist/cli.js CHANGED
@@ -17,7 +17,7 @@ import { APPROX_DISK_MB, BROWSER_ENGINES, browserPresence, defaultAttachNote, de
17
17
  import { CLIENT_LABELS, firstMessageHint, manualFor, parseClients, registerWithClient, vscodeBinary } from "./clients.js";
18
18
  import { CLI_NAME, diagnose, ensureCommand, findOnUserPath, installSkill, isEphemeralRoot, launchCommand, manualRegisterCommand, planCommand, registerMcp, resolveClaudeDir, spawnRunner, } from "./installer.js";
19
19
  import { LEGACY_MEMORY_DIRNAME, MEMORY_DIRNAME } from "./engine/memory.js";
20
- import { localClock, formatSessionLine, LIVE_TOKEN_FILE, watchTarget, wholeSessions } from "./engine/live.js";
20
+ import { formatStatus, localClock, LIVE_TOKEN_FILE, watchTarget } from "./engine/live.js";
21
21
  import { formatScan, scanProject } from "./scan.js";
22
22
  const here = path.dirname(fileURLToPath(import.meta.url));
23
23
  const packageRoot = path.resolve(here, "..");
@@ -98,28 +98,8 @@ function status(projectPath) {
98
98
  return;
99
99
  }
100
100
  const alive = pidAlive(st.pid);
101
- const age = st.at ? Math.round((Date.now() - new Date(st.at).getTime()) / 1000) : null;
102
- console.log(`Engine pid ${st.pid ?? "?"} — ${alive ? "ALIVE" : "not running (stale status)"}`);
103
- console.log(`${st.phase === "running" ? "⏳ running" : "· idle after"}: ${st.tool ?? "?"}${age !== null ? ` (as of ${age}s ago)` : ""}`);
104
- // The file is written by another process and can be caught mid-write, so
105
- // only entries whole enough to describe are described.
106
- const sessions = wholeSessions(st.detail);
107
- if (sessions.length > 0) {
108
- // One line per session. The single "Session:" line below it is all an
109
- // engine from before the live view can offer.
110
- console.log(`Sessions (${sessions.length}):`);
111
- for (const entry of sessions)
112
- console.log(` ${formatSessionLine(entry, Date.now())}`);
113
- if (alive && st.live?.port)
114
- console.log("Live view: scenescout watch");
115
- if (alive && st.live?.error)
116
- console.log(`Live view unavailable: ${st.live.error}`);
117
- }
118
- else {
119
- console.log(`Session: ${st.session ?? "?"} (${st.role ?? "?"})${st.sessions && st.sessions.length > 1 ? ` · all sessions: ${st.sessions.join(", ")}` : ""}`);
120
- if (st.url)
121
- console.log(`URL: ${st.url}`);
122
- }
101
+ for (const line of formatStatus(st, alive, Date.now()))
102
+ console.log(line);
123
103
  // Recent actions from the newest session log — the "what has it been doing" trail.
124
104
  const logs = fs.existsSync(dir)
125
105
  ? fs
@@ -3,6 +3,7 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { elementKey, fingerprintState, isNonPageRoute, normalizePath } from "./fingerprint.js";
5
5
  import { AUTH_LOSS_PREFIX, JOURNEY_END, JOURNEY_START, MemoryStore } from "./memory.js";
6
+ import { describeInjection, newInjections, probeQueries, probeScript, probeShape, rememberProbe } from "./injection.js";
6
7
  import { AuthLossTracker } from "./authloss.js";
7
8
  import { COLLECT_INTERACTABLES_SCRIPT, VISIBLE_SRC, geometryIssues, BROKEN_IMAGES_SCRIPT, brokenImageIssues, } from "./collector.js";
8
9
  import { OracleMonitor, formatViolations } from "./oracles.js";
@@ -184,6 +185,20 @@ export class BrowserEngine {
184
185
  return this.memory?.createdResources ?? this.localCreatedResources;
185
186
  }
186
187
  localCreatedResources = [];
188
+ /** Creations not yet named in a tool result. Unlike the once-per-session mutation notice, every one is reported. */
189
+ newlyCreated = [];
190
+ /**
191
+ * Name every record created since the last result. The mutation notice
192
+ * reports each endpoint once per session so a read-only run is not flooded;
193
+ * in safe-write that hid every creation after the first on an endpoint,
194
+ * so the agent could not tell from the result that it had just made one.
195
+ */
196
+ drainCreated() {
197
+ if (this.newlyCreated.length === 0)
198
+ return "";
199
+ const list = this.newlyCreated.splice(0);
200
+ return `\n(created: ${list.join(", ")} — this session may edit or delete ${list.length === 1 ? "it" : "them"})`;
201
+ }
187
202
  /** Design audits run this session — the report gate requires at least one. */
188
203
  designAuditCount = 0;
189
204
  /** Active task-efficiency measurement (scout_journey), if any. */
@@ -248,6 +263,54 @@ export class BrowserEngine {
248
263
  * allowed POST /items that shares its prefix.
249
264
  */
250
265
  abortedByPolicy = new WeakSet();
266
+ /** Markup-shaped values this session typed, so every later page can be checked for them rendering as elements. */
267
+ probes = [];
268
+ /** Injections already reported, by payload and route, so a page is not reported on every snapshot. */
269
+ injectionsReported = new Set();
270
+ /**
271
+ * Remember a typed value when it holds an element worth watching for. What
272
+ * to type is the agent's choice; this only watches for the outcome. The
273
+ * page's current count of such elements is the baseline: shared chrome that
274
+ * happens to look like the payload is not an injection.
275
+ */
276
+ async noteProbe(value, field) {
277
+ const shape = probeShape(value);
278
+ if (!shape || this.probes.some((p) => p.payload === shape.payload))
279
+ return;
280
+ const page = this.page;
281
+ let baseline = 0;
282
+ if (page && !page.isClosed()) {
283
+ try {
284
+ baseline = (await page.evaluate(probeScript([shape]))).length;
285
+ }
286
+ catch {
287
+ // A page mid-navigation: no baseline, so a coincidence on this page would be reported. Rare, and visible.
288
+ }
289
+ }
290
+ this.probes = rememberProbe(this.probes, { ...shape, field, typedOn: page?.url() ?? "", baseline });
291
+ }
292
+ /**
293
+ * The DOM-injection oracle: has anything this session typed come back as an
294
+ * element on the current page? Runs wherever violations are drained, so the
295
+ * finding reaches the agent in the result of the action that revealed it.
296
+ */
297
+ async scanForInjections() {
298
+ const page = this.page;
299
+ if (!page || page.isClosed() || this.probes.length === 0)
300
+ return;
301
+ const url = page.url();
302
+ let hits;
303
+ try {
304
+ hits = (await page.evaluate(probeScript(probeQueries(this.probes))));
305
+ }
306
+ catch {
307
+ // A page mid-navigation has no DOM to ask; the next drain looks again.
308
+ return;
309
+ }
310
+ for (const found of newInjections(this.probes, hits, url, this.injectionsReported)) {
311
+ this.oracles.noteInjection(describeInjection(found.probe, url, found.outer), url);
312
+ }
313
+ }
251
314
  /** Raw mutation sigs of the most recent action (pre-dedup) — double-submit detection. */
252
315
  lastActionMutationSigs = [];
253
316
  /** Write-policy blocks drained by the last action — counted, so an action can know a request fired even when the policy stopped it. */
@@ -641,6 +704,7 @@ export class BrowserEngine {
641
704
  const isNew = memory.visitState(fp, url, route, elements.map((el) => el.key));
642
705
  memory.recordRoleAccess(this.role, route, "reached");
643
706
  this.logAction({ action: "snapshot", url, result: fp });
707
+ await this.scanForInjections();
644
708
  const line = (el) => {
645
709
  const dup = el.key.match(/~(\d+)$/);
646
710
  const flags = [
@@ -798,8 +862,9 @@ export class BrowserEngine {
798
862
  formatViolations(this.oracles.drain()));
799
863
  }
800
864
  this.logAction({ action, target, url });
865
+ await this.scanForInjections();
801
866
  const violations = this.oracles.drain();
802
- const mutations = this.drainMutations() + this.drainBlocked();
867
+ const mutations = this.drainMutations() + this.drainBlocked() + this.drainCreated();
803
868
  const navigated = this.snapshotUrl !== "" && url !== this.snapshotUrl;
804
869
  if (navigated) {
805
870
  // Refs point into the previous page's DOM; invalidate so a stale ref
@@ -847,6 +912,7 @@ export class BrowserEngine {
847
912
  const desc = `${verdict.collection} id=${id}`;
848
913
  if (!this.createdResources.includes(desc)) {
849
914
  this.createdResources.push(desc);
915
+ this.newlyCreated.push(desc);
850
916
  this.logAction({ action: "created-resource", target: desc, url: this.page?.url() ?? "" });
851
917
  }
852
918
  }
@@ -1081,6 +1147,8 @@ export class BrowserEngine {
1081
1147
  if (refusal)
1082
1148
  return refusal;
1083
1149
  const locator = page.locator(`xpath=${el.xpath}`);
1150
+ // Before the fill: a page that reflects input as it is typed already holds the element afterwards.
1151
+ await this.noteProbe(text, `${el.role} "${el.name}"`);
1084
1152
  const fillNote = await this.fillOrAppend(locator, text, replace);
1085
1153
  if (pressEnter) {
1086
1154
  // Enter inside a form submits it — check the form's submit target, or
@@ -1614,6 +1682,7 @@ export class BrowserEngine {
1614
1682
  if (typeof status === "number" && status >= 400)
1615
1683
  memory.markAttempted(requestedRoute, `status:${status}`, this.role);
1616
1684
  this.logAction({ action: "crawl", target: path, url: finalUrl });
1685
+ await this.scanForInjections();
1617
1686
  const violations = this.oracles.drain();
1618
1687
  const deadEnd = elements.length === 0;
1619
1688
  const unnamed = elements.filter((el) => !el.name).length;
@@ -1686,6 +1755,9 @@ export class BrowserEngine {
1686
1755
  let preTestid = null;
1687
1756
  let preLabel = "";
1688
1757
  let forcedClick = false;
1758
+ // What a type step has to say about the field it typed into; it goes on
1759
+ // the step's own line, so it cannot read as the previous step's.
1760
+ let note = "";
1689
1761
  let preState = null;
1690
1762
  try {
1691
1763
  if (step.action === "navigate") {
@@ -1755,9 +1827,9 @@ export class BrowserEngine {
1755
1827
  : ` hover revealed nothing within ${HOVER_REVEAL_WINDOW_MS / 1000}s`);
1756
1828
  }
1757
1829
  else if (step.action === "type") {
1830
+ await this.noteProbe(step.value ?? "", step.target ?? "the field");
1758
1831
  const fillNote = await this.fillOrAppend(loc, step.value ?? "", step.replace ?? false);
1759
- if (fillNote)
1760
- transcript.push(` ${fillNote.trim()}`);
1832
+ note = fillNote;
1761
1833
  if (step.pressEnter) {
1762
1834
  if (this.readOnly) {
1763
1835
  const submit = loc.locator("xpath=ancestor::form[1]").locator('[type="submit"], button:not([type="button"]):not([type="reset"])').first();
@@ -1826,19 +1898,20 @@ export class BrowserEngine {
1826
1898
  /* coverage bookkeeping must never fail the plan */
1827
1899
  }
1828
1900
  }
1901
+ await this.scanForInjections();
1829
1902
  const violations = this.oracles.drain();
1830
- const mutations = this.drainMutations() + this.drainBlocked();
1903
+ const mutations = this.drainMutations() + this.drainBlocked() + this.drainCreated();
1831
1904
  // Abort only on NEW violations: a known-failing endpoint repeating on
1832
1905
  // every navigation must not make every plan abort at step 1.
1833
1906
  if (violations.some((v) => !v.repeat)) {
1834
- transcript.push(`${desc} → OK, but oracle fired:${formatViolations(violations)}${mutations}`);
1907
+ transcript.push(`${desc} → OK${note}, but oracle fired:${formatViolations(violations)}${mutations}`);
1835
1908
  transcript.push(`PLAN ABORTED at step ${i + 1} — investigate before continuing.`);
1836
1909
  break;
1837
1910
  }
1838
1911
  const forcedNote = forcedClick
1839
1912
  ? " (forced — the strict click timed out on this element's hit-test/stability check but a forced click still landed; something may render on top of it or delegate via a label, cross-check GEOMETRY overlaps before calling it a bug)"
1840
1913
  : "";
1841
- transcript.push(`${desc} → OK (${page.url()})${mutations}${forcedNote}`);
1914
+ transcript.push(`${desc} → OK (${page.url()})${note}${mutations}${forcedNote}`);
1842
1915
  }
1843
1916
  catch (err) {
1844
1917
  const fullMsg = err instanceof Error ? err.message : String(err);
@@ -0,0 +1,162 @@
1
+ /**
2
+ * The DOM-injection oracle's rules: what a typed value has to look like to be
3
+ * worth watching for, how to look for it on a later page, and how to describe
4
+ * it when it turns up as an element.
5
+ *
6
+ * The engine never tells the agent what to type. It only remembers the shape
7
+ * of any markup-shaped value the agent chose to type, and checks every page it
8
+ * sees afterwards for an element of that shape. A value that comes back as an
9
+ * element rather than text means whoever opens that page runs the input:
10
+ * stored or reflected injection (XSS).
11
+ *
12
+ * An element counts as the typed one only when it carries exactly the
13
+ * payload's attributes (an app's own link has extras such as a testid; an
14
+ * injected one has just what was typed) and, when the payload had text, that
15
+ * text. A payload with neither is not watched: `<script>` or `<br>` on their
16
+ * own would match any page. No browser is needed for these rules, so they are
17
+ * table-tested; the one DOM query lives in browser.ts.
18
+ */
19
+ import { normalizePath } from "./fingerprint.js";
20
+ /** Most probes a session keeps. Every page is checked for each in one query, so the list can be generous: a fuzzing pass types many. */
21
+ export const MAX_PROBES = 60;
22
+ /** Text is compared on this many characters, on both sides, so a long fuzz value still matches its element. */
23
+ export const INJECTION_TEXT_MAX = 200;
24
+ /** Most matching elements reported per probe per page. */
25
+ export const MAX_HITS = 20;
26
+ const OPEN_RE = /<([a-zA-Z][a-zA-Z0-9-]*)/;
27
+ // One attribute at a time, anchored where the last one ended: a slash between
28
+ // attributes is the same as a space to a browser (`<svg/onload=…>`). Each
29
+ // match consumes at least one character, so a hostile value cannot make the
30
+ // parse backtrack.
31
+ const ATTR_RE = /[\s/]*([^\s=>/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/y;
32
+ const CLOSE_RE = /[\s/]*>/y;
33
+ /** Values longer than this are cut before parsing; no payload needs more. */
34
+ const MAX_VALUE_LENGTH = 2000;
35
+ /** Attribute names that can go into a selector as they are. Anything else is dropped rather than escaped. */
36
+ const ATTR_NAME_RE = /^[A-Za-z_][-A-Za-z0-9_]*$/;
37
+ function cssValue(value) {
38
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\a ").replace(/\r/g, "\\d ");
39
+ }
40
+ function routeOf(url) {
41
+ try {
42
+ return normalizePath(new URL(url).pathname);
43
+ }
44
+ catch {
45
+ return url || "?";
46
+ }
47
+ }
48
+ /**
49
+ * The shape for a typed value, or null when the value holds no element worth
50
+ * watching: plain text, an email, a URL, a stray `<`, and an element with
51
+ * nothing to tell it apart by (`<script>`, `<br>`) are none of the oracle's
52
+ * business.
53
+ */
54
+ export function probeShape(raw) {
55
+ const value = raw.slice(0, MAX_VALUE_LENGTH);
56
+ const open = OPEN_RE.exec(value);
57
+ if (!open)
58
+ return null;
59
+ const tag = open[1].toLowerCase();
60
+ const attrs = [];
61
+ let pos = open.index + open[0].length;
62
+ for (;;) {
63
+ CLOSE_RE.lastIndex = pos;
64
+ const close = CLOSE_RE.exec(value);
65
+ if (close) {
66
+ pos += close[0].length;
67
+ break;
68
+ }
69
+ ATTR_RE.lastIndex = pos;
70
+ const a = ATTR_RE.exec(value);
71
+ // Neither an attribute nor the end of the tag: not an element, just a "<".
72
+ if (!a)
73
+ return null;
74
+ pos += a[0].length;
75
+ const name = a[1].toLowerCase();
76
+ if (!ATTR_NAME_RE.test(name))
77
+ continue;
78
+ attrs.push([name, a[2] ?? a[3] ?? a[4] ?? ""]);
79
+ }
80
+ const rest = value.slice(pos);
81
+ const close = rest.toLowerCase().indexOf(`</${tag}`);
82
+ const inner = (close >= 0 ? rest.slice(0, close) : rest).trim();
83
+ const text = inner ? inner.slice(0, INJECTION_TEXT_MAX) : null;
84
+ if (attrs.length === 0 && text === null)
85
+ return null;
86
+ return {
87
+ payload: value.slice(0, 200),
88
+ tag,
89
+ attrs,
90
+ text,
91
+ selector: tag + attrs.map(([name, v]) => `[${name}="${cssValue(v)}"]`).join(""),
92
+ };
93
+ }
94
+ export function injectionProbe(value, field, typedOn, baseline = 0) {
95
+ const shape = probeShape(value);
96
+ return shape ? { ...shape, field, typedOn, baseline } : null;
97
+ }
98
+ /**
99
+ * The session's probe list with one more: a payload already on it is kept as
100
+ * first seen (its baseline came from where it was first typed), and past the
101
+ * cap the oldest goes. Returns the list to keep, so the rule is testable.
102
+ */
103
+ export function rememberProbe(probes, probe) {
104
+ if (probes.some((p) => p.payload === probe.payload))
105
+ return [...probes];
106
+ const kept = probes.length >= MAX_PROBES ? probes.slice(probes.length - MAX_PROBES + 1) : [...probes];
107
+ return [...kept, probe];
108
+ }
109
+ export function probeQueries(probes) {
110
+ return probes.map((p) => ({ selector: p.selector, attrs: p.attrs, text: p.text }));
111
+ }
112
+ /**
113
+ * The script the page runs to find matching elements: the same rules as
114
+ * matchesElement, applied in the page so the cap counts matches, not
115
+ * candidates. Returns [{ index, outer }] with at most MAX_HITS per probe.
116
+ */
117
+ export function probeScript(queries) {
118
+ return (`(() => { const queries = ${JSON.stringify(queries)}; const max = ${MAX_HITS}; const textMax = ${INJECTION_TEXT_MAX}; const out = []; ` +
119
+ `queries.forEach((q, index) => { let els; try { els = document.querySelectorAll(q.selector); } catch { return; } let n = 0; ` +
120
+ `for (const el of els) { if (n >= max) break; if (el.attributes.length !== q.attrs.length) continue; ` +
121
+ `if (!q.attrs.every(([name, value]) => el.getAttribute(name) === value)) continue; ` +
122
+ `if (q.text !== null && (el.textContent || '').trim().slice(0, textMax) !== q.text) continue; ` +
123
+ `out.push({ index, outer: el.outerHTML.slice(0, 200) }); n += 1; } }); return out; })()`);
124
+ }
125
+ /** The rule the script applies, in one place a test can reach. */
126
+ export function matchesElement(query, el) {
127
+ if (Object.keys(el.attrs).length !== query.attrs.length)
128
+ return false;
129
+ if (!query.attrs.every(([name, value]) => el.attrs[name] === value))
130
+ return false;
131
+ return query.text === null || el.text.trim().slice(0, INJECTION_TEXT_MAX) === query.text;
132
+ }
133
+ /**
134
+ * Which hits on a page are injections to report: more matching elements than
135
+ * the page the value was typed on already held (shared chrome such as a nav
136
+ * link is the same on every page), and not reported for this route before.
137
+ */
138
+ export function newInjections(probes, hits, url, reported) {
139
+ const out = [];
140
+ const route = routeOf(url);
141
+ probes.forEach((probe, index) => {
142
+ const mine = hits.filter((h) => h.index === index);
143
+ if (mine.length <= probe.baseline)
144
+ return;
145
+ const key = `${probe.payload}|${route}`;
146
+ if (reported.has(key))
147
+ return;
148
+ reported.add(key);
149
+ out.push({ probe, outer: mine[mine.length - 1]?.outer ?? "", key });
150
+ });
151
+ return out;
152
+ }
153
+ /**
154
+ * The violation's detail: what it became, where it fired, and where it was
155
+ * typed. The element comes first because the oracle log signs a violation on
156
+ * the first characters of its detail: two payloads that fired on one page
157
+ * must not read as one.
158
+ */
159
+ export function describeInjection(probe, url, outerHtml) {
160
+ return (`${outerHtml.slice(0, 160)} on ${routeOf(url)} is a value typed into ${probe.field} on ${routeOf(probe.typedOn)}, come back as markup — ` +
161
+ `whoever opens this page runs what was typed there (stored or reflected injection, XSS)`);
162
+ }
@@ -31,12 +31,16 @@ export const LIVE_PAGE = `<!doctype html>
31
31
  }
32
32
  }
33
33
  * { box-sizing: border-box; }
34
+ ::selection { background: color-mix(in srgb, var(--accent) 28%, transparent); }
35
+ .feed, .brief, #report { scrollbar-width: thin; scrollbar-color: var(--line) transparent; }
36
+ .badge, .meta, .feed .t, .since, #focus .bar .line { font-variant-numeric: tabular-nums; }
34
37
  body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
35
38
  header { position: sticky; top: 0; z-index: 2; display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center;
36
39
  padding: 12px 16px; background: var(--panel); border-bottom: 1px solid var(--line); }
37
40
  h1 { margin: 0; font-size: 16px; font-weight: 650; }
38
41
  .meta { color: var(--muted); font-size: 13px; }
39
42
  .spacer { flex: 1 1 auto; }
43
+ .actions { display: flex; gap: 8px; flex: 0 0 auto; }
40
44
  button { font: inherit; color: var(--text); background: var(--panel); border: 1px solid var(--line); border-radius: 6px;
41
45
  padding: 5px 10px; cursor: pointer; }
42
46
  button:hover { border-color: var(--accent); }
@@ -71,7 +75,7 @@ export const LIVE_PAGE = `<!doctype html>
71
75
  font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; max-height: 108px; overflow-y: auto; overscroll-behavior: contain; }
72
76
  .feed .row { display: flex; gap: 6px; white-space: nowrap; }
73
77
  /* Consecutive actions of one journey share a tint, so where one goal ends and the next begins is visible in the log. */
74
- .feed .group { border-left: 3px solid transparent; padding-left: 4px; margin-left: -4px; border-radius: 3px; }
78
+ .feed .group { border-left: 1px solid transparent; padding-left: 5px; margin-left: -6px; border-radius: 3px; }
75
79
  .feed .g0 { background: rgba(96, 165, 250, .13); border-color: rgba(96, 165, 250, .7); }
76
80
  .feed .g1 { background: rgba(52, 211, 153, .13); border-color: rgba(52, 211, 153, .7); }
77
81
  .feed .g2 { background: rgba(251, 191, 36, .13); border-color: rgba(251, 191, 36, .7); }
@@ -83,9 +87,9 @@ export const LIVE_PAGE = `<!doctype html>
83
87
  .feed .bad { color: var(--stuck); }
84
88
  .feed .none { color: var(--muted); }
85
89
  #focus .lower { display: flex; gap: 10px; flex: 0 0 auto; height: 30vh; min-height: 140px; }
86
- #focus .feed { margin: 0; max-height: none; flex: 2 1 0; min-width: 0; background: #11151b; border-color: #2a303a; }
90
+ #focus .feed { margin: 0; max-height: none; flex: 2 1 0; min-width: 0; background: #11151b; border-color: #2a303a; scrollbar-color: #2a303a transparent; }
87
91
  #focus .brief { flex: 1 1 0; min-width: 0; overflow-y: auto; padding: 10px 14px; background: #11151b; border: 1px solid #2a303a;
88
- border-radius: 6px; color: #e6e9ee; }
92
+ border-radius: 6px; color: #e6e9ee; scrollbar-color: #2a303a transparent; }
89
93
  #focus .brief h3 { margin: 0 0 4px; font-size: 11px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: #98a2b3; }
90
94
  #focus .brief p { margin: 0 0 14px; font-size: 14px; line-height: 1.45; overflow-wrap: anywhere; }
91
95
  #focus .brief p.unset { color: #98a2b3; font-style: italic; }
@@ -132,8 +136,10 @@ export const LIVE_PAGE = `<!doctype html>
132
136
  <span class="meta" id="engine" data-testid="live-engine-summary"></span>
133
137
  <span class="spacer"></span>
134
138
  <span class="meta" id="counts" data-testid="live-session-counts"></span>
135
- <button type="button" id="report-open" data-testid="live-report-toggle">Report</button>
136
- <button type="button" id="all" aria-pressed="false" data-testid="live-all-toggle">Stream all</button>
139
+ <span class="actions">
140
+ <button type="button" id="report-open" data-testid="live-report-toggle">Report</button>
141
+ <button type="button" id="all" aria-pressed="false" data-testid="live-all-toggle">Stream all</button>
142
+ </span>
137
143
  </header>
138
144
  <div id="banner" role="alert" data-testid="live-unreachable-banner">The engine is not answering. It may have exited; this page will pick up again if it comes back.</div>
139
145
  <div id="empty" data-testid="live-empty-state">No session is attached yet. Cards appear here as soon as one attaches.</div>
@@ -169,6 +169,36 @@ export function writeStatusFile(dir, body) {
169
169
  statusWrites.set(dir, next);
170
170
  return next;
171
171
  }
172
+ /**
173
+ * The lines `scenescout status` prints for a status file it could read: the
174
+ * engine, what it is doing, and one line per session (or the single legacy
175
+ * line an engine from before the live view wrote). The recent-actions tail
176
+ * comes from the session log and is printed by the CLI.
177
+ */
178
+ export function formatStatus(st, alive, nowMs) {
179
+ const lines = [];
180
+ const age = st.at ? Math.round((nowMs - new Date(st.at).getTime()) / 1000) : null;
181
+ lines.push(`Engine pid ${st.pid ?? "?"} — ${alive ? "ALIVE" : "not running (stale status)"}`);
182
+ lines.push(`${st.phase === "running" ? "⏳ running" : "· idle after"}: ${st.tool ?? "?"}${age !== null ? ` (as of ${age}s ago)` : ""}`);
183
+ // The file is written by another process and can be caught mid-write, so
184
+ // only entries whole enough to describe are described.
185
+ const sessions = wholeSessions(st.detail);
186
+ if (sessions.length > 0) {
187
+ lines.push(`Sessions (${sessions.length}):`);
188
+ for (const entry of sessions)
189
+ lines.push(` ${formatSessionLine(entry, nowMs)}`);
190
+ if (alive && st.live?.port)
191
+ lines.push("Live view: scenescout watch");
192
+ if (alive && st.live?.error)
193
+ lines.push(`Live view unavailable: ${st.live.error}`);
194
+ }
195
+ else {
196
+ lines.push(`Session: ${st.session ?? "?"} (${st.role ?? "?"})${st.sessions && st.sessions.length > 1 ? ` · all sessions: ${st.sessions.join(", ")}` : ""}`);
197
+ if (st.url)
198
+ lines.push(`URL: ${st.url}`);
199
+ }
200
+ return lines;
201
+ }
172
202
  /** The entries of a status file that are whole enough to describe. A truncated write or an older engine can leave others. */
173
203
  export function wholeSessions(detail) {
174
204
  return (detail ?? []).filter((e) => typeof e.session === "string" &&
@@ -267,10 +267,15 @@ function endpointSignatures(evidence) {
267
267
  function sharesEndpointSignature(a, b) {
268
268
  if (!a.evidence || !b.evidence)
269
269
  return false;
270
- const aSigs = endpointSignatures(a.evidence);
270
+ // Only a triple with a failure status is a signature of a bug. A bare
271
+ // `POST /api/orders` (the endpoint answered 2xx, or no status was named)
272
+ // says which endpoint was involved, not what went wrong: a double submit
273
+ // and an accepted negative quantity both name it, and are two bugs.
274
+ const failing = (evidence) => new Set([...endpointSignatures(evidence)].filter((sig) => /\s[45]\d{2}$/.test(sig)));
275
+ const aSigs = failing(a.evidence);
271
276
  if (aSigs.size === 0)
272
277
  return false;
273
- for (const sig of endpointSignatures(b.evidence))
278
+ for (const sig of failing(b.evidence))
274
279
  if (aSigs.has(sig))
275
280
  return true;
276
281
  return false;
@@ -129,6 +129,14 @@ export class OracleMonitor {
129
129
  notePolicyBlock() {
130
130
  this.lastPolicyBlockAt = Date.now();
131
131
  }
132
+ /**
133
+ * A markup-shaped value the session typed has come back as an element on
134
+ * this page: whoever opens it runs the input. Found by the engine's DOM scan
135
+ * (injection.ts), not by a page event, so it is reported through here.
136
+ */
137
+ noteInjection(detail, url) {
138
+ this.record({ kind: "dom_injection", severity: "high", detail, url });
139
+ }
132
140
  record(v) {
133
141
  if (isPolicyInduced(v, this.lastPolicyBlockAt === null ? null : Date.now() - this.lastPolicyBlockAt)) {
134
142
  this.policyAttributed += 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenescout",
3
- "version": "1.3.0",
3
+ "version": "1.4.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",
@@ -71,7 +71,7 @@
71
71
  "mcp-check": "npm run build && npm run mcp-check:run",
72
72
  "mcp-check:run": "tsx scripts/mcp-check.ts",
73
73
  "test": "npm run build && npm run test:unit && npm run smoke:run && npm run mcp-check:run",
74
- "test:unit": "npm run scan-test && npm run oracle-test && npm run policy-test && npm run fixture-test && npm run dispatch-test && npm run design-test && npm run contract-test && npm run memory-test && npm run install-test && npm run live-test && npm run hygiene-test",
74
+ "test:unit": "npm run scan-test && npm run oracle-test && npm run policy-test && npm run fixture-test && npm run dispatch-test && npm run design-test && npm run contract-test && npm run memory-test && npm run install-test && npm run live-test && npm run demo-test && npm run hygiene-test",
75
75
  "scan-test": "tsx scripts/scan-test.ts",
76
76
  "oracle-test": "tsx --test scripts/oracle-test.ts",
77
77
  "policy-test": "tsx --test scripts/policy-test.ts",
@@ -82,6 +82,7 @@
82
82
  "memory-test": "tsx --test scripts/memory-test.ts",
83
83
  "install-test": "tsx --test scripts/install-test.ts",
84
84
  "live-test": "tsx --test scripts/live-test.ts",
85
+ "demo-test": "tsx --test scripts/demo-test.ts",
85
86
  "hygiene-test": "tsx --test scripts/hygiene-test.ts"
86
87
  },
87
88
  "dependencies": {
@@ -48,7 +48,7 @@ You are the brain of an exploratory UI tester. The SceneScout MCP server gives y
48
48
  |---|---|---|
49
49
  | `minimal` | Every known route visited (crawl does this) + ≥1 design audit + 1–2 primary journeys as plans + crawl-flagged problems triaged. The report DISCLOSES remaining gaps in its Gap Ledger — minimal is honest-but-fast, not silent. | 1 crawl + 2–4 plans + a few snapshots |
50
50
  | `medium` (default) | minimal + design audits on several distinct routes (gate: ≥ min(3, visited/10)) + every unexercised interactable class exercised once + every form submitted valid AND invalid | + interactive passes per module |
51
- | `extensive` | medium + fuzzing (empty, 1000-char, unicode, `<script>`; for uploads, `scout_upload {name}` with a wrong extension against `accept`, a 255-char name, unicode), back/refresh/deep-link resilience, keyboard-only pass, **an `scout_journey` per module's primary task**, **the impatient-user pass** (below), **≥2 roles compared**, **the anonymous auth-surface pass** (signup / forgot-password walked to the mailbox wall, step 8 above), and source-grounding for every absence-claim. **`scout_report {level:'extensive'}` REFUSES while the Gap Ledger is non-empty** — that refusal IS the completeness guarantee: an extensive report can only exist when every known route is visited, exercised, audited, journey-measured, and role-compared. | budget-capped by user |
51
+ | `extensive` | medium + fuzzing (empty, 1000-char, unicode, and markup such as `<script>` or `<img src=x onerror=…>` — the engine remembers every markup-shaped value you type and reports a `dom_injection` violation when it later renders as an element on any page, which is a stored or reflected XSS: file it as `security`, high, naming the field and the page; for uploads, `scout_upload {name}` with a wrong extension against `accept`, a 255-char name, unicode), back/refresh/deep-link resilience, keyboard-only pass, **an `scout_journey` per module's primary task**, **the impatient-user pass** (below), **≥2 roles compared**, **the anonymous auth-surface pass** (signup / forgot-password walked to the mailbox wall, step 8 above), and source-grounding for every absence-claim. **`scout_report {level:'extensive'}` REFUSES while the Gap Ledger is non-empty** — that refusal IS the completeness guarantee: an extensive report can only exist when every known route is visited, exercised, audited, journey-measured, and role-compared. | budget-capped by user |
52
52
 
53
53
  **Levels in `observe` mode.** No form submission reaches the server, so "every form submitted valid AND invalid" means submitted and observed client-side; the unsubmitted-forms gap stays in the ledger, worded as the mode's doing. `extensive` refuses while any gap remains, so an `observe` run tops out at `medium` — say so rather than forcing the report.
54
54