agent-sanitizer 2.46.0 → 2.47.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 CHANGED
@@ -60,18 +60,18 @@ Split into subpaths so the heavy HTML dependency stays opt-in. **Seam** names
60
60
  the callback you inject for the agent-specific concern; `—` is a pure transform,
61
61
  `fs (direct)` does its own file I/O instead of taking one.
62
62
 
63
- | # | Import | Purpose | Seam |
64
- | --- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- |
65
- | 1 | `/invisible` | Strip zero-width, bidi, variation-selector and tag chars + ANSI/SGR escapes. Preserves ZWNJ/ZWJ for Arabic/Indic/emoji. Zero deps. | — |
66
- | 1 | `/layer1` | Apply Layer 1 as one transform strip payload-capable invisible chars, then every ANSI/terminal control sequencereturning the exact model-facing view the other layers build on. Zero deps. | — |
67
- | 2 | `/html` | Splice out HTML comments and elements hidden via `display:none`, off-screen, white-on-white, `hidden`. Each splice leaves a keyed, round-trippable placeholder. | — |
68
- | 3 | `/html` | Detect exfil-shaped URLs (payloads in query/path, embedded creds, `data:`/`javascript:`, off-origin redirects) and confusable HOSTS (`аpple.com`). Reports only — never rewrites. | — |
69
- | 4 | `/confusables` | Fold look-alike glyphs in tool-call input (paths, commands) to ASCII, closing a cross-script deny-rule bypass. Gated per token, so non-Latin prose passes through unfolded. | `scan` (optional) |
70
- | 5 | `/instructions` | Scan/auto-clean `CLAUDE.md`, `AGENTS.md`, `SKILL.md`, etc., decoding Unicode-tag + zero-width-binary payloads. | `fs` (direct) |
71
- | 6 | `/prompt` | Classify a prompt pass / note / block on payload-capable invisible/ANSI content (inert escapes get the note). | — |
72
- | 7 | `/output` | Run Layers 1–4 over structured tool output, preserving shape. The Layer-5 slot takes a delete-only filter. | `redact`, `filterInjection` |
73
- | 8 | `/rehydrate` | Re-anchor a model Edit or whole-file Write composed from the _sanitized_ view back onto real bytes; gate MultiEdit on a verified view==disk; deny anything ambiguous or secret-exposing. | `io` |
74
- | — | `/view-map` | Pure offset/text machinery mapping a file's on-disk bytes ↔ the sanitized view (Layer-1 deletions, Layer-4 redactions). No I/O — consumed by `/rehydrate`. | — |
63
+ | # | Import | Purpose | Seam |
64
+ | --- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- |
65
+ | 1 | `/invisible` | Strip zero-width, bidi, variation-selector and tag chars + ANSI/SGR escapes. Preserves ZWNJ/ZWJ for Arabic/Indic/emoji. Zero deps. | — |
66
+ | 1 | `/layer1` | Layer 1 in one call: the invisible-char and ANSI strips run to a FIXED POINT over each other (either removal can reconstitute what the other matches), then a residual control-introducer sweep closes it not a two-step pipeline a caller can reproduce in a fixed order. `applyLayer1` leaves an unpaired UTF-16 surrogate in place; `applyLayer1WellFormed` also maps it to U+FFFD, which is the string `/output` shows a model take that one when the result feeds a redactor, an offset calculation or a view map. Zero deps. | — |
67
+ | 2 | `/html` | Splice out HTML comments and elements hidden via `display:none`, off-screen, white-on-white, `hidden`. Each splice leaves a keyed, round-trippable placeholder. | — |
68
+ | 3 | `/html` | Detect exfil-shaped URLs (payloads in query/path, embedded creds, `data:`/`javascript:`, off-origin redirects) and confusable HOSTS (`аpple.com`). Reports only — never rewrites. | — |
69
+ | 4 | `/confusables` | Fold look-alike glyphs in tool-call input (paths, commands) to ASCII, closing a cross-script deny-rule bypass. Gated per token, so non-Latin prose passes through unfolded. | `scan` (optional) |
70
+ | 5 | `/instructions` | Scan/auto-clean `CLAUDE.md`, `AGENTS.md`, `SKILL.md`, etc., decoding Unicode-tag + zero-width-binary payloads. | `fs` (direct) |
71
+ | 6 | `/prompt` | Classify a prompt pass / note / block on payload-capable invisible/ANSI content (inert escapes get the note). | — |
72
+ | 7 | `/output` | Run Layers 1–4 over structured tool output, preserving shape. The Layer-5 slot takes a delete-only filter. | `redact`, `filterInjection` |
73
+ | 8 | `/rehydrate` | Re-anchor a model Edit or whole-file Write composed from the _sanitized_ view back onto real bytes; gate MultiEdit on a verified view==disk; deny anything ambiguous or secret-exposing. | `io` |
74
+ | — | `/view-map` | Pure offset/text machinery mapping a file's on-disk bytes ↔ the sanitized view (Layer-1 deletions, Layer-4 redactions). No I/O — consumed by `/rehydrate`. | — |
75
75
 
