agent-sanitizer 2.63.0 → 2.63.1
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/README.md +1 -0
- package/THREAT-MODEL.md +41 -17
- package/claude-hooks/lib/redactor-client.mjs +60 -5
- package/claude-hooks/sanitize-output.mjs +41 -269
- package/package.json +1 -1
- package/src/confusable-host.mjs +18 -14
- package/src/confusables.mjs +43 -5
- package/src/gates.mjs +53 -21
- package/src/html.mjs +72 -59
- package/src/index.mjs +1 -0
- package/src/invisible.mjs +2 -0
- package/src/output.mjs +421 -297
- package/src/severity.mjs +14 -2
- package/src/unicode-properties.mjs +331 -0
- package/src/view-map.mjs +6 -4
- package/src/warnings.mjs +7 -0
- package/types/claude-hooks/lib/redactor-client.d.mts +18 -0
- package/types/claude-hooks/sanitize-output.d.mts +14 -30
- package/types/confusable-host.d.mts +7 -6
- package/types/confusables.d.mts +12 -0
- package/types/gates.d.mts +30 -17
- package/types/html.d.mts +4 -2
- package/types/index.d.mts +1 -1
- package/types/invisible.d.mts +1 -0
- package/types/output.d.mts +131 -39
- package/types/severity.d.mts +0 -4
- package/types/src/invisible.d.mts +1 -0
- package/types/src/unicode-properties.d.mts +2 -0
- package/types/unicode-properties.d.mts +2 -0
- package/types/view-map.d.mts +2 -0
- package/types/warnings.d.mts +5 -0
package/src/output.mjs
CHANGED
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
* filter can at most remove legitimate content — it can never inject new bytes
|
|
21
21
|
* into the model's view. A consumer running a live LLM filter wires it here.
|
|
22
22
|
* Because a span deletion joins the bytes on either side of the deleted span,
|
|
23
|
-
*
|
|
24
|
-
* actually removes something, so a secret that a
|
|
25
|
-
* still caught before this function returns.
|
|
23
|
+
* Layers 3 (`exfilScan`) and 4 (`redact`) are re-run on the post-deletion text
|
|
24
|
+
* whenever Layer 5 actually removes something, so a URL or secret that a
|
|
25
|
+
* deletion assembles is still caught before this function returns.
|
|
26
26
|
*/
|
|
27
27
|
import {
|
|
28
28
|
CATEGORY,
|
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
HTML_UNPARSEABLE_WARNING,
|
|
44
44
|
describeWarned,
|
|
45
45
|
LONE_SURROGATE_WARNING,
|
|
46
|
+
UNSCANNABLE_WARNING,
|
|
46
47
|
} from "./warnings.mjs";
|
|
47
48
|
import {
|
|
48
49
|
finding,
|
|
@@ -145,9 +146,9 @@ function errMessage(err) {
|
|
|
145
146
|
*/
|
|
146
147
|
|
|
147
148
|
/**
|
|
148
|
-
* @typedef {{ text: string, found: string[], findings: import("./severity.mjs").Finding[], modified: boolean
|
|
149
|
+
* @typedef {{ text: string, found: string[], findings: import("./severity.mjs").Finding[], modified: boolean }} PipelineState
|
|
149
150
|
* The running state of one {@link sanitizeText} call. Layers read `text` and
|
|
150
|
-
* mutate it ONLY through {@link
|
|
151
|
+
* mutate it ONLY through {@link mutate}. Findings carry their own
|
|
151
152
|
* severity (see ./severity.mjs) and are split into `warnings`/`notes` at the
|
|
152
153
|
* single exit, so no layer can push into the wrong list. `found` is the
|
|
153
154
|
* machine-readable twin, unaffected by the split: the {@link CATEGORY} codes
|
|
@@ -155,49 +156,36 @@ function errMessage(err) {
|
|
|
155
156
|
*/
|
|
156
157
|
|
|
157
158
|
/**
|
|
158
|
-
* THE only way a layer may change `state.text
|
|
159
|
-
*
|
|
160
|
-
* rather than at each mutation site:
|
|
159
|
+
* THE only way a layer may change `state.text`; it re-establishes every stage
|
|
160
|
+
* invariant a byte change breaks, in one place:
|
|
161
161
|
*
|
|
162
|
-
* 1. lone surrogates are normalized
|
|
163
|
-
* but
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
* Repairing it inside whichever layer happens to need it — e.g. only in
|
|
167
|
-
* the post-span-deletion re-redact, so it runs only when a redactor is
|
|
168
|
-
* configured — would make an invariant of Layer 1 conditional on an
|
|
169
|
-
* unrelated option.
|
|
162
|
+
* 1. lone surrogates are normalized: the module doc promises this "always",
|
|
163
|
+
* but a Layer-5 span deletion joins the bytes around it and can strand
|
|
164
|
+
* one. No warning is pushed for it: the mutation that created it reports
|
|
165
|
+
* itself.
|
|
170
166
|
* 2. `modified` is set — the caller's "bytes changed" banner.
|
|
171
|
-
* 3. `
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
* rose above a note") must not invite a quiet banner over one. A mutation
|
|
175
|
-
* that DID report itself is covered by its own WARNING, so this flag only
|
|
176
|
-
* ever costs a note that would have been misleading.
|
|
167
|
+
* 3. `report` is pushed. It is required and a Finding cannot carry an empty
|
|
168
|
+
* message, so an unreported byte change is unconstructible — what
|
|
169
|
+
* `sgrNote` rests on.
|
|
177
170
|
*
|
|
178
|
-
* Callers decide WHETHER a mutation happened
|
|
179
|
-
*
|
|
180
|
-
* the new bytes; a no-op call would falsely set `modified`.
|
|
181
|
-
*
|
|
182
|
-
* No warning is pushed for the normalization: the mutation that created the
|
|
183
|
-
* lone surrogate reported itself, and a second "Normalized lone UTF-16
|
|
184
|
-
* surrogates" line would describe the library repairing its own splice rather
|
|
185
|
-
* than a finding about the input.
|
|
171
|
+
* Callers decide WHETHER a mutation happened; a no-op call would falsely set
|
|
172
|
+
* `modified`.
|
|
186
173
|
* @param {PipelineState} state
|
|
187
|
-
* @param {string}
|
|
174
|
+
* @param {string} text
|
|
175
|
+
* @param {import("./severity.mjs").Finding} report
|
|
188
176
|
* @returns {void}
|
|
189
177
|
*/
|
|
190
|
-
function
|
|
191
|
-
state.text = normalizeLoneSurrogates(
|
|
178
|
+
function mutate(state, text, report) {
|
|
179
|
+
state.text = normalizeLoneSurrogates(text);
|
|
192
180
|
state.modified = true;
|
|
193
|
-
state.
|
|
181
|
+
state.findings.push(report);
|
|
194
182
|
}
|
|
195
183
|
|
|
196
184
|
/**
|
|
197
|
-
* Run Layer 4 (`redact
|
|
198
|
-
* back in. The single Layer-4 invocation site FOR THE PIPELINE
|
|
199
|
-
* pass and the re-
|
|
200
|
-
* share one fail-closed handling, warning prose and
|
|
185
|
+
* Run Layer 4 (`options.redact`, when set) over the state's current text and
|
|
186
|
+
* fold any finding back in. The single Layer-4 invocation site FOR THE PIPELINE
|
|
187
|
+
* STATE: the first pass and the re-run after a span deletion are the same call,
|
|
188
|
+
* so they share one fail-closed handling, warning prose and invariants.
|
|
201
189
|
*
|
|
202
190
|
* One other site runs Layer 4 deliberately: {@link vetStageValue}, which vets a
|
|
203
191
|
* stage value on its way out and has no `PipelineState` to fold a finding into.
|
|
@@ -210,10 +198,12 @@ function applyMutation(state, nextText) {
|
|
|
210
198
|
* the throw is rethrown wrapped and the caller suppresses the output rather than
|
|
211
199
|
* emitting an unvetted value with a warning.
|
|
212
200
|
* @param {PipelineState} state
|
|
213
|
-
* @param {
|
|
201
|
+
* @param {SanitizeTextOptions} options
|
|
214
202
|
* @returns {Promise<void>}
|
|
215
203
|
*/
|
|
216
|
-
async function runRedact(state,
|
|
204
|
+
async function runRedact(state, options) {
|
|
205
|
+
const { redact } = options;
|
|
206
|
+
if (!redact) return;
|
|
217
207
|
/** @type {RedactResult|null} */
|
|
218
208
|
let secrets;
|
|
219
209
|
try {
|
|
@@ -226,10 +216,11 @@ async function runRedact(state, redact) {
|
|
|
226
216
|
);
|
|
227
217
|
}
|
|
228
218
|
if (!secrets) return;
|
|
229
|
-
applyMutation(state, secrets.text);
|
|
230
219
|
// Always a WARNING: a secret reached this output, and the caller-supplied
|
|
231
220
|
// `note` (which credential, from where) is the part an operator acts on.
|
|
232
|
-
|
|
221
|
+
mutate(
|
|
222
|
+
state,
|
|
223
|
+
secrets.text,
|
|
233
224
|
warning(
|
|
234
225
|
`API keys/secrets redacted: ${secrets.found.join(", ")}${secrets.note ?? ""}${REDACTION_DOCTRINE}`,
|
|
235
226
|
),
|
|
@@ -380,6 +371,110 @@ function processLayer1(text, sgrCarveOut) {
|
|
|
380
371
|
return { cleaned, found, findings, modified };
|
|
381
372
|
}
|
|
382
373
|
|
|
374
|
+
/**
|
|
375
|
+
* Refuse to START a parse with no budget left. Each of Layers 2/3 parses the
|
|
376
|
+
* whole document in ONE synchronous call, so nothing interrupts it, and a host
|
|
377
|
+
* that kills the overrun hook shows the RAW text. Called before EACH parse, and
|
|
378
|
+
* only after a pre-gate passed, so a declined call costs no time. Fails closed,
|
|
379
|
+
* as Layer 4 does.
|
|
380
|
+
* @param {Deadline | undefined} deadline
|
|
381
|
+
* @returns {void}
|
|
382
|
+
*/
|
|
383
|
+
function refuseIfSpent(deadline) {
|
|
384
|
+
if (deadline && deadline.remainingMs() <= 0)
|
|
385
|
+
throw new Error(
|
|
386
|
+
"CRITICAL: the sanitization time budget ran out before the hidden-HTML " +
|
|
387
|
+
"and exfil-URL layers finished, so this text was not fully checked. " +
|
|
388
|
+
"Failing closed — tool output suppressed.",
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* The heavy HTML layer (./html.mjs), loaded on first use. The awaited load can
|
|
394
|
+
* spend the budget the caller's pre-check saw, and every caller goes straight
|
|
395
|
+
* on to an uninterruptible parse, so the deadline is re-checked HERE.
|
|
396
|
+
* @param {Deadline | undefined} deadline
|
|
397
|
+
* @returns {Promise<typeof import("./html.mjs")>}
|
|
398
|
+
*/
|
|
399
|
+
async function loadHtmlLayer(deadline) {
|
|
400
|
+
let layer;
|
|
401
|
+
/* c8 ignore start -- a rejected import of a module this package ships needs a
|
|
402
|
+
corrupted install, and forcing one needs --experimental-test-module-mocks,
|
|
403
|
+
which this repo's test script does not wire. Fail loudly if it fires. */
|
|
404
|
+
try {
|
|
405
|
+
layer = await import("./html.mjs");
|
|
406
|
+
} catch (importErr) {
|
|
407
|
+
throw new Error(
|
|
408
|
+
"agent-sanitizer: failed to load ./html.mjs, so Layers 2/3 could not run " +
|
|
409
|
+
"(are the package's remark/rehype dependencies installed?)",
|
|
410
|
+
{ cause: importErr },
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
/* c8 ignore stop */
|
|
414
|
+
refuseIfSpent(deadline);
|
|
415
|
+
return layer;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Layer 3 over `text`, folded into `state`: exfil-URL and confusable-host
|
|
420
|
+
* detection only — the URLs stay intact, the model is told not to use them.
|
|
421
|
+
* @param {PipelineState} state
|
|
422
|
+
* @param {typeof import("./html.mjs")} htmlLayer
|
|
423
|
+
* @param {string} text
|
|
424
|
+
* @param {{ flagDigestValues?: boolean, deadline?: Deadline }} options
|
|
425
|
+
* @returns {void}
|
|
426
|
+
*/
|
|
427
|
+
function applyLayer3(state, htmlLayer, text, { flagDigestValues, deadline }) {
|
|
428
|
+
refuseIfSpent(deadline);
|
|
429
|
+
const exfil = htmlLayer.detectExfil(text, { flagDigestValues });
|
|
430
|
+
refuseIfSpent(deadline);
|
|
431
|
+
const hosts = htmlLayer.detectConfusableHosts(text);
|
|
432
|
+
/** @type {Array<[string, import("./severity.mjs").Finding]>} */
|
|
433
|
+
const reports = [];
|
|
434
|
+
// An overflowed scan returns only its sentinel and names no URL, so it is its
|
|
435
|
+
// own finding: `exfil-urls`/`confusable-host` would claim a URL exists.
|
|
436
|
+
const overflowed = (
|
|
437
|
+
/** @type {Array<{ unscannable?: true }> | null} */ list,
|
|
438
|
+
) => list?.some((threat) => threat.unscannable) ?? false;
|
|
439
|
+
if (overflowed(exfil) || overflowed(hosts))
|
|
440
|
+
reports.push([CATEGORY.UNSCANNABLE, warning(UNSCANNABLE_WARNING)]);
|
|
441
|
+
// Severity tracks who does the fetching. An auto-fetched target (image,
|
|
442
|
+
// stylesheet, form action, meta refresh) exfiltrates the moment the content
|
|
443
|
+
// renders: a WARNING. A plain LINK needs the model to follow it, and the
|
|
444
|
+
// sentence it is reported in is the instruction not to: a NOTE. The loudest
|
|
445
|
+
// member wins, since they share one line.
|
|
446
|
+
if (exfil && !overflowed(exfil))
|
|
447
|
+
reports.push([
|
|
448
|
+
CATEGORY.EXFIL_URLS,
|
|
449
|
+
finding(
|
|
450
|
+
exfil.some((threat) => threat.autoFetched),
|
|
451
|
+
describeExfil(exfil),
|
|
452
|
+
),
|
|
453
|
+
]);
|
|
454
|
+
// Confusable hosts ride the same scan flag but are a SEPARATE finding: a
|
|
455
|
+
// look-alike domain needs no exfil-shaped query to be the whole attack, and
|
|
456
|
+
// the two say different things to a reader. Tier per ./confusable-host.mjs,
|
|
457
|
+
// and the loudest member wins the shared line.
|
|
458
|
+
if (hosts && !overflowed(hosts))
|
|
459
|
+
reports.push([
|
|
460
|
+
CATEGORY.CONFUSABLE_HOST,
|
|
461
|
+
finding(
|
|
462
|
+
hosts.some((threat) => threat.severity === SEVERITY.WARNING),
|
|
463
|
+
describeConfusableHosts(hosts),
|
|
464
|
+
),
|
|
465
|
+
]);
|
|
466
|
+
// A re-run after a span deletion re-finds the same hits: report each once.
|
|
467
|
+
for (const [code, report] of reports) {
|
|
468
|
+
if (!state.found.includes(code)) state.found.push(code);
|
|
469
|
+
if (
|
|
470
|
+
!state.findings.some(
|
|
471
|
+
(f) => f.severity === report.severity && f.message === report.message,
|
|
472
|
+
)
|
|
473
|
+
)
|
|
474
|
+
state.findings.push(report);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
383
478
|
/**
|
|
384
479
|
* Layers 2+3: HTML sanitisation (`html`) and exfil-URL detection (`exfilScan`),
|
|
385
480
|
* folded into `state`. Returns the pre-splice text when Layer 2 removed bytes so
|
|
@@ -393,13 +488,11 @@ function processLayer1(text, sgrCarveOut) {
|
|
|
393
488
|
* vet them before they leave. The transform itself stays pure — the caller owns
|
|
394
489
|
* any persistence.
|
|
395
490
|
* @param {PipelineState} state
|
|
396
|
-
* @param {
|
|
491
|
+
* @param {SanitizeTextOptions} options
|
|
397
492
|
* @returns {Promise<{ reveal: string | undefined, splices: Array<{ placeholder: string, original: string }> }>}
|
|
398
493
|
*/
|
|
399
|
-
async function applyMarkdownPipeline(
|
|
400
|
-
|
|
401
|
-
{ html, exfilScan, flagDigestValues, deadline },
|
|
402
|
-
) {
|
|
494
|
+
async function applyMarkdownPipeline(state, options) {
|
|
495
|
+
const { html, exfilScan, deadline } = options;
|
|
403
496
|
const inputText = state.text;
|
|
404
497
|
/** @type {string | undefined} */
|
|
405
498
|
let reveal;
|
|
@@ -412,43 +505,13 @@ async function applyMarkdownPipeline(
|
|
|
412
505
|
const runLayer2 = Boolean(html) && needsMarkdownPipeline(inputText);
|
|
413
506
|
const runLayer3 = Boolean(exfilScan) && needsUrlScan(inputText);
|
|
414
507
|
if (!runLayer2 && !runLayer3) return { reveal: undefined, splices };
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
// interrupts it, and a host that kills the overrun hook shows the RAW text.
|
|
418
|
-
// Called before EACH parse: Layer 2 spends the budget Layer 3 then runs on.
|
|
419
|
-
// After the pre-gate: a declined call costs no time. Fail closed, as Layer 4.
|
|
420
|
-
const refuseIfSpent = () => {
|
|
421
|
-
if (deadline && deadline.remainingMs() <= 0)
|
|
422
|
-
throw new Error(
|
|
423
|
-
"CRITICAL: the sanitization time budget ran out before the hidden-HTML " +
|
|
424
|
-
"and exfil-URL layers finished, so this text was not fully checked. " +
|
|
425
|
-
"Failing closed — tool output suppressed.",
|
|
426
|
-
);
|
|
427
|
-
};
|
|
428
|
-
refuseIfSpent();
|
|
429
|
-
let sanitizeHtml, detectExfil, detectConfusableHosts;
|
|
430
|
-
/* c8 ignore start -- a rejected dynamic import of a module that ships in
|
|
431
|
-
this very package (not an optional peer dep) requires corrupting
|
|
432
|
-
node_modules or the filesystem to trigger; there's no clean way to force
|
|
433
|
-
this from a test without fragile module-loader mocking (Node's
|
|
434
|
-
mock.module needs --experimental-test-module-mocks, which isn't wired
|
|
435
|
-
into this repo's test script). Fail loudly with context if it ever fires. */
|
|
436
|
-
try {
|
|
437
|
-
({ sanitizeHtml, detectExfil, detectConfusableHosts } =
|
|
438
|
-
await import("./html.mjs"));
|
|
439
|
-
} catch (importErr) {
|
|
440
|
-
throw new Error(
|
|
441
|
-
"agent-sanitizer: failed to load ./html.mjs, so Layers 2/3 could not run " +
|
|
442
|
-
"(are the package's remark/rehype dependencies installed?)",
|
|
443
|
-
{ cause: importErr },
|
|
444
|
-
);
|
|
445
|
-
}
|
|
446
|
-
/* c8 ignore stop */
|
|
508
|
+
refuseIfSpent(deadline);
|
|
509
|
+
const htmlLayer = await loadHtmlLayer(deadline);
|
|
447
510
|
// Layer 2 — strips what a rendered page would not show (comments, hidden
|
|
448
511
|
// elements); scripting/resource tags preserved+reported. Each cut leaves a
|
|
449
512
|
// keyed placeholder whose original bytes ride out in `splices`.
|
|
450
513
|
if (runLayer2) {
|
|
451
|
-
const layer2 = sanitizeHtml(state.text);
|
|
514
|
+
const layer2 = htmlLayer.sanitizeHtml(state.text);
|
|
452
515
|
if (layer2) {
|
|
453
516
|
if (layer2.text !== state.text) {
|
|
454
517
|
reveal = state.text;
|
|
@@ -456,7 +519,6 @@ async function applyMarkdownPipeline(
|
|
|
456
519
|
// point into THIS stage's text, which Layers 4/5 may still mutate.
|
|
457
520
|
for (const { placeholder, original } of layer2.splices)
|
|
458
521
|
splices.push({ placeholder, original });
|
|
459
|
-
applyMutation(state, layer2.text);
|
|
460
522
|
if (layer2.removed.comments > 0)
|
|
461
523
|
state.found.push(CATEGORY.HTML_COMMENTS);
|
|
462
524
|
if (layer2.removed.hidden > 0) state.found.push(CATEGORY.HIDDEN_HTML);
|
|
@@ -466,7 +528,9 @@ async function applyMarkdownPipeline(
|
|
|
466
528
|
// without the reveal sidecar. The unparseable fail-closed path withheld
|
|
467
529
|
// the WHOLE output, not a spliced span, so it gets its own sentence
|
|
468
530
|
// rather than a misleading "1 hidden element(s) replaced".
|
|
469
|
-
|
|
531
|
+
mutate(
|
|
532
|
+
state,
|
|
533
|
+
layer2.text,
|
|
470
534
|
warning(
|
|
471
535
|
layer2.unparseable
|
|
472
536
|
? HTML_UNPARSEABLE_WARNING
|
|
@@ -482,48 +546,31 @@ async function applyMarkdownPipeline(
|
|
|
482
546
|
if (preserved) state.findings.push(note(preserved));
|
|
483
547
|
}
|
|
484
548
|
}
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
|
|
489
|
-
if (runLayer3) {
|
|
490
|
-
refuseIfSpent();
|
|
491
|
-
const threats = detectExfil(inputText, { flagDigestValues });
|
|
492
|
-
// Severity tracks who does the fetching. An auto-fetched target — an image,
|
|
493
|
-
// a stylesheet, a form action, a meta refresh — exfiltrates the moment the
|
|
494
|
-
// content renders, with nobody deciding anything: a WARNING. A plain LINK
|
|
495
|
-
// cannot exfiltrate unless the model chooses to follow it, and the sentence
|
|
496
|
-
// it is reported in is precisely the instruction not to, so it is a NOTE.
|
|
497
|
-
// One auto-fetched threat raises the whole finding — the loudest member
|
|
498
|
-
// wins, since they share one line.
|
|
499
|
-
if (threats) {
|
|
500
|
-
state.found.push(CATEGORY.EXFIL_URLS);
|
|
501
|
-
state.findings.push(
|
|
502
|
-
finding(
|
|
503
|
-
threats.some((threat) => threat.autoFetched),
|
|
504
|
-
describeExfil(threats),
|
|
505
|
-
),
|
|
506
|
-
);
|
|
507
|
-
}
|
|
508
|
-
// Confusable hosts ride the same scan flag but are a SEPARATE finding: a
|
|
509
|
-
// look-alike domain needs no exfil-shaped query to be the whole attack, and
|
|
510
|
-
// the two say different things to a reader. Tier per ./confusable-host.mjs,
|
|
511
|
-
// and the loudest member wins the shared line.
|
|
512
|
-
refuseIfSpent();
|
|
513
|
-
const confusable = detectConfusableHosts(inputText);
|
|
514
|
-
if (confusable) {
|
|
515
|
-
state.found.push(CATEGORY.CONFUSABLE_HOST);
|
|
516
|
-
state.findings.push(
|
|
517
|
-
finding(
|
|
518
|
-
confusable.some((threat) => threat.severity === SEVERITY.WARNING),
|
|
519
|
-
describeConfusableHosts(confusable),
|
|
520
|
-
),
|
|
521
|
-
);
|
|
522
|
-
}
|
|
523
|
-
}
|
|
549
|
+
// Scan the ORIGINAL text, not the Layer-2 splice output: a beacon URL hidden
|
|
550
|
+
// inside a display:none element or an HTML comment is MORE suspicious, not
|
|
551
|
+
// less, yet Layer 2 has already removed it from `cleaned`.
|
|
552
|
+
if (runLayer3) applyLayer3(state, htmlLayer, inputText, options);
|
|
524
553
|
return { reveal, splices };
|
|
525
554
|
}
|
|
526
555
|
|
|
556
|
+
/**
|
|
557
|
+
* Layer 3 over the state's CURRENT text, when `options.exfilScan` is set and
|
|
558
|
+
* its gate opens: the re-run after a Layer-5 span deletion. Layer 2 has none —
|
|
559
|
+
* it hides what the ORIGINAL rendering hid, which a later mutation cannot change.
|
|
560
|
+
* @param {PipelineState} state
|
|
561
|
+
* @param {SanitizeTextOptions} options
|
|
562
|
+
* @returns {Promise<void>}
|
|
563
|
+
*/
|
|
564
|
+
async function rescanUrls(state, options) {
|
|
565
|
+
if (!options.exfilScan || !needsUrlScan(state.text)) return;
|
|
566
|
+
applyLayer3(
|
|
567
|
+
state,
|
|
568
|
+
await loadHtmlLayer(options.deadline),
|
|
569
|
+
state.text,
|
|
570
|
+
options,
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
|
|
527
574
|
/**
|
|
528
575
|
* Vet a pipeline STAGE value on its way out of {@link sanitizeText}. Only
|
|
529
576
|
* `cleaned` traverses every layer; anything else a caller is handed (today the
|
|
@@ -542,10 +589,10 @@ async function applyMarkdownPipeline(
|
|
|
542
589
|
* runs on attacker-influenced content. `label` names the withheld field in that
|
|
543
590
|
* warning and comes from the call site below, never from a seam.
|
|
544
591
|
*
|
|
545
|
-
* Normalizes the redactor's output for the same reason {@link
|
|
592
|
+
* Normalizes the redactor's output for the same reason {@link mutate}
|
|
546
593
|
* does — a redaction that cuts between the halves of an astral pair strands a
|
|
547
594
|
* code unit, and this string is persisted and read back. It cannot USE
|
|
548
|
-
* `
|
|
595
|
+
* `mutate`: that folds into the `PipelineState`, and a stage value is not
|
|
549
596
|
* the pipeline text — setting `modified`/`sgrNote` from a sidecar's redaction
|
|
550
597
|
* would describe `cleaned`, which this call never touches. Only the
|
|
551
598
|
* normalization is shared. `text` arrives post-Layer-1, so the no-redactor and
|
|
@@ -609,7 +656,7 @@ async function vetStageValue(text, redact, findings, label) {
|
|
|
609
656
|
* and one that cannot be vetted is WITHHELD (dropped from the array) under the
|
|
610
657
|
* same doctrine — Layer 4 never saw pre-splice text.
|
|
611
658
|
*
|
|
612
|
-
* Every byte mutation goes through {@link
|
|
659
|
+
* Every byte mutation goes through {@link mutate} and every Layer-4 call
|
|
613
660
|
* through {@link runRedact}, so a layer cannot re-establish some of the
|
|
614
661
|
* post-mutation invariants and forget the rest, and every string in the returned
|
|
615
662
|
* object has traversed Layer 4.
|
|
@@ -636,19 +683,13 @@ export async function sanitizeText(text, options = {}) {
|
|
|
636
683
|
sgrCarveOut,
|
|
637
684
|
);
|
|
638
685
|
/** @type {PipelineState} */
|
|
639
|
-
const state = {
|
|
640
|
-
text: cleaned,
|
|
641
|
-
found,
|
|
642
|
-
findings,
|
|
643
|
-
modified,
|
|
644
|
-
unreportedChange: false,
|
|
645
|
-
};
|
|
686
|
+
const state = { text: cleaned, found, findings, modified };
|
|
646
687
|
|
|
647
688
|
const { reveal: revealText, splices: stageSplices } =
|
|
648
689
|
await applyMarkdownPipeline(state, options);
|
|
649
690
|
|
|
650
691
|
// Layer 4 — fail closed (see runRedact).
|
|
651
|
-
|
|
692
|
+
await runRedact(state, options);
|
|
652
693
|
|
|
653
694
|
// Layer 5 — secure span-deletion slot (see module doc). A warning-only result
|
|
654
695
|
// flags without changing bytes; only a deleted span sets `modified`. Awaited
|
|
@@ -658,23 +699,39 @@ export async function sanitizeText(text, options = {}) {
|
|
|
658
699
|
if (filterInjection) {
|
|
659
700
|
const res = await filterInjection(state.text);
|
|
660
701
|
if (res) {
|
|
661
|
-
if (res.removeSpans && res.removeSpans.length > 0) {
|
|
662
|
-
const out = deleteVerbatimSpans(state.text, res.removeSpans);
|
|
663
|
-
if (out.removed > 0) {
|
|
664
|
-
applyMutation(state, out.text);
|
|
665
|
-
// A span deletion joins the bytes on either side of it, which can
|
|
666
|
-
// reconstitute a secret Layer 4 never saw intact (it ran on the
|
|
667
|
-
// ORIGINAL text, before the join). Re-vet the post-deletion text so a
|
|
668
|
-
// compromised filter can still only ever REMOVE legitimate content,
|
|
669
|
-
// never smuggle an unvetted secret through by splicing around it.
|
|
670
|
-
if (redact) await runRedact(state, redact);
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
702
|
// A filter warning is a library-owned ENUM CODE, mapped here to its fixed
|
|
674
703
|
// message; free text is refused (throws) so no filter-supplied byte ever
|
|
675
704
|
// reaches the model-facing context. `null`/`undefined` means no warning.
|
|
676
|
-
|
|
677
|
-
|
|
705
|
+
// Mapped BEFORE any deletion, so an invalid code fails the call whether
|
|
706
|
+
// or not a span matched.
|
|
707
|
+
const filterWarning =
|
|
708
|
+
res.warning == null ? undefined : mapFilterWarning(res.warning);
|
|
709
|
+
const removed =
|
|
710
|
+
res.removeSpans && res.removeSpans.length > 0
|
|
711
|
+
? deleteVerbatimSpans(state.text, res.removeSpans)
|
|
712
|
+
: { text: state.text, removed: 0 };
|
|
713
|
+
if (removed.removed > 0) {
|
|
714
|
+
// Reported in the library's own words whatever code the filter sent
|
|
715
|
+
// (none would be a silent byte change). A deletion JOINS the bytes
|
|
716
|
+
// around it — `htt<span>ps://аpple.com` becomes a URL, a split key a
|
|
717
|
+
// secret — so Layers 3 and 4 re-run: a compromised filter can only
|
|
718
|
+
// REMOVE. (A splice or redaction leaves a placeholder, which joins none.)
|
|
719
|
+
mutate(
|
|
720
|
+
state,
|
|
721
|
+
removed.text,
|
|
722
|
+
warning(FILTER_WARNING_LABELS[FILTER_WARNING.SPANS_REMOVED]),
|
|
723
|
+
);
|
|
724
|
+
await rescanUrls(state, options);
|
|
725
|
+
await runRedact(state, options);
|
|
726
|
+
}
|
|
727
|
+
// The deletion's own report already says spans were removed, and
|
|
728
|
+
// "flagged (content not modified)" would contradict it.
|
|
729
|
+
const subsumed =
|
|
730
|
+
removed.removed > 0 &&
|
|
731
|
+
(res.warning === FILTER_WARNING.SPANS_REMOVED ||
|
|
732
|
+
res.warning === FILTER_WARNING.FILTER_FLAGGED);
|
|
733
|
+
if (filterWarning !== undefined && !subsumed)
|
|
734
|
+
state.findings.push(warning(filterWarning));
|
|
678
735
|
}
|
|
679
736
|
}
|
|
680
737
|
|
|
@@ -720,9 +777,9 @@ export async function sanitizeText(text, options = {}) {
|
|
|
720
777
|
// published field for a wording win is a breaking change) but derived
|
|
721
778
|
// rather than tracked: "nothing here rose above a note". The inert-ANSI
|
|
722
779
|
// strip is simply the most common way to end up note-only, not the only
|
|
723
|
-
// one this field now covers.
|
|
724
|
-
|
|
725
|
-
|
|
780
|
+
// one this field now covers. Sound because every byte change carries its
|
|
781
|
+
// own finding (see {@link mutate}).
|
|
782
|
+
sgrNote: notes.length > 0 && warnings.length === 0,
|
|
726
783
|
...(reveal !== undefined && { reveal }),
|
|
727
784
|
// Presence-gated like `reveal`: the field exists only when Layer 2 spliced
|
|
728
785
|
// and at least one original survived vetting, so the common-case result
|
|
@@ -818,39 +875,19 @@ function depthMemo() {
|
|
|
818
875
|
}
|
|
819
876
|
|
|
820
877
|
/**
|
|
821
|
-
* Sanitize every string
|
|
822
|
-
*
|
|
823
|
-
*
|
|
824
|
-
*
|
|
825
|
-
*
|
|
826
|
-
*
|
|
827
|
-
*
|
|
828
|
-
* Fails CLOSED on two hostile shapes that would otherwise throw a `RangeError`
|
|
829
|
-
* as an unhandled async rejection (a DoS that leaves the output un-sanitized):
|
|
830
|
-
* nesting past {@link MAX_DEPTH}, and a reference cycle. Either replaces the
|
|
831
|
-
* offending subtree with a placeholder string + a warning, never passing the
|
|
832
|
-
* raw subtree through. Keys are also screened for hidden chars (see below).
|
|
833
|
-
*
|
|
834
|
-
* `reveals` accumulates each string leaf's pre-Layer-2 text (present only when
|
|
835
|
-
* the HTML splice removed bytes) so a caller can persist what was hidden — the
|
|
836
|
-
* structured-output analogue of {@link sanitizeText}'s `reveal`. Same
|
|
837
|
-
* mutated-accumulator contract as `warnings`.
|
|
838
|
-
*
|
|
839
|
-
* `splices` accumulates each string leaf's Layer-2 placeholder→original pairs
|
|
840
|
-
* (each `original` already vetted, withheld entries dropped — see
|
|
841
|
-
* {@link sanitizeText}) — the per-placeholder twin of `reveals`, so a hook
|
|
842
|
-
* caller gets them for object-shaped tool output too. Same mutated-accumulator
|
|
843
|
-
* contract as `reveals`.
|
|
878
|
+
* Sanitize every string of a tool-output value with `options` — each string
|
|
879
|
+
* leaf AND each object key — preserving its shape. The walk itself, and every
|
|
880
|
+
* rule it applies, is {@link sanitizeValueWith}'s; this binds the leaf and key
|
|
881
|
+
* sanitizers to {@link sanitizeText}. A key runs every layer a leaf does except
|
|
882
|
+
* Layer 5: a span deletion in a NAME can collapse two fields into one, which
|
|
883
|
+
* costs both their values (see the collision rule).
|
|
844
884
|
* @param {any} value
|
|
845
885
|
* @param {SanitizeTextOptions} options
|
|
846
886
|
* @param {string[]} warnings
|
|
847
887
|
* @param {string[]} [reveals]
|
|
848
|
-
* @param {string[]} [notes] the NOTE-severity counterpart of `warnings
|
|
849
|
-
*
|
|
850
|
-
*
|
|
851
|
-
* @param {Array<{ placeholder: string, original: string }>} [splices] appended
|
|
852
|
-
* after `notes` for the same positional-compatibility reason
|
|
853
|
-
* @returns {Promise<{ value: any, modified: boolean, sgrNote: boolean }>}
|
|
888
|
+
* @param {string[]} [notes] the NOTE-severity counterpart of `warnings`
|
|
889
|
+
* @param {Array<{ placeholder: string, original: string }>} [splices]
|
|
890
|
+
* @returns {Promise<WalkResult>}
|
|
854
891
|
*/
|
|
855
892
|
export async function sanitizeValue(
|
|
856
893
|
value,
|
|
@@ -860,70 +897,121 @@ export async function sanitizeValue(
|
|
|
860
897
|
notes = [],
|
|
861
898
|
splices = [],
|
|
862
899
|
) {
|
|
863
|
-
|
|
900
|
+
const keyOptions = { ...options, filterInjection: undefined };
|
|
901
|
+
return sanitizeValueWith(
|
|
864
902
|
value,
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
splices,
|
|
903
|
+
{
|
|
904
|
+
leaf: (text) => sanitizeText(text, options),
|
|
905
|
+
key: (text) => sanitizeText(text, keyOptions),
|
|
906
|
+
},
|
|
907
|
+
{ warnings, notes, reveals, splices },
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* THE structured-output walk, shared by {@link sanitizeValue} and the output
|
|
913
|
+
* hook so the two cannot disagree about a value's strings. Runs `leaf` on every
|
|
914
|
+
* string leaf and `key` on every object key (a field name is as
|
|
915
|
+
* attacker-controlled as a leaf) and rebuilds the same shape (a harness ignores
|
|
916
|
+
* a reshaped output and shows the RAW one). Each string's findings, `reveal` and
|
|
917
|
+
* `splices` fold into `sinks`, whose caller owns dedup.
|
|
918
|
+
*
|
|
919
|
+
* Keys that sanitize to the same name fail closed: every value under that name
|
|
920
|
+
* becomes {@link COLLISION_WITHHELD_MESSAGE}, the later ones under a
|
|
921
|
+
* disambiguated key so the field count holds. Nesting past {@link MAX_DEPTH}
|
|
922
|
+
* and a reference cycle become a placeholder plus a warning, not a `RangeError`.
|
|
923
|
+
* `sgrNote` holds only when the walk added no warning.
|
|
924
|
+
* @param {any} value
|
|
925
|
+
* @param {StringSanitizers} sanitizers
|
|
926
|
+
* @param {WalkSinks} sinks
|
|
927
|
+
* @returns {Promise<WalkResult>}
|
|
928
|
+
*/
|
|
929
|
+
export async function sanitizeValueWith(value, sanitizers, sinks) {
|
|
930
|
+
const warnedBefore = sinks.warnings.length;
|
|
931
|
+
const result = await sanitizeValueAt(
|
|
932
|
+
value,
|
|
933
|
+
{
|
|
934
|
+
...sinks,
|
|
935
|
+
...sanitizers,
|
|
936
|
+
seen: new WeakSet(),
|
|
937
|
+
memo: depthMemo(),
|
|
938
|
+
},
|
|
870
939
|
0,
|
|
871
|
-
|
|
872
|
-
|
|
940
|
+
"",
|
|
941
|
+
);
|
|
942
|
+
return {
|
|
943
|
+
...result,
|
|
944
|
+
sgrNote: result.sgrNote && sinks.warnings.length === warnedBefore,
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/** A colliding field's WHOLE value, so no scalar passes under a legitimate name (the JSON type change is accepted). */
|
|
949
|
+
export const COLLISION_WITHHELD_MESSAGE =
|
|
950
|
+
"[WITHHELD — this field's name collided with another after sanitization]";
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* The warning for a post-sanitization key collision, naming where it sits.
|
|
954
|
+
* Exported so tests assert by reference rather than re-typing the prose.
|
|
955
|
+
* @param {string} name the collapsed-to name
|
|
956
|
+
* @param {string} path dotted location of the owning object ("" = top level)
|
|
957
|
+
* @returns {string}
|
|
958
|
+
*/
|
|
959
|
+
export function collisionWarning(name, path) {
|
|
960
|
+
return (
|
|
961
|
+
`two or more fields in the tool output collapsed to the name "${name}" after ` +
|
|
962
|
+
`sanitization (at ${path === "" ? "the top level" : path}); their values were ` +
|
|
963
|
+
`WITHHELD (fail closed) because there is no way to tell which field was ` +
|
|
964
|
+
`legitimate. Sibling fields are unaffected — do not treat the withheld values ` +
|
|
965
|
+
`as empty or absent; re-request them by a means that does not depend on this ` +
|
|
966
|
+
`field name, or ask the user`
|
|
873
967
|
);
|
|
874
968
|
}
|
|
875
969
|
|
|
876
970
|
/**
|
|
877
|
-
*
|
|
878
|
-
*
|
|
879
|
-
*
|
|
880
|
-
*
|
|
881
|
-
*
|
|
971
|
+
* @typedef {{ value: any, modified: boolean, sgrNote: boolean }} WalkResult
|
|
972
|
+
* @typedef {{ cleaned: string, warnings: string[], notes: string[], modified: boolean, sgrNote: boolean, reveal?: string, splices?: Array<{ placeholder: string, original: string }> }} StringVerdict
|
|
973
|
+
* @typedef {{ leaf: (text: string) => Promise<StringVerdict>, key: (text: string) => Promise<StringVerdict> }} StringSanitizers
|
|
974
|
+
* @typedef {{ warnings: string[], notes: string[], reveals: string[], splices: Array<{ placeholder: string, original: string }> }} WalkSinks
|
|
975
|
+
* @typedef {WalkSinks & StringSanitizers & { seen: WeakSet<object>, memo: ReturnType<typeof depthMemo<WalkResult>> }} WalkContext
|
|
976
|
+
* `seen` holds the ancestors on the active path only (a value shared across
|
|
977
|
+
* sibling branches is not a cycle). `memo` caches each FULLY-PROCESSED result
|
|
978
|
+
* by `(node, depth)` (see {@link depthMemo}); without it a shared-substructure
|
|
979
|
+
* DAG is re-sanitized once per PATH — a ~25-object diamond measured 68 s.
|
|
980
|
+
* Skipping a cached node's duplicate findings is harmless: callers dedup.
|
|
981
|
+
*/
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Run one string (a leaf or a key) through `sanitize`, folding it into the sinks.
|
|
985
|
+
* @param {string} text
|
|
986
|
+
* @param {(text: string) => Promise<StringVerdict>} sanitize
|
|
987
|
+
* @param {WalkContext} ctx
|
|
988
|
+
* @returns {Promise<WalkResult>}
|
|
989
|
+
*/
|
|
990
|
+
async function sanitizeString(text, sanitize, ctx) {
|
|
991
|
+
const result = await sanitize(text);
|
|
992
|
+
ctx.warnings.push(...result.warnings);
|
|
993
|
+
ctx.notes.push(...result.notes);
|
|
994
|
+
if (result.reveal !== undefined) ctx.reveals.push(result.reveal);
|
|
995
|
+
if (result.splices !== undefined) ctx.splices.push(...result.splices);
|
|
996
|
+
return {
|
|
997
|
+
value: result.cleaned,
|
|
998
|
+
modified: result.modified,
|
|
999
|
+
sgrNote: result.sgrNote,
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Recursion core for {@link sanitizeValueWith}.
|
|
882
1005
|
* @param {any} value
|
|
883
|
-
* @param {
|
|
884
|
-
* @param {string[]} warnings
|
|
885
|
-
* @param {string[]} notes accumulates each string leaf's NOTE-severity findings
|
|
886
|
-
* @param {string[]} reveals accumulates each string leaf's pre-Layer-2 text
|
|
887
|
-
* @param {Array<{ placeholder: string, original: string }>} splices accumulates
|
|
888
|
-
* each string leaf's Layer-2 placeholder→original pairs (already vetted)
|
|
1006
|
+
* @param {WalkContext} ctx
|
|
889
1007
|
* @param {number} depth
|
|
890
|
-
* @param {
|
|
891
|
-
*
|
|
892
|
-
*
|
|
893
|
-
* {@link depthMemo} for why the depth belongs in the key. Without a memo at
|
|
894
|
-
* all, a shared-substructure DAG (one node reached by many parents) is
|
|
895
|
-
* re-sanitized once per PATH — exponential in the number of shared nodes (a
|
|
896
|
-
* ~25-object diamond measured at 68 s, far under MAX_DEPTH) — since the path-
|
|
897
|
-
* scoped `seen` set only guards cycles, not repeated work. Because warnings
|
|
898
|
-
* dedup in composeContext, skipping a cached node's duplicate warnings is
|
|
899
|
-
* harmless. A cached node's `reveals` and `splices` are likewise not
|
|
900
|
-
* re-emitted, harmless for the same reason (the caller dedups reveals by
|
|
901
|
-
* content and splices by their content-addressed key).
|
|
902
|
-
* @returns {Promise<{ value: any, modified: boolean, sgrNote: boolean }>}
|
|
1008
|
+
* @param {string} path dotted location of `value`, named only in a key
|
|
1009
|
+
* collision's warning
|
|
1010
|
+
* @returns {Promise<WalkResult>}
|
|
903
1011
|
*/
|
|
904
|
-
async function sanitizeValueAt(
|
|
905
|
-
value,
|
|
906
|
-
|
|
907
|
-
warnings,
|
|
908
|
-
notes,
|
|
909
|
-
reveals,
|
|
910
|
-
splices,
|
|
911
|
-
depth,
|
|
912
|
-
seen,
|
|
913
|
-
memo,
|
|
914
|
-
) {
|
|
915
|
-
if (typeof value === "string") {
|
|
916
|
-
const result = await sanitizeText(value, options);
|
|
917
|
-
warnings.push(...result.warnings);
|
|
918
|
-
notes.push(...result.notes);
|
|
919
|
-
if (result.reveal !== undefined) reveals.push(result.reveal);
|
|
920
|
-
if (result.splices !== undefined) splices.push(...result.splices);
|
|
921
|
-
return {
|
|
922
|
-
value: result.cleaned,
|
|
923
|
-
modified: result.modified,
|
|
924
|
-
sgrNote: result.sgrNote,
|
|
925
|
-
};
|
|
926
|
-
}
|
|
1012
|
+
async function sanitizeValueAt(value, ctx, depth, path) {
|
|
1013
|
+
if (typeof value === "string") return sanitizeString(value, ctx.leaf, ctx);
|
|
1014
|
+
const { warnings, seen, memo } = ctx;
|
|
927
1015
|
// Memo hit: a shared node already fully sanitized AT THIS DEPTH on another
|
|
928
1016
|
// path. Returning the cached result (same reference) collapses the DAG to
|
|
929
1017
|
// linear work and preserves shape; it never short-circuits the cycle guard,
|
|
@@ -1000,17 +1088,12 @@ async function sanitizeValueAt(
|
|
|
1000
1088
|
const out = [];
|
|
1001
1089
|
let modified = false;
|
|
1002
1090
|
let sgrNote = false;
|
|
1003
|
-
for (const item of value) {
|
|
1091
|
+
for (const [index, item] of value.entries()) {
|
|
1004
1092
|
const result = await sanitizeValueAt(
|
|
1005
1093
|
item,
|
|
1006
|
-
|
|
1007
|
-
warnings,
|
|
1008
|
-
notes,
|
|
1009
|
-
reveals,
|
|
1010
|
-
splices,
|
|
1094
|
+
ctx,
|
|
1011
1095
|
depth + 1,
|
|
1012
|
-
|
|
1013
|
-
memo,
|
|
1096
|
+
`${path}[${index}]`,
|
|
1014
1097
|
);
|
|
1015
1098
|
out.push(result.value);
|
|
1016
1099
|
if (result.modified) modified = true;
|
|
@@ -1020,50 +1103,7 @@ async function sanitizeValueAt(
|
|
|
1020
1103
|
memo.set(value, depth, arrResult);
|
|
1021
1104
|
return arrResult;
|
|
1022
1105
|
}
|
|
1023
|
-
|
|
1024
|
-
const out = {};
|
|
1025
|
-
let modified = false;
|
|
1026
|
-
let sgrNote = false;
|
|
1027
|
-
for (const [key, item] of Object.entries(value)) {
|
|
1028
|
-
// Screen the KEY for hidden chars (Layer 1). We FLAG but do NOT rewrite:
|
|
1029
|
-
// a sanitized key can collide with a sibling key (silently dropping a
|
|
1030
|
-
// field) or break a downstream schema that matches on the exact name, so
|
|
1031
|
-
// precision wins — we keep the original key and warn, letting an operator
|
|
1032
|
-
// decide, rather than mangle the object's shape. (A clean key is silent.)
|
|
1033
|
-
// A key-only finding does NOT set `modified`: `modified` means output
|
|
1034
|
-
// BYTES changed (see composeContext's contract), and the key is left
|
|
1035
|
-
// intact here on purpose — only the warning fires.
|
|
1036
|
-
const { cleaned: cleanKey } = applyLayer1(key);
|
|
1037
|
-
if (cleanKey !== key)
|
|
1038
|
-
warnings.push(
|
|
1039
|
-
"An object key in structured tool output carried hidden/invisible characters (key left intact, value sanitized)",
|
|
1040
|
-
);
|
|
1041
|
-
const result = await sanitizeValueAt(
|
|
1042
|
-
item,
|
|
1043
|
-
options,
|
|
1044
|
-
warnings,
|
|
1045
|
-
notes,
|
|
1046
|
-
reveals,
|
|
1047
|
-
splices,
|
|
1048
|
-
depth + 1,
|
|
1049
|
-
seen,
|
|
1050
|
-
memo,
|
|
1051
|
-
);
|
|
1052
|
-
// Bracket assignment on a literal "__proto__" key triggers the special
|
|
1053
|
-
// Object.prototype setter instead of creating an own property — the
|
|
1054
|
-
// field would silently vanish from `out`'s own keys and `out`'s
|
|
1055
|
-
// prototype would become attacker-controlled. defineProperty always
|
|
1056
|
-
// creates a normal own data property regardless of the key's name.
|
|
1057
|
-
Object.defineProperty(out, key, {
|
|
1058
|
-
value: result.value,
|
|
1059
|
-
enumerable: true,
|
|
1060
|
-
writable: true,
|
|
1061
|
-
configurable: true,
|
|
1062
|
-
});
|
|
1063
|
-
if (result.modified) modified = true;
|
|
1064
|
-
if (result.sgrNote) sgrNote = true;
|
|
1065
|
-
}
|
|
1066
|
-
const objResult = { value: out, modified, sgrNote };
|
|
1106
|
+
const objResult = await sanitizeObject(value, ctx, depth, path);
|
|
1067
1107
|
memo.set(value, depth, objResult);
|
|
1068
1108
|
return objResult;
|
|
1069
1109
|
} finally {
|
|
@@ -1071,24 +1111,116 @@ async function sanitizeValueAt(
|
|
|
1071
1111
|
}
|
|
1072
1112
|
}
|
|
1073
1113
|
|
|
1114
|
+
/**
|
|
1115
|
+
* A plain object: keys and values per {@link sanitizeValueWith}'s rules.
|
|
1116
|
+
* @param {Record<string, any>} value
|
|
1117
|
+
* @param {WalkContext} ctx
|
|
1118
|
+
* @param {number} depth
|
|
1119
|
+
* @param {string} path
|
|
1120
|
+
* @returns {Promise<WalkResult>}
|
|
1121
|
+
*/
|
|
1122
|
+
async function sanitizeObject(value, ctx, depth, path) {
|
|
1123
|
+
/** @type {Record<string, any>} */
|
|
1124
|
+
const out = {};
|
|
1125
|
+
let modified = false;
|
|
1126
|
+
let sgrNote = false;
|
|
1127
|
+
// Every source name is sanitized and RESERVED before any is written, so a
|
|
1128
|
+
// generated `<name> [withheld duplicate N]` can never equal a later raw
|
|
1129
|
+
// field's name and falsely withhold an unrelated value.
|
|
1130
|
+
/** @type {Array<{ name: Awaited<ReturnType<typeof sanitizeString>>, item: unknown }>} */
|
|
1131
|
+
const named = [];
|
|
1132
|
+
/** @type {Set<string>} */
|
|
1133
|
+
const reserved = new Set();
|
|
1134
|
+
for (const [key, item] of Object.entries(value)) {
|
|
1135
|
+
const name = await sanitizeString(key, ctx.key, ctx);
|
|
1136
|
+
reserved.add(name.value);
|
|
1137
|
+
named.push({ name, item });
|
|
1138
|
+
}
|
|
1139
|
+
// Names already withheld, so a THIRD field colliding onto the same name does
|
|
1140
|
+
// not re-warn; and the next free duplicate index per name, so N collisions
|
|
1141
|
+
// probe in O(N) rather than O(N²) — the count is the tool output's to choose.
|
|
1142
|
+
/** @type {Set<string>} */
|
|
1143
|
+
const collided = new Set();
|
|
1144
|
+
/** @type {Map<string, number>} */
|
|
1145
|
+
const nextDuplicate = new Map();
|
|
1146
|
+
for (const { name, item } of named) {
|
|
1147
|
+
const result = await sanitizeValueAt(
|
|
1148
|
+
item,
|
|
1149
|
+
ctx,
|
|
1150
|
+
depth + 1,
|
|
1151
|
+
path === "" ? name.value : `${path}.${name.value}`,
|
|
1152
|
+
);
|
|
1153
|
+
if (name.modified || result.modified) modified = true;
|
|
1154
|
+
if (name.sgrNote || result.sgrNote) sgrNote = true;
|
|
1155
|
+
if (!Object.hasOwn(out, name.value)) {
|
|
1156
|
+
defineOwn(out, name.value, result.value);
|
|
1157
|
+
continue;
|
|
1158
|
+
}
|
|
1159
|
+
modified = true;
|
|
1160
|
+
if (!collided.has(name.value)) {
|
|
1161
|
+
collided.add(name.value);
|
|
1162
|
+
ctx.warnings.push(collisionWarning(name.value, path));
|
|
1163
|
+
defineOwn(out, name.value, COLLISION_WITHHELD_MESSAGE);
|
|
1164
|
+
}
|
|
1165
|
+
// Skip every reserved source name AND every earlier generated one.
|
|
1166
|
+
const slot = (/** @type {number} */ n) =>
|
|
1167
|
+
`${name.value} [withheld duplicate ${n}]`;
|
|
1168
|
+
let index = nextDuplicate.get(name.value) ?? 2;
|
|
1169
|
+
while (reserved.has(slot(index)) || Object.hasOwn(out, slot(index)))
|
|
1170
|
+
index++;
|
|
1171
|
+
nextDuplicate.set(name.value, index + 1);
|
|
1172
|
+
defineOwn(out, slot(index), COLLISION_WITHHELD_MESSAGE);
|
|
1173
|
+
}
|
|
1174
|
+
return { value: out, modified, sgrNote };
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* Write `key` as an own data property. Not `out[key] = value`: a "__proto__"
|
|
1179
|
+
* key assigned with `=` hits Object.prototype's setter, silently dropping the
|
|
1180
|
+
* field from `out`'s own keys and making `out`'s prototype attacker-controlled.
|
|
1181
|
+
* @param {Record<string, any>} out
|
|
1182
|
+
* @param {string} key
|
|
1183
|
+
* @param {unknown} value
|
|
1184
|
+
* @returns {void}
|
|
1185
|
+
*/
|
|
1186
|
+
function defineOwn(out, key, value) {
|
|
1187
|
+
Object.defineProperty(out, key, {
|
|
1188
|
+
value,
|
|
1189
|
+
enumerable: true,
|
|
1190
|
+
writable: true,
|
|
1191
|
+
configurable: true,
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1074
1195
|
/**
|
|
1075
1196
|
* Compose the model-facing context line for a sanitized/flagged tool output.
|
|
1076
1197
|
* `injectionAlert` is the caller's optional trailing alert (e.g. appended only
|
|
1077
1198
|
* for untrusted-ingress tools where a semantic-injection filter actually ran).
|
|
1199
|
+
*
|
|
1200
|
+
* `notes` follow the WARNING text: a note can carry the instruction a
|
|
1201
|
+
* detect-only finding rests on ("do not fetch them"), which an unrelated
|
|
1202
|
+
* warning does not make redundant. {@link INERT_ANSI_NOTE} alone is dropped
|
|
1203
|
+
* beside a warning (matched by the constant, not its wording).
|
|
1078
1204
|
* @param {boolean} modified output bytes were changed (vs. flagged only)
|
|
1079
1205
|
* @param {string[]} warnings
|
|
1080
|
-
* @param {{ injectionAlert?: string }} [options]
|
|
1206
|
+
* @param {{ injectionAlert?: string, notes?: string[] }} [options]
|
|
1081
1207
|
* @returns {string}
|
|
1082
1208
|
*/
|
|
1083
1209
|
export function composeContext(
|
|
1084
1210
|
modified,
|
|
1085
1211
|
warnings,
|
|
1086
|
-
{ injectionAlert = "" } = {},
|
|
1212
|
+
{ injectionAlert = "", notes = [] } = {},
|
|
1087
1213
|
) {
|
|
1088
1214
|
const prefix = modified
|
|
1089
1215
|
? "WARNING: Tool output sanitized. "
|
|
1090
1216
|
: "WARNING: Tool output flagged (content not modified). ";
|
|
1091
|
-
|
|
1217
|
+
const kept = [...new Set(notes)].filter(
|
|
1218
|
+
(text) => warnings.length === 0 || text !== INERT_ANSI_NOTE,
|
|
1219
|
+
);
|
|
1220
|
+
return [
|
|
1221
|
+
prefix + [...new Set(warnings)].join(". ") + "." + injectionAlert,
|
|
1222
|
+
...kept,
|
|
1223
|
+
].join(" ");
|
|
1092
1224
|
}
|
|
1093
1225
|
|
|
1094
1226
|
/**
|
|
@@ -1288,15 +1420,7 @@ function suppressAt(value, message, depth, seen, memo) {
|
|
|
1288
1420
|
/** @type {Record<string, any>} */
|
|
1289
1421
|
const out = {};
|
|
1290
1422
|
for (const [key, item] of Object.entries(value))
|
|
1291
|
-
|
|
1292
|
-
// "__proto__" key hits the special setter instead of creating an own
|
|
1293
|
-
// property, silently dropping the field and mutating out's prototype.
|
|
1294
|
-
Object.defineProperty(out, key, {
|
|
1295
|
-
value: suppressAt(item, message, depth + 1, seen, memo),
|
|
1296
|
-
enumerable: true,
|
|
1297
|
-
writable: true,
|
|
1298
|
-
configurable: true,
|
|
1299
|
-
});
|
|
1423
|
+
defineOwn(out, key, suppressAt(item, message, depth + 1, seen, memo));
|
|
1300
1424
|
memo.set(value, depth, out);
|
|
1301
1425
|
return out;
|
|
1302
1426
|
} finally {
|