agent-sanitizer 2.27.0 → 2.28.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
@@ -35,6 +35,10 @@ const { cleaned, found, warnings, notes } = await sanitize(untrustedText);
35
35
 
36
36
  // Opt into the HTML layers for web ingress (lazy-loads ~200 ms of deps):
37
37
  const result = await sanitize(pageSource, { html: true });
38
+
39
+ // Layer 3 alone: flag exfil-shaped URLs without splicing anything (for text
40
+ // that must stay byte-faithful, e.g. a PR diff). Implied by `html: true`.
41
+ const scanned = await sanitize(diffText, { exfilScan: true });
38
42
  ```
39
43
 
40
44
  `sanitize` never throws and never silently drops content—any change comes with
@@ -77,17 +77,22 @@ function maxInputBytes() {
77
77
  const errorMessage = (err) =>
78
78
  /** @type {{ message?: string }} */ (err)?.message ?? String(err);
79
79
 
80
- /** Throw if `text` exceeds the configured byte cap. The message names the limit
81
- * and the env var so a caller can act on it.
80
+ /** The one oversize-rejection message, for every path that enforces the cap.
81
+ * Names the limit and the env var so a caller can act on it; `size` is included
82
+ * when the rejecting path knows it (the streaming paths discard the input
83
+ * unbuffered, so they only know the cap was crossed).
84
+ * @param {number} limit @param {number} [size] */
85
+ const oversizeMessage = (limit, size) =>
86
+ `request too large: ${size === undefined ? "input" : `${size} bytes`} ` +
87
+ `exceeds the ${limit}-byte limit ` +
88
+ "(raise AGENT_SANITIZER_MAX_INPUT_BYTES to accept it)";
89
+
90
+ /** Throw if `text` exceeds the configured byte cap.
82
91
  * @param {string} text */
83
92
  function enforceSizeLimit(text) {
84
93
  const limit = maxInputBytes();
85
94
  const size = Buffer.byteLength(text, "utf8");
86
- if (size > limit)
87
- throw new Error(
88
- `request too large: ${size} bytes exceeds the ${limit}-byte limit ` +
89
- "(raise AGENT_SANITIZER_MAX_INPUT_BYTES to accept it)",
90
- );
95
+ if (size > limit) throw new Error(oversizeMessage(limit, size));
91
96
  }
92
97
 
93
98
  /** Read a required string field, throwing when it is absent or the wrong type.
@@ -105,7 +110,7 @@ function requireString(req, key) {
105
110
  /** Operations the CLI exposes. Each takes the parsed request, returns the JSON
106
111
  * payload object. Non-`sanitize` modules are imported lazily so a caller that
107
112
  * only ever sanitizes never loads prompt/output/instructions code. */
108
- const OPS = {
113
+ export const OPS = {
109
114
  /** @param {Record<string, unknown>} req */
110
115
  async sanitize(req) {
111
116
  const text = requireString(req, "text");
@@ -203,11 +208,7 @@ async function readAll(stream) {
203
208
  let bytes = 0;
204
209
  for await (const chunk of stream) {
205
210
  bytes += Buffer.byteLength(chunk, "utf8");
206
- if (bytes > limit)
207
- throw new Error(
208
- `request too large: input exceeds the ${limit}-byte limit ` +
209
- "(raise AGENT_SANITIZER_MAX_INPUT_BYTES to accept it)",
210
- );
211
+ if (bytes > limit) throw new Error(oversizeMessage(limit));
211
212
  text += chunk;
212
213
  }
213
214
  return text;
@@ -339,11 +340,7 @@ function createLineSplitter(limit) {
339
340
 
340
341
  /** @param {number} limit */
341
342
  const OVERSIZE_ERROR = (limit) =>
342
- JSON.stringify({
343
- error:
344
- `request too large: input exceeds the ${limit}-byte limit ` +
345
- "(raise AGENT_SANITIZER_MAX_INPUT_BYTES to accept it)",
346
- });
343
+ JSON.stringify({ error: oversizeMessage(limit) });
347
344
 
348
345
  async function runWorker() {
349
346
  // Stream raw bytes (no encoding) so the splitter tracks UTF-8 byte length, not
@@ -417,6 +414,11 @@ function invokedAsScript() {
417
414
  return false;
418
415
  }
419
416
  }
417
+ // Derived from OPS (the dispatch table is the SSOT) so adding or renaming an
418
+ // op can never leave the help text advertising a stale set.
419
+ const OPS_SENTENCE = Object.keys(OPS)
420
+ .map((op) => (op === "sanitize" ? "sanitize (default)" : op))
421
+ .join(", ");
420
422
  export const USAGE = `sanitize-cli — sanitize untrusted text before an LLM sees it.
421
423
 
422
424
  Reads JSON on stdin, writes one JSON response line on stdout.
@@ -426,8 +428,8 @@ Usage:
426
428
  sanitize-cli --worker worker: read newline-delimited requests until EOF, one response per line
427
429
  sanitize-cli --help show this help
428
430
 