76
76
  See [`THREAT-MODEL.md`](./THREAT-MODEL.md) for per-vector detail.
77
77
 
@@ -329,13 +329,6 @@ export function failOpenContext(
329
329
  );
330
330
  }
331
331
 
332
- // Unpaired UTF-16 surrogates: a high half with no low follower, or a low half
333
- // with no high lead. Hook text spliced into the model's context must be
334
- // well-formed UTF-16 there, so the sanitizers normalize these out before
335
- // serializing.
336
- const LONE_SURROGATE_RE =
337
- /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
338
-
339
332
  /**
340
333
  * Hard cap on hook stdin. A well-formed Claude Code hook payload is at most a
341
334
  * few MB (tool input plus the harness-truncated tool output); 64 MiB leaves
@@ -622,20 +615,24 @@ const UNTRUSTED_TEXT_CAP = 500;
622
615
  /**
623
616
  * Scrub untrusted text before it is spliced into the model's context via a
624
617
  * warning/reason field: strip ANSI and payload-capable invisibles to a fixed
625
- * point (via the injected `layer1`, the package's composite Layer-1 view),
626
- * replace lone surrogates so the model's UTF-16 context stays well-formed, then
627
- * cap by whole code points (never mid-pair, which the surrogate pass above
628
- * already swept). `layer1` is injected rather than imported so this
629
- * dependency-light module never eagerly loads the sanitizer package — each
630
- * caller passes its own caught-import binding.
618
+ * point (via the injected `layer1`), then cap by whole code points. `layer1` is
619
+ * injected rather than imported so this dependency-light module never eagerly
620
+ * loads the sanitizer package each caller passes its own caught-import
621
+ * binding.
622
+ *
623
+ * Pass a layer1 that also normalizes lone surrogates — `applyLayer1WellFormed`,
624
+ * never the bare `applyLayer1`. The model's UTF-16 context must be well-formed,
625
+ * and the code-point cap below must not slice a half it mistook for a whole
626
+ * character. This module used to re-spell that substitution over a private
627
+ * regex; the package now owns the one definition.
631
628
  * @param {unknown} raw
632
- * @param {(text: string) => { cleaned: string }} layer1
629
+ * @param {(text: string) => { cleaned: string }} layer1 MUST normalize lone surrogates
633
630
  * @param {number} [cap]
634
631
  * @returns {string}
635
632
  */
