agent-sanitizer 2.66.6 → 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.
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The file-editing tools whose input fields are the bytes persisted to disk, as
3
+ * ONE table, and the reader for the field specs it holds.
4
+ *
5
+ * PROBLEM CLASS — the set of file-writing tools restated per consumer. The
6
+ * fault carve-out that refuses a placeholder-bearing write, the advisories that
7
+ * stay silent on rehydrated tools, the authored-content sanitizer and the
8
+ * rehydration layer each kept their own list. A tool added to one list (a new
9
+ * write-shaped tool, or a renamed field) left the others silently stale, which
10
+ * is a placeholder persisted over a secret on disk. Every consumer reads this
11
+ * table instead.
12
+ *
13
+ * Package-free and dependency-free by construction: the PreToolUse fault path
14
+ * imports it relatively and must work exactly when the agent-sanitizer package
15
+ * failed to load.
16
+ *
17
+ * Each entry:
18
+ * - `authored` — the fields the model writes, which Layer 3 sanitizes.
19
+ * - `placeholders` — every field a redaction placeholder can sit in.
20
+ * - `rehydrates` — whether the rehydration layer re-anchors the call onto
21
+ * disk bytes (`false`: it refuses a placeholder-bearing call instead).
22
+ *
23
+ * A field spec is `key`, or `key[].sub` for `sub` on every element of the
24
+ * array at `key` (MultiEdit batches its writes as `edits[]`).
25
+ */
26
+
27
+ /** @typedef {{ authored: readonly string[], placeholders: readonly string[], rehydrates: boolean }} FileWriteTool */
28
+
29
+ /**
30
+ * Null-prototype: the tool name comes from an untrusted payload, and on a plain
31
+ * object literal `TABLE["constructor"]` answers a truthy inherited value.
32
+ * @type {Readonly<Record<string, FileWriteTool>>}
33
+ */
34
+ export const FILE_WRITE_TOOLS = Object.freeze(
35
+ Object.assign(Object.create(null), {
36
+ Write: Object.freeze({
37
+ authored: Object.freeze(["content"]),
38
+ placeholders: Object.freeze(["content"]),
39
+ rehydrates: true,
40
+ }),
41
+ Edit: Object.freeze({
42
+ authored: Object.freeze(["new_string"]),
43
+ placeholders: Object.freeze(["old_string", "new_string"]),
44
+ rehydrates: true,
45
+ }),
46
+ MultiEdit: Object.freeze({
47
+ authored: Object.freeze(["edits[].new_string"]),
48
+ placeholders: Object.freeze(["edits[].old_string", "edits[].new_string"]),
49
+ rehydrates: true,
50
+ }),
51
+ NotebookEdit: Object.freeze({
52
+ authored: Object.freeze(["new_source"]),
53
+ placeholders: Object.freeze(["new_source"]),
54
+ rehydrates: false,
55
+ }),
56
+ }),
57
+ );
58
+
59
+ /**
60
+ * True when `tool` is one of {@link FILE_WRITE_TOOLS}.
61
+ * @param {unknown} tool
62
+ * @returns {boolean}
63
+ */
64
+ export function isFileWriteTool(tool) {
65
+ return typeof tool === "string" && FILE_WRITE_TOOLS[tool] !== undefined;
66
+ }
67
+
68
+ const NESTED_SPEC = /^(?<arr>\w+)\[\]\.(?<sub>\w+)$/u;
69
+
70
+ /**
71
+ * A field spec, parsed: `{ key }` for a top-level field, or `{ arr, sub }` for
72
+ * `sub` on every element of the array at `arr`.
73
+ * @param {string} spec
74
+ * @returns {{ key: string } | { arr: string, sub: string }}
75
+ */
76
+ export function parseFieldSpec(spec) {
77
+ const nested = NESTED_SPEC.exec(spec);
78
+ if (!nested?.groups) return { key: spec };
79
+ return { arr: nested.groups.arr, sub: nested.groups.sub };
80
+ }
81
+
82
+ /**
83
+ * Every value the field `spec` addresses in `input`: one for a top-level
84
+ * field, one per array element for a nested one. `null` when the array a
85
+ * nested spec names is absent, not an array, or empty.
86
+ * @param {string} spec
87
+ * @param {any} input
88
+ * @returns {unknown[] | null}
89
+ */
90
+ export function fieldValues(spec, input) {
91
+ const parsed = parseFieldSpec(spec);
92
+ if ("key" in parsed) return [input?.[parsed.key]];
93
+ const arr = input?.[parsed.arr];
94
+ if (!Array.isArray(arr) || arr.length === 0) return null;
95
+ return arr.map((/** @type {any} */ element) => element?.[parsed.sub]);
96
+ }
package/src/gates.mjs CHANGED
@@ -5,13 +5,14 @@
5
5
  * These are pulled out of `html.mjs` so the package root can re-export them
