agent-sanitizer 2.66.7 → 2.67.0

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
@@ -51,18 +51,20 @@ Split into subpaths so the heavy HTML dependency stays opt-in. **Seam** names
51
51
  the callback you inject for the agent-specific concern; `—` is a pure transform,
52
52
  `fs (direct)` does its own file I/O instead of taking one.
53
53
 
54
- | Layer | Import | Purpose | Seam |
55
- | ----- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- |
56
- | 1 | `/invisible` | Strip zero-width, bidi, variation-selector and tag chars + ANSI/SGR escapes. Preserves ZWNJ/ZWJ for Arabic/Indic/emoji. Zero deps. | — |
57
- | 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. | — |
58
- | 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. | — |
59
- | 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. | — |
60
- | — | `/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) |
61
- | — | `/instructions` | Scan/auto-clean `CLAUDE.md`, `AGENTS.md`, `SKILL.md`, etc., decoding Unicode-tag + zero-width-binary payloads. | `fs` (direct) |
62
- | — | `/prompt` | Classify a prompt pass / note / block on payload-capable invisible/ANSI content (inert escapes get the note). | — |
63
- | 1–5 | `/output` | Run Layers 1–4 over structured tool output, preserving shape. The Layer-5 slot takes a delete-only filter. | `redact`, `filterInjection` |
64
- | — | `/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` |
65
- | — | `/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`. | — |
54
+ | Layer | Import | Purpose | Seam |
55
+ | ----- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- |
56
+ | 1 | `/invisible` | Strip zero-width, bidi, variation-selector and tag chars + ANSI/SGR escapes. Preserves ZWNJ/ZWJ for Arabic/Indic/emoji. Zero deps. | — |
57
+ | 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. | — |
58
+ | 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. | — |
59
+ | 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. | — |
60
+ | — | `/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) |
61
+ | — | `/instructions` | Scan/auto-clean `CLAUDE.md`, `AGENTS.md`, `SKILL.md`, etc., decoding Unicode-tag + zero-width-binary payloads. | `fs` (direct) |
62
+ | — | `/prompt` | Classify a prompt pass / note / block on payload-capable invisible/ANSI content (inert escapes get the note). | — |
63
+ | 1–5 | `/output` | Run Layers 1–4 over structured tool output, preserving shape. The Layer-5 slot takes a delete-only filter. | `redact`, `filterInjection` |
64
+ | — | `/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` |
65
+ | — | `/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`. | — |
66
+ | — | `/credential-names-matcher` | Build an env-var NAME matcher from the credential-noun vocabulary: `credentialNameMatcher({ scope })` for a redactor (`"trailing"`) or an env scrub (`"any-segment"`), linear in the name's length. | — |
67
+ | — | `/credential-names` | The credential-noun vocabulary itself, as JSON — the same file the Python package reads. | — |
66
68
 
67
69
  See [`THREAT-MODEL.md`](./THREAT-MODEL.md) for per-vector detail.
68
70
 
@@ -471,12 +473,18 @@ import {
471
473
  scanInstructionFiles,
472
474
  cleanFile,
473
475
  } from "agent-sanitizer/instructions";
474
- const findings = scanInstructionFiles(["CLAUDE.md", "**/SKILL.md"], {
475
- cwd: projectDir,
476
- // Skip what git says is not this checkout's source: ignored directories and
477
- // nested worktrees. Ignored FILES (CLAUDE.local.md) are still scanned.
478
- exclude: contextScanExclude(projectDir),
479
- });
476
+ const { findings, skipped } = scanInstructionFiles(
477
+ ["CLAUDE.md", "**/SKILL.md"],
478
+ {
479
+ cwd: projectDir,
480
+ // Skip what git says is not this checkout's source: ignored directories and
481
+ // nested worktrees. Ignored FILES (CLAUDE.local.md) are still scanned.
482
+ exclude: contextScanExclude(projectDir),
483
+ },
484
+ );
485
+ // `skipped` lists files that exist but could not be read ({ file, reason }).
486
+ // Treat them as unvetted: they were never checked.
487
+ if (skipped.length) console.warn("not scanned:", skipped);
480
488
  // `root` is required: a rewrite whose realpath leaves it throws instead.
