agent-sanitizer 2.34.9 → 2.34.11
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/package.json +1 -1
- package/src/ansi.mjs +25 -7
- package/src/output.mjs +27 -2
- package/types/output.d.mts +20 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.34.
|
|
3
|
+
"version": "2.34.11",
|
|
4
4
|
"description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
package/src/ansi.mjs
CHANGED
|
@@ -92,6 +92,13 @@ const ESC = 0x1b;
|
|
|
92
92
|
const CSI_C1 = 0x9b;
|
|
93
93
|
const ST_C1 = 0x9c;
|
|
94
94
|
const BEL = 0x07;
|
|
95
|
+
// CAN/SUB cancel a control string per ECMA-48 and the xterm parser; LF/CR do
|
|
96
|
+
// not, but bound the body anyway as a fail-closed blast-radius limit (see
|
|
97
|
+
// scanControlString).
|
|
98
|
+
const CAN = 0x18;
|
|
99
|
+
const SUB = 0x1a;
|
|
100
|
+
const LF = 0x0a;
|
|
101
|
+
const CR = 0x0d;
|
|
95
102
|
|
|
96
103
|
// PROBLEM CLASS — a control string whose body the grammar leaves as visible
|
|
97
104
|
// text. ECMA-48 opens FIVE strings, not one: OSC (`ESC ]` / U+009D), DCS
|
|
@@ -209,15 +216,24 @@ export function orphanKindFor(ch, next) {
|
|
|
209
216
|
* application command — i.e. attacker-controlled PAYLOAD TEXT in every case.
|
|
210
217
|
* Consuming the introducer alone would leave that payload in the model's view,
|
|
211
218
|
* so the whole string is one token. Three ways it can end:
|
|
212
|
-
* 1. a real terminator — ST (`ESC\` or the 8-bit C1 ST U+009C)
|
|
213
|
-
* BEL
|
|
219
|
+
* 1. a real terminator — ST (`ESC\` or the 8-bit C1 ST U+009C), the legacy
|
|
220
|
+
* BEL, or the CAN/SUB (U+0018/U+001A) that ECMA-48 and xterm cancel a
|
|
221
|
+
* string on — which is consumed with the body.
|
|
214
222
|
* 2. an ABORT: per ECMA-48/xterm a bare ESC (one not forming ST) drops the
|
|
215
223
|
* terminal out of the string, and a nested C1 string introducer likewise
|
|
216
224
|
* starts something new. The token ends BEFORE that byte so the scan
|
|
217
225
|
* re-reads it as its own sequence — without this, an interior ESC deleted
|
|
218
|
-
* the rest of the document via case
|
|
219
|
-
* 3.
|
|
220
|
-
*
|
|
226
|
+
* the rest of the document via case 4.
|
|
227
|
+
* 3. a line break (LF/CR) BOUNDS the body, before the break. This is a fail-
|
|
228
|
+
* closed blast-radius limit, NOT terminal behavior: a real terminal ignores
|
|
229
|
+
* an interior LF and keeps collecting to a true terminator. Without the
|
|
230
|
+
* bound one stray `ESC ]` deleted every later line to end of input, so on a
|
|
231
|
+
* consumer that reads the strip as a RECORD (a model, not a display) one
|
|
232
|
+
* introducer blinded the whole tail behind a clean-looking prefix. The
|
|
233
|
+
* break survives; the payload after it on the same line is dropped.
|
|
234
|
+
* 4. end of input, for a genuinely unterminated string with no line break:
|
|
235
|
+
* fail closed and drop everything from the introducer on, so no body
|
|
236
|
+
* survives.
|
|
221
237
|
*
|
|
222
238
|
* BEL terminates every arm here, not just OSC. Only xterm's OSC parser accepts
|
|
223
239
|
* it, so a DCS ending at BEL over-consumes by the width of one body — the
|
|
@@ -239,9 +255,11 @@ function scanControlString(text, start) {
|
|
|
239
255
|
let i = sevenBit ? start + 2 : start + 1;
|
|
240
256
|
for (; i < text.length; i++) {
|
|
241
257
|
const byte = text.charCodeAt(i);
|
|
242
|
-
if (byte === BEL || byte === ST_C1
|
|
258
|
+
if (byte === BEL || byte === ST_C1 || byte === CAN || byte === SUB)
|
|
259
|
+
return { end: i + 1, kind };
|
|
243
260
|
if (byte === ESC) return { end: text[i + 1] === "\\" ? i + 2 : i, kind };
|
|
244
|
-
if (STRING_INTRO_C1.has(byte)
|
|
261
|
+
if (STRING_INTRO_C1.has(byte) || byte === LF || byte === CR)
|
|
262
|
+
return { end: i, kind };
|
|
245
263
|
}
|
|
246
264
|
return { end: text.length, kind };
|
|
247
265
|
}
|
package/src/output.mjs
CHANGED
|
@@ -407,10 +407,10 @@ function processLayer1(text, sgrCarveOut) {
|
|
|
407
407
|
* vet them before they leave. The transform itself stays pure — the caller owns
|
|
408
408
|
* any persistence.
|
|
409
409
|
* @param {PipelineState} state
|
|
410
|
-
* @param {{ html?: boolean, exfilScan?: boolean }} options
|
|
410
|
+
* @param {{ html?: boolean, exfilScan?: boolean, deadline?: Deadline }} options
|
|
411
411
|
* @returns {Promise<{ reveal: string | undefined, splices: Array<{ placeholder: string, original: string }> }>}
|
|
412
412
|
*/
|
|
413
|
-
async function applyMarkdownPipeline(state, { html, exfilScan }) {
|
|
413
|
+
async function applyMarkdownPipeline(state, { html, exfilScan, deadline }) {
|
|
414
414
|
const inputText = state.text;
|
|
415
415
|
/** @type {string | undefined} */
|
|
416
416
|
let reveal;
|
|
@@ -418,6 +418,20 @@ async function applyMarkdownPipeline(state, { html, exfilScan }) {
|
|
|
418
418
|
const splices = [];
|
|
419
419
|
if ((!html && !exfilScan) || !needsMarkdownPipeline(inputText))
|
|
420
420
|
return { reveal: undefined, splices };
|
|
421
|
+
// INVARIANT: this refusal stops a layer below from STARTING with no budget
|
|
422
|
+
// left. Each parses the whole document in ONE synchronous call, so nothing
|
|
423
|
+
// interrupts it, and a host that kills the overrun hook shows the RAW text.
|
|
424
|
+
// Called before EACH parse: Layer 2 spends the budget Layer 3 then runs on.
|
|
425
|
+
// After the pre-gate: a declined call costs no time. Fail closed, as Layer 4.
|
|
426
|
+
const refuseIfSpent = () => {
|
|
427
|
+
if (deadline && deadline.remainingMs() <= 0)
|
|
428
|
+
throw new Error(
|
|
429
|
+
"CRITICAL: the sanitization time budget ran out before the hidden-HTML " +
|
|
430
|
+
"and exfil-URL layers finished, so this text was not fully checked. " +
|
|
431
|
+
"Failing closed — tool output suppressed.",
|
|
432
|
+
);
|
|
433
|
+
};
|
|
434
|
+
refuseIfSpent();
|
|
421
435
|
let sanitizeHtml, detectExfil;
|
|
422
436
|
/* c8 ignore start -- a rejected dynamic import of a module that ships in
|
|
423
437
|
this very package (not an optional peer dep) requires corrupting
|
|
@@ -478,6 +492,7 @@ async function applyMarkdownPipeline(state, { html, exfilScan }) {
|
|
|
478
492
|
// URL hidden inside a display:none element or an HTML comment is MORE
|
|
479
493
|
// suspicious, not less, yet Layer 2 has already removed it from `cleaned`.
|
|
480
494
|
if (exfilScan) {
|
|
495
|
+
refuseIfSpent();
|
|
481
496
|
const threats = detectExfil(inputText);
|
|
482
497
|
// Severity tracks who does the fetching. An auto-fetched target — an image,
|
|
483
498
|
// a stylesheet, a form action, a meta refresh — exfiltrates the moment the
|
|
@@ -552,9 +567,19 @@ async function vetStageValue(text, redact, findings, label) {
|
|
|
552
567
|
* redact?: (text: string) => Promise<RedactResult|null> | (RedactResult|null),
|
|
553
568
|
* filterInjection?: (text: string) => Promise<Layer5Result|null> | (Layer5Result|null),
|
|
554
569
|
* sgrCarveOut?: boolean,
|
|
570
|
+
* deadline?: Deadline,
|
|
555
571
|
* }} SanitizeTextOptions
|
|
556
572
|
*/
|
|
557
573
|
|
|
574
|
+
/**
|
|
575
|
+
* A caller's shared wall-clock budget across one run of this pipeline.
|
|
576
|
+
* `remainingMs()` returns the milliseconds left; at or below zero it is spent.
|
|
577
|
+
* Layer 4's injected redactor reads its own copy of the same budget, so this
|
|
578
|
+
* option is what lets the layers inside this module read it too. Omitted means
|
|
579
|
+
* no budget, which is the standalone default: every layer runs to completion.
|
|
580
|
+
* @typedef {{ remainingMs: () => number }} Deadline
|
|
581
|
+
*/
|
|
582
|
+
|
|
558
583
|
/**
|
|
559
584
|
* Run the configured layers over a single text blob. Layer 1 always runs; the
|
|
560
585
|
* rest are opt-in via `options`. Layer 4 (`redact`) is the fail-closed path: a
|
package/types/output.d.mts
CHANGED
|
@@ -41,8 +41,17 @@ export function deleteVerbatimSpans(text: string, spans: string[]): {
|
|
|
41
41
|
* redact?: (text: string) => Promise<RedactResult|null> | (RedactResult|null),
|
|
42
42
|
* filterInjection?: (text: string) => Promise<Layer5Result|null> | (Layer5Result|null),
|
|
43
43
|
* sgrCarveOut?: boolean,
|
|
44
|
+
* deadline?: Deadline,
|
|
44
45
|
* }} SanitizeTextOptions
|
|
45
46
|
*/
|
|
47
|
+
/**
|
|
48
|
+
* A caller's shared wall-clock budget across one run of this pipeline.
|
|
49
|
+
* `remainingMs()` returns the milliseconds left; at or below zero it is spent.
|
|
50
|
+
* Layer 4's injected redactor reads its own copy of the same budget, so this
|
|
51
|
+
* option is what lets the layers inside this module read it too. Omitted means
|
|
52
|
+
* no budget, which is the standalone default: every layer runs to completion.
|
|
53
|
+
* @typedef {{ remainingMs: () => number }} Deadline
|
|
54
|
+
*/
|
|
46
55
|
/**
|
|
47
56
|
* Run the configured layers over a single text blob. Layer 1 always runs; the
|
|
48
57
|
* rest are opt-in via `options`. Layer 4 (`redact`) is the fail-closed path: a
|
|
@@ -262,6 +271,17 @@ export type SanitizeTextOptions = {
|
|
|
262
271
|
redact?: (text: string) => Promise<RedactResult | null> | (RedactResult | null);
|
|
263
272
|
filterInjection?: (text: string) => Promise<Layer5Result | null> | (Layer5Result | null);
|
|
264
273
|
sgrCarveOut?: boolean;
|
|
274
|
+
deadline?: Deadline;
|
|
275
|
+
};
|
|
276
|
+
/**
|
|
277
|
+
* A caller's shared wall-clock budget across one run of this pipeline.
|
|
278
|
+
* `remainingMs()` returns the milliseconds left; at or below zero it is spent.
|
|
279
|
+
* Layer 4's injected redactor reads its own copy of the same budget, so this
|
|
280
|
+
* option is what lets the layers inside this module read it too. Omitted means
|
|
281
|
+
* no budget, which is the standalone default: every layer runs to completion.
|
|
282
|
+
*/
|
|
283
|
+
export type Deadline = {
|
|
284
|
+
remainingMs: () => number;
|
|
265
285
|
};
|
|
266
286
|
import { needsMarkdownPipeline } from "./gates.mjs";
|
|
267
287
|
export { describeExfil, describeRemoved, describeWarned } from "./warnings.mjs";
|