agent-sanitizer 2.66.4 → 2.66.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -250,7 +250,7 @@ To wire them yourself instead, one entry dispatches every mode on `--hook=`:
|
|
|
250
250
|
| `SessionStart` | `scan-invisible-chars` |
|
|
251
251
|
| `InstructionsLoaded` | `scan-loaded-instructions` |
|
|
252
252
|
|
|
253
|
-
**Wire all five.** The instruction-file scan is split across two of them: `SessionStart` covers the files that load at launch, and `InstructionsLoaded` covers every one a subdirectory loads later. A host that wires the first without the second leaves a nested `CLAUDE.md` scanned by nothing
|
|
253
|
+
**Wire all five.** The instruction-file scan is split across two of them: `SessionStart` covers the files that load at launch, and `InstructionsLoaded` covers every one a subdirectory loads later. A host that wires the first without the second leaves a nested `CLAUDE.md` scanned by nothing. The one-time PreToolUse coverage notice says so, but only when a file announced at launch went unscanned: a project whose only `CLAUDE.md` files sit in subdirectories gets no notice.
|
|
254
254
|
|
|
255
255
|
`InstructionsLoaded` needs Claude Code **2.1.69 or newer** — 2.1.68 does not emit the event, so the hook never fires however you wire it. Check with `claude --version`.
|
|
256
256
|
|
package/THREAT-MODEL.md
CHANGED
|
@@ -547,8 +547,10 @@ entirely: a host that never wired the `InstructionsLoaded` event to
|
|
|
547
547
|
`scan-loaded-instructions`, a Claude Code older than 2.1.69, which is the first
|
|
548
548
|
build that emits that event, and `scan-loaded-instructions` switched off in
|
|
549
549
|
`AGENT_SANITIZER_DISABLED_HOOKS`.
|
|
550
|
-
|
|
551
|
-
|
|
550
|
+
The PreToolUse gate reports the gap once per session rather than leaving it
|
|
551
|
+
silent. It checks the disable list and asks the session's own `claude --version`,
|
|
552
|
+
so the notice names only the cause it could not rule out: usually the wiring,
|
|
553
|
+
which only `/hooks` shows.
|
|
552
554
|
|
|
553
555
|
A fourth state is not one of them and is not reported as one: the host emits no
|
|
554
556
|
event when it has nothing to announce. The gate therefore asks not "does anything
|
|
@@ -561,10 +563,11 @@ or an empty `~/.claude/CLAUDE.md` fires no event however the hook is wired, so
|
|
|
561
563
|
the missing scan there is evidence of nothing. The trade this accepts is a false
|
|
562
564
|
NEGATIVE: a repo whose announced instruction files all sit in subdirectories —
|
|
563
565
|
a monorepo of per-package `CLAUDE.md` under a root carrying only `AGENTS.md`, on
|
|
564
|
-
a machine with no user-global memory —
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
566
|
+
a machine with no user-global memory — gets no notice at all. A tool call that
|
|
567
|
+
touches such a directory is no evidence either: Claude Code loads the nested
|
|
568
|
+
file only after that call, so its PreToolUse runs before any scan could have.
|
|
569
|
+
Weighed against a false POSITIVE on every session launched outside a project,
|
|
570
|
+
which trains the operator to skip the notice everywhere.
|
|
568
571
|
|
|
569
572
|
That table is a claim about someone else's product, so the event that names a
|
|
570
573
|
loaded file is also what falsifies it. `contextScopeContradiction` checks every
|
|
@@ -19,10 +19,13 @@ import {
|
|
|
19
19
|
rmSync,
|
|
20
20
|
statSync,
|
|
21
21
|
} from "node:fs";
|
|
22
|
+
import { spawnSync } from "node:child_process";
|
|
22
23
|
import { randomBytes } from "node:crypto";
|
|
23
24
|
import { basename, join } from "node:path";
|
|
24
25
|
import { homedir } from "node:os";
|
|
25
26
|
import {
|
|
27
|
+
DISABLED_HOOKS_ENV,
|
|
28
|
+
disabledHooks,
|
|
26
29
|
hookgateMarkerPath,
|
|
27
30
|
lazyImport,
|
|
28
31
|
markerIsTrusted,
|
|
@@ -340,12 +343,13 @@ export function recordInstructionsLoaded(sessionId) {
|
|
|
340
343
|
*/
|
|
341
344
|
const EVENT_MIN_CLI_VERSION = "2.1.69";
|
|
342
345
|
|
|
346
|
+
/** The `--hook=` name of the InstructionsLoaded scanner. */
|
|
347
|
+
const LOADED_SCAN_HOOK = "scan-loaded-instructions";
|
|
348
|
+
|
|
343
349
|
/**
|
|
344
350
|
* Companion marker: this session already found the LAUNCH set empty — that set
|
|
345
351
|
* (which files load at session start) cannot change mid-session, so a second
|
|
346
|
-
* glob of `dir` can never change
|
|
347
|
-
* a directory touched later; {@link instructionsLoadedGapNotice}'s `touchedDir`
|
|
348
|
-
* covers that half fresh on every call instead.
|
|
352
|
+
* glob of `dir` can never change the answer.
|
|
349
353
|
* @param {string} [sessionId]
|
|
350
354
|
* @returns {string}
|
|
351
355
|
*/
|
|
@@ -455,36 +459,106 @@ function userGlobalLaunchHasContent(env = process.env) {
|
|
|
455
459
|
);
|
|
456
460
|
}
|
|
457
461
|
|
|
462
|
+
/**
|
|
463
|
+
* The running Claude Code's version as `[major, minor, patch]`, or null when it
|
|
464
|
+
* cannot be read. Asks the binary rather than `CLAUDE_CODE_VERSION`: a remote
|
|
465
|
+
* host hands hooks a stale value there (2.1.42 under a 2.1.304 CLI).
|
|
466
|
+
* `CLAUDE_CODE_EXECPATH` names the binary running this session. Without it the
|
|
467
|
+
* answer is null: PATH's `claude` may be another install, and reporting its
|
|
468
|
+
* version as this session's would name a false cause. On an npm install that
|
|
469
|
+
* path is `node`, whose `v…` output also reads as null.
|
|
470
|
+
* @param {Record<string, string | undefined>} [env]
|
|
471
|
+
* @returns {number[] | null}
|
|
472
|
+
*/
|
|
473
|
+
function runningCliVersion(env = process.env) {
|
|
474
|
+
if (!env.CLAUDE_CODE_EXECPATH) return null;
|
|
475
|
+
const result = spawnSync(env.CLAUDE_CODE_EXECPATH, ["--version"], {
|
|
476
|
+
encoding: "utf8",
|
|
477
|
+
timeout: 2000,
|
|
478
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
479
|
+
});
|
|
480
|
+
const match = /^(\d+)\.(\d+)\.(\d+)/u.exec(result.stdout ?? "");
|
|
481
|
+
return match ? match.slice(1).map(Number) : null;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Whether `version` is older than EVENT_MIN_CLI_VERSION.
|
|
486
|
+
* @param {number[]} version
|
|
487
|
+
* @returns {boolean}
|
|
488
|
+
*/
|
|
489
|
+
function predatesEvent(version) {
|
|
490
|
+
const floor = EVENT_MIN_CLI_VERSION.split(".").map(Number);
|
|
491
|
+
for (let i = 0; i < floor.length; i++)
|
|
492
|
+
if (version[i] !== floor[i]) return version[i] < floor[i];
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* The causes of a missing InstructionsLoaded scan that this hook could NOT rule
|
|
498
|
+
* out, each with the command that settles it. The disable list and the CLI
|
|
499
|
+
* version are checked here; only the wiring is left for `/hooks`. A cause that
|
|
500
|
+
* is confirmed is returned alone, since it explains the gap by itself.
|
|
501
|
+
* @param {Record<string, string | undefined>} env
|
|
502
|
+
* @param {(env: Record<string, string | undefined>) => number[] | null} cliVersion
|
|
503
|
+
* @returns {string}
|
|
504
|
+
*/
|
|
505
|
+
function unexplainedGapCauses(env, cliVersion) {
|
|
506
|
+
// A real list of hook names never reaches this one-name `known`, so the
|
|
507
|
+
// unknown-name report would misfire on every other entry: it is silenced.
|
|
508
|
+
if (disabledHooks([LOADED_SCAN_HOOK], env, () => {}).size > 0)
|
|
509
|
+
return `${LOADED_SCAN_HOOK} is switched off in ${DISABLED_HOOKS_ENV}; removing it from that list restores the scan.`;
|
|
510
|
+
const unwired =
|
|
511
|
+
`this host never wired the InstructionsLoaded event to ${LOADED_SCAN_HOOK} ` +
|
|
512
|
+
"(the `/hooks` command lists what this session registered; wiring it " +
|
|
513
|
+
"restores the scan), or the hook crashed before it ran";
|
|
514
|
+
const version = cliVersion(env);
|
|
515
|
+
if (version === null)
|
|
516
|
+
return (
|
|
517
|
+
`${LOADED_SCAN_HOOK} is not disabled. Either ${unwired}, or this Claude ` +
|
|
518
|
+
`Code is older than ${EVENT_MIN_CLI_VERSION}, the first build that emits ` +
|
|
519
|
+
"the event (its version could not be read: run `claude --version`)."
|
|
520
|
+
);
|
|
521
|
+
if (predatesEvent(version))
|
|
522
|
+
return (
|
|
523
|
+
`this Claude Code is ${version.join(".")}, older than ` +
|
|
524
|
+
`${EVENT_MIN_CLI_VERSION}, the first build that emits the event; ` +
|
|
525
|
+
"upgrading restores the scan."
|
|
526
|
+
);
|
|
527
|
+
return (
|
|
528
|
+
`${LOADED_SCAN_HOOK} is not disabled and Claude Code ${version.join(".")} ` +
|
|
529
|
+
`emits the event, so either ${unwired}.`
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
458
533
|
/**
|
|
459
534
|
* The one-time context line for a session where no InstructionsLoaded scan ran,
|
|
460
535
|
* or null when the scan has been seen, the notice already ran this session, the
|
|
461
|
-
* host's cold-start marker says setup is still running, or
|
|
462
|
-
*
|
|
536
|
+
* host's cold-start marker says setup is still running, or nothing announced at
|
|
537
|
+
* launch could have fired the event.
|
|
463
538
|
*
|
|
464
539
|
* PURE for the notice: nothing is recorded until the caller confirms it landed
|
|
465
|
-
* in a returned response. Launch-emptiness is cached once found
|
|
466
|
-
*
|
|
467
|
-
* SUBDIRECTORY files, so an empty launch must not silence a later call whose
|
|
468
|
-
* tool touched a directory that DOES carry real, unscanned content.
|
|
540
|
+
* in a returned response. Launch-emptiness is cached once found, since the
|
|
541
|
+
* launch set is fixed for the session.
|
|
469
542
|
*
|
|
470
|
-
*
|
|
471
|
-
* the
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
* the marker cannot tell them apart: an unwired event, a Claude Code older than
|
|
475
|
-
* EVENT_MIN_CLI_VERSION, or the hook disabled via AGENT_SANITIZER_DISABLED_HOOKS.
|
|
543
|
+
* Launch is the only evidence read. Claude Code fires the session_start events
|
|
544
|
+
* before the first PreToolUse, but a subdirectory's CLAUDE.md loads only AFTER
|
|
545
|
+
* the tool call that reaches it, so a PreToolUse can never already see that
|
|
546
|
+
* file's scan, however the hook is wired.
|
|
476
547
|
* @param {string} [sessionId] the harness's session identity (see
|
|
477
548
|
* instructionsLoadedFile)
|
|
478
549
|
* @param {string} [dir] the project root to check (injectable; defaults to
|
|
479
550
|
* the real project)
|
|
480
|
-
* @param {
|
|
481
|
-
*
|
|
551
|
+
* @param {{
|
|
552
|
+
* env?: Record<string, string | undefined>,
|
|
553
|
+
* cliVersion?: (env: Record<string, string | undefined>) => number[] | null,
|
|
554
|
+
* }} [options] injectable for tests; any other value, such as a directory
|
|
555
|
+
* string from an older caller, reads as no options
|
|
482
556
|
* @returns {string | null}
|
|
483
557
|
*/
|
|
484
558
|
export function instructionsLoadedGapNotice(
|
|
485
559
|
sessionId,
|
|
486
560
|
dir = PROJECT_DIR,
|
|
487
|
-
|
|
561
|
+
{ env = process.env, cliVersion = runningCliVersion } = {},
|
|
488
562
|
) {
|
|
489
563
|
if (instructionsLoadedSeen(sessionId)) return null;
|
|
490
564
|
if (markerIsTrusted(instructionsLoadedNoticeFile(sessionId))) return null;
|
|
@@ -497,31 +571,20 @@ export function instructionsLoadedGapNotice(
|
|
|
497
571
|
// notice for every later session on the host.
|
|
498
572
|
const marker = hookgateMarkerPath();
|
|
499
573
|
if (markerIsTrusted(marker) && probeSetupAlive(marker)) return null;
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
!
|
|
503
|
-
(
|
|
504
|
-
|
|
505
|
-
if (!launchCached && !launchHasBytes)
|
|
574
|
+
if (markerIsTrusted(launchEmptyFile(sessionId))) return null;
|
|
575
|
+
if (
|
|
576
|
+
!anyFileHasBytes(announcedLaunchFiles(dir)) &&
|
|
577
|
+
!userGlobalLaunchHasContent(env)
|
|
578
|
+
) {
|
|
506
579
|
writeSentinelFile(launchEmptyFile(sessionId));
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
anyFileHasBytes(announcedLaunchFiles(touchedDir));
|
|
510
|
-
if (!launchHasBytes && !touchedHasBytes) return null;
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
511
582
|
return (
|
|
512
583
|
"agent-sanitizer: no InstructionsLoaded scan has run this session, so " +
|
|
513
584
|
"instruction files loaded from SUBDIRECTORIES (a nested CLAUDE.md, a " +
|
|
514
585
|
"directory-scoped rule) are reaching the model unscanned for hidden " +
|
|
515
586
|
"Unicode — the session-start scan covers only the files loaded at launch. " +
|
|
516
|
-
|
|
517
|
-
"each: this host never wired the InstructionsLoaded event to " +
|
|
518
|
-
"scan-loaded-instructions (the `/hooks` command lists what this session " +
|
|
519
|
-
"actually registered, whichever config dir or plugin root the host uses; " +
|
|
520
|
-
"wiring it restores the coverage), a Claude Code " +
|
|
521
|
-
`older than ${EVENT_MIN_CLI_VERSION}, the first build that emits the event ` +
|
|
522
|
-
"(`claude --version`; upgrading restores it), or scan-loaded-instructions " +
|
|
523
|
-
"switched off in AGENT_SANITIZER_DISABLED_HOOKS (`echo " +
|
|
524
|
-
"$AGENT_SANITIZER_DISABLED_HOOKS`)."
|
|
587
|
+
`Tell the user: ${unexplainedGapCauses(env, cliVersion)}`
|
|
525
588
|
);
|
|
526
589
|
}
|
|
527
590
|
|
|
@@ -29,7 +29,6 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import { createRequire } from "node:module";
|
|
31
31
|
import { readFileSync } from "node:fs";
|
|
32
|
-
import { dirname } from "node:path";
|
|
33
32
|
import {
|
|
34
33
|
isMain,
|
|
35
34
|
lazyImport,
|
|
@@ -438,40 +437,6 @@ export function preToolUseLayers(
|
|
|
438
437
|
);
|
|
439
438
|
}
|
|
440
439
|
|
|
441
|
-
// The tools whose path field Claude Code sends as an absolute path by
|
|
442
|
-
// contract, so reading it needs no cwd to resolve against. Bounded to these:
|
|
443
|
-
// Glob/Grep's `path` can be relative or omitted (defaults to a cwd this
|
|
444
|
-
// payload does not carry), and Bash has no reliable target at all — the same
|
|
445
|
-
// carve-out WRITE_SHAPED_TOOLS below takes for Bash writes.
|
|
446
|
-
const PATH_FIELD_BY_TOOL = /** @type {Record<string, string>} */ (
|
|
447
|
-
Object.freeze({
|
|
448
|
-
Read: "file_path",
|
|
449
|
-
Edit: "file_path",
|
|
450
|
-
Write: "file_path",
|
|
451
|
-
MultiEdit: "file_path",
|
|
452
|
-
NotebookEdit: "notebook_path",
|
|
453
|
-
})
|
|
454
|
-
);
|
|
455
|
-
|
|
456
|
-
/**
|
|
457
|
-
* The directory THIS tool call targets, for the InstructionsLoaded gap-notice
|
|
458
|
-
* check — or undefined when the tool carries no reliable absolute path. An
|
|
459
|
-
* imprecise guess here only ever WIDENS coverage (see instructionsLoadedGapNotice's
|
|
460
|
-
* `touchedDir`): missing a real target loses nothing this check did not
|
|
461
|
-
* already lack, and there is no wrong-directory case that suppresses a real
|
|
462
|
-
* finding.
|
|
463
|
-
* @param {string} tool
|
|
464
|
-
* @param {any} toolInput
|
|
465
|
-
* @returns {string | undefined}
|
|
466
|
-
*/
|
|
467
|
-
function toolTargetDir(tool, toolInput) {
|
|
468
|
-
const field = PATH_FIELD_BY_TOOL[tool];
|
|
469
|
-
const path = field && toolInput?.[field];
|
|
470
|
-
return typeof path === "string" && path.startsWith("/")
|
|
471
|
-
? dirname(path)
|
|
472
|
-
: undefined;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
440
|
/**
|
|
476
441
|
* Compose the five stages. Returns the `hookSpecificOutput` fields to emit, or
|
|
477
442
|
* null for a clean no-op. Throws only if a stage's engine throws; the caller
|
|
@@ -523,11 +488,7 @@ export async function buildPreToolUseResponse(input, rehydrate, sink, layers) {
|
|
|
523
488
|
// the notice was surfaced: a rehydrate deny below returns before the response
|
|
524
489
|
// is assembled, and recording here would burn the session's one report on a
|
|
525
490
|
// call that never carried it.
|
|
526
|
-
const gapNotice = instructionsLoadedGapNotice(
|
|
527
|
-
input.session_id,
|
|
528
|
-
undefined,
|
|
529
|
-
toolTargetDir(tool, toolInput),
|
|
530
|
-
);
|
|
491
|
+
const gapNotice = instructionsLoadedGapNotice(input.session_id);
|
|
531
492
|
if (gapNotice !== null) contexts.push(gapNotice);
|
|
532
493
|
|
|
533
494
|
// The rewriting stages, run by the declared pipeline: the driver — not this
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.66.
|
|
3
|
+
"version": "2.66.6",
|
|
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": {
|
|
@@ -82,9 +82,7 @@ export function recordInstructionsLoaded(sessionId?: string): void;
|
|
|
82
82
|
/**
|
|
83
83
|
* Companion marker: this session already found the LAUNCH set empty — that set
|
|
84
84
|
* (which files load at session start) cannot change mid-session, so a second
|
|
85
|
-
* glob of `dir` can never change
|
|
86
|
-
* a directory touched later; {@link instructionsLoadedGapNotice}'s `touchedDir`
|
|
87
|
-
* covers that half fresh on every call instead.
|
|
85
|
+
* glob of `dir` can never change the answer.
|
|
88
86
|
* @param {string} [sessionId]
|
|
89
87
|
* @returns {string}
|
|
90
88
|
*/
|
|
@@ -105,30 +103,32 @@ export function launchInstructionFiles(dir: string): string[];
|
|
|
105
103
|
/**
|
|
106
104
|
* The one-time context line for a session where no InstructionsLoaded scan ran,
|
|
107
105
|
* or null when the scan has been seen, the notice already ran this session, the
|
|
108
|
-
* host's cold-start marker says setup is still running, or
|
|
109
|
-
*
|
|
106
|
+
* host's cold-start marker says setup is still running, or nothing announced at
|
|
107
|
+
* launch could have fired the event.
|
|
110
108
|
*
|
|
111
109
|
* PURE for the notice: nothing is recorded until the caller confirms it landed
|
|
112
|
-
* in a returned response. Launch-emptiness is cached once found
|
|
113
|
-
*
|
|
114
|
-
* SUBDIRECTORY files, so an empty launch must not silence a later call whose
|
|
115
|
-
* tool touched a directory that DOES carry real, unscanned content.
|
|
110
|
+
* in a returned response. Launch-emptiness is cached once found, since the
|
|
111
|
+
* launch set is fixed for the session.
|
|
116
112
|
*
|
|
117
|
-
*
|
|
118
|
-
* the
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
* the marker cannot tell them apart: an unwired event, a Claude Code older than
|
|
122
|
-
* EVENT_MIN_CLI_VERSION, or the hook disabled via AGENT_SANITIZER_DISABLED_HOOKS.
|
|
113
|
+
* Launch is the only evidence read. Claude Code fires the session_start events
|
|
114
|
+
* before the first PreToolUse, but a subdirectory's CLAUDE.md loads only AFTER
|
|
115
|
+
* the tool call that reaches it, so a PreToolUse can never already see that
|
|
116
|
+
* file's scan, however the hook is wired.
|
|
123
117
|
* @param {string} [sessionId] the harness's session identity (see
|
|
124
118
|
* instructionsLoadedFile)
|
|
125
119
|
* @param {string} [dir] the project root to check (injectable; defaults to
|
|
126
120
|
* the real project)
|
|
127
|
-
* @param {
|
|
128
|
-
*
|
|
121
|
+
* @param {{
|
|
122
|
+
* env?: Record<string, string | undefined>,
|
|
123
|
+
* cliVersion?: (env: Record<string, string | undefined>) => number[] | null,
|
|
124
|
+
* }} [options] injectable for tests; any other value, such as a directory
|
|
125
|
+
* string from an older caller, reads as no options
|
|
129
126
|
* @returns {string | null}
|
|
130
127
|
*/
|
|
131
|
-
export function instructionsLoadedGapNotice(sessionId?: string, dir?: string,
|
|
128
|
+
export function instructionsLoadedGapNotice(sessionId?: string, dir?: string, { env, cliVersion }?: {
|
|
129
|
+
env?: Record<string, string | undefined>;
|
|
130
|
+
cliVersion?: (env: Record<string, string | undefined>) => number[] | null;
|
|
131
|
+
}): string | null;
|
|
132
132
|
/**
|
|
133
133
|
* Record that the gap notice above was surfaced, so it rides on ONE tool call
|
|
134
134
|
* rather than every one — the per-call repeat is what trains a reader to skip it.
|