481
489
  for (const { file } of findings)
482
490
  cleanFile(`${projectDir}/${file}`, { root: projectDir });
@@ -17,7 +17,7 @@
17
17
  * sanitize { text, html? } -> { cleaned, found, warnings, notes, splices? }
18
18
  * sanitizeText { text, html?, exfilScan?, flagDigestValues? } -> { cleaned, warnings, notes, modified, sgrNote }
19
19
  * classifyPrompt { text } -> { action, reason? }
20
- * scanInstructionFiles { globs, cwd? } -> { findings: [{ file, findings }] }
20
+ * scanInstructionFiles { globs, cwd? } -> { findings: [{ file, findings }], skipped: [{ file, reason }] }
21
21
  * Skips `node_modules`, the directories git ignores wholesale, and any
22
22
  * nested worktree — none of them is the scanned checkout's own source.
23
23
  * cleanFile { path, root? } -> { changed }
@@ -166,12 +166,10 @@ export const OPS = {
166
166
  // Whole-tree scope: a gitignored build directory and a nested worktree are
167
167
  // not this checkout's source, so a finding from one names a file the caller
168
168
  // does not own — and `cleanFile` would rewrite it.
169
- return {
170
- findings: scanInstructionFiles(globs, {
171
- cwd,
172
- exclude: contextScanExclude(cwd),
173
- }),
174
- };
169
+ return scanInstructionFiles(globs, {
170
+ cwd,
171
+ exclude: contextScanExclude(cwd),
172
+ });
175
173
  },
176
174
 
177
175
  /** @param {Record<string, unknown>} req */
@@ -53,6 +53,10 @@
53
53
  */
54
54
  import { lazyImport } from "./hook-io.mjs";
55
55
  import { readKnob } from "./knobs.mjs";
56
+ import {
57
+ FILE_WRITE_TOOLS,
58
+ parseFieldSpec,
59
+ } from "../../src/file-write-tools.mjs";
56
60
 
57
61
  // Bound via lazyImport (see its doc for the fail-OPEN hazard of a bare static
58
62
  // npm import — here the load crash would fire inside pretooluse-sanitize.mjs's
@@ -68,25 +72,24 @@ const { payloadVerdict, stripInvisibleWithReport } =
68
72
  await lazyImport("agent-sanitizer/invisible")
69
73
  );
70
74
 
71
- // Content fields the model authors, per tool. Paths and confusables are the
72
- // confusable layer's domain; here we target the free-text fields that carry
73
- // model-authored prose / code / data out into persisted or displayed artifacts.
74
- // A "key[].sub" entry addresses `sub` on every element of the array at `key`
75
- // (MultiEdit batches its writes as edits[].new_string), so the nested authored
76
- // content is sanitized too — not just the top-level fields.
77
- //
78
- // Null-prototype: `tool` comes from the payload, and on a plain object literal
79
- // `FIELDS["constructor"]` answers a truthy inherited value that the field loop
80
- // below would then try to iterate.
81
- /** @type {Record<string, string[]>} */
75
+ // Content fields the model authors, per tool: every file-write tool's
76
+ // `authored` fields from the one table, plus Bash's command. Paths and
77
+ // confusables are the confusable layer's domain; here we target the free-text
78
+ // fields that carry model-authored prose / code / data out into persisted or
79
+ // displayed artifacts. Null-prototype: `tool` comes from the payload, and on a
80
+ // plain object `FIELDS["constructor"]` answers a truthy inherited value.
81
+ /** @type {Readonly<Record<string, readonly string[]>>} */
82
82
  export const AUTHORED_FIELDS = Object.freeze(
83
- Object.assign(Object.create(null), {
84
- Write: ["content"],
85
- Edit: ["new_string"],
86
- MultiEdit: ["edits[].new_string"],
87
- NotebookEdit: ["new_source"],
88
- Bash: ["command"],
89
- }),
83
+ Object.assign(
84
+ Object.create(null),
85
+ Object.fromEntries(
86
+ Object.entries(FILE_WRITE_TOOLS).map(([tool, { authored }]) => [
87
+ tool,
88
+ authored,
89
+ ]),
90
+ ),
91
+ { Bash: Object.freeze(["command"]) },
92
+ ),
90
93
  );