6
6
  * without dragging in the heavy remark/rehype/unified graph: a static
7
7
  * `export … from "./html.mjs"` would eagerly evaluate that ~200ms module on
8
- * every root import, defeating the lazy-load design. This module imports
9
- * nothing, so re-exporting it is free.
8
+ * every root import, defeating the lazy-load design. This module imports only
9
+ * the dependency-free credential vocabulary, so re-exporting it is free.
10
10
  *
11
11
  * INVARIANT: every gate is a SUPERSET of its parser (a narrower one silently
12
12
  * turns its layer off; a needless fire only costs a parse), checked by
13
13
  * test/gates-superset.test.mjs.
14
14
  */
15
+ import { credentialNames } from "./credential-names.mjs";
15
16
 
16
17
  // ─── Cheap pre-gates ─────────────────────────────────────────────────────────
17
18
 
@@ -117,17 +118,43 @@ export const SECRET_HINT =
117
118
  export const SECRET_HINT_EXT =
118
119
  /(?:AC|SK)[a-z0-9]{32}|SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}|sq0csp-[0-9A-Za-z_-]{43}|(?<![0-9])[0-9]{8,10}:[0-9A-Za-z_-]{35}|(?<![0-9a-z])[0-9a-z]{32}-us[0-9]{1,2}|(?<![A-Za-z0-9_-])[MNO][A-Za-z0-9_-]{23,25}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27}|T3BlbkFJ|pypi-AgE|(?<![A-Za-z0-9])AKC[A-Za-z0-9]{10}|(?<![A-Za-z0-9])AP[0-9A-Fa-f][A-Za-z0-9]{8}|:\/\/[^\s:/@]{1,64}:[^\s:/@]{1,64}@|(?:key|pw|pass)["']?[\s:=>]+["']?[A-Za-z0-9_/+-]{20}/i;
119
120
 
121
+ /** @type {RegExp | undefined} */
122
+ let _fieldNameHint;
120
123
  /**
121
- * Either alternation against `text` exactly as written.
124
+ * Every noun the redactor cuts as `<noun> = <value>`, rendered from the same
125
+ * vocabulary the redactor reads.
126
+ *
127
+ * PROBLEM CLASS — a secret pre-gate narrower than the redactor behind it. A
128
+ * hand-written keyword list misses each noun added to the vocabulary later
129
+ * (`passphrase = …` reached the model unredacted), so this arm is generated
130
+ * from credential-names.json and is a superset of the field-value redactor by
131
+ * construction. Each part is `[a-z0-9]+`, so the alternation holds only
132
+ * bounded literals and stays linear. Built on first use, never at load: a
133
+ * malformed vocabulary then throws inside the caller's fail-closed catch.
134
+ * @returns {RegExp}
135
+ */
136
+ function fieldNameHint() {
137
+ return (_fieldNameHint ??= new RegExp(
138
+ credentialNames().fieldNamePatterns.join("|"),
139
+ "i",
140
+ ));
141
+ }
142
+
143
+ /**
144
+ * Any hint arm against `text` exactly as written.
122
145
  * @param {string} text
123
146
  * @returns {boolean}
124
147
  */
125
148
  function hintMatchesView(text) {
126
- return SECRET_HINT.test(text) || SECRET_HINT_EXT.test(text);
149
+ return (
150
+ SECRET_HINT.test(text) ||
151
+ SECRET_HINT_EXT.test(text) ||
152
+ fieldNameHint().test(text)
153
+ );
127
154
  }
128
155
 
129
156
  /**
130
- * True when either pre-gate alternation shape-matches `text`, or the same text
157
+ * True when any pre-gate arm shape-matches `text`, or the same text
131
158
  * with every `\n` removed (the view the engine's `_redact_cross_line` scans, so
132
159
  * this stays a superset of the redactor). Two literals OR'd so neither grows
133
160
  * into a polynomial-backtracking shape (see SECRET_HINT).
@@ -39,7 +39,11 @@ import {
39
39
  payloadVerdict,
40
40
  stripInvisible,
41
41
  } from "./invisible.mjs";
42
- import { excludeNodeModules, isInsideDir } from "./claude-context.mjs";
42
+ import {
43
+ classifyReadFailure,
44
+ excludeNodeModules,
45
+ isInsideDir,
46
+ } from "./claude-context.mjs";
43
47
  import { compatGlobs } from "./glob-compat.mjs";
44
48
 
45
49
  // The library's public door onto Claude Code's context scope. The definitions
@@ -56,6 +60,7 @@ export {
56
60
  CLAUDE_INSTRUCTION_GLOBS,
57
61
  CLAUDE_LAUNCH_GLOBS,
58
62
  CLAUDE_MEMORY_FILES,
63
+ classifyReadFailure,
59
64
  contextScopeContradiction,
60
65
  excludeFromContextScan,
61
66
  USER_GLOBAL_EVENT_NAMED_GLOBS,
@@ -310,47 +315,26 @@ function containedWritePath(absPath, root) {
310
315
  }
311
316
 
312
317
  /**
313
- * Classify a glob match for containment: resolve it to a real (symlink-
314
- * followed) path and decide whether to keep, drop, or reject it. Three
315
- * outcomes are kept distinct:
316
- *
317
- * - The realpath is contained in `realRoot` → KEEP.
318
- * - The match is contained LEXICALLY (the glob pattern itself, followed
319
- * literally with no symlink resolution, never leaves `literalRoot`) but its
320
- * REALPATH escapes `realRoot` — an in-tree symlink (e.g. a planted
321
- * `CLAUDE.md -> /etc/passwd`) whose target lives outside the tree → SKIP.
322
- * One planted symlink must not abort scanning every other instruction
323
- * file; treat it like the existing dangling-symlink case.
324
- * - The match ESCAPES LEXICALLY — the glob pattern itself reaches outside
325
- * `literalRoot` via `..` or an absolute path outside the tree, with no
326
- * symlink involved → THROW. That is a caller misconfiguration (a scanner
327
- * pointed outside its own tree) that must surface loudly, not be silently
328
- * skipped.
329
- * - The path cannot be resolved at all (ENOENT/EACCES — a dangling symlink or
330
- * unreadable entry that still lives inside the tree) → return false to SKIP
331
- * it, matching scanInstructionFiles' existing skip-on-unreadable behavior.
318
+ * Refuse a glob match the caller's PATTERN carried outside the scan root.
332
319
  *
333
- * A genuine resolution failure is never allowed to masquerade as a
334
- * containment pass: an unresolvable path is skipped, only a successfully
335
- * resolved path is classified as kept/skipped/thrown above.
320
+ * A match whose literal path stays inside `literalRoot` is kept whatever it
321
+ * resolves to: an in-tree symlink to a file outside the tree (a planted
322
+ * `CLAUDE.md -> /tmp/payload.md`), or a dangling one. The host loads such a
323
+ * file through the same path, so dropping it reported an unscanned file as
324
+ * clean. Scanning it is a read; {@link cleanFile} still refuses to write
325
+ * through it. A match that ESCAPES LEXICALLY — `..` or an absolute glob outside
326
+ * the tree, no symlink involved — is a caller misconfiguration and throws.
336
327
  * @param {string} absPath absolute path to a glob match
337
328
  * @param {string} realRoot realpath of the scan root
338
329
  * @param {string} literalRoot `cwd`, resolved but NOT symlink-followed
339
330
  * @param {string} pattern the glob that produced this match
340
- * @returns {boolean} true to keep the match, false to skip it
341
331
  */
342
- function keepContained(absPath, realRoot, literalRoot, pattern) {
343
- let real;
344
- try {
345
- real = realpathSync(absPath);
346
- } catch {
347
- return false; // dangling/unreadable in-cwd match: skip, do not abort
348
- }
349
- if (isContained(realRoot, real)) return true;
350
- // The glob pattern itself never left the scan root lexically, so the escape
351
- // is caused by an in-tree symlink resolving outside the tree, not by the
352
- // caller's glob configuration. Skip this one match, do not abort the scan.
353
- if (isContained(literalRoot, absPath)) return false;
332
+ function assertContained(absPath, realRoot, literalRoot, pattern) {
333
+ if (isContained(literalRoot, absPath)) return;
334
+ // A match outside the tree that resolves to nothing throws its own errno:
335
+ // still loud, and still refused.
336
+ const real = realpathSync(absPath);
337
+ if (isContained(realRoot, real)) return;
354
338
  throw new Error(
355
339
  `instruction-file path escapes scan root: pattern ${JSON.stringify(
356
340
  pattern,
@@ -422,14 +406,12 @@ function ancestorEntries(rel) {
422
406
  * Expand `globs` (relative to `cwd`) to absolute file paths, skipping
423
407
  * `node_modules`. The glob set is the caller's instruction-file convention.
424
408
  *
425
- * Containment is enforced per match (see {@link keepContained}): a match whose
426
- * glob pattern itself escapes `cwd` — via `..` or an absolute-path glob
409
+ * Containment is enforced per match (see {@link assertContained}): a match
410
+ * whose glob pattern itself escapes `cwd` — via `..` or an absolute-path glob
427
411
  * outside the tree — THROWS, since reaching outside the tree is a caller
428
- * misconfiguration. A match that lexically stays inside `cwd` but resolves
429
- * (via an in-tree symlink) to a target outside the tree, or that simply
430
- * cannot be resolved (a dangling symlink or unreadable entry inside the
431
- * tree), is SKIPPED, so one bad symlink never aborts scanning the rest of the
432
- * project.
412
+ * misconfiguration. A match that lexically stays inside `cwd` is RETURNED even
413
+ * when an in-tree symlink resolves it outside the tree or to nothing, so the
414
+ * reader decides what an unreadable one means.
433
415
  *
434
416
  * `exclude` prunes the walk via {@link walkContextGlobs}, so it is handed one
435
417
  * root-relative, `/`-separated path per entry. It is composed with, never
@@ -450,37 +432,53 @@ export function findInstructionFiles(
450
432
  // One pattern at a time, so a containment failure can name the glob that
451
433
  // reached outside the tree.
452
434
  for (const pattern of globs)
453
- for (const absPath of walkContextGlobs([pattern], cwd, exclude))
454
- if (keepContained(absPath, realRoot, literalRoot, pattern))
455
- seen.add(absPath);
435
+ for (const absPath of walkContextGlobs([pattern], cwd, exclude)) {
436
+ assertContained(absPath, realRoot, literalRoot, pattern);
437
+ seen.add(absPath);
438
+ }
456
439
  return [...seen];
457
440
  }
458
441
 
459
442
  /**
460
- * Scan every instruction file matched by `globs` and return only those with
461
- * findings, each path reported relative to `cwd`. Unreadable/missing files are
462
- * skipped. Pure scan — no mutation; pair with {@link cleanFile} to strip.
443
+ * Scan every instruction file matched by `globs`. `findings` lists only the
444
+ * files with findings; `skipped` lists every file that exists but could not be
445
+ * read, so a caller never reports an unread file as clean. A missing file
446
+ * (ENOENT, a dangling symlink) is in neither: no bytes of it can load. Paths
447
+ * are relative to `cwd`. Pure scan — no mutation; pair with {@link cleanFile}.
463
448
  * @param {string[]} globs
464
449
  * @param {{ cwd?: string, exclude?: (entry: string) => boolean }} [options]
465
450
  * `exclude` is forwarded to {@link findInstructionFiles}
466
- * @returns {Array<{ file: string, findings: ReturnType<typeof scanText> }>}
451
+ * @returns {{
452
+ * findings: Array<{ file: string, findings: ReturnType<typeof scanText> }>,
453
+ * skipped: Array<{ file: string, reason: string }>,
454
+ * }}
467
455
  */
468
456
  export function scanInstructionFiles(
469
457
  globs,
470
458
  { cwd = process.cwd(), exclude } = {},
471
459
  ) {
472
- const out = [];
460
+ const findings = [];
461
+ const skipped = [];
473
462
  for (const file of findInstructionFiles(globs, { cwd, exclude })) {
474
463
  let content;
475
464
  try {
476
465
  content = readFileSync(file, "utf-8");
477
- } catch {
478
- continue; // missing or unreadable
466
+ } catch (err) {
467
+ if (classifyReadFailure(err) === "skipped")
468
+ skipped.push({
469
+ file: relative(cwd, file),
470
+ // classifyReadFailure returns only for an errno, so `code` is set.
471
+ reason: /** @type {string} */ (
472
+ /** @type {NodeJS.ErrnoException} */ (err).code
473
+ ),
474
+ });
475
+ continue;
479
476
  }
480
- const findings = scanText(content);
481
- if (findings.length > 0) out.push({ file: relative(cwd, file), findings });
477
+ const fileFindings = scanText(content);
478
+ if (fileFindings.length > 0)
479
+ findings.push({ file: relative(cwd, file), findings: fileFindings });
482
480
  }
483
- return out;
481
+ return { findings, skipped };
484
482
  }
485
483
 
486
484
  /**
package/src/output.mjs CHANGED
@@ -54,6 +54,7 @@ import {
54
54
  warningMessages,
55
55
  } from "./severity.mjs";
56
56
  import { orderedMatches, spliceOrdered } from "./view-map.mjs";
57
+ import { errMessage } from "./err-message.mjs";
57
58
 
58
59
  /**
59
60
  * Closed enum of LIBRARY-OWNED Layer-5 warning codes — the ONLY warning values
@@ -120,18 +121,6 @@ function mapFilterWarning(code) {
120
121
  return label;
121
122
  }
122
123
 
123
- /**
124
- * Message from a caught value (`unknown` under strict mode), with one level of
125
- * cause chain appended so a wrapped failure reads "outer: root".
126
- * @param {unknown} err
127
- * @returns {string}
128
- */
129
- function errMessage(err) {
130
- if (!(err instanceof Error)) return String(err);
131
- const cause = err.cause instanceof Error ? `: ${err.cause.message}` : "";
132
- return err.message + cause;
133
- }
134
-
135
124
  /**
136
125
  * @typedef {{ text: string, found: string[], note?: string }} RedactResult
137
126
  * Layer-4 result: the redacted text, the category labels redacted, and an
package/src/rehydrate.mjs CHANGED
@@ -55,6 +55,7 @@
55
55
  * {@link rehydrateRedacted}).
56
56
  */
57
57
  import { applyLayer1, normalizeLoneSurrogates } from "./layer1.mjs";
58
+ import { FILE_WRITE_TOOLS, fieldValues } from "./file-write-tools.mjs";
58
59
  import {
59
60
  occurrences,
60
61
  overlapAwareCount,
@@ -771,42 +772,45 @@ function multiEditDeny(filePath) {
771
772
  };
772
773
  }
773
774
 
775
+ /**
776
+ * Every string a placeholder can sit in, per one {@link FILE_WRITE_TOOLS}
777
+ * entry, or null when the input is not well-formed for it: a placeholder field
778
+ * that is not a string, or a nested field over an absent or empty array.
779
+ * @param {import("./file-write-tools.mjs").FileWriteTool} entry
780
+ * @param {any} ti
781
+ * @returns {string[] | null}
782
+ */
783
+ function placeholderFields(entry, ti) {
784
+ const values = [];
785
+ for (const spec of entry.placeholders) {
786
+ const found = fieldValues(spec, ti);
787
+ if (found === null || found.some((v) => typeof v !== "string")) return null;
788
+ values.push(.../** @type {string[]} */ (found));
789
+ }
790
+ return values;
791
+ }
792
+
774
793
  /**
775
794
  * True when this tool call could need re-anchoring against the target file's
776
- * sanitized view: any well-formed Edit or Write (the view may differ from
777
- * disk even without placeholders, via stripped invisible characters — a
778
- * hint-free whole-file Write of such a file would silently persist the
779
- * stripped bytes), and any well-formed MultiEdit (gated on the same
780
- * grounds).
795
+ * sanitized view: any well-formed call to a tool whose table entry
796
+ * `rehydrates`. An Edit or Write qualifies even without placeholders: the view
797
+ * may differ from disk via stripped invisible characters, and a hint-free
798
+ * whole-file Write of such a file would silently persist the stripped bytes.
799
+ *
800
+ * MultiEdit applies its edits SEQUENTIALLY, each against the result of the
801
+ * previous, so the span machinery below (which maps one old_string against
802
+ * one static view) cannot re-anchor it. It is still a candidate: on a
803
+ * divergent file an unguarded pass-through is both a silent clobber and the
804
+ * same char-extraction oracle R1 closes for Edit. The dispatch at the bottom
805
+ * passes the clean-file case through and denies the divergent one.
781
806
  * @param {string} tool
782
807
  * @param {any} ti
783
808
  */
784
809
  function isCandidate(tool, ti) {
785
810
  if (typeof ti?.file_path !== "string") return false;
786
- if (tool === "Edit")
787
- return (
788
- typeof ti.old_string === "string" && typeof ti.new_string === "string"
789
- );
790
- if (tool === "Write") return typeof ti.content === "string";
791
- // MultiEdit applies its edits SEQUENTIALLY, each against the result of the
792
- // previous, so the span machinery below (which maps one old_string against
793
- // one static view) cannot re-anchor it. It is still a candidate: on a
794
- // divergent file an unguarded pass-through is both a silent clobber (a
795
- // placeholder in new_string persisted verbatim) and the same char-extraction
796
- // oracle R1 closes for Edit (an old_string matching bytes inside a redacted
797
- // span). The dispatch at the bottom pass-throughs the clean-file case and
798
- // denies the divergent one with use-single-Edit guidance.
799
- if (tool === "MultiEdit")
800
- return (
801
- Array.isArray(ti.edits) &&
802
- ti.edits.length > 0 &&
803
- ti.edits.every(
804
- (/** @type {any} */ edit) =>
805
- typeof edit?.old_string === "string" &&
806
- typeof edit?.new_string === "string",
807
- )
808
- );
809
- return false;
811
+ const entry = FILE_WRITE_TOOLS[tool];
812
+ if (!entry?.rehydrates) return false;
813
+ return placeholderFields(entry, ti) !== null;
810
814
  }
811
815
 
812
816
  /**
@@ -838,8 +842,9 @@ export async function rehydrateRedacted(
838
842
  // secret; mapping .ipynb JSON is not supported, so refuse with guidance.
839
843
  if (
840
844
  tool === "NotebookEdit" &&
841
- typeof toolInput?.new_source === "string" &&
842
- toolInput.new_source.includes(hint)
845
+ placeholderFields(FILE_WRITE_TOOLS.NotebookEdit, toolInput)?.some((v) =>
846
+ v.includes(hint),
847
+ )
843
848
  )
844
849
  return {
845
850
  deny:
@@ -848,16 +853,9 @@ export async function rehydrateRedacted(
848
853
  `the secret-bearing cell unchanged, or ask the user to edit it.`,
849
854
  };
850
855
  if (!isCandidate(tool, toolInput)) return null;
851
- const hinted =
852
- tool === "Write"
853
- ? toolInput.content.includes(hint)
854
- : tool === "MultiEdit"
855
- ? toolInput.edits.some(
856
- (/** @type {{old_string: string, new_string: string}} */ edit) =>
857
- edit.old_string.includes(hint) || edit.new_string.includes(hint),
858
- )
859
- : toolInput.old_string.includes(hint) ||
860
- toolInput.new_string.includes(hint);
856
+ const hinted = /** @type {string[]} */ (
857
+ placeholderFields(FILE_WRITE_TOOLS[tool], toolInput)
858
+ ).some((v) => v.includes(hint));
861
859
 
862
860
  let content;
863
861
  try {
@@ -37,6 +37,23 @@ export function isInsideDir(dir: string, file: string): boolean;
37
37
  * @returns {string}
38
38
  */
39
39
  export function displayUntrusted(text: string): string;
40
+ /**
41
+ * PROBLEM CLASS — how a failed instruction-file read is classified. Every
42
+ * scanner reads this one bucketing; nothing re-derives it from an errno.
43
+ *
44
+ * A read failure is never swallowed into an empty findings list — that turns
45
+ * "we could not read this file" into "this file is fine". Three buckets:
46
+ *
47
+ * - SKIPPED — the file exists and this uid cannot read it (EACCES, EISDIR,
48
+ * ELOOP…). Unvetted context, which the caller must report.
49
+ * - ABSENT — ENOENT. The path resolves to nothing, and the host loads
50
+ * instruction files through the same open, so no bytes can reach the model.
51
+ * - THROWN — no errno at all, i.e. a bug. Nothing can be trusted, so the
52
+ * error propagates to the caller's failure posture.
53
+ * @param {unknown} err
54
+ * @returns {"absent" | "skipped"} never returns for a non-errno throw
55
+ */
56
+ export function classifyReadFailure(err: unknown): "absent" | "skipped";
40
57
  /**
41
58
  * The one directory no instruction-file walk ever descends into. Its own
42
59
  * function so the name is spelled once, and so the two predicates that need it
@@ -8,11 +8,11 @@
8
8
  * fatigue, and the doctrine here is precision over recall. The signal is the
9
9
  * partition test, which reads this function.
10
10
  * @param {string} tool
11
- * @returns {{ kind: "covered", fields: string[] } | { kind: "exempt", reason: string } | { kind: "undeclared" }}
11
+ * @returns {{ kind: "covered", fields: readonly string[] } | { kind: "exempt", reason: string } | { kind: "undeclared" }}
12
12
  */
13
13
  export function authoredScopeDecision(tool: string): {
14
14
  kind: "covered";
15
- fields: string[];
15
+ fields: readonly string[];
16
16
  } | {
17
17
  kind: "exempt";
18
18
  reason: string;
@@ -34,8 +34,8 @@ export function sanitizeAuthoredContent(tool: string, toolInput: any): {
34
34
  updatedInput: any;
35
35
  changed: string[];
36
36
  } | null;
37
- /** @type {Record<string, string[]>} */
38
- export const AUTHORED_FIELDS: Record<string, string[]>;
37
+ /** @type {Readonly<Record<string, readonly string[]>>} */
38
+ export const AUTHORED_FIELDS: Readonly<Record<string, readonly string[]>>;
39
39
  /** @type {Record<string, string>} */
40
40
  export const EXEMPT_TOOLS: Record<string, string>;
41
41
  /** @type {ReadonlyArray<{ pattern: RegExp, reason: string }>} */
@@ -264,14 +264,6 @@ export function makeDeadline(budgetMs: number, now?: () => number): {
264
264
  export function scrubUntrustedText(raw: unknown, layer1: (text: string) => {
265
265
  cleaned: string;
266
266
  }, cap?: number): string;
267
- /**
268
- * Message from a caught value, which is `unknown` under strict mode. Appends
269
- * the cause chain (one level) when the cause is itself an Error so callers
270
- * get "outer: root" instead of just "outer" when an error wraps another.
271
- * @param {unknown} err
272
- * @returns {string}
273
- */
274
- export function errMessage(err: unknown): string;
275
267
  /**
276
268
  * errMessage() for an error whose message may embed attacker-chosen bytes: V8
277
269
  * quotes a snippet of the offending input in a JSON.parse SyntaxError, so a hook
@@ -622,6 +614,7 @@ export class EmptyStdinError extends Error {
622
614
  * command the reader should actually run.
623
615
  */
624
616
  export const DEFAULT_MISSING_PACKAGE_REMEDY: "reinstall the hook dependencies (pnpm install) and retry.";
617
+ export { errMessage };
625
618
  /**
626
619
  * The project the hooks are guarding. Every per-project $TMPDIR store is keyed to
627
620
  * it, so it lives here — beside the other shared identity these hooks agree on —
@@ -668,3 +661,4 @@ export type HookIoSharedState = {
668
661
  hookgateMarker: string | null;
669
662
  hookgateMarkerResolved: boolean;
670
663
  };
664
+ import { errMessage } from "../../src/err-message.mjs";
@@ -94,7 +94,6 @@ export const PLACEHOLDER_LABEL_CHARS: "A-Za-z0-9 ()._-";
94
94
  * `grep "\[REDACTED"`) is not mistaken for a placeholder.
95
95
  */
96
96
  export const PLACEHOLDER_RE: RegExp;
97
- export const REHYDRATED_TOOLS: Set<string>;
98
97
  /**
99
98
  * One found token: the exact placeholder text and the dotted field path of the
100
99
  * FIRST input field carrying it (empty for a bare string input).
@@ -154,8 +154,13 @@ export function failClosedFields(parsedOk: boolean, err: unknown, opts?: {
154
154
  * view; with the sanitizer down, rehydration cannot translate it back, so
155
155
  * letting the call through would persist the literal placeholder text over the
156
156
  * real secret on disk — a destructive clobber, not a missed scan.
157
- * Package-free by construction (a Set lookup and a walk of the already-parsed
157
+ * Package-free by construction (a table lookup and a walk of the already-parsed
158
158
  * payload), so it holds when the failure IS the missing package.
159
+ *
160
+ * Deliberately NOT exhaustive over every clobber path: Bash can also write a
161
+ * placeholder to disk (`>`, `tee`, `sed -i`, a heredoc), but command strings
162
+ * mention "[REDACTED" benignly far too often for an ask to hold precision
163
+ * there. That is an accepted gap, named in THREAT-MODEL.md's carve-out.
159
164
  * @param {unknown} input raw parsed PreToolUse payload (undefined if unparsed)
160
165
  * @returns {boolean}
161
166
  */
@@ -234,7 +239,6 @@ export const PRE_TOOL_USE_MESSAGES: Readonly<{
234
239
  remedy: string;
235
240
  }>;
236
241
  export const REDACTION_HINT: "[REDACTED";
237
- export const WRITE_SHAPED_TOOLS: Set<string>;
238
242
  /**
239
243
  * A host-supplied deny gate: given the PreToolUse input, the reason this call
240
244
  * must be blocked, or null to let the pipeline continue. Hosts use these for
@@ -10,10 +10,11 @@
10
10
  export function parseCredentialNames(spec: Record<string, unknown>): CredentialNames;
11
11
  /** The validated renderings of the packaged vocabulary, memoized.
12
12
  *
13
- * Read lazily, never at module load: a static importer that crashed at LOAD would
14
- * abort before its own fail-closed catch installs, and a guardrail that fails to
15
- * load is a guardrail that fails OPEN. Deferring to first use routes a missing or
16
- * corrupt data file into the caller's catch instead.
13
+ * The data arrives with the module (the one static import above, so bundles
14
+ * carry it), but it is VALIDATED lazily, never at module load: a malformed
15
+ * vocabulary then throws on first use, inside the caller's fail-closed catch. A
16
+ * throw at load would abort before that catch installs, and a guardrail that
17
+ * fails to load is a guardrail that fails OPEN.
17
18
  * @returns {CredentialNames} */
18
19
  export function credentialNames(): CredentialNames;
19
20
  /** @typedef {"trailing" | "any-segment"} CredentialNameScope */
@@ -0,0 +1,16 @@
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
+ * Message from a caught value, with one level of cause chain appended when the
12
+ * cause is itself an Error.
13
+ * @param {unknown} err
14
+ * @returns {string}
15
+ */
16
+ export function errMessage(err: unknown): string;