agent-sanitizer 2.34.8 → 2.34.9

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.
@@ -53,36 +53,11 @@ import { lazyImport } from "./hook-io.mjs";
53
53
  const { stripAnsiFully } = /** @type {typeof import("agent-sanitizer")} */ (
54
54
  await lazyImport("agent-sanitizer")
55
55
  );
56
- const { STRIP, LONG_RUN_THRESHOLD, SCATTERED_THRESHOLD, stripInvisible } =
56
+ const { STRIP, SCATTERED_THRESHOLD, hasLongRun, stripInvisible } =
57
57
  /** @type {typeof import("agent-sanitizer/invisible")} */ (
58
58
  await lazyImport("agent-sanitizer/invisible")
59
59
  );
60
60
 
61
- /**
62
- * "A run of {@link LONG_RUN_THRESHOLD} or more invisibles", bounded per match.
63
- *
64
- * Built from the engine's own class and threshold rather than imported as a
65
- * ready-made pattern or scan function, because the bundle resolves
66
- * `agent-sanitizer` to the PINNED published engine, which trails this repo:
67
- * anything this hook imports has to exist in that pin, or the import binds
68
- * undefined and the hook fails closed on every payload. STRIP and
69
- * LONG_RUN_THRESHOLD are the primitives that define a long run, so deriving the
70
- * pattern here keeps the answer identical to the engine's across pins, with no
71
- * version-specific scan API to adopt when the pin moves.
72
- *
73
- * The upper bound is what makes it safe on a large payload: V8 pushes one
74
- * backtrack entry per iteration of a quantifier onto a stack capped at 64 MB,
75
- * so an UNBOUNDED run pattern throws `RangeError: Maximum call stack size
76
- * exceeded` once a single run passes ~8.4 M code points — an 8 MB paste of
77
- * zero-widths into a Write body is exactly that. A bound of 2^20 iterations
78
- * sits ~8x under the ceiling, and a longer run still answers yes: any run of at
79
- * least the threshold contains a prefix this matches.
80
- */
81
- const LONG_RUN_CHUNK_RE = new RegExp(
82
- `(?:${STRIP.source}){${LONG_RUN_THRESHOLD},${1 << 20}}`,
83
- "gu",
84
- );
85
-
86
61
  // Content fields the model authors, per tool. Paths and confusables are the
87
62
  // confusable layer's domain; here we target the free-text fields that carry
88
63
  // model-authored prose / code / data out into persisted or displayed artifacts.