91
94
 
92
95
  // The other half of the partition: tools this layer has LOOKED AT and decided
@@ -131,7 +134,7 @@ export const EXEMPT_TOOL_PATTERNS = Object.freeze([
131
134
  * fatigue, and the doctrine here is precision over recall. The signal is the
132
135
  * partition test, which reads this function.
133
136
  * @param {string} tool
134
- * @returns {{ kind: "covered", fields: string[] } | { kind: "exempt", reason: string } | { kind: "undeclared" }}
137
+ * @returns {{ kind: "covered", fields: readonly string[] } | { kind: "exempt", reason: string } | { kind: "undeclared" }}
135
138
  */
136
139
  export function authoredScopeDecision(tool) {
137
140
  const fields = AUTHORED_FIELDS[tool];
@@ -279,12 +282,9 @@ export function sanitizeAuthoredContent(tool, toolInput) {
279
282
  // every own field (including a literal `__proto__`) as a plain own property.
280
283
  const updatedInput = Object.assign(Object.create(null), toolInput);
281
284
  for (const k of keys) {
282
- // Named groups satisfy prefer-named-capture-group; reading the numeric
283
- // indices keeps the values typed as string (match.groups is optional).
284
- const nested = k.match(/^(?<arr>\w+)\[\]\.(?<sub>\w+)$/);
285
- if (nested) {
286
- const arrKey = nested[1];
287
- const subKey = nested[2];
285
+ const spec = parseFieldSpec(k);
286
+ if ("arr" in spec) {
287
+ const { arr: arrKey, sub: subKey } = spec;
288
288
  const arr = toolInput[arrKey];
289
289
  if (!Array.isArray(arr)) continue;
290
290
  let nestedChanged = false;
@@ -9,7 +9,9 @@
9
9
  * set of credential-bearing variable names. A rendered second copy is what let
10
10
  * this matcher fall twelve segments behind the engine.
11
11
  *
12
- * The JSON configs are imported as modules, not read from disk at call time:
12
+ * The vocabulary comes from `credentialNames()`, the package's one loader of
13
+ * that file. The JSON configs below are imported as modules, not read from disk
14
+ * at call time:
13
15
  * esbuild inlines them into the plugin bundle (which ships with no config
14
16
  * directory beside it) and Node resolves them natively from the package when the
15
17
  * hooks run from source. Their VALIDATION stays lazy — a malformed credential
@@ -21,7 +23,6 @@
21
23
  // engine alias and an external consumer resolves the same specifier.
22
24
  import { credentialNameMatcher } from "agent-sanitizer/credential-names-matcher";
23
25
 
24
- import credentialNames from "../../python/agent_sanitizer/secrets/data/credential-names.json" with { type: "json" };
25
26
  import redactionFloor from "../../python/agent_sanitizer/secrets/data/redaction-floor.json" with { type: "json" };
26
27
  import inferenceKeys from "../config/inference-key-vars.json" with { type: "json" };
27
28
  import scrubbed from "../config/scrubbed-env-vars.json" with { type: "json" };
@@ -155,7 +156,6 @@ let _credentialRule;
155
156
  function credentialRule() {
156
157
  if (_credentialRule !== undefined) return _credentialRule;
157
158
  return (_credentialRule = credentialNameMatcher({
158
- spec: credentialNames,
159
159
  scope: "trailing",
160
160
  declineNonSecret: true,
161
161
  }));
@@ -17,6 +17,7 @@ import { createHash } from "node:crypto";
17
17
  import { tmpdir } from "node:os";
18
18
  import { isAbsolute, join } from "node:path";
19
19
  import { pathToFileURL } from "node:url";
20
+ import { errMessage } from "../../src/err-message.mjs";
20
21
  import { excludeProvisioning } from "./hook-timing.mjs";
21
22
  import { readKnob } from "./knobs.mjs";
22
23
 
@@ -637,18 +638,9 @@ export function scrubUntrustedText(raw, layer1, cap = UNTRUSTED_TEXT_CAP) {
637
638
  : cleaned;
638
639
  }
639
640
 
640
- /**
641
- * Message from a caught value, which is `unknown` under strict mode. Appends
642
- * the cause chain (one level) when the cause is itself an Error so callers
643
- * get "outer: root" instead of just "outer" when an error wraps another.
644
- * @param {unknown} err
645
- * @returns {string}
646
- */
647
- export function errMessage(err) {
648
- if (!(err instanceof Error)) return String(err);
649
- const cause = err.cause instanceof Error ? `: ${err.cause.message}` : "";
650
- return err.message + cause;
651
- }
641
+ // Published from this module as part of the hooks' I/O surface; defined once
642
+ // beside the library that renders the same failures.
643
+ export { errMessage };
652
644
 
653
645
  /**
654
646
  * errMessage() for an error whose message may embed attacker-chosen bytes: V8
@@ -15,6 +15,7 @@ import {
15
15
  UNPARSEABLE_PLACEHOLDER,
16
16
  } from "../../src/layer2-placeholder.mjs";
17
17
  import { spanPath, revealDir } from "./reveal.mjs";
18
+ import { isFileWriteTool } from "../../src/file-write-tools.mjs";
18
19
 
19
20
  // Re-exported under this module's published names: consumers of the hooks
20
21
  // surface import the Layer-2 grammar from here.
@@ -70,16 +71,6 @@ export function layer2KeysIn(value, depth = 0) {
70
71
  return [...keys];
71
72
  }
72
73
 
73
- // Tools whose inputs the rehydration layer itself resolves (or, for
74
- // MultiEdit/NotebookEdit, refuses with guidance). Both advisories stay silent
75
- // on these: their placeholder handling is a verdict, not a note.
76
- export const REHYDRATED_TOOLS = new Set([
77
- "Edit",
78
- "Write",
79
- "MultiEdit",
80
- "NotebookEdit",
81
- ]);
82
-
83
74
  /**
84
75
  * Depth-capped walk: does any string in `value` carry secret-placeholder-shaped
85
76
  * text? The cap fails OPEN (deeper content is unseen) — every caller feeds a
@@ -197,7 +188,9 @@ function tokenList(found) {
197
188
  * @returns {string | null}
198
189
  */
199
190
  export function placeholderNotice(tool, toolInput) {
200
- if (REHYDRATED_TOOLS.has(tool)) return null;
191
+ // The rehydration layer resolves (or refuses) a file-write tool's
192
+ // placeholders itself: there the handling is a verdict, not a note.
193
+ if (isFileWriteTool(tool)) return null;
201
194
  const { secret } = collectPlaceholders(toolInput);
202
195
  if (secret.length === 0) return null;
203
196
  return (
@@ -233,7 +226,9 @@ export function placeholderNotice(tool, toolInput) {
233
226
  * @returns {string | null}
234
227
  */
235
228
  export function layer2PlaceholderNotice(tool, toolInput) {
236
- if (REHYDRATED_TOOLS.has(tool)) return null;
229
+ // The rehydration layer resolves (or refuses) a file-write tool's
230
+ // placeholders itself: there the handling is a verdict, not a note.
231
+ if (isFileWriteTool(tool)) return null;
237
232
  const { layer2 } = collectPlaceholders(toolInput);
238
233
  if (layer2.length === 0) return null;
239
234
  // Both routes can be named at once: an input can mix keyed placeholders
@@ -37,6 +37,7 @@ import {
37
37
  } from "./redactor-wire.mjs";
38
38
  import {
39
39
  currentUid,
40
+ errMessage,
40
41
  isPrivateDir,
41
42
  ownedByCurrentUser,
42
43
  privateDirAt,
@@ -200,9 +201,8 @@ function isRespawnable(err) {
200
201
  * @returns {Error}
201
202
  */
202
203
  function failClosed(cause) {
203
- const detail = cause instanceof Error ? cause.message : String(cause);
204
204
  return new Error(
205
- `secret redaction unavailable (${detail}); secret-shaped output could not be vetted`,
205
+ `secret redaction unavailable (${errMessage(cause)}); secret-shaped output could not be vetted`,
206
206
  );
207
207
  }
208
208
 
@@ -94,7 +94,7 @@ const ENV_VALUE_REGEX_CACHE = new Map();
94
94
  */
95
95
  export function hasEnvBoundSecret(text, env = process.env) {
96
96
  const minLen = minEnvSecretLen();
97
- return envBoundSecretVars().some((name) => {
97
+ return envBoundSecretVars(env).some((name) => {
98
98
  const value = env[name];
99
99
  // Code-point length to match envValueRegex's code-point split — an astral
100
100
  // char counts once, not as two UTF-16 units, so minLen means the same thing
@@ -49,6 +49,9 @@ import {
49
49
  defaultOpen,
50
50
  } from "./lib/hook-fault.mjs";
51
51
  import { runLayerPipeline } from "./lib/layer-pipeline.mjs";
52
+ // Relative, like src/claude-context.mjs: the fault path reads this table and
53
+ // must work when the agent-sanitizer package failed to load.
54
+ import { isFileWriteTool } from "../src/file-write-tools.mjs";
52
55
  import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
53
56
  import {
54
57
  invisibleCharAlert,
@@ -764,18 +767,6 @@ export function failClosedFields(parsedOk, err, opts = {}) {
764
767
  // DEFAULT_HINT) failed to load. Exported so test/claude-hooks-fail-open.test.mjs
765
768
  // can pin the two spellings together.
766
769
  export const REDACTION_HINT = "[REDACTED";
767
- // The file-editing tools whose input fields ARE the bytes persisted to disk.
768
- // Deliberately NOT exhaustive over every clobber path: Bash can also write a
769
- // placeholder to disk (`>`, `tee`, `sed -i`, a heredoc), but command strings
770
- // mention "[REDACTED" benignly far too often — grepping for it, discussing
771
- // it — for an ask to hold precision there. That is an accepted gap, named in
772
- // THREAT-MODEL.md's carve-out paragraph, not a completeness claim.
773
- export const WRITE_SHAPED_TOOLS = new Set([
774
- "Write",
775
- "Edit",
776
- "MultiEdit",
777
- "NotebookEdit",
778
- ]);
779
770
 
780
771
  /**
781
772
  * True when a faulting PreToolUse call is the one case the OPEN posture must
@@ -786,8 +777,13 @@ export const WRITE_SHAPED_TOOLS = new Set([
786
777
  * view; with the sanitizer down, rehydration cannot translate it back, so
787
778
  * letting the call through would persist the literal placeholder text over the
788
779
  * real secret on disk — a destructive clobber, not a missed scan.
789
- * Package-free by construction (a Set lookup and a walk of the already-parsed
780
+ * Package-free by construction (a table lookup and a walk of the already-parsed
790
781
  * payload), so it holds when the failure IS the missing package.
782
+ *
783
+ * Deliberately NOT exhaustive over every clobber path: Bash can also write a
784
+ * placeholder to disk (`>`, `tee`, `sed -i`, a heredoc), but command strings
785
+ * mention "[REDACTED" benignly far too often for an ask to hold precision
786
+ * there. That is an accepted gap, named in THREAT-MODEL.md's carve-out.
791
787
  * @param {unknown} input raw parsed PreToolUse payload (undefined if unparsed)
792
788
  * @returns {boolean}
793
789
  */
@@ -797,7 +793,7 @@ export function hintedWriteFault(input) {
797
793
  input
798
794
  );
799
795
  return (
800
- WRITE_SHAPED_TOOLS.has(/** @type {string} */ (payload?.tool_name)) &&
796
+ isFileWriteTool(payload?.tool_name) &&
801
797
  containsPlaceholder(payload?.tool_input)
802
798
  );
803
799
  }
@@ -69,6 +69,7 @@ import {
69
69
  CLAUDE_CONTEXT_SUBDIRS,
70
70
  CLAUDE_INSTRUCTION_GLOBS,
71
71
  CLAUDE_LAUNCH_GLOBS,
72
+ classifyReadFailure,
72
73
  displayUntrusted,
73
74
  isInsideDir,
74
75
  } from "../src/claude-context.mjs";
@@ -219,12 +220,11 @@ function decodeRun(run) {
219
220
  return instrDecodeRun(run);
220
221
  }
221
222
 
222
- // Target discovery stays hook-local glue, NOT a copy of the SSOT's
223
- // containment-checked `findInstructionFiles`: the two have different contracts.
224
- // The SSOT finder drops every target it cannot resolve, which is right for a
225
- // pure scan API; this hook must instead bucket it (see classifyReadFailure).
226
- // The write-side symlink hazard the SSOT finder guards against is covered here
227
- // by cleanFile's own root containment and O_NOFOLLOW open.
223
+ // Target discovery stays hook-local glue, NOT a call to the library's
224
+ // `findInstructionFiles`: this hook walks the bounded LAUNCH scope, the library
225
+ // the whole-tree one. Both bucket an unreadable target through
226
+ // classifyReadFailure. The write-side symlink hazard is covered by cleanFile's
227
+ // own root containment and O_NOFOLLOW open.
228
228
 
229
229
  /**
230
230
  * Every file Claude Code loads as model context AT LAUNCH: `dir`'s own
@@ -286,35 +286,6 @@ export {
286
286
 
287
287
  // Main (skip when imported for testing)
288
288
 
289
- /**
290
- * PROBLEM CLASS — how a failed instruction-file read is classified. Every
291
- * consumer reads this one bucketing; nothing re-derives it from an errno.
292
- *
293
- * The scan is the only thing between a poisoned `CLAUDE.md` and a session that
294
- * loads it as instructions, and its caller announces "clean" on the trace
295
- * channel, whose whole purpose is that a MISSING announcement is loud. So a
296
- * read failure is never swallowed into an empty findings list — that turns "we
297
- * could not read this file" into "this file is fine". Three buckets:
298
- *
299
- * - SKIPPED — the file exists and this uid cannot read it (EACCES, EISDIR,
300
- * ELOOP…). Unvetted context: reported to the operator and it arms the gate.
301
- * - ABSENT — ENOENT. The path resolves to nothing, and Claude Code loads
302
- * instruction files through the same open, so no bytes can reach the model.
303
- * Announced on the trace channel only; naming a risk that does not exist
304
- * teaches the operator to dismiss the gate.
305
- * - THROWN — no errno at all, i.e. a bug (a TypeError from an unloaded
306
- * binding). Nothing here can be trusted, so it goes to the caller's
307
- * declared failure posture. Same errno-vs-bug split {@link
308
- * autoCleanFindings} uses.
309
- * @param {unknown} err
310
- * @returns {"absent" | "skipped"} never returns for a non-errno throw
311
- */
312
- function classifyReadFailure(err) {
313
- const code = /** @type {NodeJS.ErrnoException} */ (err).code;
314
- if (code === undefined) throw err;
315
- return code === "ENOENT" ? "absent" : "skipped";
316
- }
317
-
318
289
  /**
319
290
  * Scan every instruction file under the project, bucketing each unreadable
320
291
  * target through {@link classifyReadFailure}. `scanned` is DERIVED from the two
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.66.7",
3
+ "version": "2.67.0",
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": {
@@ -250,6 +250,28 @@ export function displayUntrusted(text) {
250
250
  );
251
251
  }
252
252
 
253
+ /**
254
+ * PROBLEM CLASS — how a failed instruction-file read is classified. Every
255
+ * scanner reads this one bucketing; nothing re-derives it from an errno.
256
+ *
257
+ * A read failure is never swallowed into an empty findings list — that turns
258
+ * "we could not read this file" into "this file is fine". Three buckets:
259
+ *
260
+ * - SKIPPED — the file exists and this uid cannot read it (EACCES, EISDIR,
261
+ * ELOOP…). Unvetted context, which the caller must report.
262
+ * - ABSENT — ENOENT. The path resolves to nothing, and the host loads
263
+ * instruction files through the same open, so no bytes can reach the model.
264
+ * - THROWN — no errno at all, i.e. a bug. Nothing can be trusted, so the
265
+ * error propagates to the caller's failure posture.
266
+ * @param {unknown} err
267
+ * @returns {"absent" | "skipped"} never returns for a non-errno throw
268
+ */
269
+ export function classifyReadFailure(err) {
270
+ const code = /** @type {NodeJS.ErrnoException} */ (err).code;
271
+ if (code === undefined) throw err;
272
+ return code === "ENOENT" ? "absent" : "skipped";
273
+ }
274
+
253
275
  /**
254
276
  * The one directory no instruction-file walk ever descends into. Its own
255
277
  * function so the name is spelled once, and so the two predicates that need it
@@ -34,20 +34,15 @@
34
34
  * — the same file the Python renderings read and the same file the
35
35
  * `agent-sanitizer/credential-names` subpath exports, so the ecosystems cannot
36
36
  * drift apart on the words either.
37
+ *
38
+ * PROBLEM CLASS — the vocabulary loaded by more than one reader. This module is
39
+ * the ONE JavaScript loader: a static JSON import, which a bundler inlines into
40
+ * every bundle that reaches it (the plugin ships no `python/` directory, so a
41
+ * module-relative file read there finds nothing). Every consumer — the secret
42
+ * pre-gate, the hooks' env-var matcher — calls {@link credentialNames} instead
43
+ * of importing the file again.
37
44
  */
38
- import { readFileSync } from "node:fs";
39
- import { dirname, join } from "node:path";
40
- import { fileURLToPath } from "node:url";
41
-
42
- const DATA_FILE = join(
43
- dirname(fileURLToPath(import.meta.url)),
44
- "..",
45
- "python",
46
- "agent_sanitizer",
47
- "secrets",
48
- "data",
49
- "credential-names.json",
50
- );
45
+ import packagedSpec from "../python/agent_sanitizer/secrets/data/credential-names.json" with { type: "json" };
51
46
 
52
47
  const FILE_LABEL = "credential-names.json";
53
48
  const ENV_NAME_USE = "env-name";
@@ -158,15 +153,14 @@ export function parseCredentialNames(spec) {
158
153
  let _packaged;
159
154
  /** The validated renderings of the packaged vocabulary, memoized.
160
155
  *
161
- * Read lazily, never at module load: a static importer that crashed at LOAD would
162
- * abort before its own fail-closed catch installs, and a guardrail that fails to
163
- * load is a guardrail that fails OPEN. Deferring to first use routes a missing or
164
- * corrupt data file into the caller's catch instead.
156
+ * The data arrives with the module (the one static import above, so bundles
157
+ * carry it), but it is VALIDATED lazily, never at module load: a malformed
158
+ * vocabulary then throws on first use, inside the caller's fail-closed catch. A
159
+ * throw at load would abort before that catch installs, and a guardrail that
160
+ * fails to load is a guardrail that fails OPEN.
165
161
  * @returns {CredentialNames} */
166
162
  export function credentialNames() {
167
- return (_packaged ??= parseCredentialNames(
168
- JSON.parse(readFileSync(DATA_FILE, "utf8")),
169
- ));
163
+ return (_packaged ??= parseCredentialNames(packagedSpec));
170
164
  }
171
165
 
172
166
  /** @typedef {"trailing" | "any-segment"} CredentialNameScope */
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The message of a caught value, which is `unknown` under strict mode.
3
+ *
4
+ * PROBLEM CLASS — rendering a caught error as text. The library's warnings and
5
+ * every hook's failure reason read this one definition, so a wrapped failure
6
+ * reads "outer: root" everywhere rather than "outer" in some copies.
7
+ * Dependency-free: the hooks import it relatively and must keep it when the
8
+ * agent-sanitizer package failed to load.
9
+ */
10
+
11
+ /**
12
+ * Message from a caught value, with one level of cause chain appended when the
13
+ * cause is itself an Error.
14
+ * @param {unknown} err
15
+ * @returns {string}
16
+ */
17
+ export function errMessage(err) {
18
+ if (!(err instanceof Error)) return String(err);
19
+ const cause = err.cause instanceof Error ? `: ${err.cause.message}` : "";
20
+ return err.message + cause;
21
+ }