agent-sanitizer 2.34.10 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.34.10",
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/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
@@ -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";