argusqa-os 9.8.1 → 9.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,498 @@
1
+ /**
2
+ * Aegis — sensitivity classifier + egress projector (Step 2 of
3
+ * REDACTION_BOUNDARY_MAX_PLAN.md).
4
+ *
5
+ * Two functions, one contract (plan §4.2):
6
+ * - classifySensitivity(report) — the DETECTOR. Runs once in the pipeline,
7
+ * tags each finding {sensitive, sensitivityReasons, localRef} on a shallow
8
+ * CLONE (never mutates the frozen original, §5.4), preserving full local
9
+ * detail. Idempotent. FAILS CLOSED per finding.
10
+ * - redactForEgress(findings) — the PROJECTOR. Pure. Returns BRAND-NEW
11
+ * objects carrying only the deny-by-default field allowlist (§5.2); a
12
+ * sensitive finding's detail becomes a 🔒 marker, a benign finding's message
13
+ * survives only after a mandatory scrubText() catch-all. FAILS CLOSED per
14
+ * finding (any error ⇒ a fully-redacted stub, never the raw finding).
15
+ *
16
+ * SECURITY POSTURE — fail CLOSED. Ambiguity, errors, and unknown shapes redact
17
+ * MORE, never less. The deny-by-default allowlist guarantees a finding field
18
+ * added next year leaks NOTHING until someone deliberately allowlists it.
19
+ *
20
+ * Layer 1 (category rules) lives here; Layers 2–5 (secrets/entropy/PII/context)
21
+ * come from secret-patterns.js.
22
+ */
23
+
24
+ import { childLogger } from './logger.js';
25
+ import { findSensitiveSpans, scrubText, stripZeroWidth } from './secret-patterns.js';
26
+ import { ensureVaultWired } from './aegis-vault.js';
27
+
28
+ const logger = childLogger('aegis');
29
+
30
+ // ── Layer 1 — category rules ────────────────────────────────────────────────
31
+
32
+ /**
33
+ * Non-`security_*` finding types treated as sensitive by category. (Every type
34
+ * beginning `security_` is caught by the prefix test in isSensitiveType.)
35
+ * @type {Set<string>}
36
+ */
37
+ export const SENSITIVE_TYPES = new Set([
38
+ 'cors_violation', 'cors_error', 'mixed_content', 'permission_policy_violation',
39
+ 'cookie_attribute_missing', 'csp_violation', 'unclassified_devtools_issue',
40
+ 'uncaught_exception', 'unhandled_rejection', 'sensitive_console',
41
+ ]);
42
+
43
+ /**
44
+ * Fields whose mere presence on a finding marks it sensitive — a body/stack/
45
+ * header/cookie blob is exploit-or-secret-bearing regardless of the finding type
46
+ * (the Layer-1 catch-all that makes an unknown future type fail closed).
47
+ * @type {string[]}
48
+ */
49
+ export const BODY_FIELDS = ['requestBody', 'responseBody', 'stack', 'headers', 'cookies'];
50
+
51
+ /** Free-text fields scanned by the content layers (2–5). */
52
+ const TEXT_FIELDS = ['message', 'evidence', 'snippet', 'payload', 'detail', 'title',
53
+ 'url', 'requestUrl', 'source', 'element', 'property'];
54
+
55
+ /**
56
+ * Deny-by-default egress field allowlist (§5.2). ONLY these keys may ever appear
57
+ * in an external object; everything else is dropped. `url`/`requestUrl` pass
58
+ * through sanitizeUrl; a string `value` is scrubbed; `message` survives only for
59
+ * a non-sensitive finding and only after scrubText.
60
+ */
61
+ export const EGRESS_ALLOWLIST = ['type', 'severity', 'route', 'url', 'requestUrl',
62
+ 'selector', 'status', 'method', 'metric', 'value', 'budget', 'count', 'title',
63
+ 'isNew', 'noisy', 'flaky', 'localRef'];
64
+
65
+ /**
66
+ * @param {string} type
67
+ * @returns {boolean}
68
+ */
69
+ export function isSensitiveType(type) {
70
+ if (typeof type !== 'string') return true; // unknown shape ⇒ fail closed
71
+ if (type.startsWith('security_')) return true;
72
+ return SENSITIVE_TYPES.has(type);
73
+ }
74
+
75
+ // ── Detection ───────────────────────────────────────────────────────────────
76
+
77
+ /**
78
+ * Build a stable, secret-free key for a finding (the localRef fragment + audit
79
+ * key). `type::route` per the plan example (`security_no_https::/login`).
80
+ * @param {object} finding
81
+ * @returns {string}
82
+ */
83
+ export function findingKey(finding) {
84
+ const type = finding?.type ?? 'unknown';
85
+ const route = finding?.route ?? routeFromUrl(finding?.url) ?? '';
86
+ return `${type}::${route}`;
87
+ }
88
+
89
+ /**
90
+ * Classify ONE finding across all five layers.
91
+ * L1 category/body-field · L2 secret regex · L3 entropy/token-efficiency ·
92
+ * L4 PII · L5 context (the latter four via findSensitiveSpans over its free
93
+ * text). Sensitive iff any layer fires.
94
+ *
95
+ * @param {object} finding
96
+ * @returns {{ sensitive: boolean, sensitivityReasons: string[] }}
97
+ */
98
+ export function classifyFinding(finding) {
99
+ const reasons = [];
100
+
101
+ // Layer 1 — category + body-field catch-all.
102
+ if (isSensitiveType(finding?.type)) reasons.push(`category:${finding?.type ?? 'unknown'}`);
103
+ for (const f of BODY_FIELDS) {
104
+ const v = finding?.[f];
105
+ if (v !== undefined && v !== null && v !== '') { reasons.push(`category:has_${f}`); break; }
106
+ }
107
+
108
+ // Layers 2–5 — content scan over every free-text + serialisable field.
109
+ let blob = '';
110
+ for (const f of TEXT_FIELDS) {
111
+ const v = finding?.[f];
112
+ if (typeof v === 'string' && v) blob += ' ' + v;
113
+ }
114
+ for (const f of BODY_FIELDS) {
115
+ const v = finding?.[f];
116
+ if (v === undefined || v === null) continue;
117
+ blob += ' ' + (typeof v === 'string' ? v : safeStringify(v));
118
+ }
119
+ const addSpan = (span) => {
120
+ const tag = span.kind === 'secret' ? `secret:${span.name}`
121
+ : span.kind === 'pii' ? `pii:${span.name}`
122
+ : 'entropy:high';
123
+ if (reasons.includes(tag)) return false;
124
+ reasons.push(tag);
125
+ return true;
126
+ };
127
+ for (const span of findSensitiveSpans(blob)) addSpan(span);
128
+ // Defeat zero-width-splice evasion: re-scan a boundary-preserving, zero-width-
129
+ // stripped variant so \b-anchored rules (email/private-host) and the secret
130
+ // regexes still fire after a U+200B/soft-hyphen splice. ADDITIVE + fail closed.
131
+ const zwStripped = stripZeroWidth(blob);
132
+ if (zwStripped !== blob) {
133
+ let addedNew = false;
134
+ for (const span of findSensitiveSpans(zwStripped)) { if (addSpan(span)) addedNew = true; }
135
+ if (addedNew) reasons.push('evasion:normalized');
136
+ }
137
+
138
+ return { sensitive: reasons.length > 0, sensitivityReasons: reasons };
139
+ }
140
+
141
+ /**
142
+ * Walk every finding in the report, tag each with sensitivity on a SHALLOW CLONE
143
+ * (never mutating the frozen original), and attach a localRef pointer. Fails
144
+ * closed per finding: any classifier error ⇒ that finding is marked sensitive.
145
+ *
146
+ * @param {object} report
147
+ * @param {{ reportRef?: string }} [opts]
148
+ * @returns {{ sensitiveCount: number }}
149
+ */
150
+ export function classifySensitivity(report, opts = {}) {
151
+ const reportRef = opts.reportRef || 'local-report';
152
+ let sensitiveCount = 0;
153
+
154
+ const tag = (finding) => {
155
+ try {
156
+ const result = classifyFinding(finding);
157
+ if (result.sensitive) sensitiveCount++;
158
+ // Shallow clone — works whether or not the source is Object.freeze'd, and
159
+ // never removes local detail (the local report stays pristine + now tagged).
160
+ // The spread can itself throw on a pathological getter, so it lives INSIDE
161
+ // the try: any failure falls through to the fail-closed stub below.
162
+ return {
163
+ ...finding,
164
+ sensitive: result.sensitive,
165
+ sensitivityReasons: result.sensitivityReasons,
166
+ localRef: `${reportRef}#${findingKey(finding)}`,
167
+ };
168
+ } catch (err) {
169
+ logger.error(`[ARGUS] Aegis classify/clone threw — failing closed: ${err.message}`);
170
+ sensitiveCount++;
171
+ // Do NOT spread the finding (a getter may re-throw). Salvage safe scalars.
172
+ const type = safeGet(finding, 'type') ?? 'unknown';
173
+ const route = safeGet(finding, 'route') ?? routeFromUrl(safeGet(finding, 'url')) ?? '';
174
+ return { type, severity: safeGet(finding, 'severity') ?? 'critical',
175
+ sensitive: true, sensitivityReasons: ['classifier-error'],
176
+ localRef: `${reportRef}#${type}::${route}` };
177
+ }
178
+ };
179
+
180
+ for (const routeResult of (report?.routes ?? [])) {
181
+ if (Array.isArray(routeResult?.errors)) routeResult.errors = routeResult.errors.map(tag);
182
+ }
183
+ for (const flowResult of (report?.flows ?? [])) {
184
+ if (Array.isArray(flowResult?.findings)) flowResult.findings = flowResult.findings.map(tag);
185
+ }
186
+ if (Array.isArray(report?.codebase)) report.codebase = report.codebase.map(tag);
187
+
188
+ return { sensitiveCount };
189
+ }
190
+
191
+ // ── Projection ──────────────────────────────────────────────────────────────
192
+
193
+ /**
194
+ * Keep origin + path; strip the query string, fragment, and userinfo (tokens
195
+ * hide in query params and `user:pass@`). Plan §5.2.
196
+ * @param {*} u
197
+ * @returns {string}
198
+ */
199
+ export function sanitizeUrl(u) {
200
+ if (typeof u !== 'string' || u === '') return '';
201
+ try {
202
+ const parsed = new URL(u);
203
+ return `${parsed.origin}${parsed.pathname}`; // origin excludes userinfo + query + hash
204
+ } catch {
205
+ // Relative / malformed — strip manually.
206
+ return u.replace(/^([a-z]+:\/\/)([^/\s@]+@)/i, '$1') // userinfo
207
+ .split('#')[0].split('?')[0];
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Logical route name for a finding: explicit `route`, else the URL pathname.
213
+ * @param {*} u
214
+ * @returns {string}
215
+ */
216
+ export function routeFromUrl(u) {
217
+ if (typeof u !== 'string' || u === '') return '';
218
+ try {
219
+ return new URL(u).pathname || '/';
220
+ } catch {
221
+ return u.replace(/^[a-z]+:\/\/[^/]+/i, '').split('#')[0].split('?')[0] || u;
222
+ }
223
+ }
224
+
225
+ function routeOf(finding) {
226
+ return finding?.route ?? routeFromUrl(finding?.url) ?? '';
227
+ }
228
+
229
+ function titleFor(finding) {
230
+ return `${finding?.type ?? 'finding'} on ${routeOf(finding) || '/'} (${finding?.severity ?? 'info'})`;
231
+ }
232
+
233
+ export const REDACT_MARKER = '🔒 redacted — full detail in local report (localRef)';
234
+
235
+ /**
236
+ * Project ONE finding to its egress-safe shape under the deny-by-default
237
+ * allowlist. Builds a brand-new object — never mutates the input.
238
+ * @param {object} finding
239
+ * @param {{ mode: string, failClosed: boolean }} ctx
240
+ * @returns {object}
241
+ */
242
+ function projectFinding(finding, ctx) {
243
+ let sensitive = ctx.failClosed === true || finding?.sensitive === true;
244
+ if (!sensitive) sensitive = classifyFinding(finding).sensitive; // untagged fallback
245
+
246
+ const out = {
247
+ type: finding?.type ?? 'unknown',
248
+ severity: finding?.severity ?? 'info',
249
+ route: routeOf(finding),
250
+ title: titleFor(finding),
251
+ };
252
+
253
+ // url / requestUrl — sanitised (origin+path, no query/fragment/userinfo).
254
+ if (typeof finding?.url === 'string' && finding.url) out.url = sanitizeUrl(finding.url);
255
+ if (typeof finding?.requestUrl === 'string' && finding.requestUrl) out.requestUrl = sanitizeUrl(finding.requestUrl);
256
+
257
+ // Structural / short-enum passthroughs (only when present).
258
+ if (typeof finding?.selector === 'string') out.selector = finding.selector;
259
+ if (finding?.status !== undefined) out.status = finding.status;
260
+ if (typeof finding?.method === 'string') out.method = finding.method;
261
+ if (typeof finding?.metric === 'string') out.metric = finding.metric;
262
+ if (finding?.budget !== undefined && typeof finding.budget !== 'string') out.budget = finding.budget;
263
+ if (typeof finding?.count === 'number') out.count = finding.count;
264
+ // value: numeric passes through; a string value is free-text ⇒ scrub it.
265
+ if (typeof finding?.value === 'number') out.value = finding.value;
266
+ else if (typeof finding?.value === 'string') out.value = scrubText(finding.value, { mode: ctx.mode });
267
+
268
+ // Booleans needed for new/persisting/resolved reconciliation.
269
+ for (const b of ['isNew', 'noisy', 'flaky']) {
270
+ if (typeof finding?.[b] === 'boolean') out[b] = finding[b];
271
+ }
272
+ if (typeof finding?.localRef === 'string') out.localRef = finding.localRef;
273
+
274
+ if (sensitive) {
275
+ // Sensitive: the RAW message never crosses. The redaction marker is carried in BOTH
276
+ // `detail` (the explicit provenance marker, plan §5.2) AND `message` — the latter so the
277
+ // canonical finding contract's required `message` (golden findingSchema) is satisfied and
278
+ // every sink that renders f.message shows the notice without special-casing. The marker is
279
+ // a compile-time constant — provably secret-free (the fuzz/red-team "no secret substring"
280
+ // invariants hold unchanged).
281
+ out.detail = REDACT_MARKER; // detail withheld; pointer is localRef
282
+ out.message = REDACT_MARKER;
283
+ } else {
284
+ // Catch-all: a benign finding keeps its helpful message, but only after the
285
+ // mandatory secret/PII scrub (an accidental key in an innocuous message dies).
286
+ out.message = scrubText(typeof finding?.message === 'string' ? finding.message : '', { mode: ctx.mode });
287
+ }
288
+ return out;
289
+ }
290
+
291
+ /**
292
+ * The egress projector. Returns a NEW array of minimal, sanitised objects safe
293
+ * to cross any trust boundary. Honours the ARGUS_REDACT_SENSITIVE=0 opt-out
294
+ * (byte-identical passthrough) and fails closed per finding.
295
+ *
296
+ * @param {object[]} findings
297
+ * @param {{ mode?: string, failClosed?: boolean }} [opts]
298
+ * @returns {object[]}
299
+ */
300
+ export function redactForEgress(findings, opts = {}) {
301
+ const list = Array.isArray(findings) ? findings : [];
302
+ // Opt-out: byte-identical to pre-Aegis (the sinks shipped raw findings).
303
+ if (process.env.ARGUS_REDACT_SENSITIVE === '0') return list.slice();
304
+
305
+ const mode = process.env.ARGUS_REDACT_MODE || opts.mode || 'mask';
306
+ const failClosed = opts.failClosed === true;
307
+ if (mode === 'token') ensureVaultWired(); // wire the reversible vault seam (Step 8) lazily
308
+
309
+ return list.map((finding) => {
310
+ try {
311
+ return projectFinding(finding, { mode, failClosed });
312
+ } catch (err) {
313
+ // Per-finding fail-closed: emit a fully-redacted stub, never the raw finding.
314
+ logger.error(`[ARGUS] Aegis redactForEgress threw — emitting redacted stub: ${err.message}`);
315
+ return {
316
+ type: finding?.type ?? 'unknown',
317
+ severity: finding?.severity ?? 'critical', // worst-case on uncertainty
318
+ route: '',
319
+ title: 'redacted',
320
+ detail: REDACT_MARKER,
321
+ localRef: typeof finding?.localRef === 'string' ? finding.localRef : null,
322
+ };
323
+ }
324
+ });
325
+ }
326
+
327
+ /**
328
+ * Aggregate counts for the "N sensitive finding(s) redacted" banners. Fails
329
+ * closed: a finding whose classification throws is counted as redacted.
330
+ *
331
+ * @param {object[]} findings
332
+ * @returns {{ total: number, redacted: number, byReason: Record<string, number> }}
333
+ */
334
+ export function summarizeRedaction(findings) {
335
+ const list = Array.isArray(findings) ? findings : [];
336
+ let redacted = 0;
337
+ const byReason = Object.create(null);
338
+ for (const finding of list) {
339
+ let sensitive;
340
+ let reasons;
341
+ if (finding?.sensitive === true) {
342
+ sensitive = true;
343
+ reasons = Array.isArray(finding.sensitivityReasons) ? finding.sensitivityReasons : [];
344
+ } else if (finding?.sensitive === false) {
345
+ sensitive = false;
346
+ reasons = [];
347
+ } else {
348
+ try {
349
+ const r = classifyFinding(finding);
350
+ sensitive = r.sensitive;
351
+ reasons = r.sensitivityReasons;
352
+ } catch {
353
+ sensitive = true; // fail closed
354
+ reasons = ['classifier-error'];
355
+ }
356
+ }
357
+ if (sensitive) {
358
+ redacted++;
359
+ for (const reason of reasons) byReason[reason] = (byReason[reason] || 0) + 1;
360
+ }
361
+ }
362
+ return { total: list.length, redacted, byReason };
363
+ }
364
+
365
+ // ── Egress riders + loose-object scrubbing (MCP/sink helpers, plan §6 Step 4) ──
366
+
367
+ /**
368
+ * Build the `redaction` rider every guarded egress response carries: a secret-free
369
+ * summary of how much detail was withheld + a pointer to the full local report.
370
+ * Powers the "🔒 N redacted" banner. Plan §6 Step 4 / §9.1 [168i].
371
+ *
372
+ * @param {object[]} rawFindings the ORIGINAL (un-projected) findings
373
+ * @param {{ localReportPath?: string|null }} [opts]
374
+ * @returns {{ redacted: number, total: number, localReportPath: string|null, note: string }}
375
+ */
376
+ export function buildRedactionRider(rawFindings, opts = {}) {
377
+ const list = Array.isArray(rawFindings) ? rawFindings : [];
378
+ const r = summarizeRedaction(list);
379
+ // Fail-closed (report-level _aegisFailClosed): EVERY finding was force-redacted, so the
380
+ // rider must say so rather than undercounting via the per-finding classifier.
381
+ const redacted = opts.failClosed === true ? list.length : r.redacted;
382
+ return {
383
+ redacted,
384
+ total: r.total,
385
+ localReportPath: opts.localReportPath ?? null,
386
+ note: 'Detail withheld at the trust boundary — full report retained on the local machine.',
387
+ };
388
+ }
389
+
390
+ /** Keys whose string value is a URL → sanitised (origin+path) before scrubbing. */
391
+ const URL_KEYS = new Set(['url', 'requestUrl', 'documentUrl', 'documentURL', 'source',
392
+ 'href', 'location', 'devUrl', 'stagingUrl', 'baseUrl', 'redirectUrl']);
393
+
394
+ /**
395
+ * Recursively scrub an arbitrary LOOSE object/array (console messages, network
396
+ * requests, env-comparison diffs) for egress: every string runs through scrubText
397
+ * (secrets/PII masked); a URL-valued key is sanitised (query/userinfo stripped)
398
+ * first; numbers/booleans pass through; a throwing getter is dropped (fail closed).
399
+ * Bounded recursion depth. Returns a BRAND-NEW value — never mutates the input.
400
+ *
401
+ * Unlike redactForEgress (deny-by-default findings projection), deepScrub PRESERVES
402
+ * the object's key set — these payloads have no canonical allowlist, so the safety
403
+ * comes from scrubbing every string rather than from dropping unknown keys.
404
+ *
405
+ * @param {*} value
406
+ * @param {{ mode?: string }} [opts]
407
+ * @param {number} [depth]
408
+ * @returns {*}
409
+ */
410
+ export function deepScrub(value, opts = {}, depth = 0) {
411
+ if (process.env.ARGUS_REDACT_SENSITIVE === '0') return value;
412
+ const mode = process.env.ARGUS_REDACT_MODE || opts.mode || 'mask';
413
+ if (mode === 'token' && depth === 0) ensureVaultWired(); // wire the vault seam once per top-level call
414
+ if (value === null || value === undefined) return value;
415
+ if (typeof value === 'string') return scrubText(value, { mode });
416
+ if (typeof value === 'number' || typeof value === 'boolean') return value;
417
+ if (depth >= 6) return undefined; // pathological nesting → drop (fail closed)
418
+ if (Array.isArray(value)) return value.map((v) => deepScrub(v, opts, depth + 1));
419
+ if (typeof value === 'object') {
420
+ const out = {};
421
+ for (const k of Object.keys(value)) {
422
+ let v;
423
+ try { v = value[k]; } catch { continue; } // throwing getter → drop the field
424
+ if (URL_KEYS.has(k) && typeof v === 'string') out[k] = scrubText(sanitizeUrl(v), { mode });
425
+ else out[k] = deepScrub(v, opts, depth + 1);
426
+ }
427
+ return out;
428
+ }
429
+ return undefined; // functions / symbols → dropped
430
+ }
431
+
432
+ /**
433
+ * Project a REPORT-shaped object (argus_audit_full / argus_compare /
434
+ * argus_last_report) for egress: every finding array (routes[].errors,
435
+ * routes[].findings, flows[].findings, codebase[]) runs through redactForEgress;
436
+ * env-comparison routes[].diffs are deepScrub'd; a `redaction` rider is attached.
437
+ * Report metadata (summary, route urls, mode, timestamps) is PRESERVED so the
438
+ * golden response schemas still hold. Never mutates the input.
439
+ *
440
+ * Honours the report-level `_aegisFailClosed` flag (set by report-processor step 3c
441
+ * on a classifier error): when set, ALL findings are force-redacted.
442
+ *
443
+ * @param {object} report
444
+ * @param {{ localReportPath?: string|null, mode?: string, failClosed?: boolean }} [opts]
445
+ * @returns {object}
446
+ */
447
+ export function redactReport(report, opts = {}) {
448
+ if (process.env.ARGUS_REDACT_SENSITIVE === '0') return report;
449
+ if (!report || typeof report !== 'object') return report;
450
+
451
+ const failClosed = report._aegisFailClosed === true || opts.failClosed === true;
452
+ const ro = { ...opts, failClosed };
453
+ const rawFindings = [];
454
+ const out = { ...report };
455
+
456
+ if (Array.isArray(report.routes)) {
457
+ out.routes = report.routes.map((route) => {
458
+ if (!route || typeof route !== 'object') return route;
459
+ const r = { ...route };
460
+ if (Array.isArray(route.errors)) { rawFindings.push(...route.errors); r.errors = redactForEgress(route.errors, ro); }
461
+ if (Array.isArray(route.findings)) { rawFindings.push(...route.findings); r.findings = redactForEgress(route.findings, ro); }
462
+ if (Array.isArray(route.diffs)) { r.diffs = route.diffs.map((d) => deepScrub(d, ro)); }
463
+ return r;
464
+ });
465
+ }
466
+ if (Array.isArray(report.flows)) {
467
+ out.flows = report.flows.map((flow) => {
468
+ if (flow && typeof flow === 'object' && Array.isArray(flow.findings)) {
469
+ rawFindings.push(...flow.findings);
470
+ return { ...flow, findings: redactForEgress(flow.findings, ro) };
471
+ }
472
+ return flow;
473
+ });
474
+ }
475
+ if (Array.isArray(report.codebase)) {
476
+ rawFindings.push(...report.codebase);
477
+ out.codebase = redactForEgress(report.codebase, ro);
478
+ }
479
+
480
+ if (Array.isArray(report.routes) || Array.isArray(report.flows) || Array.isArray(report.codebase)) {
481
+ out.redaction = buildRedactionRider(rawFindings, { ...opts, failClosed });
482
+ }
483
+ return out;
484
+ }
485
+
486
+ /** Read a property without ever throwing (a pathological getter ⇒ undefined). */
487
+ function safeGet(obj, key) {
488
+ try { return obj?.[key]; } catch { return undefined; }
489
+ }
490
+
491
+ /** JSON.stringify that never throws (cyclic / exotic values ⇒ a short token). */
492
+ function safeStringify(v) {
493
+ try {
494
+ return JSON.stringify(v) ?? '';
495
+ } catch {
496
+ return '[unserialisable]';
497
+ }
498
+ }