scenescout 1.0.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 +15 -0
- package/LICENSE +21 -0
- package/README.md +429 -0
- package/dist/cli.js +269 -0
- package/dist/engine/authloss.js +125 -0
- package/dist/engine/browser.js +1954 -0
- package/dist/engine/collector.js +266 -0
- package/dist/engine/design.js +716 -0
- package/dist/engine/dispatch.js +100 -0
- package/dist/engine/fingerprint.js +100 -0
- package/dist/engine/fixtures.js +162 -0
- package/dist/engine/journey.js +71 -0
- package/dist/engine/launch.js +25 -0
- package/dist/engine/memory.js +1116 -0
- package/dist/engine/oracles.js +187 -0
- package/dist/engine/ownership.js +223 -0
- package/dist/engine/policy.js +84 -0
- package/dist/engine/probes.js +293 -0
- package/dist/engine/reaper.js +72 -0
- package/dist/engine/report.js +515 -0
- package/dist/engine/uploads.js +74 -0
- package/dist/installer.js +315 -0
- package/dist/mcp-server.js +810 -0
- package/dist/scan.js +335 -0
- package/package.json +86 -0
- package/skills/scenescout/SKILL.md +96 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { redactSecrets } from "./memory.js";
|
|
2
|
+
/** URLs whose failures are noise, not findings (favicons, source maps). */
|
|
3
|
+
const BENIGN_URL_RE = /favicon|\.map($|\?)/i;
|
|
4
|
+
/**
|
|
5
|
+
* Violations quote request URLs verbatim — query string included — and end up
|
|
6
|
+
* in tool output, memory and the report. A failed `GET /api/x?api_key=…` or a
|
|
7
|
+
* 500 on a magic-link page must not re-publish the credential it carried.
|
|
8
|
+
* Redacted at record time so no later consumer has to remember to.
|
|
9
|
+
*/
|
|
10
|
+
export function redactViolation(v) {
|
|
11
|
+
return { ...v, detail: redactSecrets(v.detail), url: redactSecrets(v.url) };
|
|
12
|
+
}
|
|
13
|
+
/** How long after a write-policy block a generic "fetch failed" error is still attributed to it. */
|
|
14
|
+
export const POLICY_BLOCK_WINDOW_MS = 2000;
|
|
15
|
+
/**
|
|
16
|
+
* Is this console or page error a consequence of the tester's OWN write-policy
|
|
17
|
+
* block rather than something the app did?
|
|
18
|
+
*
|
|
19
|
+
* Aborting a request makes the browser print a console error, and — when the
|
|
20
|
+
* app does not catch the rejection — raise a page error. Left in, a report
|
|
21
|
+
* lists the tool's own safety net as defects of the app under test. (The
|
|
22
|
+
* failed REQUEST itself is matched exactly, by request identity, in the
|
|
23
|
+
* monitor; these two carry no request to match on, so they are attributed by
|
|
24
|
+
* wording and by time.)
|
|
25
|
+
*
|
|
26
|
+
* Both rules need a block to have happened in the current action's window. A
|
|
27
|
+
* bare "Failed to fetch" is otherwise a real defect — a wrong origin, a CORS
|
|
28
|
+
* error, a refused connection — and must be reported.
|
|
29
|
+
*/
|
|
30
|
+
export function isPolicyInduced(v, msSincePolicyBlock) {
|
|
31
|
+
if (msSincePolicyBlock === null || msSincePolicyBlock > POLICY_BLOCK_WINDOW_MS)
|
|
32
|
+
return false;
|
|
33
|
+
if (v.kind !== "page_error" && v.kind !== "console_error")
|
|
34
|
+
return false;
|
|
35
|
+
return /ERR_BLOCKED_BY_CLIENT|Failed to fetch|NetworkError when attempting to fetch|Load failed/i.test(v.detail);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Invariant oracles: passive listeners that record violations regardless of
|
|
39
|
+
* what the agent is doing. The engine drains the buffer after every action and
|
|
40
|
+
* appends violations to the tool result, so the agent is told when something
|
|
41
|
+
* broke without having to remember to check.
|
|
42
|
+
*/
|
|
43
|
+
export class OracleMonitor {
|
|
44
|
+
buffer = [];
|
|
45
|
+
/** Full-session log, kept for the final report. */
|
|
46
|
+
all = [];
|
|
47
|
+
attach(page) {
|
|
48
|
+
page.on("console", (msg) => {
|
|
49
|
+
if (msg.type() !== "error")
|
|
50
|
+
return;
|
|
51
|
+
const text = msg.text();
|
|
52
|
+
// Benign noise: failed favicon / source map fetches show up as console errors.
|
|
53
|
+
if (/favicon|source map/i.test(text))
|
|
54
|
+
return;
|
|
55
|
+
this.record({
|
|
56
|
+
kind: "console_error",
|
|
57
|
+
severity: "high",
|
|
58
|
+
detail: text.slice(0, 500),
|
|
59
|
+
url: page.url(),
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
page.on("pageerror", (err) => {
|
|
63
|
+
this.record({
|
|
64
|
+
kind: "page_error",
|
|
65
|
+
severity: "high",
|
|
66
|
+
detail: String(err.message ?? err).slice(0, 500),
|
|
67
|
+
url: page.url(),
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
page.on("requestfailed", (req) => {
|
|
71
|
+
const failure = req.failure()?.errorText ?? "unknown";
|
|
72
|
+
// Aborted requests are routine during SPA navigation.
|
|
73
|
+
if (failure.includes("ERR_ABORTED"))
|
|
74
|
+
return;
|
|
75
|
+
if (BENIGN_URL_RE.test(req.url()))
|
|
76
|
+
return;
|
|
77
|
+
if (this.abortedByPolicy(req)) {
|
|
78
|
+
this.policyAttributed += 1;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
this.record({
|
|
82
|
+
kind: "request_failed",
|
|
83
|
+
severity: "medium",
|
|
84
|
+
detail: `${req.method()} ${req.url().slice(0, 200)} → ${failure}`,
|
|
85
|
+
url: page.url(),
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
page.on("response", (res) => {
|
|
89
|
+
const status = res.status();
|
|
90
|
+
if (status < 400)
|
|
91
|
+
return;
|
|
92
|
+
// Keep this filter consistent with the console oracle: a missing favicon
|
|
93
|
+
// reported here on every page load teaches the driver to ignore http_error.
|
|
94
|
+
if (BENIGN_URL_RE.test(res.url()))
|
|
95
|
+
return;
|
|
96
|
+
// 401/403 are often expected (auth probes); still report, but as medium.
|
|
97
|
+
this.record({
|
|
98
|
+
kind: "http_error",
|
|
99
|
+
severity: status >= 500 ? "high" : "medium",
|
|
100
|
+
detail: `${res.request().method()} ${res.url().slice(0, 200)} → HTTP ${status}`,
|
|
101
|
+
url: page.url(),
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/** Signatures already shown in full — a known-failing endpoint repeating on every page must not flood every tool result. */
|
|
106
|
+
reportedSigs = new Set();
|
|
107
|
+
/**
|
|
108
|
+
* Cap on remembered signatures. The set only ever grew, so a long run against
|
|
109
|
+
* an app with many distinct failures held every one for the life of the
|
|
110
|
+
* process. Dropping the oldest half on overflow costs at most a re-report of
|
|
111
|
+
* a violation last seen thousands of actions ago — which is arguably the
|
|
112
|
+
* right thing to surface again anyway.
|
|
113
|
+
*/
|
|
114
|
+
static MAX_REPORTED_SIGS = 5000;
|
|
115
|
+
lastPolicyBlockAt = null;
|
|
116
|
+
abortedByPolicy = () => false;
|
|
117
|
+
/**
|
|
118
|
+
* Errors attributed to the write policy's own blocks and therefore not
|
|
119
|
+
* recorded as violations. Counted, so a report can say how many there were:
|
|
120
|
+
* dropping them silently would make a clean run and a run that hid three
|
|
121
|
+
* errors look the same.
|
|
122
|
+
*/
|
|
123
|
+
policyAttributed = 0;
|
|
124
|
+
/** The engine knows exactly which requests it aborted; failed requests are matched against that, not against wording. */
|
|
125
|
+
setPolicyAbortCheck(check) {
|
|
126
|
+
this.abortedByPolicy = check;
|
|
127
|
+
}
|
|
128
|
+
/** Called by the engine when the write policy aborts a request, so the errors that abort causes are not held against the app. */
|
|
129
|
+
notePolicyBlock() {
|
|
130
|
+
this.lastPolicyBlockAt = Date.now();
|
|
131
|
+
}
|
|
132
|
+
record(v) {
|
|
133
|
+
if (isPolicyInduced(v, this.lastPolicyBlockAt === null ? null : Date.now() - this.lastPolicyBlockAt)) {
|
|
134
|
+
this.policyAttributed += 1;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const violation = { ...redactViolation(v), at: new Date().toISOString() };
|
|
138
|
+
this.buffer.push(violation);
|
|
139
|
+
this.all.push(violation);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Return and clear violations accumulated since the last drain, flagging
|
|
143
|
+
* repeats of already-reported signatures. Repeat bookkeeping happens HERE,
|
|
144
|
+
* at delivery time, not at record time: a drain whose output is discarded
|
|
145
|
+
* (crawl's pre-route attribution reset) must pass register=false so it
|
|
146
|
+
* cannot mark a signature as reported that no one ever saw.
|
|
147
|
+
*/
|
|
148
|
+
drain(register = true) {
|
|
149
|
+
const out = this.buffer;
|
|
150
|
+
this.buffer = [];
|
|
151
|
+
// The attribution window belongs to the action that caused the block. A
|
|
152
|
+
// delivered drain ends that action, so the window must not reach into the next one.
|
|
153
|
+
if (register)
|
|
154
|
+
this.lastPolicyBlockAt = null;
|
|
155
|
+
for (const v of out) {
|
|
156
|
+
// Same normalization as the report rollup, so "the same violation"
|
|
157
|
+
// means the same thing in tool output and in the final report.
|
|
158
|
+
const sig = `${v.kind}: ${v.detail
|
|
159
|
+
.replace(/\b\d+\b/g, ":n")
|
|
160
|
+
.replace(/[0-9a-f]{8,}/gi, ":h")
|
|
161
|
+
.slice(0, 140)}`;
|
|
162
|
+
v.repeat = this.reportedSigs.has(sig);
|
|
163
|
+
if (register) {
|
|
164
|
+
if (this.reportedSigs.size >= OracleMonitor.MAX_REPORTED_SIGS) {
|
|
165
|
+
// Sets iterate in insertion order, so this drops the oldest half.
|
|
166
|
+
const keep = [...this.reportedSigs].slice(OracleMonitor.MAX_REPORTED_SIGS / 2);
|
|
167
|
+
this.reportedSigs = new Set(keep);
|
|
168
|
+
}
|
|
169
|
+
this.reportedSigs.add(sig);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
export function formatViolations(violations) {
|
|
176
|
+
if (violations.length === 0)
|
|
177
|
+
return "";
|
|
178
|
+
const fresh = violations.filter((v) => !v.repeat);
|
|
179
|
+
const repeats = violations.length - fresh.length;
|
|
180
|
+
const repeatLine = repeats > 0 ? `\n ↻ plus ${repeats} repeat(s) of previously reported violations (still logged for the report)` : "";
|
|
181
|
+
if (fresh.length === 0) {
|
|
182
|
+
return `\nORACLE: ${repeats} repeat violation(s) of previously reported signatures — nothing new.`;
|
|
183
|
+
}
|
|
184
|
+
const lines = fresh.slice(0, 10).map((v) => ` ⚠ [${v.severity}] ${v.kind}: ${v.detail}`);
|
|
185
|
+
const more = fresh.length > 10 ? `\n … and ${fresh.length - 10} more` : "";
|
|
186
|
+
return `\nORACLE VIOLATIONS since last action (${fresh.length} new):\n${lines.join("\n")}${more}${repeatLine}`;
|
|
187
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ownership tracking for `safe-write` mode: which records did THIS run create?
|
|
3
|
+
*
|
|
4
|
+
* It is the most dangerous rule in the engine. An id wrongly claimed as "ours"
|
|
5
|
+
* licenses a real DELETE on data that existed before the run, so precision
|
|
6
|
+
* matters more than recall everywhere in this file: when in doubt, the answer
|
|
7
|
+
* is "not ours" and the write is blocked.
|
|
8
|
+
*
|
|
9
|
+
* It lives outside browser.ts because every rule here is a pure function of a
|
|
10
|
+
* URL, a status code and two bodies — which means it can be table-tested
|
|
11
|
+
* without launching a browser (ADR 5), and a rule this consequential should
|
|
12
|
+
* never have been reachable only through a smoke test.
|
|
13
|
+
*/
|
|
14
|
+
/** Collections that hold identities/accounts — never claimable as session-created, whatever the response says. */
|
|
15
|
+
const IDENTITY_COLLECTION_RE = /\b(users?|accounts?|profiles?|members?|identit(y|ies)|me)\b/i;
|
|
16
|
+
/** Matches the original, unprefixed id shape: bare "id"/"_id"/"uuid" only. */
|
|
17
|
+
const BARE_ID_KEY_RE = /^(id|_id|uuid)$/i;
|
|
18
|
+
/**
|
|
19
|
+
* Matches a resource-prefixed id key: snake_case-suffixed ("widget_id",
|
|
20
|
+
* "order_item_id") or camelCase-suffixed ("widgetId"). Many REST APIs
|
|
21
|
+
* name a create response's own primary key after the resource rather than
|
|
22
|
+
* a bare "id" — an unprefixed-only match silently drops every one of those
|
|
23
|
+
* responses from ownership tracking. Kept as a SEPARATE, more-strictly-
|
|
24
|
+
* filtered bucket from BARE_ID_KEY_RE (see extractCreatedIds): this shape is
|
|
25
|
+
* exactly what a foreign key echoing a client-supplied value also looks
|
|
26
|
+
* like ("template_id"), so it must never ride the explicitCreation bypass
|
|
27
|
+
* that lets a bare id survive even when its value was in the request body.
|
|
28
|
+
*/
|
|
29
|
+
const PREFIXED_ID_KEY_RE = /^[a-z][a-z0-9_]*_(id|uuid)$/i;
|
|
30
|
+
const PREFIXED_ID_KEY_CAMEL_RE = /[a-z0-9](Id|Uuid|UUID)$/;
|
|
31
|
+
/**
|
|
32
|
+
* RPC-style action verbs that follow a resource collection in
|
|
33
|
+
* create-from/clone/bulk endpoints (POST /api/things/from-template/:id,
|
|
34
|
+
* /api/things/clone/:id, ...). A collection derived for ownership matching
|
|
35
|
+
* must stop BEFORE one of these, or a later plain CRUD path on the created
|
|
36
|
+
* resource (/api/things/:id) won't share a path prefix with the creation
|
|
37
|
+
* URL and legitimate follow-up writes get wrongly blocked. Deliberately
|
|
38
|
+
* does NOT also stop at a bare numeric segment: a nested create like
|
|
39
|
+
* POST /api/documents/5/comments has no ownership-scoping problem (its
|
|
40
|
+
* exact pathname already prefixes any later path under that comment), and
|
|
41
|
+
* truncating there would only widen the stored collection unnecessarily.
|
|
42
|
+
*/
|
|
43
|
+
const ACTION_SEGMENT_RE = /^(from|via|clone|duplicate|copy|bulk|import|export|generate|batch)([-_].+)?$/i;
|
|
44
|
+
/** At most this many ids are claimed from one response — a list endpoint answering a POST must not mint a page of ownership. */
|
|
45
|
+
const MAX_IDS_PER_RESPONSE = 5;
|
|
46
|
+
/** Ids are compared case-insensitively and percent-decoded: an API may mint "7B2E…" and route on "/widgets/7b2e…". */
|
|
47
|
+
export function normalizeId(id) {
|
|
48
|
+
let decoded = id;
|
|
49
|
+
try {
|
|
50
|
+
decoded = decodeURIComponent(id);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
/* a stray "%" — compare it as written */
|
|
54
|
+
}
|
|
55
|
+
return decoded.toLowerCase();
|
|
56
|
+
}
|
|
57
|
+
/** A path segment that looks like a record id (number, uuid, long hex/token) rather than a collection or verb name. */
|
|
58
|
+
function looksLikeId(segment) {
|
|
59
|
+
return /^\d+$/.test(segment) || /^[0-9a-f]{8,}$/i.test(segment) || /^[0-9a-f]{8}-[0-9a-f-]{12,}$/i.test(segment);
|
|
60
|
+
}
|
|
61
|
+
/** True when none of these path segments is a record id. */
|
|
62
|
+
function noIdsIn(segments) {
|
|
63
|
+
return segments.filter(Boolean).every((seg) => !looksLikeId(seg));
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Words that act ON a record when they appear between a collection and an id
|
|
67
|
+
* (/api/widgets/archive/9). An allowlist, not "any non-id word": the word in
|
|
68
|
+
* that position is just as often a SUB-COLLECTION (/api/widgets/links/9 is
|
|
69
|
+
* link 9, not widget 9), and guessing wrong licenses a write on a record this
|
|
70
|
+
* run never created.
|
|
71
|
+
*/
|
|
72
|
+
const RECORD_VERB_RE = /^(delete|remove|destroy|archive|unarchive|restore|update|edit|patch|save|publish|unpublish|rename|move|duplicate|clone|copy|bulk|batch)([-_].+)?$/i;
|
|
73
|
+
/**
|
|
74
|
+
* Words that mean "create one, a particular way" when they follow a collection
|
|
75
|
+
* in a creation URL (/api/documents/quick). Same reasoning: /api/widgets/links
|
|
76
|
+
* is where links are created, and a link's id says nothing about widgets.
|
|
77
|
+
*/
|
|
78
|
+
const CREATE_VARIANT_RE = /^(quick|instant|new|create|add|draft|init|start|upload|compose)([-_].+)?$/i;
|
|
79
|
+
const allMatch = (segments, re) => segments.length > 0 && segments.every((seg) => re.test(seg));
|
|
80
|
+
/**
|
|
81
|
+
* Does this request path address a record this run created?
|
|
82
|
+
*
|
|
83
|
+
* For each path segment that is an id we own, the request's PARENT path (the
|
|
84
|
+
* segments before the id) is compared with each collection the id was created
|
|
85
|
+
* under. Four shapes are accepted, and every one refuses to step across
|
|
86
|
+
* another record's id — because numeric ids collide across tables, "9 is ours
|
|
87
|
+
* under tasks" says nothing about project 9:
|
|
88
|
+
*
|
|
89
|
+
* 1. direct: created under /api/widgets → /api/widgets/9[/…]
|
|
90
|
+
* 2. a record verb in the CRUD path (allowlisted): /api/widgets → /api/widgets/archive/9
|
|
91
|
+
* 3. a create variant in the creation path (allowlisted): /api/widgets/quick → /api/widgets/9
|
|
92
|
+
* 4. shallow nesting: /api/projects/3/tasks → /api/tasks/9
|
|
93
|
+
*/
|
|
94
|
+
export function isOwnedResource(ownedIds, pathname) {
|
|
95
|
+
const segments = pathname.split("/");
|
|
96
|
+
for (let i = 0; i < segments.length; i++) {
|
|
97
|
+
const collections = ownedIds.get(normalizeId(segments[i]));
|
|
98
|
+
if (!collections)
|
|
99
|
+
continue;
|
|
100
|
+
const parent = segments.slice(0, i).join("/");
|
|
101
|
+
const parentSegs = parent.split("/").filter(Boolean);
|
|
102
|
+
for (const stored of collections) {
|
|
103
|
+
const c = stored.replace(/\/$/, "");
|
|
104
|
+
const cSegs = c.split("/").filter(Boolean);
|
|
105
|
+
// 1. The id sits directly under the collection it was created in.
|
|
106
|
+
if (parent === c)
|
|
107
|
+
return true;
|
|
108
|
+
// 2. Verb-suffixed CRUD routes. (/api/widgets/12/links/88 is refused:
|
|
109
|
+
// 12 is somebody else's record and "links" is a sub-collection.)
|
|
110
|
+
if (parent.startsWith(`${c}/`) && allMatch(parentSegs.slice(cSegs.length), RECORD_VERB_RE))
|
|
111
|
+
return true;
|
|
112
|
+
// 3. Creation went through a variant endpoint below the collection. The
|
|
113
|
+
// parent needs ≥2 segments so a bare "/api" never matches.
|
|
114
|
+
if (parentSegs.length >= 2 && c.startsWith(`${parent}/`) && allMatch(cSegs.slice(parentSegs.length), CREATE_VARIANT_RE))
|
|
115
|
+
return true;
|
|
116
|
+
// 4. Shallow nesting: created under a parent record, addressed at the
|
|
117
|
+
// top level afterwards. Same collection name, and the creation path
|
|
118
|
+
// really was nested under an id.
|
|
119
|
+
const last = cSegs[cSegs.length - 1];
|
|
120
|
+
if (last && parentSegs[parentSegs.length - 1] === last && cSegs.slice(0, -1).some(looksLikeId) && noIdsIn(parentSegs))
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
/** Collapse a creation request's pathname down to its resource collection, stopping before any RPC-action segment. */
|
|
127
|
+
export function deriveCollection(pathname) {
|
|
128
|
+
const segments = pathname.split("/");
|
|
129
|
+
const kept = [];
|
|
130
|
+
for (const seg of segments) {
|
|
131
|
+
if (seg !== "" && ACTION_SEGMENT_RE.test(seg))
|
|
132
|
+
break;
|
|
133
|
+
kept.push(seg);
|
|
134
|
+
}
|
|
135
|
+
const collection = kept.join("/");
|
|
136
|
+
return collection || pathname; // never produce an empty collection
|
|
137
|
+
}
|
|
138
|
+
/** Strip an id-key's id/uuid suffix and normalize away separators, for comparing against a URL path segment (e.g. "document_id" → "document", "sopDocumentUuid" → "sopdocument"). */
|
|
139
|
+
function keyStem(key) {
|
|
140
|
+
return key
|
|
141
|
+
.replace(/(?:_)?(id|uuid)$/i, "")
|
|
142
|
+
.replace(/[-_]/g, "")
|
|
143
|
+
.toLowerCase();
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* True when a prefixed id key plausibly names the resource this creation
|
|
147
|
+
* URL is actually about — e.g. "widget_id" for a request under
|
|
148
|
+
* "/api/widgets/...". Without this, any OTHER *_id-shaped field a
|
|
149
|
+
* create response happens to include (owner_id, parent_id, assigned_to_id
|
|
150
|
+
* — server-derived, so never caught by the request-echo filter) would be
|
|
151
|
+
* wrongly claimed as the new resource's own id. A stem that shares no
|
|
152
|
+
* substring relationship with any path segment is assumed foreign.
|
|
153
|
+
*/
|
|
154
|
+
export function keyMatchesUrl(key, pathname) {
|
|
155
|
+
const stem = keyStem(key);
|
|
156
|
+
if (!stem)
|
|
157
|
+
return false;
|
|
158
|
+
return (pathname
|
|
159
|
+
.split("/")
|
|
160
|
+
.filter(Boolean)
|
|
161
|
+
// RPC-action segments (from-template, clone, ...) aren't the resource's
|
|
162
|
+
// name — a foreign key that happens to be named after the verb itself
|
|
163
|
+
// (e.g. "duplicate_id" on a POST .../duplicate endpoint) must not pass
|
|
164
|
+
// just because it echoes the verb.
|
|
165
|
+
.filter((seg) => !ACTION_SEGMENT_RE.test(seg))
|
|
166
|
+
.some((seg) => {
|
|
167
|
+
const normSeg = seg.replace(/[-_]/g, "").toLowerCase();
|
|
168
|
+
return normSeg.length > 0 && (normSeg.includes(stem) || stem.includes(normSeg));
|
|
169
|
+
}));
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Extract created-resource ids from a successful POST (JSON body + Location
|
|
173
|
+
* header). Upsert echoes — ids the client already sent in the URL or body —
|
|
174
|
+
* are excluded. A 201 or a Location header marks a true creation.
|
|
175
|
+
*/
|
|
176
|
+
export function extractCreatedIds(ev) {
|
|
177
|
+
// The identity check runs against the raw pathname (broadest net); the
|
|
178
|
+
// stored collection is collapsed via deriveCollection so a later plain
|
|
179
|
+
// CRUD path on the created resource still prefix-matches it even when
|
|
180
|
+
// creation went through an RPC-style action endpoint.
|
|
181
|
+
const identityCollection = IDENTITY_COLLECTION_RE.test(ev.pathname);
|
|
182
|
+
const collection = deriveCollection(ev.pathname);
|
|
183
|
+
// Kept as two buckets, not one merged list: a bare "id" is unambiguously
|
|
184
|
+
// the response's own subject, so a 201/Location can excuse it appearing
|
|
185
|
+
// in the request body too (client-supplied-id creates). A prefixed key
|
|
186
|
+
// ("template_id") is exactly what an echoed FOREIGN key also looks like,
|
|
187
|
+
// so it must always be checked against the request body — never excused
|
|
188
|
+
// by explicitCreation — or a 201 response that echoes a foreign id
|
|
189
|
+
// (e.g. {document_id, template_id}) would wrongly grant ownership of
|
|
190
|
+
// the template.
|
|
191
|
+
const bareIds = [];
|
|
192
|
+
const prefixedIds = [];
|
|
193
|
+
if (ev.location) {
|
|
194
|
+
const last = ev.location.split("?")[0].split("/").filter(Boolean).pop();
|
|
195
|
+
if (last)
|
|
196
|
+
bareIds.push(last);
|
|
197
|
+
}
|
|
198
|
+
const scan = (obj) => {
|
|
199
|
+
if (!obj || typeof obj !== "object")
|
|
200
|
+
return;
|
|
201
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
202
|
+
const isIdValue = typeof v === "string" || typeof v === "number";
|
|
203
|
+
if (isIdValue && BARE_ID_KEY_RE.test(k))
|
|
204
|
+
bareIds.push(String(v));
|
|
205
|
+
else if (isIdValue && (PREFIXED_ID_KEY_RE.test(k) || PREFIXED_ID_KEY_CAMEL_RE.test(k)) && keyMatchesUrl(k, ev.pathname))
|
|
206
|
+
prefixedIds.push(String(v));
|
|
207
|
+
else if (k === "data" || k === "result" || k === "item")
|
|
208
|
+
scan(v);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
scan(ev.body);
|
|
212
|
+
// An upsert echoes an id the client already had; a create mints a new one.
|
|
213
|
+
// An id already in the REQUEST PATH is never ours (POST /items/123 targets
|
|
214
|
+
// an existing resource whatever the response says); a 201/Location only
|
|
215
|
+
// excuses a BARE id echoed in the request body (see bucket comment above).
|
|
216
|
+
const explicitCreation = ev.status === 201 || !!ev.location;
|
|
217
|
+
const notEchoedInPath = (id) => !ev.pathname.includes(id);
|
|
218
|
+
const ids = [
|
|
219
|
+
...bareIds.filter((id) => notEchoedInPath(id) && (explicitCreation || !ev.requestBody.includes(id))),
|
|
220
|
+
...prefixedIds.filter((id) => notEchoedInPath(id) && !ev.requestBody.includes(id)),
|
|
221
|
+
].slice(0, MAX_IDS_PER_RESPONSE);
|
|
222
|
+
return { collection, identityCollection, ids };
|
|
223
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Destructive-action policy. In read-only mode the engine refuses to interact
|
|
3
|
+
* with elements whose accessible name / testid / id matches these patterns —
|
|
4
|
+
* enforcement lives here, at the tool layer, never in the model's prompt.
|
|
5
|
+
*/
|
|
6
|
+
const DESTRUCTIVE_PATTERNS = [
|
|
7
|
+
/\bdelete\b/i,
|
|
8
|
+
/\bremove\b/i,
|
|
9
|
+
/\brevoke\b/i,
|
|
10
|
+
/\bdestroy\b/i,
|
|
11
|
+
/\bpurge\b/i,
|
|
12
|
+
/\bterminate\b/i,
|
|
13
|
+
/\bdeactivate\b/i,
|
|
14
|
+
/\barchive\b/i,
|
|
15
|
+
/\bwipe\b/i,
|
|
16
|
+
// "Reset" is destructive only in some of its senses. Matching it bare refused
|
|
17
|
+
// "Reset filters", "Reset zoom", "Reset search" and "Reset password" — none
|
|
18
|
+
// of which destroy anything — so read-only runs skipped ordinary controls and
|
|
19
|
+
// lost coverage on the very pages they were sent to explore. Exempt the
|
|
20
|
+
// view/form senses; everything else ("Reset workspace", "Factory reset",
|
|
21
|
+
// "Reset all data") still counts.
|
|
22
|
+
/\breset\b(?!\s+(filters?|zoom|search|view|sort|order|form|password|layout|columns?|selection|preferences?|defaults?))/i,
|
|
23
|
+
/\bdiscard\b/i,
|
|
24
|
+
/\bcancel subscription\b/i,
|
|
25
|
+
/\bsign(?: |-)?off\b/i,
|
|
26
|
+
// The tool is generic — destructive labels come in many languages.
|
|
27
|
+
/\b(eliminar|borrar|suprimir)\b/i, // es
|
|
28
|
+
/\b(excluir|apagar|remover)\b/i, // pt
|
|
29
|
+
/\bsupprimer\b/i, // fr
|
|
30
|
+
/\b(löschen|loeschen|entfernen)\b/i, // de
|
|
31
|
+
/\belimina(?:re)?\b/i, // it
|
|
32
|
+
/\bverwijder(?:en)?\b/i, // nl
|
|
33
|
+
// \b is ASCII-only in JS regexes — non-Latin scripts match as substrings.
|
|
34
|
+
/(удалить|удаление)/i, // ru
|
|
35
|
+
/(削除|删除|삭제)/, // ja/zh/ko
|
|
36
|
+
];
|
|
37
|
+
/**
|
|
38
|
+
* Network-layer destructive signals that delete/disable data regardless of
|
|
39
|
+
* what the button was labeled. Used by the write-policy interceptor.
|
|
40
|
+
*
|
|
41
|
+
* URL and BODY are judged by DIFFERENT rules, on purpose. A URL path is
|
|
42
|
+
* STRUCTURE — a bare destructive verb as a path segment (`/users/3/delete`,
|
|
43
|
+
* `/widgets/bulk-delete`) is a strong, unambiguous signal. A request BODY is
|
|
44
|
+
* often user CONTENT — a document being analysed, a record description, a document
|
|
45
|
+
* uploaded for review — so a bare keyword in it is usually prose, not intent.
|
|
46
|
+
* Scanning the body for bare `delete`/`remove` blocked ordinary create/submit
|
|
47
|
+
* POSTs whose payload merely MENTIONED a destructive word: seen live, an document
|
|
48
|
+
* text containing "Remove jewellery" and a description saying "Safe to delete"
|
|
49
|
+
* each got their `POST /api/ai/analyze` (and a plain create) refused. That is
|
|
50
|
+
* pure lost coverage with no safety gain — the analyse/create was never
|
|
51
|
+
* destructive. So the body is matched only for STRUCTURED destructive intent.
|
|
52
|
+
*/
|
|
53
|
+
/** A destructive verb occupying a URL PATH segment. Bare keywords are meaningful here — a path is not prose. */
|
|
54
|
+
const DESTRUCTIVE_URL_RE = /(\/|\b|_)(delete|remove|purge|destroy|archive|revoke|deactivate|wipe|bulk[-_]?delete|force[-_]?delete)(\/|\b|_)/i;
|
|
55
|
+
/**
|
|
56
|
+
* Structured destructive intent inside a body — never a bare keyword.
|
|
57
|
+
* Two shapes: (a) a GraphQL destructive mutation (the 200-char window after
|
|
58
|
+
* `mutation` catches the anonymous `mutation { deleteUser(id: 7) }`, not just
|
|
59
|
+
* the named `mutation DeleteUser {`); (b) a destructive verb as the VALUE of a
|
|
60
|
+
* command-like key (`"action":"delete"`, `operation=archive`, `"_method":"DELETE"`).
|
|
61
|
+
* A destructive word sitting in any OTHER field value (a title, a description,
|
|
62
|
+
* document text) is content, not a command, and is deliberately not matched.
|
|
63
|
+
*
|
|
64
|
+
* The key must be preceded by a STRUCTURAL character (quote, brace, bracket,
|
|
65
|
+
* comma, or the start of the body / a query string) so the same sentence-shaped
|
|
66
|
+
* prose this fix exists to allow cannot sneak back in through the command
|
|
67
|
+
* branch: "Our intent: delete duplicate accounts" is still content. `intent`
|
|
68
|
+
* and `verb` are not in the key list at all — they read as prose far more often
|
|
69
|
+
* than as command fields.
|
|
70
|
+
*/
|
|
71
|
+
const DESTRUCTIVE_BODY_RE = /\bmutation\b[\s\S]{0,200}?\b(?:delete|remove|archive|destroy|purge|revoke)[A-Za-z_]|(?:^|[{,[\s]*["']|[?&])\s*(?:action|operation|op|method|_method|command|cmd)["']?\s*[:=]\s*["']?(?:delete|remove|purge|destroy|archive|revoke|deactivate|wipe)\b/i;
|
|
72
|
+
export function isDestructiveWire(url, body) {
|
|
73
|
+
return DESTRUCTIVE_URL_RE.test(url) || (!!body && DESTRUCTIVE_BODY_RE.test(body.slice(0, 2000)));
|
|
74
|
+
}
|
|
75
|
+
/** Auth/session flows must work even under strict write policies (login, token refresh, logout). */
|
|
76
|
+
export const AUTH_FLOW_RE = /\/(auth|login|logout|signin|sign-in|signup|sign-up|session|token|verify|oauth|sso|password)\b/i;
|
|
77
|
+
export function isDestructive(...labels) {
|
|
78
|
+
return labels.some((label) => typeof label === "string" && label.length > 0 && DESTRUCTIVE_PATTERNS.some((re) => re.test(label)));
|
|
79
|
+
}
|
|
80
|
+
export function destructiveRefusal(label) {
|
|
81
|
+
return (`REFUSED by read-only policy: "${label}" matches a destructive-action pattern. ` +
|
|
82
|
+
`This run is read-only; do not attempt this element again. If destructive flows must be tested, ` +
|
|
83
|
+
`the user has to re-attach with mode="destructive" against a disposable/seeded environment.`);
|
|
84
|
+
}
|