@@ -165,8 +140,10 @@ export function authoredScopeDecision(tool) {
165
140
  // user→model surfaces share one definition of "stego payload".
166
141
  /** @param {string} text */
167
142
  function isPayloadCapable(text) {
168
- LONG_RUN_CHUNK_RE.lastIndex = 0;
169
- if (LONG_RUN_CHUNK_RE.test(text)) return true;
143
+ // hasLongRun, not a pattern built here: the engine's scan is bounded per
144
+ // `exec`, which is what keeps an 8 MB run of zero-widths in a Write body from
145
+ // throwing `RangeError: Maximum call stack size exceeded` out of this hook.
146
+ if (hasLongRun(text)) return true;
170
147
  return (text.match(STRIP)?.length ?? 0) >= SCATTERED_THRESHOLD;
171
148
  }
172
149
 
@@ -82,8 +82,11 @@ export const { applyLayer1, matchesSecretHint, SECRET_HINT, SECRET_HINT_EXT } =
82
82
  const _output = /** @type {typeof import("agent-sanitizer/output")} */ (
83
83
  await lazyImport("agent-sanitizer/output")
84
84
  );
85
- const { sanitizeText: sanitizeTextSeam, composeContext: composeContextSeam } =
86
- _output;
85
+ const {
86
+ sanitizeText: sanitizeTextSeam,
87
+ composeContext: composeContextSeam,
88
+ withheldWarning,
89
+ } = _output;
87
90
  export const { describeRemoved, describeWarned, suppressToolOutput } = _output;
88
91
 
89
92
  const HOOK_NAME = "sanitize-output";
@@ -94,12 +97,7 @@ const HOOK_NAME = "sanitize-output";
94
97
  // splice/withhold warnings make is NOT kept for this output. Fixed prose, no
95
98
  // error text — the redactor runs on attacker-influenced content and this line
96
99
  // reaches the model-facing context. Exported so tests assert it by reference.
97
- // Deliberately a LOCAL constant rather than a shared engine builder alongside
98
- // output.mjs's "Withheld the ${label}" template: the plugin bundle resolves
99
- // the engine to the pinned registry release, so hook code cannot use a new
100
- // engine export until the pin advances past it.
101
- export const REVEAL_WITHHELD_WARNING =
102
- "Withheld the reveal sidecar: it could not be vetted for secrets";
100
+ export const REVEAL_WITHHELD_WARNING = withheldWarning("reveal sidecar");
103
101
 
104
102
  // Total wall-clock budget for one hook invocation's blocking daemon calls — the
105
103
  // Layer-4 redactor — SHARED across every string leaf of the tool output. Each
@@ -116,18 +114,6 @@ const SANITIZE_BUDGET_MS = positiveMsOr(
116
114
  120000,
117
115
  );
118
116
 
119
- // Non-WARNING note for a strip whose only change was INERT ANSI on a local tool:
120
- // the display-only colour git/pytest/npm/etc. emit by default, and/or a stray
121
- // escape byte that formed no sequence at all. The engine now returns this text
122
- // itself, as a NOTE-severity finding alongside the warnings, so this copy is the
123
- // FALLBACK for exactly one case: a bundle built against a pinned engine older
124
- // than that severity split, whose result carries `sgrNote` but no `notes`. Same
125
- // sentence, so a plugin on the old pin keeps today's wording instead of falling
126
- // back to a bare "output sanitized".
127
- const SGR_OUTPUT_NOTE =
128
- "Inert ANSI stripped (display-only colour and/or a stray escape byte that " +
129
- "formed no control sequence); pipe through cat -v to inspect raw escapes.";
130
-
131
117
  // Web-ingress tools always get the Layer 2 HTML rewrite; local tools — Read,
132
118
  // Bash, Grep, gh — never do. A local HTML/markdown pass either rewrites bytes the
133
119
  // model is about to edit or deletes content (diffs, PR bodies, page
@@ -309,10 +295,8 @@ export async function sanitizeText(
309
295
  /** @type {{ cleaned: string, warnings: string[], notes?: string[], modified: boolean, sgrNote: boolean, reveal?: string, splices?: Array<{ placeholder: string, original: string }> }} */ (
310
296
  await sanitizeTextSeam(text, seamOptions)
311
297
  );
312
- // The one place the seam's shape is normalized: `notes` is absent when the
313
- // engine predates the severity split, which is the shipped plugin's pinned
314
- // case (see SGR_OUTPUT_NOTE). Defaulting here means nothing downstream has to
315
- // know that, and the banner composer sees one shape either way.
298
+ // The one place the seam's shape is normalized, so nothing downstream has to
299
+ // branch on an absent `notes` and the banner composer sees one shape.
316
300
  const result = { ...seamResult, notes: seamResult.notes ?? [] };
317
301
  return ext.postText
318
302
  ? applyPostText(
@@ -1006,7 +990,7 @@ export async function evaluateToolOutput(input, ext = {}) {
1006
990
  // hidden-HTML splice to read about does not also need the colour codes).
1007
991
  const baseContext =
1008
992
  sgrNote && warnings.length === 0
1009
- ? noteContext(notes)
993
+ ? [...new Set(notes)].join(" ")
1010
994
  : composeContext(modified, warnings, input.tool_name);
1011
995
  const additionalContext = revealRead
1012
996
  ? `${REVEAL_READ_ENVELOPE} ${baseContext}`
@@ -1017,20 +1001,6 @@ export async function evaluateToolOutput(input, ext = {}) {
1017
1001
  return emit(modified ? "modified" : "flagged", fields);
1018
1002
  }
1019
1003
 
1020
- /**
1021
- * The model-facing line for a note-only result: the seam's own note text,
1022
- * deduped and joined, with no WARNING prefix.
1023
- *
1024
- * Empty only against a pinned engine that predates the severity split (see
1025
- * SGR_OUTPUT_NOTE): there `sgrNote` still arrives true with no `notes` to go
1026
- * with it, and printing nothing would drop the one thing that run had to say.
1027
- * @param {string[]} notes
1028
- * @returns {string}
1029
- */
1030
- function noteContext(notes) {
1031
- return notes.length === 0 ? SGR_OUTPUT_NOTE : [...new Set(notes)].join(" ");
1032
- }
1033
-
1034
1004
  /**
1035
1005
  * Judge a normalized PostToolUse event: run the sanitization pipeline and
1036
1006
  * express its outcome as a control-plane Verdict. sanitize-output only ever
@@ -41,12 +41,11 @@ import {
41
41
  import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
42
42
  import { reportSlowHook, startHookTimer } from "./lib/hook-timing.mjs";
43
43
  // Relative, not the `agent-sanitizer` specifier every other engine import uses:
44
- // this is the scan's SCOPE, which is hook policy and must move with the hook.
45
- // Routing it through the specifier would resolve it, in the shipped plugin
46
- // bundle, against a PINNED older engine that does not export it — leaving the
47
- // walk with undefined globs while believing it had scanned everything. The
48
- // module is dependency-free data (see src/claude-context.mjs), so importing it
49
- // statically carries none of the fail-open hazard lazyImport exists to cover.
44
+ // this is the scan's SCOPE, which is hook policy, and package.json's exports map
45
+ // deliberately does not publish it routing it through the specifier would fail
46
+ // to resolve. The module is dependency-free data (see src/claude-context.mjs),
47
+ // so importing it statically carries none of the fail-open hazard lazyImport
48
+ // exists to cover.
50
49
  import {
51
50
  CLAUDE_CONTEXT_SUBDIRS,
52
51
  CLAUDE_INSTRUCTION_GLOBS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.34.8",
3
+ "version": "2.34.9",
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": {
@@ -53,6 +53,7 @@
53
53
  "@stryker-mutator/tap-runner": "^9.6.1",
54
54
  "@types/node": "25.9.1",
55
55
  "acorn": "^8.18.0",
56
+ "agent-sanitizer": "link:.",
56
57
  "c8": "11.0.0",
57
58
  "esbuild": "0.28.1",
58
59
  "eslint": "10.4.0",
@@ -60,7 +61,7 @@
60
61
  "globals": "17.6.0",
61
62
  "lint-staged": "^17.0.5",
62
63
  "prettier": "^3.0.0",
63
- "sanitizer-engine": "npm:agent-sanitizer@2.20.0",
64
+ "smol-toml": "^1.7.1",
64
65
  "typescript": "6.0.3",
65
66
  "typescript-eslint": "8.61.0",
66
67
  "yaml": "^2.9.0"
package/src/output.mjs CHANGED
@@ -263,6 +263,19 @@ export const REDACTION_DOCTRINE =
263
263
  " (placeholders rehydrate only via Edit/Write on the owning file; other " +
264
264
  "write paths persist the placeholder text and lose the secret)";
265
265
 
266
+ /**
267
+ * The warning prose for a value the caller asked for and is not getting,
268
+ * because the redactor could not vet it for secrets.
269
+ *
270
+ * Exported so the hook layer composes the same sentence for the artifacts it
271
+ * withholds itself (the reveal sidecar) rather than restating the wording.
272
+ * @param {string} label what was withheld, as a noun phrase
273
+ * @returns {string}
274
+ */
275
+ export function withheldWarning(label) {
276
+ return `Withheld the ${label}: it could not be vetted for secrets`;
277
+ }
278
+
266
279
  // Layer 2/3 pre-gate and warning prose are shared with the root entry
267
280
  // (./index.mjs), which runs the same layers; re-exported here because both were
268
281
  // part of this module's public surface before they moved.
@@ -527,9 +540,7 @@ async function vetStageValue(text, redact, findings, label) {
527
540
  // A WARNING: the caller asked for this field and is not getting it, and the
528
541
  // reason is an unrunnable redactor — the same fault that fails `cleaned`
529
542
  // closed, just with a survivable remedy here.
530
- findings.push(
531
- warning(`Withheld the ${label}: it could not be vetted for secrets`),
532
- );
543
+ findings.push(warning(withheldWarning(label)));
533
544
  return undefined;
534
545
  }
535
546
  }
@@ -274,7 +274,7 @@ export const SECRET_HINT_EXT: RegExp;
274
274
  export const describeRemoved: typeof import("agent-sanitizer/output").describeRemoved;
275
275
  export const describeWarned: typeof import("agent-sanitizer/output").describeWarned;
276
276
  export const suppressToolOutput: typeof import("agent-sanitizer/output").suppressToolOutput;
277
- export const REVEAL_WITHHELD_WARNING: "Withheld the reveal sidecar: it could not be vetted for secrets";
277
+ export const REVEAL_WITHHELD_WARNING: string;
278
278
  export const COLLISION_WITHHELD_MESSAGE: "[WITHHELD \u2014 this field's name collided with another after sanitization]";
279
279
  export const ON_DISK_PLACEHOLDER_WARNING: string;
280
280
  /**
@@ -1,3 +1,13 @@
1
+ /**
2
+ * The warning prose for a value the caller asked for and is not getting,
3
+ * because the redactor could not vet it for secrets.
4
+ *
5
+ * Exported so the hook layer composes the same sentence for the artifacts it
6
+ * withholds itself (the reveal sidecar) rather than restating the wording.
7
+ * @param {string} label what was withheld, as a noun phrase
8
+ * @returns {string}
9
+ */
10
+ export function withheldWarning(label: string): string;
1
11
  /**
2
12
  * Delete each verbatim span in `spans` from `text`. The secure Layer-5
3
13
  * primitive: a filter can only ask for deletions, so this can never inject