agent-sanitizer 2.36.0 → 2.37.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/THREAT-MODEL.md CHANGED
@@ -287,7 +287,7 @@ root's own instruction files, the `CLAUDE.md` chain above it, and the root
287
287
  `.claude/` context subdirectories — and `scan-loaded-instructions`
288
288
  (InstructionsLoaded) scans every other instruction file — including the
289
289
  user-global `~/.claude` memory and rules, which load into every session on the
290
- machine — from the bytes the event carries, at the moment it loads. The second cannot block: the file is already in
290
+ machine — reading the one path the event names, at the moment it loads. The second cannot block: the file is already in
291
291
  context when it fires, so its neutralization is to strip the payload from disk
292
292
  (so no reload re-reads it) and tell the model to treat what it just read as
293
293
  untrusted data. Auto-cleaning is confined to `CLAUDE_PROJECT_DIR` in both — an
@@ -4,9 +4,9 @@
4
4
  *
5
5
  * This is the lazy half of the instruction-file scan. Claude Code loads a
6
6
  * subdirectory's CLAUDE.md (and a path-scoped rule, an `@import`, a
7
- * post-compaction reload) only when it needs it, and this event fires with the
8
- * loaded bytes in hand — so the scan costs one `scanText` over text already read
9
- * for us, with no glob, no walk, and no second read. The SessionStart scan
7
+ * post-compaction reload) only when it needs it, and this event names the file
8
+ * it just loaded — so the scan costs one read and one `scanText` on exactly the
9
+ * file that entered context, with no glob and no walk. The SessionStart scan
10
10
  * covers what loads at launch from the project root and its parents; everything
11
11
  * else arrives here, including the user-global `~/.claude` memory and rules that
12
12
  * load into every session on the machine — a second root that would otherwise
@@ -20,6 +20,7 @@
20
20
  * reload re-reads it, say so where the user and the model both see it, and arm
21
21
  * the PreToolUse gate when the strip did not happen.
22
22
  */
