agent-sanitizer 2.47.14 → 2.48.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 +16 -0
- package/claude-hooks/lib/invisible-alert.mjs +46 -30
- package/package.json +1 -1
- package/src/claude-context.mjs +40 -0
- package/src/instructions.mjs +2 -0
- package/types/claude-context.d.mts +32 -0
- package/types/claude-hooks/lib/invisible-alert.d.mts +3 -2
- package/types/instructions.d.mts +1 -1
- package/types/src/claude-context.d.mts +32 -0
package/THREAT-MODEL.md
CHANGED
|
@@ -383,6 +383,22 @@ build that emits that event, and `scan-loaded-instructions` switched off in
|
|
|
383
383
|
Nothing on disk tells them apart, so the PreToolUse gate names all three, once
|
|
384
384
|
per session, rather than leaving the gap silent.
|
|
385
385
|
|
|
386
|
+
A fourth state is not one of them and is not reported as one: the host emits no
|
|
387
|
+
event when it has nothing to announce. The gate therefore asks not "does anything
|
|
388
|
+
load at launch" but "does anything load whose load `InstructionsLoaded` NAMES,
|
|
389
|
+
with bytes in it" — the `eventNamed` column of the same table, read through
|
|
390
|
+
`announcedByInstructionsLoaded` for the project's own tree and through
|
|
391
|
+
`USER_GLOBAL_EVENT_NAMED_GLOBS` for the user-global root, whose files carry no
|
|
392
|
+
`.claude` segment to classify by. A launch holding only an `AGENTS.md`, a skill
|
|
393
|
+
or an empty `~/.claude/CLAUDE.md` fires no event however the hook is wired, so
|
|
394
|
+
the missing scan there is evidence of nothing. The trade this accepts is a false
|
|
395
|
+
NEGATIVE: a repo whose announced instruction files all sit in subdirectories —
|
|
396
|
+
a monorepo of per-package `CLAUDE.md` under a root carrying only `AGENTS.md`, on
|
|
397
|
+
a machine with no user-global memory — loses the notice until a tool call touches
|
|
398
|
+
one of those directories, which is what the gate's per-call `touchedDir` check
|
|
399
|
+
recovers. Weighed against a false POSITIVE on every session launched outside a
|
|
400
|
+
project, which trains the operator to skip the notice everywhere.
|
|
401
|
+
|
|
386
402
|
That table is a claim about someone else's product, so the event that names a
|
|
387
403
|
loaded file is also what falsifies it. `contextScopeContradiction` checks every
|
|
388
404
|
path the hook is handed and reports two observations: context loading out of a
|
|
@@ -38,9 +38,10 @@ import {
|
|
|
38
38
|
// module's, not a copy of the SessionStart hook's target-discovery glue.
|
|
39
39
|
import {
|
|
40
40
|
ancestorInstructionFiles,
|
|
41
|
-
|
|
41
|
+
announcedByInstructionsLoaded,
|
|
42
42
|
CLAUDE_LAUNCH_GLOBS,
|
|
43
43
|
excludeFromContextScan,
|
|
44
|
+
USER_GLOBAL_EVENT_NAMED_GLOBS,
|
|
44
45
|
} from "../../src/claude-context.mjs";
|
|
45
46
|
|
|
46
47
|
// Layer-1 scrubber for the untrusted alert-store contents the gate splices into a
|
|
@@ -370,11 +371,10 @@ export function launchInstructionFiles(dir) {
|
|
|
370
371
|
}
|
|
371
372
|
|
|
372
373
|
/**
|
|
373
|
-
* Whether
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
* sign the scanner is unwired.
|
|
374
|
+
* Whether any of `files` has bytes for the host to load. An existing but EMPTY
|
|
375
|
+
* file (a freshly `touch`ed `~/.claude/CLAUDE.md`) is nothing to load: Claude
|
|
376
|
+
* Code 2.1.246 fires no InstructionsLoaded event when a launch has nothing in
|
|
377
|
+
* it, so the missing event is expected there, not a sign the scanner is unwired.
|
|
378
378
|
*
|
|
379
379
|
* A file this uid cannot even STAT (EACCES, EISDIR, ELOOP…) is unvetted
|
|
380
380
|
* context, not evidence of absence — the same split scan-invisible-chars.mjs's
|
|
@@ -382,11 +382,11 @@ export function launchInstructionFiles(dir) {
|
|
|
382
382
|
* as "no content" would suppress the notice over a file that may carry a real,
|
|
383
383
|
* unscanned payload, so only ENOENT counts as nothing there; anything else
|
|
384
384
|
* counts as content and leaves the notice free to fire.
|
|
385
|
-
* @param {string}
|
|
385
|
+
* @param {string[]} files
|
|
386
386
|
* @returns {boolean}
|
|
387
387
|
*/
|
|
388
|
-
function
|
|
389
|
-
for (const file of
|
|
388
|
+
function anyFileHasBytes(files) {
|
|
389
|
+
for (const file of files) {
|
|
390
390
|
try {
|
|
391
391
|
if (statSync(file).size > 0) return true;
|
|
392
392
|
} catch (err) {
|
|
@@ -397,13 +397,32 @@ function launchHasContent(dir) {
|
|
|
397
397
|
return false;
|
|
398
398
|
}
|
|
399
399
|
|
|
400
|
+
/**
|
|
401
|
+
* The files loading at launch from `dir` whose load `InstructionsLoaded` would
|
|
402
|
+
* ANNOUNCE. Claude Code names CLAUDE.md, CLAUDE.local.md and `.claude/rules` as
|
|
403
|
+
* it loads them and stays silent for every other kind, so a launch carrying only
|
|
404
|
+
* an `AGENTS.md` or a skill fires no event however the hook is wired — and its
|
|
405
|
+
* silence is evidence of nothing.
|
|
406
|
+
* @param {string} dir
|
|
407
|
+
* @returns {string[]}
|
|
408
|
+
*/
|
|
409
|
+
function announcedLaunchFiles(dir) {
|
|
410
|
+
return launchInstructionFiles(dir).filter(announcedByInstructionsLoaded);
|
|
411
|
+
}
|
|
412
|
+
|
|
400
413
|
/**
|
|
401
414
|
* Whether the USER-GLOBAL `~/.claude` memory and rules — a second root Claude
|
|
402
415
|
* Code loads at launch regardless of the project directory (see
|
|
403
|
-
* scan-loaded-instructions.mjs's header) —
|
|
404
|
-
* cannot see this root on its own: it is
|
|
405
|
-
* outside `dir`'s own tree whenever the
|
|
406
|
-
* the case a project opened anywhere but
|
|
416
|
+
* scan-loaded-instructions.mjs's header) — holds an announced file with bytes.
|
|
417
|
+
* {@link announcedLaunchFiles} cannot see this root on its own: it is
|
|
418
|
+
* `dir`-relative, and `~/.claude` is outside `dir`'s own tree whenever the
|
|
419
|
+
* project is not `$HOME` itself, exactly the case a project opened anywhere but
|
|
420
|
+
* home is in.
|
|
421
|
+
*
|
|
422
|
+
* Globs the announced kinds directly rather than filtering paths afterwards:
|
|
423
|
+
* this root IS a `.claude` directory, and under `CLAUDE_CONFIG_DIR` it may carry
|
|
424
|
+
* no `.claude` segment at all, so a path-shape classifier has nothing to read
|
|
425
|
+
* there and would answer "not announced" for every user-global rule.
|
|
407
426
|
*
|
|
408
427
|
* Honours `CLAUDE_CONFIG_DIR` the way plugin/scripts/enable-auto-update.mjs's
|
|
409
428
|
* own resolution does, since that root — not always `~/.claude` — is where
|
|
@@ -413,19 +432,12 @@ function launchHasContent(dir) {
|
|
|
413
432
|
*/
|
|
414
433
|
function userGlobalLaunchHasContent(env = process.env) {
|
|
415
434
|
const configDir = env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
|
|
416
|
-
|
|
417
|
-
[
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
if (statSync(file).size > 0) return true;
|
|
423
|
-
} catch (err) {
|
|
424
|
-
if (/** @type {NodeJS.ErrnoException} */ (err).code !== "ENOENT")
|
|
425
|
-
return true;
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
return false;
|
|
435
|
+
return anyFileHasBytes(
|
|
436
|
+
globSync([...USER_GLOBAL_EVENT_NAMED_GLOBS], {
|
|
437
|
+
cwd: configDir,
|
|
438
|
+
exclude: excludeFromContextScan,
|
|
439
|
+
}).map((name) => join(configDir, name)),
|
|
440
|
+
);
|
|
429
441
|
}
|
|
430
442
|
|
|
431
443
|
/**
|
|
@@ -440,8 +452,9 @@ function userGlobalLaunchHasContent(env = process.env) {
|
|
|
440
452
|
* tool touched a directory that DOES carry real, unscanned content.
|
|
441
453
|
*
|
|
442
454
|
* SessionStart scans what loads at launch; a subdirectory's file is scanned by
|
|
443
|
-
* the event; no scan means nothing says so — unless nothing touched so far
|
|
444
|
-
*
|
|
455
|
+
* the event; no scan means nothing says so — unless nothing touched so far held
|
|
456
|
+
* a file the event would have ANNOUNCED, with bytes in it. The three remaining
|
|
457
|
+
* causes are named together, since
|
|
445
458
|
* the marker cannot tell them apart: an unwired event, a Claude Code older than
|
|
446
459
|
* EVENT_MIN_CLI_VERSION, or the hook disabled via AGENT_SANITIZER_DISABLED_HOOKS.
|
|
447
460
|
* @param {string} [sessionId] the harness's session identity (see
|
|
@@ -461,11 +474,14 @@ export function instructionsLoadedGapNotice(
|
|
|
461
474
|
if (markerIsTrusted(instructionsLoadedNoticeFile(sessionId))) return null;
|
|
462
475
|
const launchCached = markerIsTrusted(launchEmptyFile(sessionId));
|
|
463
476
|
const launchHasBytes =
|
|
464
|
-
!launchCached &&
|
|
477
|
+
!launchCached &&
|
|
478
|
+
(anyFileHasBytes(announcedLaunchFiles(dir)) ||
|
|
479
|
+
userGlobalLaunchHasContent());
|
|
465
480
|
if (!launchCached && !launchHasBytes)
|
|
466
481
|
writeSentinelFile(launchEmptyFile(sessionId));
|
|
467
482
|
const touchedHasBytes =
|
|
468
|
-
touchedDir !== undefined &&
|
|
483
|
+
touchedDir !== undefined &&
|
|
484
|
+
anyFileHasBytes(announcedLaunchFiles(touchedDir));
|
|
469
485
|
if (!launchHasBytes && !touchedHasBytes) return null;
|
|
470
486
|
return (
|
|
471
487
|
"agent-sanitizer: no InstructionsLoaded scan has run this session, so " +
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.48.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": {
|
package/src/claude-context.mjs
CHANGED
|
@@ -301,6 +301,46 @@ function classifyContextPath(path) {
|
|
|
301
301
|
);
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
+
/**
|
|
305
|
+
* The `eventNamed` kinds spelled relative to the USER-GLOBAL config root — the
|
|
306
|
+
* `~/.claude` (or `CLAUDE_CONFIG_DIR`) directory Claude Code loads at launch
|
|
307
|
+
* whatever the project is.
|
|
308
|
+
*
|
|
309
|
+
* A second spelling because that root is a `.claude` directory ITSELF: its files
|
|
310
|
+
* are `CLAUDE.md` and `rules/x.md`, not `.claude/rules/x.md`, and under
|
|
311
|
+
* `CLAUDE_CONFIG_DIR` the path may carry no `.claude` segment at all — so
|
|
312
|
+
* {@link announcedByInstructionsLoaded}, which reads a path's own segments, has
|
|
313
|
+
* nothing there to classify by. Every row's shape is asserted in
|
|
314
|
+
* test/claude-context.test.mjs, since a shape with no spelling here would glob
|
|
315
|
+
* to nothing and read as "no event is coming".
|
|
316
|
+
*/
|
|
317
|
+
export const USER_GLOBAL_EVENT_NAMED_GLOBS = Object.freeze(
|
|
318
|
+
CLAUDE_CONTEXT_KINDS.filter((row) => row.eventNamed).map((row) =>
|
|
319
|
+
row.shape === "dir-file" ? row.name : `${row.name}/**/*.md`,
|
|
320
|
+
),
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Whether Claude Code announces loading `path` with an `InstructionsLoaded`
|
|
325
|
+
* event — the `eventNamed` column of {@link CLAUDE_CONTEXT_KINDS}, asked of one
|
|
326
|
+
* path.
|
|
327
|
+
*
|
|
328
|
+
* The complement of {@link contextScopeContradiction}, which checks the same
|
|
329
|
+
* column against an event that DID fire. This one answers before any fires, so a
|
|
330
|
+
* consumer can tell "the event is not coming" from "the event never came": a
|
|
331
|
+
* launch carrying only an `AGENTS.md` or a skill has nothing for the host to
|
|
332
|
+
* announce, and its silence is evidence of nothing.
|
|
333
|
+
*
|
|
334
|
+
* A path the table does not name gets `false`, the same conservative answer a
|
|
335
|
+
* row added without the flag gets: this says an event IS coming, never that a
|
|
336
|
+
* file is uninteresting.
|
|
337
|
+
* @param {string} path absolute or relative; only its segments are read
|
|
338
|
+
* @returns {boolean}
|
|
339
|
+
*/
|
|
340
|
+
export function announcedByInstructionsLoaded(path) {
|
|
341
|
+
return classifyContextPath(path)?.eventNamed === true;
|
|
342
|
+
}
|
|
343
|
+
|
|
304
344
|
// The `load_reason` values that mean Claude Code reached the file on its own —
|
|
305
345
|
// its launch scan, and its walk into a directory. Every other reason names a
|
|
306
346
|
// file something else chose, which is not evidence about the scan's scope.
|
package/src/instructions.mjs
CHANGED
|
@@ -51,6 +51,7 @@ import { excludeNodeModules } from "./claude-context.mjs";
|
|
|
51
51
|
// reads that scope from instead of re-spelling it.
|
|
52
52
|
export {
|
|
53
53
|
ancestorInstructionFiles,
|
|
54
|
+
announcedByInstructionsLoaded,
|
|
54
55
|
CLAUDE_CONTEXT_KINDS,
|
|
55
56
|
CLAUDE_CONTEXT_SUBDIRS,
|
|
56
57
|
CLAUDE_DIR_INSTRUCTION_FILES,
|
|
@@ -59,6 +60,7 @@ export {
|
|
|
59
60
|
CLAUDE_MEMORY_FILES,
|
|
60
61
|
contextScopeContradiction,
|
|
61
62
|
excludeFromContextScan,
|
|
63
|
+
USER_GLOBAL_EVENT_NAMED_GLOBS,
|
|
62
64
|
} from "./claude-context.mjs";
|
|
63
65
|
|
|
64
66
|
// Prefix on any decoded tag-character payload. The decoded text is
|
|
@@ -56,6 +56,24 @@ export function excludeNodeModules(entry: string): boolean;
|
|
|
56
56
|
* @returns {boolean}
|
|
57
57
|
*/
|
|
58
58
|
export function excludeFromContextScan(entry: string): boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Whether Claude Code announces loading `path` with an `InstructionsLoaded`
|
|
61
|
+
* event — the `eventNamed` column of {@link CLAUDE_CONTEXT_KINDS}, asked of one
|
|
62
|
+
* path.
|
|
63
|
+
*
|
|
64
|
+
* The complement of {@link contextScopeContradiction}, which checks the same
|
|
65
|
+
* column against an event that DID fire. This one answers before any fires, so a
|
|
66
|
+
* consumer can tell "the event is not coming" from "the event never came": a
|
|
67
|
+
* launch carrying only an `AGENTS.md` or a skill has nothing for the host to
|
|
68
|
+
* announce, and its silence is evidence of nothing.
|
|
69
|
+
*
|
|
70
|
+
* A path the table does not name gets `false`, the same conservative answer a
|
|
71
|
+
* row added without the flag gets: this says an event IS coming, never that a
|
|
72
|
+
* file is uninteresting.
|
|
73
|
+
* @param {string} path absolute or relative; only its segments are read
|
|
74
|
+
* @returns {boolean}
|
|
75
|
+
*/
|
|
76
|
+
export function announcedByInstructionsLoaded(path: string): boolean;
|
|
59
77
|
/**
|
|
60
78
|
* What a file the host just loaded as model context says about this table, or
|
|
61
79
|
* null when it says nothing new. The InstructionsLoaded event is the only
|
|
@@ -156,3 +174,17 @@ export const CLAUDE_INSTRUCTION_GLOBS: readonly string[];
|
|
|
156
174
|
* set, and with {@link excludeFromContextScan} to prune the `.claude` walk.
|
|
157
175
|
*/
|
|
158
176
|
export const CLAUDE_LAUNCH_GLOBS: readonly string[];
|
|
177
|
+
/**
|
|
178
|
+
* The `eventNamed` kinds spelled relative to the USER-GLOBAL config root — the
|
|
179
|
+
* `~/.claude` (or `CLAUDE_CONFIG_DIR`) directory Claude Code loads at launch
|
|
180
|
+
* whatever the project is.
|
|
181
|
+
*
|
|
182
|
+
* A second spelling because that root is a `.claude` directory ITSELF: its files
|
|
183
|
+
* are `CLAUDE.md` and `rules/x.md`, not `.claude/rules/x.md`, and under
|
|
184
|
+
* `CLAUDE_CONFIG_DIR` the path may carry no `.claude` segment at all — so
|
|
185
|
+
* {@link announcedByInstructionsLoaded}, which reads a path's own segments, has
|
|
186
|
+
* nothing there to classify by. Every row's shape is asserted in
|
|
187
|
+
* test/claude-context.test.mjs, since a shape with no spelling here would glob
|
|
188
|
+
* to nothing and read as "no event is coming".
|
|
189
|
+
*/
|
|
190
|
+
export const USER_GLOBAL_EVENT_NAMED_GLOBS: readonly string[];
|
|
@@ -109,8 +109,9 @@ export function launchInstructionFiles(dir: string): string[];
|
|
|
109
109
|
* tool touched a directory that DOES carry real, unscanned content.
|
|
110
110
|
*
|
|
111
111
|
* SessionStart scans what loads at launch; a subdirectory's file is scanned by
|
|
112
|
-
* the event; no scan means nothing says so — unless nothing touched so far
|
|
113
|
-
*
|
|
112
|
+
* the event; no scan means nothing says so — unless nothing touched so far held
|
|
113
|
+
* a file the event would have ANNOUNCED, with bytes in it. The three remaining
|
|
114
|
+
* causes are named together, since
|
|
114
115
|
* the marker cannot tell them apart: an unwired event, a Claude Code older than
|
|
115
116
|
* EVENT_MIN_CLI_VERSION, or the hook disabled via AGENT_SANITIZER_DISABLED_HOOKS.
|
|
116
117
|
* @param {string} [sessionId] the harness's session identity (see
|
package/types/instructions.d.mts
CHANGED
|
@@ -148,4 +148,4 @@ export function atomicReplaceFile(absPath: string, data: string, mode: number, t
|
|
|
148
148
|
* @returns {boolean}
|
|
149
149
|
*/
|
|
150
150
|
export function cleanFile(absPath: string, lstat?: (path: string) => import("node:fs").Stats): boolean;
|
|
151
|
-
export { ancestorInstructionFiles, CLAUDE_CONTEXT_KINDS, CLAUDE_CONTEXT_SUBDIRS, CLAUDE_DIR_INSTRUCTION_FILES, CLAUDE_INSTRUCTION_GLOBS, CLAUDE_LAUNCH_GLOBS, CLAUDE_MEMORY_FILES, contextScopeContradiction, excludeFromContextScan } from "./claude-context.mjs";
|
|
151
|
+
export { ancestorInstructionFiles, announcedByInstructionsLoaded, CLAUDE_CONTEXT_KINDS, CLAUDE_CONTEXT_SUBDIRS, CLAUDE_DIR_INSTRUCTION_FILES, CLAUDE_INSTRUCTION_GLOBS, CLAUDE_LAUNCH_GLOBS, CLAUDE_MEMORY_FILES, contextScopeContradiction, excludeFromContextScan, USER_GLOBAL_EVENT_NAMED_GLOBS } from "./claude-context.mjs";
|
|
@@ -56,6 +56,24 @@ export function excludeNodeModules(entry: string): boolean;
|
|
|
56
56
|
* @returns {boolean}
|
|
57
57
|
*/
|
|
58
58
|
export function excludeFromContextScan(entry: string): boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Whether Claude Code announces loading `path` with an `InstructionsLoaded`
|
|
61
|
+
* event — the `eventNamed` column of {@link CLAUDE_CONTEXT_KINDS}, asked of one
|
|
62
|
+
* path.
|
|
63
|
+
*
|
|
64
|
+
* The complement of {@link contextScopeContradiction}, which checks the same
|
|
65
|
+
* column against an event that DID fire. This one answers before any fires, so a
|
|
66
|
+
* consumer can tell "the event is not coming" from "the event never came": a
|
|
67
|
+
* launch carrying only an `AGENTS.md` or a skill has nothing for the host to
|
|
68
|
+
* announce, and its silence is evidence of nothing.
|
|
69
|
+
*
|
|
70
|
+
* A path the table does not name gets `false`, the same conservative answer a
|
|
71
|
+
* row added without the flag gets: this says an event IS coming, never that a
|
|
72
|
+
* file is uninteresting.
|
|
73
|
+
* @param {string} path absolute or relative; only its segments are read
|
|
74
|
+
* @returns {boolean}
|
|
75
|
+
*/
|
|
76
|
+
export function announcedByInstructionsLoaded(path: string): boolean;
|
|
59
77
|
/**
|
|
60
78
|
* What a file the host just loaded as model context says about this table, or
|
|
61
79
|
* null when it says nothing new. The InstructionsLoaded event is the only
|
|
@@ -156,3 +174,17 @@ export const CLAUDE_INSTRUCTION_GLOBS: readonly string[];
|
|
|
156
174
|
* set, and with {@link excludeFromContextScan} to prune the `.claude` walk.
|
|
157
175
|
*/
|
|
158
176
|
export const CLAUDE_LAUNCH_GLOBS: readonly string[];
|
|
177
|
+
/**
|
|
178
|
+
* The `eventNamed` kinds spelled relative to the USER-GLOBAL config root — the
|
|
179
|
+
* `~/.claude` (or `CLAUDE_CONFIG_DIR`) directory Claude Code loads at launch
|
|
180
|
+
* whatever the project is.
|
|
181
|
+
*
|
|
182
|
+
* A second spelling because that root is a `.claude` directory ITSELF: its files
|
|
183
|
+
* are `CLAUDE.md` and `rules/x.md`, not `.claude/rules/x.md`, and under
|
|
184
|
+
* `CLAUDE_CONFIG_DIR` the path may carry no `.claude` segment at all — so
|
|
185
|
+
* {@link announcedByInstructionsLoaded}, which reads a path's own segments, has
|
|
186
|
+
* nothing there to classify by. Every row's shape is asserted in
|
|
187
|
+
* test/claude-context.test.mjs, since a shape with no spelling here would glob
|
|
188
|
+
* to nothing and read as "no event is coming".
|
|
189
|
+
*/
|
|
190
|
+
export const USER_GLOBAL_EVENT_NAMED_GLOBS: readonly string[];
|