429
- Request: { "op"?: string, ...fields }. Ops: sanitize (default), sanitizeText,
430
- classifyPrompt, scanInstructionFiles, cleanFile. A failure response is { "error": string }.
431
+ Request: { "op"?: string, ...fields }. Ops: ${OPS_SENTENCE}.
432
+ A failure response is { "error": string }.
431
433
  `;
432
434
 
433
435
  /**
@@ -1,6 +1,5 @@
1
1
  {
2
- "description": "Inference-provider API-key env vars whose VALUES the redactor masks by exact match, plus the placeholder floor below which a configured value is treated as a doc stub rather than a real key. Mirrors agent_sanitizer.secrets.config.DEFAULT_MIN_SECRET_LEN; the hooks send these names' current values to the redactor daemon per request (see lib/redactor-client.mjs).",
3
- "min_secret_len": 16,
2
+ "description": "Inference-provider API-key env vars whose VALUES the redactor masks by exact match; the hooks send these names' current values to the redactor daemon per request (see lib/redactor-client.mjs). The placeholder floor lives in python/agent_sanitizer/secrets/data/redaction-floor.json — the one physical file both ecosystems read — not here.",
4
3
  "vars": [
5
4
  "ANTHROPIC_API_KEY",
6
5
  "ANTHROPIC_AUTH_TOKEN",
@@ -22,6 +22,7 @@
22
22
  import { credentialNameMatcher } from "agent-sanitizer/credential-names-matcher";
23
23
 
24
24
  import credentialNames from "../../python/agent_sanitizer/secrets/data/credential-names.json" with { type: "json" };
25
+ import redactionFloor from "../../python/agent_sanitizer/secrets/data/redaction-floor.json" with { type: "json" };
25
26
  import inferenceKeys from "../config/inference-key-vars.json" with { type: "json" };
26
27
  import scrubbed from "../config/scrubbed-env-vars.json" with { type: "json" };
27
28
 
@@ -102,7 +103,10 @@ function hostSource() {
102
103
  */
103
104
  export function minEnvSecretLen() {
104
105
  const hostLen = hostSource()?.minSecretLen;
105
- if (hostLen === undefined) return inferenceKeys.min_secret_len;
106
+ // The package floor comes from the same physical file
107
+ // agent_sanitizer.secrets.config reads (DEFAULT_MIN_SECRET_LEN), so the JS
108
+ // pre-gate and the Python daemon cannot drift apart on it.
109
+ if (hostLen === undefined) return redactionFloor.min_secret_len;
106
110
  if (!Number.isInteger(hostLen) || hostLen <= 0)
107
111
  throw new Error(
108
112
  `${HOST_SOURCE_LABEL}: minSecretLen must be a positive integer, got ${JSON.stringify(hostLen)}`,
@@ -5,33 +5,70 @@
5
5
  */
6
6
  import { minEnvSecretLen, envBoundSecretVars } from "./env-config.mjs";
7
7
 
8
- // Zero-width / format (Cf) characters an attacker can splice between a value's
9
- // characters to break an exact-substring pre-gate while the daemon's redactor
10
- // still matches across them. This is a curated subset of the common bidi /
11
- // zero-width controls, NOT an exact copy of the daemon's full dynamic Cf set — it
12
- // is a defense-in-depth backstop, because Layer 1 (applyLayer1) strips every Cf
13
- // splice from the text BEFORE this pre-gate runs, so hasEnvBoundSecret already
14
- // sees the plain value. A run of zero-or-more is allowed at each interior gap, so
15
- // the plain value still matches (a superset of `includes`). Required literals
16
- // between every gap keep the pattern linear — no ReDoS.
8
+ // The generated cross-language charset SSOT, imported as a module like
9
+ // env-config.mjs's JSON configs: esbuild inlines it into the plugin bundle
10
+ // (which ships with no data directory beside it) and Node resolves it from the
11
+ // package when the hooks run from source. A DATA import, deliberately not a
12
+ // static `agent-sanitizer/invisible` import a top-level package import in a
13
+ // hook lib aborts the process before the consuming hook's fail-closed catch
14
+ // installs, which is a fail OPEN on the module that withholds secrets.
15
+ import charset from "../../python/agent_sanitizer/data/invisible-charset.json" with { type: "json" };
16
+
17
+ // Invisible characters an attacker can splice between a value's characters to
18
+ // break an exact-substring pre-gate while the daemon's redactor still matches
19
+ // across them. Built from the SAME pinned charset Layer 1 strips and the daemon
20
+ // tolerates (`invisible_run_pattern` in agent_sanitizer.secrets.invisible
21
+ // builds the same shape from the same set), not a hand-curated subset: a subset
22
+ // pre-gate silently under-matches whenever this gate runs on text Layer 1 has
23
+ // not already stripped (the module is exported and callable on its own), and a
24
+ // value spliced with a code point in the gap then never reaches the daemon at
25
+ // all. A run of zero-or-more is allowed at each interior gap, so the plain
26
+ // value still matches (a superset of `includes`). The required literals between
27
+ // every gap are what bound the match — a `*` gap is never adjacent to another
28
+ // gap, so there is no ambiguity to backtrack over and no ReDoS. (The class size
29
+ // is irrelevant to that: a character class matches in O(1) whether it holds one
30
+ // member or all 435.)
17
31
  const ENV_INVIS_RUN =
18
- "[\\u200b\\u200c\\u200d\\u2060\\ufeff\\u00ad\\u180e\\u200e\\u200f\\u202a-\\u202e\\u2066-\\u2069]*";
32
+ "[" +
33
+ [...new Set([...charset.cf_codepoints, ...charset.extra_codepoints])]
34
+ .sort((a, b) => a - b)
35
+ .map((cp) => `\\u{${cp.toString(16)}}`)
36
+ .join("") +
37
+ "]*";
19
38
 
20
39
  /**
21
40
  * Regex matching `value` tolerating invisible chars spliced between its
22
41
  * characters (mirrors the engine's env-value regex). Code-point split so
23
- * an astral character is escaped whole, not as two surrogate halves.
42
+ * an astral character is escaped whole, not as two surrogate halves — and the
43
+ * `u` flag, which the astral `\u{…}` class members in {@link ENV_INVIS_RUN}
44
+ * require.
45
+ * Memoized per distinct value: {@link ENV_INVIS_RUN} renders ~435 code points
46
+ * as ~4 KB of source and is joined at EVERY interior gap, so a 20-char secret
47
+ * compiles a ~75 KB pattern — and `hasEnvBoundSecret` builds one per configured
48
+ * var on every PostToolUse output. Env values are stable for the process's
49
+ * lifetime, so the cache is bounded by the number of distinct values. Sharing an
50
+ * instance is safe because the regex carries no `g`/`y` flag, hence no
51
+ * `lastIndex` state to leak between calls.
24
52
  * @param {string} value
25
53
  * @returns {RegExp}
26
54
  */
27
55
  export function envValueRegex(value) {
28
- return new RegExp(
29
- [...value]
30
- .map((ch) => ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
31
- .join(ENV_INVIS_RUN),
32
- );
56
+ let re = ENV_VALUE_REGEX_CACHE.get(value);
57
+ if (re === undefined) {
58
+ re = new RegExp(
59
+ [...value]
60
+ .map((ch) => ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
61
+ .join(ENV_INVIS_RUN),
62
+ "u",
63
+ );
64
+ ENV_VALUE_REGEX_CACHE.set(value, re);
65
+ }
66
+ return re;
33
67
  }
34
68
 
69
+ /** value → compiled matcher; see {@link envValueRegex}. */
70
+ const ENV_VALUE_REGEX_CACHE = new Map();
71
+
35
72
  /**
36
73
  * True when tool output contains the literal value of a configured env-bound
37
74
  * secret. The shape-based secret hint can't match a prefix-less key or a host
@@ -65,6 +65,7 @@ const LAZY_LOADERS = {
65
65
  import("agent-control-plane-core/claude"),
66
66
  "agent-sanitizer": () => import("agent-sanitizer"),
67
67
  "agent-sanitizer/confusables": () => import("agent-sanitizer/confusables"),
68
+ "agent-sanitizer/instructions": () => import("agent-sanitizer/instructions"),
68
69
  "agent-sanitizer/invisible": () => import("agent-sanitizer/invisible"),
69
70
  "agent-sanitizer/output": () => import("agent-sanitizer/output"),
70
71
  "agent-sanitizer/prompt": () => import("agent-sanitizer/prompt"),
@@ -4,8 +4,17 @@
4
4
  * invisible sequences (tag chars, zero-width encodings) that hijack the model's
5
5
  * behavior — invisible in an editor but read by the LLM. These files load as
6
6
  * project instructions at session start, bypassing the PostToolUse sanitizer.
7
+ *
8
+ * The scan/decode/clean LOGIC lives in `agent-sanitizer/instructions` — this
9
+ * hook is glue (target discovery, accounting, alert persistence, fault
10
+ * posture) over that SSOT. A hand-written twin used to live here and drifted
11
+ * three ways at once: its report re-emitted the decoded payload with no
12
+ * `untrusted data` framing or escaping (re-injecting the very instruction the
13
+ * scan exists to catch), its scattered counter re-grew the linguistic-joiner
14
+ * false positive the SSOT had already fixed, and its clean path was a bare
15
+ * `writeFileSync` with none of cleanFile's symlink/UTF-8/TOCTOU guards.
7
16
  */
8
- import { readFileSync, globSync, writeFileSync, unlinkSync } from "node:fs";
17
+ import { readFileSync, globSync, unlinkSync } from "node:fs";
9
18
  import { join, relative } from "node:path";
10
19
  import {
11
20
  awaitLazyDependency,
@@ -44,35 +53,43 @@ import {
44
53
  excludeFromContextScan,
45
54
  } from "../src/claude-context.mjs";
46
55
 
47
- // Layer-1 primitives, bound via lazyImport (see its doc for the fail-OPEN
48
- // hazard of a bare static npm import — here the instruction files would load
49
- // UNSCANNED). A failed load leaves the bindings undefined; on a cold container
50
- // (node deps not yet installed) cliMain's guard below waits out session-setup
51
- // before giving up, and fails loud rather than silently passing.
56
+ // Layer-1 primitives + the instruction-scanner SSOT, bound via lazyImport (see
57
+ // its doc for the fail-OPEN hazard of a bare static npm import — here the
58
+ // instruction files would load UNSCANNED). A failed load leaves the bindings
59
+ // undefined; on a cold container (node deps not yet installed) cliMain's guard
60
+ // below waits out session-setup before giving up, and fails loud rather than
61
+ // silently passing.
52
62
  // `let`, not `const`: the cold-start poll re-binds these once the package loads.
53
63
  let {
54
64
  LONG_RUN_RE,
55
65
  LONG_RUN_THRESHOLD,
56
66
  SCATTERED_THRESHOLD: TOTAL_INVISIBLE_THRESHOLD,
57
- STRIP,
58
- stripInvisible,
59
67
  } = /** @type {typeof import("agent-sanitizer/invisible")} */ (
60
68
  await lazyImport("agent-sanitizer/invisible")
61
69
  );
70
+ let {
71
+ decodeRun: instrDecodeRun,
72
+ scanText,
73
+ cleanFile,
74
+ } = /** @type {typeof import("agent-sanitizer/instructions")} */ (
75
+ await lazyImport("agent-sanitizer/instructions")
76
+ );
62
77
 
63
78
  /**
64
- * Re-attempt the sanitizer import, waiting out an in-flight session-setup before
65
- * giving up. On a cold container the node deps this hook needs are still being
66
- * installed when SessionStart fires; without this wait `stripInvisible` is
67
- * undefined, the scan is skipped, and the instruction files load UNSCANNED for the
68
- * whole session (fail open) — silently. Reuses the control-plane poll (marker +
69
- * PID liveness) so the wait bound matches every other cold-start-aware gate.
79
+ * Re-attempt the sanitizer imports, waiting out an in-flight session-setup
80
+ * before giving up. On a cold container the node deps this hook needs are still
81
+ * being installed when SessionStart fires; without this wait `scanText` is
82
+ * undefined, the scan is skipped, and the instruction files load UNSCANNED for
83
+ * the whole session (fail open) — silently. Reuses the control-plane poll
84
+ * (marker + PID liveness) so the wait bound matches every other
85
+ * cold-start-aware gate.
70
86
  * @returns {Promise<boolean>} whether the sanitizer is now bound
71
87
  */
72
88
  async function ensureSanitizerLoaded() {
73
- if (typeof stripInvisible === "function") return true;
89
+ if (typeof scanText === "function" && typeof cleanFile === "function")
90
+ return true;
74
91
  /* c8 ignore start -- cold-start reload: only runs when the top-level
75
- agent-sanitizer import above failed (node deps not yet installed), which
92
+ agent-sanitizer imports above failed (node deps not yet installed), which
76
93
  can't be simulated in-process or in the spawned-subprocess CLI run the tests
77
94
  observe (the test env always has the deps, so the guard above early-returns).
78
95
  The reload reuses awaitLazyDependency / markerIsTrusted /
@@ -80,20 +97,28 @@ async function ensureSanitizerLoaded() {
80
97
  const marker = hookgateMarkerPath();
81
98
  const reloaded = await awaitLazyDependency({
82
99
  tryImport: async () => {
83
- const mod = await lazyImport("agent-sanitizer/invisible");
84
- return typeof mod.stripInvisible === "function" ? mod : null;
100
+ const invisible = await lazyImport("agent-sanitizer/invisible");
101
+ const instructions = await lazyImport("agent-sanitizer/instructions");
102
+ return typeof instructions.scanText === "function" &&
103
+ typeof instructions.cleanFile === "function" &&
104
+ invisible.LONG_RUN_RE !== undefined
105
+ ? { invisible, instructions }
106
+ : null;
85
107
  },
86
108
  markerPresent: () => markerIsTrusted(marker),
87
109
  setupAlive: () => probeSetupAlive(marker),
88
110
  });
89
111
  if (!reloaded) return false;
112
+ const bound = /** @type {{
113
+ invisible: typeof import("agent-sanitizer/invisible"),
114
+ instructions: typeof import("agent-sanitizer/instructions"),
115
+ }} */ (reloaded);
90
116
  ({
91
117
  LONG_RUN_RE,
92
118
  LONG_RUN_THRESHOLD,
93
119
  SCATTERED_THRESHOLD: TOTAL_INVISIBLE_THRESHOLD,
94
- STRIP,
95
- stripInvisible,
96
- } = /** @type {typeof import("agent-sanitizer/invisible")} */ (reloaded));
120
+ } = bound.invisible);
121
+ ({ decodeRun: instrDecodeRun, scanText, cleanFile } = bound.instructions);
97
122
  return true;
98
123
  /* c8 ignore stop */
99
124
  }
@@ -164,46 +189,29 @@ function persistAlert(parts) {
164
189
  // Decoder
165
190
 
166
191
  /**
192
+ * The SSOT decoder, re-exported through a lazy-bound wrapper (the binding is
193
+ * `let` and may be re-bound by the cold-start reload, so the export must read
194
+ * it at call time). A hand-written twin used to live here; it decoded tag
195
+ * characters to RAW bytes — including actual C0 controls for U+E0001–U+E001F —
196
+ * with no `untrusted data, not instructions:` framing or escaping, so the
197
+ * hook's own report re-injected the hidden payload it had just caught.
167
198
  * @param {string} run
168
199
  * @returns {{ method: string, decoded: string }}
169
200
  */
170
201
  function decodeRun(run) {
171
- const cps = [...run].map((ch) => /** @type {number} */ (ch.codePointAt(0)));
172
-
173
- // Tag characters U+E0001-U+E007F map directly to ASCII
174
- const tagAscii = cps
175
- .filter((cp) => cp >= 0xe0001 && cp <= 0xe007f)
176
- // Stryker disable next-line ArithmeticOperator: cp - 0xe0000 → cp + 0xe0000 is equivalent — 0xe0000 is a multiple of 2^16 and String.fromCharCode truncates to 16 bits, so both yield the same character.
177
- .map((cp) => String.fromCharCode(cp - 0xe0000))
178
- .join("");
179
-
180
- if (tagAscii.length > 0) {
181
- return { method: "Unicode tag characters → ASCII", decoded: tagAscii };
182
- }
183
-
184
- // Zero-width binary encoding: ZWSP=0, ZWNJ=1, ZWJ=group separator.
185
- const ZW_BIT = new Map([
186
- [0x200b, "0"],
187
- [0x200c, "1"],
188
- [0x200d, "|"],
189
- ]);
190
- if (cps.every((cp) => ZW_BIT.has(cp))) {
191
- const bits = cps.map((cp) => ZW_BIT.get(cp)).join("");
192
- return {
193
- method: "zero-width binary encoding",
194
- decoded: `[${cps.length} zero-width chars: ${bits.slice(0, 80)}]`,
195
- };
196
- }
197
-
198
- // Mixed/unknown
199
- return {
200
- method: "invisible Unicode sequence",
201
- decoded: cps
202
- .map((cp) => `U+${cp.toString(16).toUpperCase().padStart(4, "0")}`)
203
- .join(" "),
204
- };
202
+ return instrDecodeRun(run);
205
203
  }
206
204
 
205
+ // Target discovery stays hook-local glue, NOT a copy of the SSOT's
206
+ // containment-checked `findInstructionFiles`: the two have different contracts.
207
+ // The SSOT finder silently DROPS a target it cannot resolve (dangling symlink,
208
+ // out-of-tree symlink), which is right for a pure scan API — but this hook's
209
+ // accounting invariant (scanned + skipped === targets, see scanProject)
210
+ // requires unreadable targets to stay LISTED so they are reported as unvetted
211
+ // rather than vanishing into an "all clean" announcement. The write-side
212
+ // symlink hazard the SSOT finder guards against is covered here by cleanFile's
213
+ // own O_NOFOLLOW open.
214
+
207
215
  /**
208
216
  * Every file under `dir` that Claude Code loads as model context: the
209
217
  * per-directory instruction files (CLAUDE.md, CLAUDE.local.md, AGENTS.md) and
@@ -230,37 +238,17 @@ function findInstructionFiles(dir) {
230
238
  // Scanner
231
239
 
232
240
  /**
241
+ * Read one file and run the SSOT scan over it. The scan logic itself (long-run
242
+ * decode + scattered threshold-evasion counting) is `scanText`'s — a local
243
+ * mirror used to re-count scatter from the raw STRIP match count, silently
244
+ * re-growing the linguistic-joiner/VS15 false positive `scanText`'s carve-out
245
+ * counter had already fixed.
233
246
  * @param {string} filePath
234
- * @returns {Array<{ line: number, charCount: number, method: string, decoded: string }>}
247
+ * @returns {ReturnType<typeof import("agent-sanitizer/instructions").scanText>}
248
+ * `line` is 1-based, or `null` for the whole-file scattered-chars finding.
235
249
  */
236
250
  function scanFile(filePath) {
237
- const content = readFileSync(filePath, "utf-8");
238
- const findings = [];
239
- LONG_RUN_RE.lastIndex = 0;
240
- let match;
241
- let runChars = 0;
242
- while ((match = LONG_RUN_RE.exec(content)) !== null) {
243
- const lineNum = content.slice(0, match.index).split("\n").length;
244
- const charCount = [...match[0]].length;
245
- runChars += charCount;
246
- findings.push({ line: lineNum, charCount, ...decodeRun(match[0]) });
247
- }
248
-
249
- // Threshold-evasion: scattered invisible chars not in a long run can still be
250
- // a payload. Always evaluated; chars already in a run are excluded so they
251
- // aren't double-counted.
252
- const allInvisible = content.match(STRIP);
253
- const scattered = (allInvisible ? allInvisible.length : 0) - runChars;
254
- if (scattered >= TOTAL_INVISIBLE_THRESHOLD) {
255
- findings.push({
256
- line: 0,
257
- charCount: scattered,
258
- method: "scattered invisible chars (possible threshold evasion)",
259
- decoded: `[${scattered} invisible chars distributed across file]`,
260
- });
261
- }
262
-
263
- return findings;
251
+ return scanText(readFileSync(filePath, "utf-8"));
264
252
  }
265
253
 
266
254
  export {
@@ -282,7 +270,7 @@ export {
282
270
  /**
283
271
  * @param {Array<{
284
272
  * file: string,
285
- * findings: Array<{ line: number, charCount: number, method: string, decoded: string }>,
273
+ * findings: Array<{ line: number | null, charCount: number, method: string, decoded: string }>,
286
274
  * }>} allFindings
287
275
  * @returns {string}
288
276
  */
@@ -304,8 +292,12 @@ function formatReport(allFindings) {
304
292
  for (const { file, findings } of allFindings) {
305
293
  lines.push(` ${file}:`);
306
294
  for (const finding of findings) {
295
+ // `line` is null for the whole-file scattered-chars finding, which is
296
+ // not tied to any single line.
297
+ const where =
298
+ finding.line === null ? "Whole file" : `Line ${finding.line}`;
307
299
  lines.push(
308
- ` Line ${finding.line}: ${finding.charCount} invisible chars (${finding.method})`,
300
+ ` ${where}: ${finding.charCount} invisible chars (${finding.method})`,
309
301
  );
310
302
  lines.push(` Decodes to: ${JSON.stringify(finding.decoded)}`);
311
303
  }
@@ -548,20 +540,29 @@ function autoCleanFindings(allFindings, dir) {
548
540
  for (const { file } of allFindings) {
549
541
  const absPath = join(dir, file);
550
542
  try {
551
- const original = readFileSync(absPath, "utf-8");
552
- const stripped = stripInvisible(original);
553
- if (stripped !== original) {
554
- writeFileSync(absPath, stripped);
555
- cleaned++;
556
- }
557
- /* c8 ignore start -- only fires on a file this uid cannot rewrite, which the test run cannot create */
543
+ // The SSOT clean: O_NOFOLLOW open, UTF-8 round-trip check, TOCTOU
544
+ // recheck, atomic rename + fsync, mode preservation. The bare
545
+ // readFileSync/writeFileSync pair that lived here had none of those
546
+ // guards — on the one hook that REWRITES instruction files.
547
+ //
548
+ // Counted ONLY on `true` (bytes changed), matching the old
549
+ // `stripped !== original` gate. `false` means cleanFile re-scanned and
550
+ // found nothing to strip in a file this run flagged — the file was
551
+ // changed under us, or the flagged run is one the stripper PRESERVES —
552
+ // so it is not a file we cleaned, and leaving `cleaned` short is what
553
+ // routes it to the alert below instead of an "all clean" report.
554
+ if (cleanFile(absPath)) cleaned++;
555
+ /* c8 ignore start -- only fires on a file cleanFile refuses (symlink,
556
+ non-UTF-8, concurrent write) or cannot rewrite, which the test run
557
+ does not create */
558
558
  } catch (err) {
559
- // Narrowed to filesystem errnos, and the reason is REPORTED rather than
560
- // swallowed: an unwritable file legitimately falls through to the alert
561
- // path below, but a throw from stripInvisible is a bug in the sanitizer
562
- // and must not be laundered into "this file resisted cleaning".
563
- if (/** @type {NodeJS.ErrnoException} */ (err).code === undefined)
564
- throw err;
559
+ // The reason is REPORTED rather than swallowed: a refusal or an
560
+ // unwritable file legitimately falls through to the alert path below. A
561
+ // TypeError is the one throw that still propagates an unbound lazy
562
+ // import, i.e. a bug in THIS hook, which must not be laundered into
563
+ // "this file resisted cleaning". (The scan phase has already exercised
564
+ // the same bindings, so this is a belt-and-braces rethrow.)
565
+ if (err instanceof TypeError) throw err;
565
566
  process.stderr.write(
566
567
  `scan-invisible-chars: could not clean ${file}: ${safeErrMessage(err)}\n`,
567
568
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.27.0",
3
+ "version": "2.28.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": {
@@ -186,7 +186,9 @@
186
186
  },
187
187
  "files": [
188
188
  "src/*.mjs",
189
+ "python/agent_sanitizer/data/invisible-charset.json",
189
190
  "python/agent_sanitizer/secrets/data/credential-names.json",
191
+ "python/agent_sanitizer/secrets/data/redaction-floor.json",
190
192
  "claude-hooks/*.mjs",
191
193
  "claude-hooks/lib/*.mjs",
192
194
  "claude-hooks/config/*.json",
@@ -0,0 +1,481 @@
1
+ {
2
+ "_comment": "SSOT for the payload-capable invisible code points, generated by scripts/gen-invisible-charset.mjs. `extra_codepoints` are the non-Cf extras (variation selectors, blank-rendering fillers, zero-width combining marks) from src/invisible.mjs (VS + BLANK_NON_CF). `cf_codepoints` is the general-category Cf set PINNED from Node's Unicode data at generation time (see `unicode_version`) — NOT resolved live per consumer, because Node and CPython ship different Unicode versions and a live-Cf split let a code point in the version delta escape one layer. The deletion set is the UNION of the two lists. `control_introducers` is the raw ANSI control-introducer set (ESC + the C1 block) from src/ansi.mjs, which Layer 1 sweeps and the Python textstrip port must sweep identically. Consumers in other languages read this file instead of forking the lists — a fork is a silent security regression.",
3
+ "unicode_version": "17.0",
4
+ "extra_codepoints": [
5
+ 847,
6
+ 4447,
7
+ 4448,
8
+ 6068,
9
+ 6069,
10
+ 6155,
11
+ 6156,
12
+ 6157,
13
+ 6159,
14
+ 10240,
15
+ 12644,
16
+ 65024,
17
+ 65025,
18
+ 65026,
19
+ 65027,
20
+ 65028,
21
+ 65029,
22
+ 65030,
23
+ 65031,
24
+ 65032,
25
+ 65033,
26
+ 65034,
27
+ 65035,
28
+ 65036,
29
+ 65037,
30
+ 65038,
31
+ 65039,
32
+ 65440,
33
+ 917760,
34
+ 917761,
35
+ 917762,
36
+ 917763,
37
+ 917764,
38
+ 917765,
39
+ 917766,
40
+ 917767,
41
+ 917768,
42
+ 917769,
43
+ 917770,
44
+ 917771,
45
+ 917772,
46
+ 917773,
47
+ 917774,
48
+ 917775,
49
+ 917776,
50
+ 917777,
51
+ 917778,
52
+ 917779,
53
+ 917780,
54
+ 917781,
55
+ 917782,
56
+ 917783,
57
+ 917784,
58
+ 917785,
59
+ 917786,
60
+ 917787,
61
+ 917788,
62
+ 917789,
63
+ 917790,
64
+ 917791,
65
+ 917792,
66
+ 917793,
67
+ 917794,
68
+ 917795,
69
+ 917796,
70
+ 917797,
71
+ 917798,
72
+ 917799,
73
+ 917800,
74
+ 917801,
75
+ 917802,
76
+ 917803,
77
+ 917804,
78
+ 917805,
79
+ 917806,
80
+ 917807,
81
+ 917808,
82
+ 917809,
83
+ 917810,
84
+ 917811,
85
+ 917812,
86
+ 917813,
87
+ 917814,
88
+ 917815,
89
+ 917816,
90
+ 917817,
91
+ 917818,
92
+ 917819,
93
+ 917820,
94
+ 917821,
95
+ 917822,
96
+ 917823,
97
+ 917824,
98
+ 917825,
99
+ 917826,
100
+ 917827,
101
+ 917828,
102
+ 917829,
103
+ 917830,
104
+ 917831,
105
+ 917832,
106
+ 917833,
107
+ 917834,
108
+ 917835,
109
+ 917836,
110
+ 917837,
111
+ 917838,
112
+ 917839,
113
+ 917840,
114
+ 917841,
115
+ 917842,
116
+ 917843,
117
+ 917844,
118
+ 917845,
119
+ 917846,
120
+ 917847,
121
+ 917848,
122
+ 917849,
123
+ 917850,
124
+ 917851,
125
+ 917852,
126
+ 917853,
127
+ 917854,
128
+ 917855,
129
+ 917856,
130
+ 917857,
131
+ 917858,
132
+ 917859,
133
+ 917860,
134
+ 917861,
135
+ 917862,
136
+ 917863,
137
+ 917864,
138
+ 917865,
139
+ 917866,
140
+ 917867,
141
+ 917868,
142
+ 917869,
143
+ 917870,
144
+ 917871,
145
+ 917872,
146
+ 917873,
147
+ 917874,
148
+ 917875,
149
+ 917876,
150
+ 917877,
151
+ 917878,
152
+ 917879,
153
+ 917880,
154
+ 917881,
155
+ 917882,
156
+ 917883,
157
+ 917884,
158
+ 917885,
159
+ 917886,
160
+ 917887,
161
+ 917888,
162
+ 917889,
163
+ 917890,
164
+ 917891,
165
+ 917892,
166
+ 917893,
167
+ 917894,
168
+ 917895,
169
+ 917896,
170
+ 917897,
171
+ 917898,
172
+ 917899,
173
+ 917900,
174
+ 917901,
175
+ 917902,
176
+ 917903,
177
+ 917904,
178
+ 917905,
179
+ 917906,
180
+ 917907,
181
+ 917908,
182
+ 917909,
183
+ 917910,
184
+ 917911,
185
+ 917912,
186
+ 917913,
187
+ 917914,
188
+ 917915,
189
+ 917916,
190
+ 917917,
191
+ 917918,
192
+ 917919,
193
+ 917920,
194
+ 917921,
195
+ 917922,
196
+ 917923,
197
+ 917924,
198
+ 917925,
199
+ 917926,
200
+ 917927,
201
+ 917928,
202
+ 917929,
203
+ 917930,
204
+ 917931,
205
+ 917932,
206
+ 917933,
207
+ 917934,
208
+ 917935,
209
+ 917936,
210
+ 917937,
211
+ 917938,
212
+ 917939,
213
+ 917940,
214
+ 917941,
215
+ 917942,
216
+ 917943,
217
+ 917944,
218
+ 917945,
219
+ 917946,
220
+ 917947,
221
+ 917948,
222
+ 917949,
223
+ 917950,
224
+ 917951,
225
+ 917952,
226
+ 917953,
227
+ 917954,
228
+ 917955,
229
+ 917956,
230
+ 917957,
231
+ 917958,
232
+ 917959,
233
+ 917960,
234
+ 917961,
235
+ 917962,
236
+ 917963,
237
+ 917964,
238
+ 917965,
239
+ 917966,
240
+ 917967,
241
+ 917968,
242
+ 917969,
243
+ 917970,
244
+ 917971,
245
+ 917972,
246
+ 917973,
247
+ 917974,
248
+ 917975,
249
+ 917976,
250
+ 917977,
251
+ 917978,
252
+ 917979,
253
+ 917980,
254
+ 917981,
255
+ 917982,
256
+ 917983,
257
+ 917984,
258
+ 917985,
259
+ 917986,
260
+ 917987,
261
+ 917988,
262
+ 917989,
263
+ 917990,
264
+ 917991,
265
+ 917992,
266
+ 917993,
267
+ 917994,
268
+ 917995,
269
+ 917996,
270
+ 917997,
271
+ 917998,
272
+ 917999
273
+ ],
274
+ "cf_codepoints": [
275
+ 173,
276
+ 1536,
277
+ 1537,
278
+ 1538,
279
+ 1539,
280
+ 1540,
281
+ 1541,
282
+ 1564,
283
+ 1757,
284
+ 1807,
285
+ 2192,
286
+ 2193,
287
+ 2274,
288
+ 6158,
289
+ 8203,
290
+ 8204,
291
+ 8205,
292
+ 8206,
293
+ 8207,
294
+ 8234,
295
+ 8235,
296
+ 8236,
297
+ 8237,
298
+ 8238,
299
+ 8288,
300
+ 8289,
301
+ 8290,
302
+ 8291,
303
+ 8292,
304
+ 8294,
305
+ 8295,
306
+ 8296,
307
+ 8297,
308
+ 8298,
309
+ 8299,
310
+ 8300,
311
+ 8301,
312
+ 8302,
313
+ 8303,
314
+ 65279,
315
+ 65529,
316
+ 65530,
317
+ 65531,
318
+ 69821,
319
+ 69837,
320
+ 78896,
321
+ 78897,
322
+ 78898,
323
+ 78899,
324
+ 78900,
325
+ 78901,
326
+ 78902,
327
+ 78903,
328
+ 78904,
329
+ 78905,
330
+ 78906,
331
+ 78907,
332
+ 78908,
333
+ 78909,
334
+ 78910,
335
+ 78911,
336
+ 113824,
337
+ 113825,
338
+ 113826,
339
+ 113827,
340
+ 119155,
341
+ 119156,
342
+ 119157,
343
+ 119158,
344
+ 119159,
345
+ 119160,
346
+ 119161,
347
+ 119162,
348
+ 917505,
349
+ 917536,
350
+ 917537,
351
+ 917538,
352
+ 917539,
353
+ 917540,
354
+ 917541,
355
+ 917542,
356
+ 917543,
357
+ 917544,
358
+ 917545,
359
+ 917546,
360
+ 917547,
361
+ 917548,
362
+ 917549,
363
+ 917550,
364
+ 917551,
365
+ 917552,
366
+ 917553,
367
+ 917554,
368
+ 917555,
369
+ 917556,
370
+ 917557,
371
+ 917558,
372
+ 917559,
373
+ 917560,
374
+ 917561,
375
+ 917562,
376
+ 917563,
377
+ 917564,
378
+ 917565,
379
+ 917566,
380
+ 917567,
381
+ 917568,
382
+ 917569,
383
+ 917570,
384
+ 917571,
385
+ 917572,
386
+ 917573,
387
+ 917574,
388
+ 917575,
389
+ 917576,
390
+ 917577,
391
+ 917578,
392
+ 917579,
393
+ 917580,
394
+ 917581,
395
+ 917582,
396
+ 917583,
397
+ 917584,
398
+ 917585,
399
+ 917586,
400
+ 917587,
401
+ 917588,
402
+ 917589,
403
+ 917590,
404
+ 917591,
405
+ 917592,
406
+ 917593,
407
+ 917594,
408
+ 917595,
409
+ 917596,
410
+ 917597,
411
+ 917598,
412
+ 917599,
413
+ 917600,
414
+ 917601,
415
+ 917602,
416
+ 917603,
417
+ 917604,
418
+ 917605,
419
+ 917606,
420
+ 917607,
421
+ 917608,
422
+ 917609,
423
+ 917610,
424
+ 917611,
425
+ 917612,
426
+ 917613,
427
+ 917614,
428
+ 917615,
429
+ 917616,
430
+ 917617,
431
+ 917618,
432
+ 917619,
433
+ 917620,
434
+ 917621,
435
+ 917622,
436
+ 917623,
437
+ 917624,
438
+ 917625,
439
+ 917626,
440
+ 917627,
441
+ 917628,
442
+ 917629,
443
+ 917630,
444
+ 917631
445
+ ],
446
+ "control_introducers": [
447
+ 27,
448
+ 128,
449
+ 129,
450
+ 130,
451
+ 131,
452
+ 132,
453
+ 133,
454
+ 134,
455
+ 135,
456
+ 136,
457
+ 137,
458
+ 138,
459
+ 139,
460
+ 140,
461
+ 141,
462
+ 142,
463
+ 143,
464
+ 144,
465
+ 145,
466
+ 146,
467
+ 147,
468
+ 148,
469
+ 149,
470
+ 150,
471
+ 151,
472
+ 152,
473
+ 153,
474
+ 154,
475
+ 155,
476
+ 156,
477
+ 157,
478
+ 158,
479
+ 159
480
+ ]
481
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "_comment": "Floor below which a configured env value is treated as a doc stub / placeholder rather than a real key, so a var set to a short test value ('fake', 'sk-test') never blanks unrelated output. ONE physical file backs both ecosystems, exactly like credential-names.json beside it: agent_sanitizer.secrets.config reads DEFAULT_MIN_SECRET_LEN from here, and claude-hooks/lib/env-config.mjs imports it for the Layer-4 pre-gate — there is no second copy to drift.",
3
+ "min_secret_len": 16
4
+ }
package/src/ansi.mjs CHANGED
@@ -25,13 +25,27 @@
25
25
  // DCS/SOS/PM/APC string the grammar does not consume still loses its introducer
26
26
  // and terminator, so no terminal can hide-render its body as a control payload.
27
27
  //
28
+ // The introducer set as DATA, so a non-JS consumer can share it: the generator
29
+ // (scripts/gen-invisible-charset.mjs) pins these code points into
30
+ // data/invisible-charset.json's `control_introducers`, which the Python port
31
+ // (python/agent_sanitizer/textstrip.py) sweeps. Before that, the Python side
32
+ // hand-wrote `\x1b` alone and the whole C1 block survived its strip — the exact
33
+ // fork this module's header says must not recur, one language over.
34
+ export const CONTROL_INTRODUCER_CODEPOINTS = Object.freeze([
35
+ 0x1b,
36
+ ...Array.from({ length: 0x9f - 0x80 + 1 }, (_, i) => 0x80 + i),
37
+ ]);
38
+
28
39
  // A SOURCE STRING, not a literal: three call sites need it with different flags
29
40
  // (`g` for the Layer-1 sweep, unflagged for the prompt gate, `g` again to drive
30
41
  // the scan below), and spelling the class out at each site is how the three
31
42
  // copies came to spell the same byte two different ways — which defeats a
32
- // grep-based drift check as well. Building from `\uXXXX` escapes keeps every
33
- // raw control byte out of the source (no `no-control-regex` disable needed).
34
- export const CONTROL_INTRODUCER_SOURCE = "[\\u001b\\u0080-\\u009f]";
43
+ // grep-based drift check as well. Derived from the code-point list above so the
44
+ // regex and the exported data cannot disagree; `\uXXXX` escapes keep every raw
45
+ // control byte out of the source (no `no-control-regex` disable needed).
46
+ export const CONTROL_INTRODUCER_SOURCE = `[${CONTROL_INTRODUCER_CODEPOINTS.map(
47
+ (cp) => `\\u${cp.toString(16).padStart(4, "0")}`,
48
+ ).join("")}]`;
35
49
 
36
50
  // SGR (Select Graphic Rendition): colors, bold, reset. The grammar is closed:
37
51
  // params are [0-9;:]* and the final byte is `m`, so a match can only restyle
package/src/index.mjs CHANGED
@@ -87,20 +87,26 @@ export {
87
87
  * to the four fields this entry promises — `modified`/`sgrNote`
88
88
  * describe the tool-output pipeline's banner, and `reveal` is produced only by
89
89
  * options this facade does not expose. `html` selects Layers 2 AND 3 together
90
- * here, which is the surface this entry has always had; `sanitizeText` takes
91
- * them as separate flags for the tool-output pipeline, which needs Layer 3's
92
- * detection without Layer 2's splice.
90
+ * here, which is the surface this entry has always had; `exfilScan` exposes
91
+ * Layer 3's non-destructive detection on its own (unconditionally implied by
92
+ * `html`, which it can add to but never switch off) for
93
+ * callers that must keep the visible bytes intact — e.g. a PR diff where the
94
+ * Layer-2 splice would corrupt legitimate markup — matching the separate
95
+ * flags `sanitizeText` takes for the tool-output pipeline.
93
96
  * @param {string} text
94
- * @param {{ html?: boolean } | null} [options]
97
+ * @param {{ html?: boolean, exfilScan?: boolean } | null} [options]
95
98
  * @returns {Promise<{ cleaned: string, found: string[], warnings: string[], notes: string[] }>}
96
99
  */
97
100
  export async function sanitize(text, options) {
98
101
  if (typeof text !== "string")
99
102
  throw new TypeError("sanitize(text, options): text must be a string");
100
- const { html = false } = options ?? {};
103
+ const { html = false, exfilScan = false } = options ?? {};
101
104
  const { cleaned, found, warnings, notes } = await sanitizeText(text, {
102
105
  html,
103
- exfilScan: html,
106
+ // `html` implies the scan unconditionally, and `exfilScan` can only ADD it:
107
+ // an opt-OUT would make `{ html: true, exfilScan: false }` splice Layer 2
108
+ // while silently dropping Layer 3's report — a fail-open the docs deny.
109
+ exfilScan: exfilScan || html,
104
110
  });
105
111
  return { cleaned, found, warnings, notes };
106
112
  }
@@ -450,11 +450,15 @@ export function atomicReplaceFile(
450
450
  /**
451
451
  * Strip payload-capable invisible characters from `absPath` in place. Returns
452
452
  * `true` when the file's bytes actually changed (a payload {@link scanText}
453
- * flags was removed), `false` when {@link scanText} reports nothing, and `null`
454
- * when scan flagged a payload but {@link stripInvisible} removes nothing a
455
- * fail-closed signal that the flagged run was PRESERVED (e.g. a well-formed
456
- * emoji-tag sequence the stripper keeps), so the caller must not treat it as
457
- * cleaned. `true` means and only means "bytes changed".
453
+ * flags was removed) and `false` when {@link scanText} reports nothing to
454
+ * strip. `true` means and only means "bytes changed", so a caller that flagged
455
+ * this file and gets `false` back must NOT record it as cleaned — the file
456
+ * changed under it, or the flagged run is one {@link stripInvisible}
457
+ * preserves (a well-formed emoji-tag sequence), and either way the payload it
458
+ * flagged is still there. There is no third return value: the `null` arm this
459
+ * doc once described was dropped as dead (`stripInvisible` cannot leave the
460
+ * bytes identical for anything `scanText` flags), and callers must branch on
461
+ * the boolean rather than testing against `null`, which is vacuously true.
458
462
  *
459
463
  * Contract (scan/clean coherence): clean strips exactly what scan flags. A
460
464
  * write happens ONLY when `scanText` reports a finding, so the "scan, then
package/types/ansi.d.mts CHANGED
@@ -55,7 +55,8 @@ export function scanAnsi(text: string): AnsiToken[];
55
55
  * Same precedent (and same reason) as `cf-charset.mjs`: a dependency-free leaf
56
56
  * module both layers read from.
57
57
  */
58
- export const CONTROL_INTRODUCER_SOURCE: "[\\u001b\\u0080-\\u009f]";
58
+ export const CONTROL_INTRODUCER_CODEPOINTS: readonly number[];
59
+ export const CONTROL_INTRODUCER_SOURCE: string;
59
60
  /**
60
61
  * Public alias kept for compatibility (re-exported by `invisible.mjs` and the
61
62
  * package root). It is now DERIVED: {@link scanAnsi} classifies a token as SGR
@@ -1,7 +1,16 @@
1
1
  /**
2
2
  * Regex matching `value` tolerating invisible chars spliced between its
3
3
  * characters (mirrors the engine's env-value regex). Code-point split so
4
- * an astral character is escaped whole, not as two surrogate halves.
4
+ * an astral character is escaped whole, not as two surrogate halves — and the
5
+ * `u` flag, which the astral `\u{…}` class members in {@link ENV_INVIS_RUN}
6
+ * require.
7
+ * Memoized per distinct value: {@link ENV_INVIS_RUN} renders ~435 code points
8
+ * as ~4 KB of source and is joined at EVERY interior gap, so a 20-char secret
9
+ * compiles a ~75 KB pattern — and `hasEnvBoundSecret` builds one per configured
10
+ * var on every PostToolUse output. Env values are stable for the process's
11
+ * lifetime, so the cache is bounded by the number of distinct values. Sharing an
12
+ * instance is safe because the regex carries no `g`/`y` flag, hence no
13
+ * `lastIndex` state to leak between calls.
5
14
  * @param {string} value
6
15
  * @returns {RegExp}
7
16
  */
@@ -72,18 +72,25 @@ export function cliMain(opts?: {
72
72
  scan?: () => ReturnType<typeof scanProject>;
73
73
  }): Promise<void>;
74
74
  /**
75
+ * Read one file and run the SSOT scan over it. The scan logic itself (long-run
76
+ * decode + scattered threshold-evasion counting) is `scanText`'s — a local
77
+ * mirror used to re-count scatter from the raw STRIP match count, silently
78
+ * re-growing the linguistic-joiner/VS15 false positive `scanText`'s carve-out
79
+ * counter had already fixed.
75
80
  * @param {string} filePath
76
- * @returns {Array<{ line: number, charCount: number, method: string, decoded: string }>}
81
+ * @returns {ReturnType<typeof import("agent-sanitizer/instructions").scanText>}
82
+ * `line` is 1-based, or `null` for the whole-file scattered-chars finding.
77
83
  */
78
- export function scanFile(filePath: string): Array<{
79
- line: number;
80
- charCount: number;
81
- method: string;
82
- decoded: string;
83
- }>;
84
+ export function scanFile(filePath: string): ReturnType<typeof import("agent-sanitizer/instructions").scanText>;
84
85
  import { CLAUDE_CONTEXT_SUBDIRS } from "../src/claude-context.mjs";
85
86
  import { CLAUDE_INSTRUCTION_GLOBS } from "../src/claude-context.mjs";
86
87
  /**
88
+ * The SSOT decoder, re-exported through a lazy-bound wrapper (the binding is
89
+ * `let` and may be re-bound by the cold-start reload, so the export must read
90
+ * it at call time). A hand-written twin used to live here; it decoded tag
91
+ * characters to RAW bytes — including actual C0 controls for U+E0001–U+E001F —
92
+ * with no `untrusted data, not instructions:` framing or escaping, so the
93
+ * hook's own report re-injected the hidden payload it had just caught.
87
94
  * @param {string} run
88
95
  * @returns {{ method: string, decoded: string }}
89
96
  */
@@ -116,14 +123,14 @@ export let TOTAL_INVISIBLE_THRESHOLD: 30;
116
123
  /**
117
124
  * @param {Array<{
118
125
  * file: string,
119
- * findings: Array<{ line: number, charCount: number, method: string, decoded: string }>,
126
+ * findings: Array<{ line: number | null, charCount: number, method: string, decoded: string }>,
120
127
  * }>} allFindings
121
128
  * @returns {string}
122
129
  */
123
130
  export function formatReport(allFindings: Array<{
124
131
  file: string;
125
132
  findings: Array<{
126
- line: number;
133
+ line: number | null;
127
134
  charCount: number;
128
135
  method: string;
129
136
  decoded: string;
package/types/index.d.mts CHANGED
@@ -24,15 +24,19 @@
24
24
  * to the four fields this entry promises — `modified`/`sgrNote`
25
25
  * describe the tool-output pipeline's banner, and `reveal` is produced only by
26
26
  * options this facade does not expose. `html` selects Layers 2 AND 3 together
27
- * here, which is the surface this entry has always had; `sanitizeText` takes
28
- * them as separate flags for the tool-output pipeline, which needs Layer 3's
29
- * detection without Layer 2's splice.
27
+ * here, which is the surface this entry has always had; `exfilScan` exposes
28
+ * Layer 3's non-destructive detection on its own (unconditionally implied by
29
+ * `html`, which it can add to but never switch off) for
30
+ * callers that must keep the visible bytes intact — e.g. a PR diff where the
31
+ * Layer-2 splice would corrupt legitimate markup — matching the separate
32
+ * flags `sanitizeText` takes for the tool-output pipeline.
30
33
  * @param {string} text
31
- * @param {{ html?: boolean } | null} [options]
34
+ * @param {{ html?: boolean, exfilScan?: boolean } | null} [options]
32
35
  * @returns {Promise<{ cleaned: string, found: string[], warnings: string[], notes: string[] }>}
33
36
  */
34
37
  export function sanitize(text: string, options?: {
35
38
  html?: boolean;
39
+ exfilScan?: boolean;
36
40
  } | null): Promise<{
37
41
  cleaned: string;
38
42
  found: string[];
@@ -100,11 +100,15 @@ export function atomicReplaceFile(absPath: string, data: string, mode: number, t
100
100
  /**
101
101
  * Strip payload-capable invisible characters from `absPath` in place. Returns
102
102
  * `true` when the file's bytes actually changed (a payload {@link scanText}
103
- * flags was removed), `false` when {@link scanText} reports nothing, and `null`
104
- * when scan flagged a payload but {@link stripInvisible} removes nothing a
105
- * fail-closed signal that the flagged run was PRESERVED (e.g. a well-formed
106
- * emoji-tag sequence the stripper keeps), so the caller must not treat it as
107
- * cleaned. `true` means and only means "bytes changed".
103
+ * flags was removed) and `false` when {@link scanText} reports nothing to
104
+ * strip. `true` means and only means "bytes changed", so a caller that flagged
105
+ * this file and gets `false` back must NOT record it as cleaned — the file
106
+ * changed under it, or the flagged run is one {@link stripInvisible}
107
+ * preserves (a well-formed emoji-tag sequence), and either way the payload it
108
+ * flagged is still there. There is no third return value: the `null` arm this
109
+ * doc once described was dropped as dead (`stripInvisible` cannot leave the
110
+ * bytes identical for anything `scanText` flags), and callers must branch on
111
+ * the boolean rather than testing against `null`, which is vacuously true.
108
112
  *
109
113
  * Contract (scan/clean coherence): clean strips exactly what scan flags. A
110
114
  * write happens ONLY when `scanText` reports a finding, so the "scan, then