23
+ import { readFileSync } from "node:fs";
23
24
  import {
24
25
  emitHookResponse,
25
26
  HookEvent,
@@ -54,6 +55,18 @@ const { scanText, cleanFile } =
54
55
 
55
56
  const HOOK_NAME = "scan-loaded-instructions";
56
57
 
58
+ /**
59
+ * Read an instruction file for scanning, the same way the SessionStart scan
60
+ * reads its targets. Cleaning is where the symlink and UTF-8 guards live
61
+ * (`cleanFile` opens `O_NOFOLLOW`), so a path this read resolves but that one
62
+ * refuses becomes a reported finding the operator sees, never a silent rewrite.
63
+ * @param {string} filePath
64
+ * @returns {string}
65
+ */
66
+ function readInstructions(filePath) {
67
+ return readFileSync(filePath, "utf-8");
68
+ }
69
+
57
70
  /**
58
71
  * The stderr line both posture arms share: what broke, and what it cost.
59
72
  * @param {{ message: string }} ctx
@@ -89,32 +102,23 @@ registerFaultPolicy(HOOK_NAME, {
89
102
  });
90
103
 
91
104
  /**
92
- * The payload fields this hook reads, validated. A payload missing either is
93
- * harness-contract drift, not a clean file: reporting "no findings" for bytes we
94
- * never saw is the one answer that must never be reachable, so this throws into
95
- * the declared fault posture instead.
105
+ * The payload fields this hook reads, validated. A payload with no `file_path`
106
+ * is harness-contract drift, not a clean file: reporting "no findings" for a
107
+ * file we never identified is the one answer that must never be reachable, so
108
+ * this throws into the declared fault posture instead.
96
109
  * @param {unknown} payload
97
- * @returns {{ filePath: string, content: string, loadReason: string }}
110
+ * @returns {{ filePath: string, loadReason: string }}
98
111
  */
99
112
  export function readLoadedFile(payload) {
100
- const {
101
- file_path: filePath,
102
- file_content: content,
103
- load_reason: loadReason,
104
- } = /** @type {Record<string, unknown>} */ (payload ?? {});
113
+ const { file_path: filePath, load_reason: loadReason } =
114
+ /** @type {Record<string, unknown>} */ (payload ?? {});
105
115
  if (typeof filePath !== "string" || filePath === "")
106
116
  throw new Error(
107
117
  "InstructionsLoaded payload carries no file_path; cannot scan or report " +
108
118
  "the instruction file that was loaded",
109
119
  );
110
- if (typeof content !== "string")
111
- throw new Error(
112
- `InstructionsLoaded payload for ${JSON.stringify(filePath)} carries no ` +
113
- "file_content; the loaded bytes are not available to scan",
114
- );
115
120
  return {
116
121
  filePath,
117
- content,
118
122
  // Metadata for the trace channel only, so an unknown/absent reason is a
119
123
  // label, never a reason to skip the scan.
120
124
  loadReason: typeof loadReason === "string" ? loadReason : "unknown",
@@ -123,23 +127,28 @@ export function readLoadedFile(payload) {
123
127
 
124
128
  /**
125
129
  * Scan one loaded instruction file. Returns the report and what to do with it:
126
- * `cleaned` says the payload is gone from disk, `alert` carries the text that
127
- * must arm the PreToolUse gate (empty when the clean succeeded).
130
+ * `cleaned` says the payload is gone from disk, and `reason` says why it is not
131
+ * when it is not which is what routes the file to the PreToolUse gate.
128
132
  *
129
- * The scan runs on the payload's bytes, never a re-read of the path: those are
130
- * the bytes that reached the model, and a file rewritten between the load and
131
- * this hook would otherwise be scanned in a state the model never saw.
132
- * @param {{ filePath: string, content: string }} loaded
133
- * @param {{ projectDir?: string, clean?: typeof cleanFile }} [opts] injectable
134
- * for tests; the default cleans through the SSOT's guarded rewrite
133
+ * The event names the file but carries none of its bytes, so the scan reads the
134
+ * path. That is also what keeps scan and clean coherent: `cleanFile` rewrites
135
+ * what is on disk, and this scan is what decides whether it should.
136
+ *
137
+ * A read that fails is never an empty findings list — an instruction file
138
+ * already in context that could not be scanned is exactly what this hook's fault
139
+ * posture exists to announce, so the error propagates to it.
140
+ * @param {string} filePath
141
+ * @param {{ projectDir?: string, clean?: typeof cleanFile,
142
+ * read?: (path: string) => string }} [opts] injectable for tests; the
143
+ * defaults read the real file and clean through the SSOT's guarded rewrite
135
144
  * @returns {{ report: string, cleaned: boolean, reason: string | null } | null}
136
145
  * null when the file is clean
137
146
  */
138
147
  export function scanLoadedFile(
139
- { filePath, content },
140
- { projectDir = PROJECT_DIR, clean = cleanFile } = {},
148
+ filePath,
149
+ { projectDir = PROJECT_DIR, clean = cleanFile, read = readInstructions } = {},
141
150
  ) {
142
- const findings = scanText(content);
151
+ const findings = scanText(read(filePath));
143
152
  if (findings.length === 0) return null;
144
153
  const report = formatReport([{ file: filePath, findings }]);
145
154
  if (!isInsideDir(projectDir, filePath))
@@ -158,15 +167,12 @@ export function scanLoadedFile(
158
167
  cleaned: false,
159
168
  reason: "the file changed between the load and the clean",
160
169
  };
161
- /* c8 ignore start -- only fires on a file cleanFile refuses (symlink,
162
- non-UTF-8, concurrent write) or cannot rewrite */
163
170
  } catch (err) {
164
171
  // A TypeError is an unbound lazy import — a bug in THIS hook — and must not
165
172
  // be laundered into "this file resisted cleaning".
166
173
  if (err instanceof TypeError) throw err;
167
174
  return { report, cleaned: false, reason: safeErrMessage(err) };
168
175
  }
169
- /* c8 ignore stop */
170
176
  }
171
177
 
172
178
  /**
@@ -215,7 +221,7 @@ export async function cliMain({ trace: sink = trace } = {}) {
215
221
  // before the payload's OWN fields are validated for the same reason.
216
222
  recordInstructionsLoaded(payload?.session_id);
217
223
  const loaded = readLoadedFile(payload);
218
- const result = scanLoadedFile(loaded);
224
+ const result = scanLoadedFile(loaded.filePath);
219
225
  if (result === null) {
220
226
  emitTrace(TraceEvent.SCAN_LOADED_INSTRUCTIONS_RAN, {
221
227
  outcome: "clean",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.36.0",
3
+ "version": "2.37.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": {
@@ -1,36 +1,38 @@
1
1
  /**
2
- * The payload fields this hook reads, validated. A payload missing either is
3
- * harness-contract drift, not a clean file: reporting "no findings" for bytes we
4
- * never saw is the one answer that must never be reachable, so this throws into
5
- * the declared fault posture instead.
2
+ * The payload fields this hook reads, validated. A payload with no `file_path`
3
+ * is harness-contract drift, not a clean file: reporting "no findings" for a
4
+ * file we never identified is the one answer that must never be reachable, so
5
+ * this throws into the declared fault posture instead.
6
6
  * @param {unknown} payload
7
- * @returns {{ filePath: string, content: string, loadReason: string }}
7
+ * @returns {{ filePath: string, loadReason: string }}
8
8
  */
9
9
  export function readLoadedFile(payload: unknown): {
10
10
  filePath: string;
11
- content: string;
12
11
  loadReason: string;
13
12
  };
14
13
  /**
15
14
  * Scan one loaded instruction file. Returns the report and what to do with it:
16
- * `cleaned` says the payload is gone from disk, `alert` carries the text that
17
- * must arm the PreToolUse gate (empty when the clean succeeded).
15
+ * `cleaned` says the payload is gone from disk, and `reason` says why it is not
16
+ * when it is not which is what routes the file to the PreToolUse gate.
18
17
  *
19
- * The scan runs on the payload's bytes, never a re-read of the path: those are
20
- * the bytes that reached the model, and a file rewritten between the load and
21
- * this hook would otherwise be scanned in a state the model never saw.
22
- * @param {{ filePath: string, content: string }} loaded
23
- * @param {{ projectDir?: string, clean?: typeof cleanFile }} [opts] injectable
24
- * for tests; the default cleans through the SSOT's guarded rewrite
18
+ * The event names the file but carries none of its bytes, so the scan reads the
19
+ * path. That is also what keeps scan and clean coherent: `cleanFile` rewrites
20
+ * what is on disk, and this scan is what decides whether it should.
21
+ *
22
+ * A read that fails is never an empty findings list — an instruction file
23
+ * already in context that could not be scanned is exactly what this hook's fault
24
+ * posture exists to announce, so the error propagates to it.
25
+ * @param {string} filePath
26
+ * @param {{ projectDir?: string, clean?: typeof cleanFile,
27
+ * read?: (path: string) => string }} [opts] injectable for tests; the
28
+ * defaults read the real file and clean through the SSOT's guarded rewrite
25
29
  * @returns {{ report: string, cleaned: boolean, reason: string | null } | null}
26
30
  * null when the file is clean
27
31
  */
28
- export function scanLoadedFile({ filePath, content }: {
29
- filePath: string;
30
- content: string;
31
- }, { projectDir, clean }?: {
32
+ export function scanLoadedFile(filePath: string, { projectDir, clean, read }?: {
32
33
  projectDir?: string;
33
34
  clean?: typeof cleanFile;
35
+ read?: (path: string) => string;
34
36
  }): {
35
37
  report: string;
36
38
  cleaned: boolean;