636
633
  export function scrubUntrustedText(raw, layer1, cap = UNTRUSTED_TEXT_CAP) {
637
634
  if (typeof raw !== "string" || raw === "") return "";
638
- const cleaned = layer1(raw).cleaned.replace(LONE_SURROGATE_RE, "�");
635
+ const cleaned = layer1(raw).cleaned;
639
636
  const points = [...cleaned];
640
637
  return points.length > cap
641
638
  ? points.slice(0, cap).join("") + "…[truncated]"
@@ -30,13 +30,16 @@ import {
30
30
  } from "./hook-io.mjs";
31
31
 
32
32
  // Layer-1 scrubber for the untrusted alert-store contents the gate splices into a
33
- // permissionDecisionReason. Bound via lazyImport (see its doc for the fail-OPEN
34
- // hazard of a bare static npm import): a load failure leaves applyLayer1 undefined,
35
- // so scrubUntrustedText throws into the caller's fail-closed catch (→ ask) rather
36
- // than emitting an unscrubbed reason.
37
- const { applyLayer1 } = /** @type {typeof import("agent-sanitizer")} */ (
38
- await lazyImport("agent-sanitizer")
39
- );
33
+ // permissionDecisionReason. The WELL-FORMED composition, not the bare applyLayer1:
34
+ // the reason is spliced into the model's UTF-16 context, and scrubUntrustedText's
35
+ // code-point cap must not slice a lone surrogate half. Bound via lazyImport (see
36
+ // its doc for the fail-OPEN hazard of a bare static npm import): a load failure
37
+ // leaves it undefined, so scrubUntrustedText throws into the caller's fail-closed
38
+ // catch (→ ask) rather than emitting an unscrubbed reason.
39
+ const { applyLayer1WellFormed } =
40
+ /** @type {typeof import("agent-sanitizer")} */ (
41
+ await lazyImport("agent-sanitizer")
42
+ );
40
43
 
41
44
  /**
42
45
  * The path prefix every alert artifact of this PROJECT shares. Never a file
@@ -392,7 +395,7 @@ export function invisibleCharAlert(sessionId) {
392
395
  }
393
396
  }
394
397
  if (parts.length === 0) return null;
395
- return scrubUntrustedText(parts.join("\n"), applyLayer1);
398
+ return scrubUntrustedText(parts.join("\n"), applyLayer1WellFormed);
396
399
  }
397
400
 
398
401
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.46.0",
3
+ "version": "2.47.1",
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/index.mjs CHANGED
@@ -25,8 +25,10 @@ import { sanitizeText } from "./output.mjs";
25
25
  // (`./rehydrate`) so every consumer derives the identical model-facing view.
26
26
  export {
27
27
  applyLayer1,
28
+ applyLayer1WellFormed,
28
29
  isBenignAnsi,
29
30
  isBenignAnsiKinds,
31
+ normalizeLoneSurrogates,
30
32
  stripAnsiFully,
31
33
  LONE_SURROGATE_RE,
32
34
  } from "./layer1.mjs";
package/src/layer1.mjs CHANGED
@@ -6,11 +6,20 @@
6
6
  * re-implementation would drift, and rehydration's soundness gate depends on
7
7
  * re-cleaning reproducing the view).
8
8
  *
9
- * Lone-surrogate normalization is NOT applied by {@link applyLayer1} itself: it
10
- * is exported as {@link LONE_SURROGATE_RE} for consumers to apply at the boundary
11
- * that needs a well-formed string (the redactor input in output.mjs's
12
- * processLayer1 / re-redact, and before the HTML tokenizer), because that is the
13
- * point where a lone surrogate would otherwise corrupt a match or a parse.
9
+ * Lone-surrogate normalization is NOT applied by {@link applyLayer1} itself. It
10
+ * belongs at the boundary that needs a well-formed string the redactor input
11
+ * in output.mjs's processLayer1 / re-redact, and before the HTML tokenizer
12
+ * because that is the point where a lone surrogate would otherwise corrupt a
13
+ * match or a parse. Prefer {@link normalizeLoneSurrogates} at each such
14
+ * boundary, the one definition of that substitution, which output.mjs's
15
+ * processLayer1 and rehydrate.mjs both call — not a fresh `.replace` over
16
+ * {@link LONE_SURROGATE_RE}.
17
+ *
18
+ * So {@link applyLayer1} alone is NOT the string the tool-output pipeline shows a
19
+ * model: on an input carrying an unpaired surrogate the two differ at exactly
20
+ * that code unit. {@link applyLayer1WellFormed} is the composition the pipeline
21
+ * runs, and a consumer deriving offsets for redaction or view mapping wants that
22
+ * one — see its own doc for why the choice matters.
14
23
  */
15
24
  import { stripInvisibleWithReport, CATEGORY } from "./invisible.mjs";
16
25
  import {
@@ -266,3 +275,48 @@ export function applyLayer1(text) {
266
275
  if (ansiKinds.size > 0) found.add(CATEGORY.ANSI);
267
276
  return { cleaned, deAnsi, found: [...found], ansiKinds: [...ansiKinds] };
268
277
  }
278
+
279
+ /**
280
+ * Map every lone UTF-16 surrogate to U+FFFD. Load-bearing on ANY path that feeds
281
+ * text to an injected redactor: a secret split by an interposed lone surrogate
282
+ * reads as adjacent to a model rendering its own UTF-16 but as broken to a
283
+ * redactor (Node maps the lone surrogate to U+FFFD en route), so a secret
284
+ * reconstituted across the surrogate survives redaction unless the text is
285
+ * normalized first. It also keeps an HTML tokenizer from throwing on a stray
286
+ * code unit. The substitution is same-LENGTH — one UTF-16 unit for one — so
287
+ * offsets computed against the un-normalized string stay valid against this one.
288
+ * @param {string} text
289
+ * @returns {string}
290
+ */
291
+ export function normalizeLoneSurrogates(text) {
292
+ return text.replace(LONE_SURROGATE_RE, "\uFFFD");
293
+ }
294
+
295
+ /**
296
+ * {@link applyLayer1} followed by {@link normalizeLoneSurrogates} — the exact
297
+ * composition `output.mjs`'s processLayer1 runs before Layers 2+ see the text,
298
+ * so this is the string a model is actually shown.
299
+ *
300
+ * Take this one, not `applyLayer1`, whenever the result feeds a redactor, an
301
+ * offset calculation or a view map: those consumers compare their own string to
302
+ * the model-facing view, and an unpaired surrogate is exactly where the two
303
+ * spellings diverge. Take `applyLayer1` when you want Layer 1's removals alone
304
+ * and intend to hand a possibly ill-formed string onward unchanged.
305
+ *
306
+ * `found` gains {@link CATEGORY.LONE_SURROGATES} exactly when the normalization
307
+ * changed the text, matching what the pipeline reports for the same input.
308
+ * `deAnsi` is untouched: it is the ANSI strip of the ORIGINAL text, the scope
309
+ * the long-run payload check needs.
310
+ * @param {string} text
311
+ * @returns {{ cleaned: string, deAnsi: string, found: string[], ansiKinds: string[] }}
312
+ */
313
+ export function applyLayer1WellFormed(text) {
314
+ const result = applyLayer1(text);
315
+ const cleaned = normalizeLoneSurrogates(result.cleaned);
316
+ if (cleaned === result.cleaned) return result;
317
+ return {
318
+ ...result,
319
+ cleaned,
320
+ found: [...result.found, CATEGORY.LONE_SURROGATES],
321
+ };
322
+ }
package/src/output.mjs CHANGED
@@ -34,7 +34,7 @@ import {
34
34
  applyLayer1,
35
35
  INERT_ANSI_NOTE,
36
36
  isBenignAnsiKinds,
37
- LONE_SURROGATE_RE,
37
+ normalizeLoneSurrogates,
38
38
  } from "./layer1.mjs";
39
39
  import {
40
40
  describeConfusableHosts,
@@ -144,21 +144,6 @@ function errMessage(err) {
144
144
  * message). Null means the filter made no finding.
145
145
  */
146
146
 
147
- /**
148
- * Map every lone UTF-16 surrogate to U+FFFD. Load-bearing on ANY path that feeds
149
- * text to the injected redactor: a secret split by an interposed lone surrogate
150
- * reads as adjacent to a model rendering its own UTF-16 but as broken to a
151
- * redactor (Node maps the lone surrogate to U+FFFD en route), so a secret
152
- * reconstituted across the surrogate survives redaction unless the text is
153
- * normalized first. Shared by {@link processLayer1} and the Layer-5 re-redact so
154
- * the two redact-input paths cannot drift.
155
- * @param {string} text
156
- * @returns {string}
157
- */
158
- function normalizeLoneSurrogates(text) {
159
- return text.replace(LONE_SURROGATE_RE, "�");
160
- }
161
-
162
147
  /**
163
148
  * @typedef {{ text: string, found: string[], findings: import("./severity.mjs").Finding[], modified: boolean, unreportedChange: boolean }} PipelineState
164
149
  * The running state of one {@link sanitizeText} call. Layers read `text` and
package/src/rehydrate.mjs CHANGED
@@ -54,7 +54,7 @@
54
54
  * denied with use-single-Edit guidance (see the dispatch in
55
55
  * {@link rehydrateRedacted}).
56
56
  */
57
- import { applyLayer1, LONE_SURROGATE_RE } from "./layer1.mjs";
57
+ import { applyLayer1, normalizeLoneSurrogates } from "./layer1.mjs";
58
58
  import {
59
59
  occurrences,
60
60
  overlapAwareCount,
@@ -114,7 +114,7 @@ function layer1View(text) {
114
114
  const { cleaned: layer1Cleaned } = applyLayer1(text);
115
115
  return {
116
116
  layer1Cleaned,
117
- cleaned: layer1Cleaned.replace(LONE_SURROGATE_RE, "\uFFFD"),
117
+ cleaned: normalizeLoneSurrogates(layer1Cleaned),
118
118
  };
119
119
  }
120
120
 
@@ -245,14 +245,18 @@ export function makeDeadline(budgetMs: number, now?: () => number): {
245
245
  /**
246
246
  * Scrub untrusted text before it is spliced into the model's context via a
247
247
  * warning/reason field: strip ANSI and payload-capable invisibles to a fixed
248
- * point (via the injected `layer1`, the package's composite Layer-1 view),
249
- * replace lone surrogates so the model's UTF-16 context stays well-formed, then
250
- * cap by whole code points (never mid-pair, which the surrogate pass above
251
- * already swept). `layer1` is injected rather than imported so this
252
- * dependency-light module never eagerly loads the sanitizer package — each
253
- * caller passes its own caught-import binding.
248
+ * point (via the injected `layer1`), then cap by whole code points. `layer1` is
249
+ * injected rather than imported so this dependency-light module never eagerly
250
+ * loads the sanitizer package each caller passes its own caught-import
251
+ * binding.
252
+ *
253
+ * Pass a layer1 that also normalizes lone surrogates — `applyLayer1WellFormed`,
254
+ * never the bare `applyLayer1`. The model's UTF-16 context must be well-formed,
255
+ * and the code-point cap below must not slice a half it mistook for a whole
256
+ * character. This module used to re-spell that substitution over a private
257
+ * regex; the package now owns the one definition.
254
258
  * @param {unknown} raw
255
- * @param {(text: string) => { cleaned: string }} layer1
259
+ * @param {(text: string) => { cleaned: string }} layer1 MUST normalize lone surrogates
256
260
  * @param {number} [cap]
257
261
  * @returns {string}
258
262
  */
package/types/index.d.mts CHANGED
@@ -52,6 +52,6 @@ export function sanitize(text: string, options?: {
52
52
  original: string;
53
53
  }>;
54
54
  }>;
55
- export { applyLayer1, isBenignAnsi, isBenignAnsiKinds, stripAnsiFully, LONE_SURROGATE_RE } from "./layer1.mjs";
55
+ export { applyLayer1, applyLayer1WellFormed, isBenignAnsi, isBenignAnsiKinds, normalizeLoneSurrogates, stripAnsiFully, LONE_SURROGATE_RE } from "./layer1.mjs";
56
56
  export { stripInvisible, stripInvisibleWithReport, isSgrOnly, STRIP, SGR_RE, CHECKS, CATEGORY, CATEGORY_LABELS, LINGUISTIC_SCRIPTS, VS, BLANK_NON_CF, LONG_RUN_RE, LONG_RUN_THRESHOLD, SCATTERED_THRESHOLD, findLongRuns, hasLongRun } from "./invisible.mjs";
57
57
  export { HTML_TAG_PRESENT, MD_LINK_HINT, SECRET_HINT, SECRET_HINT_EXT, matchesSecretHint } from "./gates.mjs";
@@ -97,6 +97,43 @@ export function applyLayer1(text: string): {
97
97
  found: string[];
98
98
  ansiKinds: string[];
99
99
  };
100
+ /**
101
+ * Map every lone UTF-16 surrogate to U+FFFD. Load-bearing on ANY path that feeds
102
+ * text to an injected redactor: a secret split by an interposed lone surrogate
103
+ * reads as adjacent to a model rendering its own UTF-16 but as broken to a
104
+ * redactor (Node maps the lone surrogate to U+FFFD en route), so a secret
105
+ * reconstituted across the surrogate survives redaction unless the text is
106
+ * normalized first. It also keeps an HTML tokenizer from throwing on a stray
107
+ * code unit. The substitution is same-LENGTH — one UTF-16 unit for one — so
108
+ * offsets computed against the un-normalized string stay valid against this one.
109
+ * @param {string} text
110
+ * @returns {string}
111
+ */
112
+ export function normalizeLoneSurrogates(text: string): string;
113
+ /**
114
+ * {@link applyLayer1} followed by {@link normalizeLoneSurrogates} — the exact
115
+ * composition `output.mjs`'s processLayer1 runs before Layers 2+ see the text,
116
+ * so this is the string a model is actually shown.
117
+ *
118
+ * Take this one, not `applyLayer1`, whenever the result feeds a redactor, an
119
+ * offset calculation or a view map: those consumers compare their own string to
120
+ * the model-facing view, and an unpaired surrogate is exactly where the two
121
+ * spellings diverge. Take `applyLayer1` when you want Layer 1's removals alone
122
+ * and intend to hand a possibly ill-formed string onward unchanged.
123
+ *
124
+ * `found` gains {@link CATEGORY.LONE_SURROGATES} exactly when the normalization
125
+ * changed the text, matching what the pipeline reports for the same input.
126
+ * `deAnsi` is untouched: it is the ANSI strip of the ORIGINAL text, the scope
127
+ * the long-run payload check needs.
128
+ * @param {string} text
129
+ * @returns {{ cleaned: string, deAnsi: string, found: string[], ansiKinds: string[] }}
130
+ */
131
+ export function applyLayer1WellFormed(text: string): {
132
+ cleaned: string;
133
+ deAnsi: string;
134
+ found: string[];
135
+ ansiKinds: string[];
136
+ };
100
137
  export const LONE_SURROGATE_RE: RegExp;
101
138
  /**
102
139
  * What a reader is told when the ONLY thing a strip removed was inert ANSI (see