scenescout 1.3.0 → 1.5.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 +19 -0
- package/README.md +3 -2
- package/dist/cli.js +3 -23
- package/dist/engine/browser.js +79 -6
- package/dist/engine/injection.js +162 -0
- package/dist/engine/live-page.js +82 -7
- package/dist/engine/live.js +30 -0
- package/dist/engine/memory.js +7 -2
- package/dist/engine/oracles.js +8 -0
- package/dist/mcp-server.js +42 -3
- package/package.json +3 -2
- package/skills/scenescout/SKILL.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# scenescout
|
|
2
2
|
|
|
3
|
+
## 1.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 791b4d0: The live view now hands over the report when the run ends. Closing the last session used to take the report with it — the view served it from the live engine, so the moment the browsers went the page said there was nothing to report. The last rendering is now kept, and when the board empties the page says the run has finished, opens the report by itself, and names the file it belongs in: `saved at <path>` once `scout_report` has written it, or plainly that it is not on disk and this page holds the only copy. A **Save a copy** button downloads that copy through the viewer's own browser (nothing is asked of the engine, which still answers `GET` and nothing else), and closing the tab on a finished run whose report was never written asks for confirmation first.
|
|
8
|
+
|
|
9
|
+
## 1.4.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- 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.
|
|
18
|
+
- 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.
|
|
19
|
+
- 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.
|
|
20
|
+
- 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.
|
|
21
|
+
|
|
3
22
|
## 1.3.0
|
|
4
23
|
|
|
5
24
|
### 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 +
|
|
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 {
|
|
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
|
|
102
|
-
|
|
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
|
package/dist/engine/browser.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/engine/live-page.js
CHANGED
|
@@ -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); }
|
|
@@ -44,6 +48,13 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
44
48
|
button[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
45
49
|
#banner { display: none; margin: 12px 16px 0; padding: 8px 12px; border-radius: 6px; background: var(--stuck-bg); color: var(--stuck); }
|
|
46
50
|
#empty { display: none; padding: 48px 16px; text-align: center; color: var(--muted); }
|
|
51
|
+
#finished { display: none; max-width: 640px; margin: 48px auto; padding: 24px; text-align: center;
|
|
52
|
+
background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
|
|
53
|
+
#finished.open { display: block; }
|
|
54
|
+
#finished h2 { margin: 0 0 8px; font-size: 18px; }
|
|
55
|
+
#finished p { margin: 0 0 8px; color: var(--muted); }
|
|
56
|
+
#finished .where { font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
|
|
57
|
+
#finished button { margin-top: 8px; padding: 7px 14px; font-weight: 600; }
|
|
47
58
|
main { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr)); gap: 12px; padding: 16px; }
|
|
48
59
|
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; display: flex; flex-direction: column; }
|
|
49
60
|
.card.stuck { border-color: var(--stuck); }
|
|
@@ -71,7 +82,7 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
71
82
|
font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; max-height: 108px; overflow-y: auto; overscroll-behavior: contain; }
|
|
72
83
|
.feed .row { display: flex; gap: 6px; white-space: nowrap; }
|
|
73
84
|
/* 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:
|
|
85
|
+
.feed .group { border-left: 1px solid transparent; padding-left: 5px; margin-left: -6px; border-radius: 3px; }
|
|
75
86
|
.feed .g0 { background: rgba(96, 165, 250, .13); border-color: rgba(96, 165, 250, .7); }
|
|
76
87
|
.feed .g1 { background: rgba(52, 211, 153, .13); border-color: rgba(52, 211, 153, .7); }
|
|
77
88
|
.feed .g2 { background: rgba(251, 191, 36, .13); border-color: rgba(251, 191, 36, .7); }
|
|
@@ -83,9 +94,9 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
83
94
|
.feed .bad { color: var(--stuck); }
|
|
84
95
|
.feed .none { color: var(--muted); }
|
|
85
96
|
#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; }
|
|
97
|
+
#focus .feed { margin: 0; max-height: none; flex: 2 1 0; min-width: 0; background: #11151b; border-color: #2a303a; scrollbar-color: #2a303a transparent; }
|
|
87
98
|
#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; }
|
|
99
|
+
border-radius: 6px; color: #e6e9ee; scrollbar-color: #2a303a transparent; }
|
|
89
100
|
#focus .brief h3 { margin: 0 0 4px; font-size: 11px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: #98a2b3; }
|
|
90
101
|
#focus .brief p { margin: 0 0 14px; font-size: 14px; line-height: 1.45; overflow-wrap: anywhere; }
|
|
91
102
|
#focus .brief p.unset { color: #98a2b3; font-style: italic; }
|
|
@@ -132,17 +143,26 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
132
143
|
<span class="meta" id="engine" data-testid="live-engine-summary"></span>
|
|
133
144
|
<span class="spacer"></span>
|
|
134
145
|
<span class="meta" id="counts" data-testid="live-session-counts"></span>
|
|
135
|
-
<
|
|
136
|
-
|
|
146
|
+
<span class="actions">
|
|
147
|
+
<button type="button" id="report-open" data-testid="live-report-toggle">Report</button>
|
|
148
|
+
<button type="button" id="all" aria-pressed="false" data-testid="live-all-toggle">Stream all</button>
|
|
149
|
+
</span>
|
|
137
150
|
</header>
|
|
138
151
|
<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
152
|
<div id="empty" data-testid="live-empty-state">No session is attached yet. Cards appear here as soon as one attaches.</div>
|
|
153
|
+
<div id="finished" data-testid="live-finished-state">
|
|
154
|
+
<h2>The run has finished</h2>
|
|
155
|
+
<p>Its browsers are closed, so there is nothing left to watch. What it found is in the report.</p>
|
|
156
|
+
<p class="where" id="finished-where" data-testid="live-finished-where"></p>
|
|
157
|
+
<button type="button" id="finished-report" data-testid="live-finished-report">Read the report</button>
|
|
158
|
+
</div>
|
|
140
159
|
<main id="grid"></main>
|
|
141
160
|
<div id="report" role="dialog" aria-modal="true" aria-label="The run's report" data-testid="live-report-dialog">
|
|
142
161
|
<div class="bar">
|
|
143
162
|
<strong>Report</strong>
|
|
144
163
|
<span class="meta" id="report-meta" data-testid="live-report-meta"></span>
|
|
145
164
|
<span class="spacer"></span>
|
|
165
|
+
<button type="button" id="report-save" data-testid="live-report-save">Save a copy</button>
|
|
146
166
|
<button type="button" id="report-close" data-testid="live-report-close">Close</button>
|
|
147
167
|
</div>
|
|
148
168
|
<div class="doc" id="report-doc" data-testid="live-report-doc"></div>
|
|
@@ -181,6 +201,14 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
181
201
|
var reportOpen = false;
|
|
182
202
|
var reportTimer = null;
|
|
183
203
|
var reportProblem = null;
|
|
204
|
+
// The run had sessions and has none now: its browsers are gone, and this
|
|
205
|
+
// page holds the only rendering of the report unless it was written to disk.
|
|
206
|
+
var sawRun = false;
|
|
207
|
+
var finished = false;
|
|
208
|
+
var reportShown = false;
|
|
209
|
+
var reportFile = null;
|
|
210
|
+
var reportMarkdown = null;
|
|
211
|
+
var savedACopy = false;
|
|
184
212
|
// A result that reads as a failure is shown in red.
|
|
185
213
|
var BAD_RESULT = /error|fail|refus|block|violation|abandoned/i;
|
|
186
214
|
|
|
@@ -429,7 +457,8 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
429
457
|
meta.textContent = '';
|
|
430
458
|
return;
|
|
431
459
|
}
|
|
432
|
-
|
|
460
|
+
reportMarkdown = d.markdown;
|
|
461
|
+
meta.textContent = (finished ? 'as the run left it at ' : 'as the run stands at ') + clock(d.at) + ' · ' + whereItIs();
|
|
433
462
|
renderMarkdown(doc, d.markdown);
|
|
434
463
|
})
|
|
435
464
|
.catch(function (err) {
|
|
@@ -442,6 +471,28 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
442
471
|
}
|
|
443
472
|
});
|
|
444
473
|
}
|
|
474
|
+
/** Where the report's file is, and whether the agent has written it there. */
|
|
475
|
+
function whereItIs() {
|
|
476
|
+
if (!reportFile) return 'scout_report writes this document to .scenescout/report.md at the end';
|
|
477
|
+
if (reportFile.written) return 'saved at ' + reportFile.path;
|
|
478
|
+
return 'NOT saved: ' + reportFile.path + ' does not exist — the agent has not run scout_report, so this page holds the only copy';
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Saving is the viewer's own browser writing a file the page already has;
|
|
482
|
+
// nothing is sent to the engine, which only ever answers GET (ADR 7).
|
|
483
|
+
function saveACopy() {
|
|
484
|
+
if (!reportMarkdown) return;
|
|
485
|
+
var url = URL.createObjectURL(new Blob([reportMarkdown], { type: 'text/markdown' }));
|
|
486
|
+
var a = document.createElement('a');
|
|
487
|
+
a.href = url;
|
|
488
|
+
a.download = 'scenescout-report.md';
|
|
489
|
+
document.body.appendChild(a);
|
|
490
|
+
a.click();
|
|
491
|
+
a.remove();
|
|
492
|
+
setTimeout(function () { URL.revokeObjectURL(url); }, 10000);
|
|
493
|
+
savedACopy = true;
|
|
494
|
+
}
|
|
495
|
+
|
|
445
496
|
function openReport() {
|
|
446
497
|
reportOpen = true;
|
|
447
498
|
document.getElementById('report').classList.add('open');
|
|
@@ -602,7 +653,21 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
602
653
|
delete frames[name];
|
|
603
654
|
});
|
|
604
655
|
syncEvents();
|
|
605
|
-
|
|
656
|
+
reportFile = snap.report || reportFile;
|
|
657
|
+
if (snap.sessions.length > 0) sawRun = true;
|
|
658
|
+
finished = sawRun && snap.sessions.length === 0;
|
|
659
|
+
document.getElementById('empty').style.display = snap.sessions.length || finished ? 'none' : 'block';
|
|
660
|
+
document.getElementById('finished').classList.toggle('open', finished);
|
|
661
|
+
if (finished) {
|
|
662
|
+
var where = document.getElementById('finished-where');
|
|
663
|
+
where.textContent = whereItIs();
|
|
664
|
+
where.className = 'where' + (reportFile && reportFile.written ? '' : ' unset');
|
|
665
|
+
// The moment somebody wants the report is the moment the run ends: show it.
|
|
666
|
+
if (!reportShown) {
|
|
667
|
+
reportShown = true;
|
|
668
|
+
if (!reportOpen) openReport();
|
|
669
|
+
}
|
|
670
|
+
}
|
|
606
671
|
document.getElementById('engine').textContent = 'engine pid ' + snap.pid + ' · v' + snap.version;
|
|
607
672
|
document.getElementById('counts').textContent = snap.sessions.length + ' session' + (snap.sessions.length === 1 ? '' : 's') +
|
|
608
673
|
' · ' + counts.running + ' running · ' + counts.idle + ' idle' + (counts.stuck ? ' · ' + counts.stuck + ' stuck' : '');
|
|
@@ -627,6 +692,16 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
627
692
|
document.getElementById('focus-feed').addEventListener('mouseleave', function () { showObjective(null); });
|
|
628
693
|
document.getElementById('focus').addEventListener('click', function (e) { if (e.target === this) closeFocus(); });
|
|
629
694
|
document.getElementById('report-open').addEventListener('click', openReport);
|
|
695
|
+
document.getElementById('finished-report').addEventListener('click', openReport);
|
|
696
|
+
document.getElementById('report-save').addEventListener('click', saveACopy);
|
|
697
|
+
// Closing the tab on a finished run whose report was never written to disk
|
|
698
|
+
// throws the only copy away. The browser shows its own confirm/dismiss, and
|
|
699
|
+
// only when the person has interacted with the page at least once.
|
|
700
|
+
window.addEventListener('beforeunload', function (e) {
|
|
701
|
+
if (!finished || savedACopy || (reportFile && reportFile.written)) return;
|
|
702
|
+
e.preventDefault();
|
|
703
|
+
e.returnValue = '';
|
|
704
|
+
});
|
|
630
705
|
document.getElementById('report-close').addEventListener('click', closeReport);
|
|
631
706
|
document.addEventListener('keydown', function (e) {
|
|
632
707
|
if (e.key !== 'Escape') return;
|
package/dist/engine/live.js
CHANGED
|
@@ -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" &&
|
package/dist/engine/memory.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
278
|
+
for (const sig of failing(b.evidence))
|
|
274
279
|
if (aSigs.has(sig))
|
|
275
280
|
return true;
|
|
276
281
|
return false;
|
package/dist/engine/oracles.js
CHANGED
|
@@ -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/dist/mcp-server.js
CHANGED
|
@@ -37,7 +37,7 @@ import { reapOrphanBrowsers } from "./engine/reaper.js";
|
|
|
37
37
|
import { MemoryStore, redactSecrets } from "./engine/memory.js";
|
|
38
38
|
import { SessionQueue, withWatchdog } from "./engine/dispatch.js";
|
|
39
39
|
import { FIXTURE_KINDS } from "./engine/fixtures.js";
|
|
40
|
-
import { feedForSession, LIVE_ENV, writeStatusFile, LIVE_TOKEN_FILE, LiveServer, StatusBoard } from "./engine/live.js";
|
|
40
|
+
import { feedForSession, LIVE_ENV, writeStatusFile, LIVE_TOKEN_FILE, LiveServer, StatusBoard, } from "./engine/live.js";
|
|
41
41
|
import { computeGaps, formatRouteCoverage, generateReport } from "./engine/report.js";
|
|
42
42
|
import { EXPLORE_PROMPT_ARGUMENTS, explorePrompt, loadPlaybook, PLAYBOOK_PROMPT, PLAYBOOK_TOOL, SERVER_INSTRUCTIONS } from "./playbook.js";
|
|
43
43
|
import { formatScan, scanProject } from "./scan.js";
|
|
@@ -105,8 +105,44 @@ const liveTokenWrites = new Map();
|
|
|
105
105
|
let liveTokenWarned = false;
|
|
106
106
|
/** Why the live view could not start, when it could not: told to the agent and written to status.json. */
|
|
107
107
|
let liveError = null;
|
|
108
|
+
/**
|
|
109
|
+
* The last run's report, kept after its sessions close. The engines are gone
|
|
110
|
+
* by then, so this is the only way the live view can still show what the run
|
|
111
|
+
* found — which is the moment somebody most wants to read it.
|
|
112
|
+
*/
|
|
113
|
+
let lastRun = null;
|
|
114
|
+
/** Render the report for a session that is about to close, so the live view keeps it. */
|
|
115
|
+
function keepReport(eng) {
|
|
116
|
+
if (!eng.memory)
|
|
117
|
+
return;
|
|
118
|
+
try {
|
|
119
|
+
const { markdown } = generateReport(eng.memory, eng.oracleLog.all, reportExtras(eng), { write: false });
|
|
120
|
+
lastRun = { markdown: redactSecrets(markdown), at: new Date().toISOString(), dir: eng.memory.dir };
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// Best-effort: a report that cannot be rendered must not fail a close.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** Where this run's report belongs, and whether the agent has written it there yet. */
|
|
127
|
+
function reportFile(dir) {
|
|
128
|
+
if (!dir)
|
|
129
|
+
return undefined;
|
|
130
|
+
const file = path.join(dir, "report.md");
|
|
131
|
+
try {
|
|
132
|
+
return { path: file, written: fs.existsSync(file) };
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return { path: file, written: false };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
108
138
|
const liveProvider = {
|
|
109
|
-
snapshot: () => ({
|
|
139
|
+
snapshot: () => ({
|
|
140
|
+
pid: process.pid,
|
|
141
|
+
version: PKG_VERSION,
|
|
142
|
+
at: new Date().toISOString(),
|
|
143
|
+
sessions: board.list(),
|
|
144
|
+
report: reportFile(engines.values().next().value?.memory?.dir ?? lastRun?.dir),
|
|
145
|
+
}),
|
|
110
146
|
// The engine holds no reasoning — it never sees one — so the feed is what the
|
|
111
147
|
// session DID: the action log, which is the same trail a finding's repro uses.
|
|
112
148
|
activity: (session, limit) => feedForSession(engines.get(session)?.memory?.actionLog ?? [], session, limit, redactSecrets),
|
|
@@ -118,7 +154,7 @@ const liveProvider = {
|
|
|
118
154
|
report: () => {
|
|
119
155
|
const eng = (lastWriter && engines.get(lastWriter.session)) ?? engines.values().next().value;
|
|
120
156
|
if (!eng?.memory)
|
|
121
|
-
return null;
|
|
157
|
+
return lastRun ? { markdown: lastRun.markdown, at: lastRun.at } : null;
|
|
122
158
|
const { markdown } = generateReport(eng.memory, eng.oracleLog.all, reportExtras(eng), { write: false });
|
|
123
159
|
return { markdown: redactSecrets(markdown), at: new Date().toISOString() };
|
|
124
160
|
},
|
|
@@ -976,6 +1012,8 @@ server.registerTool("scout_close", {
|
|
|
976
1012
|
for (const e of engines.values())
|
|
977
1013
|
if (e.memory?.dir)
|
|
978
1014
|
dirs.add(e.memory.dir);
|
|
1015
|
+
for (const e of engines.values())
|
|
1016
|
+
keepReport(e);
|
|
979
1017
|
for (const name of engines.keys())
|
|
980
1018
|
live?.dropSession(name);
|
|
981
1019
|
await Promise.allSettled([...engines.values()].map((e) => e.close()));
|
|
@@ -991,6 +1029,7 @@ server.registerTool("scout_close", {
|
|
|
991
1029
|
const eng = engines.get(name);
|
|
992
1030
|
if (!eng)
|
|
993
1031
|
return text(`No live session "${name}".`, name);
|
|
1032
|
+
keepReport(eng);
|
|
994
1033
|
live?.dropSession(name);
|
|
995
1034
|
await eng.close();
|
|
996
1035
|
const saveError = eng.memory?.lastSaveError;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scenescout",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.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
|
|
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
|
|