agent-sanitizer 2.47.13 → 2.47.15
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 +174 -28
- package/claude-hooks/pretooluse-sanitize.mjs +40 -1
- package/claude-hooks/scan-invisible-chars.mjs +7 -20
- 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 +43 -19
- package/types/claude-hooks/scan-invisible-chars.d.mts +3 -3
- 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
|
|
@@ -11,23 +11,38 @@
|
|
|
11
11
|
* have one definition.
|
|
12
12
|
*/
|
|
13
13
|
import {
|
|
14
|
+
existsSync,
|
|
15
|
+
globSync,
|
|
14
16
|
lstatSync,
|
|
15
17
|
mkdirSync,
|
|
16
18
|
readdirSync,
|
|
17
19
|
readFileSync,
|
|
18
20
|
rmSync,
|
|
21
|
+
statSync,
|
|
19
22
|
} from "node:fs";
|
|
20
23
|
import { randomBytes } from "node:crypto";
|
|
21
24
|
import { basename, join } from "node:path";
|
|
22
|
-
import { tmpdir, userInfo } from "node:os";
|
|
25
|
+
import { homedir, tmpdir, userInfo } from "node:os";
|
|
23
26
|
import {
|
|
24
27
|
lazyImport,
|
|
25
28
|
markerIsTrusted,
|
|
29
|
+
PROJECT_DIR,
|
|
26
30
|
PROJECT_HASH,
|
|
27
31
|
scrubUntrustedText,
|
|
28
32
|
writeFileNoFollow,
|
|
29
33
|
writeSentinelFile,
|
|
30
34
|
} from "./hook-io.mjs";
|
|
35
|
+
// Relative, like scan-invisible-chars.mjs's own import of this module: the
|
|
36
|
+
// launch scope is hook POLICY (see src/claude-context.mjs), and this table is
|
|
37
|
+
// pure data with no fs access of its own — the fs calls below are this
|
|
38
|
+
// module's, not a copy of the SessionStart hook's target-discovery glue.
|
|
39
|
+
import {
|
|
40
|
+
ancestorInstructionFiles,
|
|
41
|
+
announcedByInstructionsLoaded,
|
|
42
|
+
CLAUDE_LAUNCH_GLOBS,
|
|
43
|
+
excludeFromContextScan,
|
|
44
|
+
USER_GLOBAL_EVENT_NAMED_GLOBS,
|
|
45
|
+
} from "../../src/claude-context.mjs";
|
|
31
46
|
|
|
32
47
|
// Layer-1 scrubber for the untrusted alert-store contents the gate splices into a
|
|
33
48
|
// permissionDecisionReason. The WELL-FORMED composition, not the bare applyLayer1:
|
|
@@ -256,13 +271,14 @@ export function sweepStaleSessions(sessionId) {
|
|
|
256
271
|
* The ack always expires; the findings only when this session has its own store
|
|
257
272
|
* and so merely INHERITED these.
|
|
258
273
|
*
|
|
259
|
-
* The
|
|
260
|
-
* run at all", so FALLBACK_TTL_MS must never reach them:
|
|
261
|
-
* would render the gap notice on a session that WAS
|
|
262
|
-
*
|
|
263
|
-
* at
|
|
264
|
-
*
|
|
265
|
-
*
|
|
274
|
+
* The three InstructionsLoaded markers under the same prefix answer "did a scan
|
|
275
|
+
* run at all" and "was launch empty", so FALLBACK_TTL_MS must never reach them:
|
|
276
|
+
* expiring one mid-session would render the gap notice on a session that WAS
|
|
277
|
+
* scanned, or re-glob a launch this session already found empty. They still go,
|
|
278
|
+
* at MARKER_TTL_MS — the same age the loop above ages a real session's prefix
|
|
279
|
+
* out at, and far past any session's life — because the loop skips this prefix
|
|
280
|
+
* and would otherwise leave a session-less host's markers in $TMPDIR forever,
|
|
281
|
+
* with the gap notice suppressed on every later session.
|
|
266
282
|
* @param {string} [sessionId]
|
|
267
283
|
* @returns {void}
|
|
268
284
|
*/
|
|
@@ -281,7 +297,11 @@ function sweepStaleFallback(sessionId) {
|
|
|
281
297
|
};
|
|
282
298
|
for (const path of [alertAckFile(), ...entries])
|
|
283
299
|
if (!withinFallbackTtl(path)) drop(path);
|
|
284
|
-
for (const path of [
|
|
300
|
+
for (const path of [
|
|
301
|
+
instructionsLoadedFile(),
|
|
302
|
+
instructionsLoadedNoticeFile(),
|
|
303
|
+
launchEmptyFile(),
|
|
304
|
+
])
|
|
285
305
|
if (!withinTtl(path, MARKER_TTL_MS)) drop(path);
|
|
286
306
|
}
|
|
287
307
|
|
|
@@ -311,32 +331,158 @@ export function recordInstructionsLoaded(sessionId) {
|
|
|
311
331
|
const EVENT_MIN_CLI_VERSION = "2.1.69";
|
|
312
332
|
|
|
313
333
|
/**
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
334
|
+
* Companion marker: this session already found the LAUNCH set empty — that set
|
|
335
|
+
* (which files load at session start) cannot change mid-session, so a second
|
|
336
|
+
* glob of `dir` can never change that half of the answer. It says nothing about
|
|
337
|
+
* a directory touched later; {@link instructionsLoadedGapNotice}'s `touchedDir`
|
|
338
|
+
* covers that half fresh on every call instead.
|
|
339
|
+
* @param {string} [sessionId]
|
|
340
|
+
* @returns {string}
|
|
341
|
+
*/
|
|
342
|
+
export function launchEmptyFile(sessionId) {
|
|
343
|
+
return `${instructionsLoadedFile(sessionId)}.launch-empty`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Every file Claude Code loads as model context AT LAUNCH from `dir`: its own
|
|
348
|
+
* instruction files, its `.claude/` context tree, and the CLAUDE.md /
|
|
349
|
+
* CLAUDE.local.md of every directory above it. THE SSOT scan-invisible-chars.mjs
|
|
350
|
+
* reads too (re-exported there as `findInstructionFiles`) — sharing the one
|
|
351
|
+
* function is what keeps the SessionStart scan's targets and this module's
|
|
352
|
+
* launch-emptiness check from drifting into two different answers for "what
|
|
353
|
+
* loads at launch". See src/claude-context.mjs for why this is the shallow
|
|
354
|
+
* launch scope and not a whole-tree walk.
|
|
355
|
+
* @param {string} dir
|
|
356
|
+
* @returns {string[]}
|
|
357
|
+
*/
|
|
358
|
+
export function launchInstructionFiles(dir) {
|
|
359
|
+
return [
|
|
360
|
+
...globSync([...CLAUDE_LAUNCH_GLOBS], {
|
|
361
|
+
cwd: dir,
|
|
362
|
+
exclude: excludeFromContextScan,
|
|
363
|
+
}).map((name) => join(dir, name)),
|
|
364
|
+
// Filtered, unlike the glob's matches: almost every parent directory holds
|
|
365
|
+
// neither memory file, so the unfiltered chain would file ~10 phantom
|
|
366
|
+
// targets per session into scan-invisible-chars.mjs's operator-facing
|
|
367
|
+
// "absent" bucket. A file that appears after this check was not loaded at
|
|
368
|
+
// launch either, so nothing is lost by not listing it.
|
|
369
|
+
...ancestorInstructionFiles(dir).filter((file) => existsSync(file)),
|
|
370
|
+
];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
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.
|
|
317
378
|
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
379
|
+
* A file this uid cannot even STAT (EACCES, EISDIR, ELOOP…) is unvetted
|
|
380
|
+
* context, not evidence of absence — the same split scan-invisible-chars.mjs's
|
|
381
|
+
* classifyReadFailure makes between ABSENT and SKIPPED. Reading such a failure
|
|
382
|
+
* as "no content" would suppress the notice over a file that may carry a real,
|
|
383
|
+
* unscanned payload, so only ENOENT counts as nothing there; anything else
|
|
384
|
+
* counts as content and leaves the notice free to fire.
|
|
385
|
+
* @param {string[]} files
|
|
386
|
+
* @returns {boolean}
|
|
387
|
+
*/
|
|
388
|
+
function anyFileHasBytes(files) {
|
|
389
|
+
for (const file of files) {
|
|
390
|
+
try {
|
|
391
|
+
if (statSync(file).size > 0) return true;
|
|
392
|
+
} catch (err) {
|
|
393
|
+
if (/** @type {NodeJS.ErrnoException} */ (err).code !== "ENOENT")
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
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
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Whether the USER-GLOBAL `~/.claude` memory and rules — a second root Claude
|
|
415
|
+
* Code loads at launch regardless of the project directory (see
|
|
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.
|
|
426
|
+
*
|
|
427
|
+
* Honours `CLAUDE_CONFIG_DIR` the way plugin/scripts/enable-auto-update.mjs's
|
|
428
|
+
* own resolution does, since that root — not always `~/.claude` — is where
|
|
429
|
+
* Claude Code actually reads this content from.
|
|
430
|
+
* @param {Record<string, string | undefined>} [env]
|
|
431
|
+
* @returns {boolean}
|
|
432
|
+
*/
|
|
433
|
+
function userGlobalLaunchHasContent(env = process.env) {
|
|
434
|
+
const configDir = env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
|
|
435
|
+
return anyFileHasBytes(
|
|
436
|
+
globSync([...USER_GLOBAL_EVENT_NAMED_GLOBS], {
|
|
437
|
+
cwd: configDir,
|
|
438
|
+
exclude: excludeFromContextScan,
|
|
439
|
+
}).map((name) => join(configDir, name)),
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* The one-time context line for a session where no InstructionsLoaded scan ran,
|
|
445
|
+
* or null when the scan has been seen, the notice already ran this session, or
|
|
446
|
+
* neither launch nor `touchedDir` could have fired the event.
|
|
322
447
|
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
448
|
+
* PURE for the notice: nothing is recorded until the caller confirms it landed
|
|
449
|
+
* in a returned response. Launch-emptiness is cached once found — fixed for the
|
|
450
|
+
* session — but `touchedDir` is re-checked every call: the claim below is about
|
|
451
|
+
* SUBDIRECTORY files, so an empty launch must not silence a later call whose
|
|
452
|
+
* tool touched a directory that DOES carry real, unscanned content.
|
|
326
453
|
*
|
|
327
|
-
*
|
|
328
|
-
* the
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
* @param {string} [sessionId] the harness's session identity
|
|
334
|
-
*
|
|
454
|
+
* SessionStart scans what loads at launch; a subdirectory's file is scanned by
|
|
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
|
|
458
|
+
* the marker cannot tell them apart: an unwired event, a Claude Code older than
|
|
459
|
+
* EVENT_MIN_CLI_VERSION, or the hook disabled via AGENT_SANITIZER_DISABLED_HOOKS.
|
|
460
|
+
* @param {string} [sessionId] the harness's session identity (see
|
|
461
|
+
* instructionsLoadedFile)
|
|
462
|
+
* @param {string} [dir] the project root to check (injectable; defaults to
|
|
463
|
+
* the real project)
|
|
464
|
+
* @param {string} [touchedDir] this tool call's own target directory, when
|
|
465
|
+
* known — re-checked every call, never cached
|
|
335
466
|
* @returns {string | null}
|
|
336
467
|
*/
|
|
337
|
-
export function instructionsLoadedGapNotice(
|
|
468
|
+
export function instructionsLoadedGapNotice(
|
|
469
|
+
sessionId,
|
|
470
|
+
dir = PROJECT_DIR,
|
|
471
|
+
touchedDir,
|
|
472
|
+
) {
|
|
338
473
|
if (instructionsLoadedSeen(sessionId)) return null;
|
|
339
474
|
if (markerIsTrusted(instructionsLoadedNoticeFile(sessionId))) return null;
|
|
475
|
+
const launchCached = markerIsTrusted(launchEmptyFile(sessionId));
|
|
476
|
+
const launchHasBytes =
|
|
477
|
+
!launchCached &&
|
|
478
|
+
(anyFileHasBytes(announcedLaunchFiles(dir)) ||
|
|
479
|
+
userGlobalLaunchHasContent());
|
|
480
|
+
if (!launchCached && !launchHasBytes)
|
|
481
|
+
writeSentinelFile(launchEmptyFile(sessionId));
|
|
482
|
+
const touchedHasBytes =
|
|
483
|
+
touchedDir !== undefined &&
|
|
484
|
+
anyFileHasBytes(announcedLaunchFiles(touchedDir));
|
|
485
|
+
if (!launchHasBytes && !touchedHasBytes) return null;
|
|
340
486
|
return (
|
|
341
487
|
"agent-sanitizer: no InstructionsLoaded scan has run this session, so " +
|
|
342
488
|
"instruction files loaded from SUBDIRECTORIES (a nested CLAUDE.md, a " +
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import { createRequire } from "node:module";
|
|
31
31
|
import { readFileSync } from "node:fs";
|
|
32
|
+
import { dirname } from "node:path";
|
|
32
33
|
import {
|
|
33
34
|
isMain,
|
|
34
35
|
lazyImport,
|
|
@@ -424,6 +425,40 @@ export function preToolUseLayers(rehydrate, env = process.env) {
|
|
|
424
425
|
: layers;
|
|
425
426
|
}
|
|
426
427
|
|
|
428
|
+
// The tools whose path field Claude Code sends as an absolute path by
|
|
429
|
+
// contract, so reading it needs no cwd to resolve against. Bounded to these:
|
|
430
|
+
// Glob/Grep's `path` can be relative or omitted (defaults to a cwd this
|
|
431
|
+
// payload does not carry), and Bash has no reliable target at all — the same
|
|
432
|
+
// carve-out WRITE_SHAPED_TOOLS below takes for Bash writes.
|
|
433
|
+
const PATH_FIELD_BY_TOOL = /** @type {Record<string, string>} */ (
|
|
434
|
+
Object.freeze({
|
|
435
|
+
Read: "file_path",
|
|
436
|
+
Edit: "file_path",
|
|
437
|
+
Write: "file_path",
|
|
438
|
+
MultiEdit: "file_path",
|
|
439
|
+
NotebookEdit: "notebook_path",
|
|
440
|
+
})
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* The directory THIS tool call targets, for the InstructionsLoaded gap-notice
|
|
445
|
+
* check — or undefined when the tool carries no reliable absolute path. An
|
|
446
|
+
* imprecise guess here only ever WIDENS coverage (see instructionsLoadedGapNotice's
|
|
447
|
+
* `touchedDir`): missing a real target loses nothing this check did not
|
|
448
|
+
* already lack, and there is no wrong-directory case that suppresses a real
|
|
449
|
+
* finding.
|
|
450
|
+
* @param {string} tool
|
|
451
|
+
* @param {any} toolInput
|
|
452
|
+
* @returns {string | undefined}
|
|
453
|
+
*/
|
|
454
|
+
function toolTargetDir(tool, toolInput) {
|
|
455
|
+
const field = PATH_FIELD_BY_TOOL[tool];
|
|
456
|
+
const path = field && toolInput?.[field];
|
|
457
|
+
return typeof path === "string" && path.startsWith("/")
|
|
458
|
+
? dirname(path)
|
|
459
|
+
: undefined;
|
|
460
|
+
}
|
|
461
|
+
|
|
427
462
|
/**
|
|
428
463
|
* Compose the four protections. Returns the `hookSpecificOutput` fields to
|
|
429
464
|
* emit, or null for a clean no-op. Throws only if a layer's engine throws; the
|
|
@@ -468,7 +503,11 @@ export async function buildPreToolUseResponse(
|
|
|
468
503
|
// the notice was surfaced: a rehydrate deny below returns before the response
|
|
469
504
|
// is assembled, and recording here would burn the session's one report on a
|
|
470
505
|
// call that never carried it.
|
|
471
|
-
const gapNotice = instructionsLoadedGapNotice(
|
|
506
|
+
const gapNotice = instructionsLoadedGapNotice(
|
|
507
|
+
input.session_id,
|
|
508
|
+
undefined,
|
|
509
|
+
toolTargetDir(tool, toolInput),
|
|
510
|
+
);
|
|
472
511
|
if (gapNotice !== null) contexts.push(gapNotice);
|
|
473
512
|
|
|
474
513
|
// Layers 2-4, run by the declared pipeline: the driver — not this call order —
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
* false positive the SSOT had already fixed, and its clean path was a bare
|
|
22
22
|
* `writeFileSync` with none of cleanFile's symlink/UTF-8/TOCTOU guards.
|
|
23
23
|
*/
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
24
|
+
import { readFileSync } from "node:fs";
|
|
25
|
+
import { relative, resolve } from "node:path";
|
|
26
26
|
import {
|
|
27
27
|
awaitLazyDependency,
|
|
28
28
|
emitHookResponse,
|
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
alertAckFile,
|
|
46
46
|
alertDir,
|
|
47
47
|
appendAlert,
|
|
48
|
+
launchInstructionFiles,
|
|
48
49
|
sweepStaleSessions,
|
|
49
50
|
} from "./lib/invisible-alert.mjs";
|
|
50
51
|
import { formatReport } from "./lib/invisible-report.mjs";
|
|
@@ -59,11 +60,9 @@ import { reportSlowHook, startHookTimer } from "./lib/hook-timing.mjs";
|
|
|
59
60
|
// so importing it statically carries none of the fail-open hazard lazyImport
|
|
60
61
|
// exists to cover.
|
|
61
62
|
import {
|
|
62
|
-
ancestorInstructionFiles,
|
|
63
63
|
CLAUDE_CONTEXT_SUBDIRS,
|
|
64
64
|
CLAUDE_INSTRUCTION_GLOBS,
|
|
65
65
|
CLAUDE_LAUNCH_GLOBS,
|
|
66
|
-
excludeFromContextScan,
|
|
67
66
|
isInsideDir,
|
|
68
67
|
} from "../src/claude-context.mjs";
|
|
69
68
|
|
|
@@ -234,26 +233,14 @@ function decodeRun(run) {
|
|
|
234
233
|
* files load when a tool reads their directory, and scan-loaded-instructions
|
|
235
234
|
* scans each one at that moment.
|
|
236
235
|
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
* rather than
|
|
236
|
+
* lib/invisible-alert.mjs's `launchInstructionFiles` IS this function — shared
|
|
237
|
+
* so its InstructionsLoaded gap-notice check reads the identical target set
|
|
238
|
+
* this scan does, rather than a second enumeration that could drift.
|
|
240
239
|
* @param {string} dir
|
|
241
240
|
* @returns {string[]}
|
|
242
241
|
*/
|
|
243
242
|
function findInstructionFiles(dir) {
|
|
244
|
-
return
|
|
245
|
-
...globSync([...CLAUDE_LAUNCH_GLOBS], {
|
|
246
|
-
cwd: dir,
|
|
247
|
-
exclude: excludeFromContextScan,
|
|
248
|
-
}).map((name) => join(dir, name)),
|
|
249
|
-
// Filtered, unlike the glob's matches: almost every parent directory holds
|
|
250
|
-
// neither memory file, so the unfiltered chain would file ~10 phantom
|
|
251
|
-
// targets per session into the `absent` bucket and bury the one thing that
|
|
252
|
-
// bucket reports — a target that existed when the scan listed it and was
|
|
253
|
-
// gone by the read. A file that appears after this check was not loaded at
|
|
254
|
-
// launch either, so nothing is lost by not listing it.
|
|
255
|
-
...ancestorInstructionFiles(dir).filter((file) => existsSync(file)),
|
|
256
|
-
];
|
|
243
|
+
return launchInstructionFiles(dir);
|
|
257
244
|
}
|
|
258
245
|
|
|
259
246
|
// Scanner
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.47.
|
|
3
|
+
"version": "2.47.15",
|
|
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[];
|
|
@@ -74,31 +74,55 @@ export function sweepStaleSessions(sessionId?: string): void;
|
|
|
74
74
|
* @returns {void}
|
|
75
75
|
*/
|
|
76
76
|
export function recordInstructionsLoaded(sessionId?: string): void;
|
|
77
|
+
/**
|
|
78
|
+
* Companion marker: this session already found the LAUNCH set empty — that set
|
|
79
|
+
* (which files load at session start) cannot change mid-session, so a second
|
|
80
|
+
* glob of `dir` can never change that half of the answer. It says nothing about
|
|
81
|
+
* a directory touched later; {@link instructionsLoadedGapNotice}'s `touchedDir`
|
|
82
|
+
* covers that half fresh on every call instead.
|
|
83
|
+
* @param {string} [sessionId]
|
|
84
|
+
* @returns {string}
|
|
85
|
+
*/
|
|
86
|
+
export function launchEmptyFile(sessionId?: string): string;
|
|
87
|
+
/**
|
|
88
|
+
* Every file Claude Code loads as model context AT LAUNCH from `dir`: its own
|
|
89
|
+
* instruction files, its `.claude/` context tree, and the CLAUDE.md /
|
|
90
|
+
* CLAUDE.local.md of every directory above it. THE SSOT scan-invisible-chars.mjs
|
|
91
|
+
* reads too (re-exported there as `findInstructionFiles`) — sharing the one
|
|
92
|
+
* function is what keeps the SessionStart scan's targets and this module's
|
|
93
|
+
* launch-emptiness check from drifting into two different answers for "what
|
|
94
|
+
* loads at launch". See src/claude-context.mjs for why this is the shallow
|
|
95
|
+
* launch scope and not a whole-tree walk.
|
|
96
|
+
* @param {string} dir
|
|
97
|
+
* @returns {string[]}
|
|
98
|
+
*/
|
|
99
|
+
export function launchInstructionFiles(dir: string): string[];
|
|
77
100
|
/**
|
|
78
101
|
* The one-time context line for a session where no InstructionsLoaded scan ran,
|
|
79
|
-
* or null when the scan has been seen
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* PURE: it does not record that the notice was handed out. The caller records
|
|
83
|
-
* separately, once the notice has actually landed in a response — a deny
|
|
84
|
-
* assembled after this call discards the notice, and a marker written here would
|
|
85
|
-
* have burned the session's one chance to report the loss.
|
|
102
|
+
* or null when the scan has been seen, the notice already ran this session, or
|
|
103
|
+
* neither launch nor `touchedDir` could have fired the event.
|
|
86
104
|
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
105
|
+
* PURE for the notice: nothing is recorded until the caller confirms it landed
|
|
106
|
+
* in a returned response. Launch-emptiness is cached once found — fixed for the
|
|
107
|
+
* session — but `touchedDir` is re-checked every call: the claim below is about
|
|
108
|
+
* SUBDIRECTORY files, so an empty launch must not silence a later call whose
|
|
109
|
+
* tool touched a directory that DOES carry real, unscanned content.
|
|
90
110
|
*
|
|
91
|
-
*
|
|
92
|
-
* the
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* @param {string} [sessionId] the harness's session identity
|
|
98
|
-
*
|
|
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 held
|
|
113
|
+
* a file the event would have ANNOUNCED, with bytes in it. The three remaining
|
|
114
|
+
* causes are named together, since
|
|
115
|
+
* the marker cannot tell them apart: an unwired event, a Claude Code older than
|
|
116
|
+
* EVENT_MIN_CLI_VERSION, or the hook disabled via AGENT_SANITIZER_DISABLED_HOOKS.
|
|
117
|
+
* @param {string} [sessionId] the harness's session identity (see
|
|
118
|
+
* instructionsLoadedFile)
|
|
119
|
+
* @param {string} [dir] the project root to check (injectable; defaults to
|
|
120
|
+
* the real project)
|
|
121
|
+
* @param {string} [touchedDir] this tool call's own target directory, when
|
|
122
|
+
* known — re-checked every call, never cached
|
|
99
123
|
* @returns {string | null}
|
|
100
124
|
*/
|
|
101
|
-
export function instructionsLoadedGapNotice(sessionId?: string): string | null;
|
|
125
|
+
export function instructionsLoadedGapNotice(sessionId?: string, dir?: string, touchedDir?: string): string | null;
|
|
102
126
|
/**
|
|
103
127
|
* Record that the gap notice above was surfaced, so it rides on ONE tool call
|
|
104
128
|
* rather than every one — the per-call repeat is what trains a reader to skip it.
|
|
@@ -116,9 +116,9 @@ export function decodeRun(run: string): {
|
|
|
116
116
|
* files load when a tool reads their directory, and scan-loaded-instructions
|
|
117
117
|
* scans each one at that moment.
|
|
118
118
|
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
* rather than
|
|
119
|
+
* lib/invisible-alert.mjs's `launchInstructionFiles` IS this function — shared
|
|
120
|
+
* so its InstructionsLoaded gap-notice check reads the identical target set
|
|
121
|
+
* this scan does, rather than a second enumeration that could drift.
|
|
122
122
|
* @param {string} dir
|
|
123
123
|
* @returns {string[]}
|
|
124
124
|
*/
|
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[];
|