agent-sanitizer 2.13.0 → 2.14.1
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.
|
@@ -12,7 +12,125 @@ import { userInfo } from "node:os";
|
|
|
12
12
|
import { createHash } from "node:crypto";
|
|
13
13
|
import { pathToFileURL } from "node:url";
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* EVERY process-wide slot these helpers keep — the four a host can observe or
|
|
17
|
+
* steer, so a second instance that adopts this object is steered in all four at
|
|
18
|
+
* once. A slot left off this object is one a host must configure per instance,
|
|
19
|
+
* and forgetting the second call fails silently; that is the whole failure class
|
|
20
|
+
* {@link adoptHookIoSharedState} exists to remove, so the object is complete
|
|
21
|
+
* rather than covering only the registry.
|
|
22
|
+
*
|
|
23
|
+
* - `lazyModules` — the namespaces {@link lazyImport} answers from. Empty when
|
|
24
|
+
* the hooks run from source; a build-time BUNDLE (which ships with no
|
|
25
|
+
* node_modules for the runtime `import()` to resolve) statically imports its
|
|
26
|
+
* packages and registers them here before importing the hooks that lazy-load
|
|
27
|
+
* them, so the same hook source runs unchanged in both worlds.
|
|
28
|
+
* - `cliEntryClaimed` — the CLI-entry latch {@link isMain} reads.
|
|
29
|
+
* - `missingPackageRemedy` — the host remedy {@link configureMissingPackageRemedy}
|
|
30
|
+
* sets; null keeps {@link DEFAULT_MISSING_PACKAGE_REMEDY}.
|
|
31
|
+
* - `hookgateMarker` — the marker path {@link configureHookgateMarker} sets, and
|
|
32
|
+
* `hookgateMarkerResolved`, the latch that makes a too-late call say so.
|
|
33
|
+
* @typedef {{
|
|
34
|
+
* lazyModules: Record<string, Record<string, any>>,
|
|
35
|
+
* cliEntryClaimed: boolean,
|
|
36
|
+
* missingPackageRemedy: string | null,
|
|
37
|
+
* hookgateMarker: string | null,
|
|
38
|
+
* hookgateMarkerResolved: boolean,
|
|
39
|
+
* }} HookIoSharedState
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A fresh state object with every slot at its default. Called per use, never
|
|
44
|
+
* hoisted to one shared literal, so each caller gets its own `lazyModules`.
|
|
45
|
+
* @returns {HookIoSharedState}
|
|
46
|
+
*/
|
|
47
|
+
const defaultSharedState = () => ({
|
|
48
|
+
lazyModules: Object.create(null),
|
|
49
|
+
cliEntryClaimed: false,
|
|
50
|
+
missingPackageRemedy: null,
|
|
51
|
+
hookgateMarker: null,
|
|
52
|
+
hookgateMarkerResolved: false,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/** @type {HookIoSharedState} */
|
|
56
|
+
let shared = defaultSharedState();
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* This instance's state object, for a host to hand to another instance.
|
|
60
|
+
* @returns {HookIoSharedState}
|
|
61
|
+
*/
|
|
62
|
+
export function hookIoSharedState() {
|
|
63
|
+
return shared;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Route every slot of {@link HookIoSharedState} on this instance through `state`.
|
|
68
|
+
*
|
|
69
|
+
* A host that ships its OWN hook-io module beside the packaged hooks ends up
|
|
70
|
+
* with two instances of this file's state in one process, each with its own
|
|
71
|
+
* registry and its own latches. Without this seam the host configures each slot
|
|
72
|
+
* twice, and a slot it sets on only one instance is invisible to the readers on
|
|
73
|
+
* the other. For the registry that means those readers resolve a specifier at
|
|
74
|
+
* RUNTIME, inside a bundle with no node_modules, and the gate fails closed on
|
|
75
|
+
* every call. Adopting one state object removes that failure mode rather than
|
|
76
|
+
* policing it.
|
|
77
|
+
*
|
|
78
|
+
* WHY AN API AND NOT A BUNDLER ALIAS: collapsing the two module records at build
|
|
79
|
+
* time (an esbuild `alias` from the host's module to this one) is the cheaper
|
|
80
|
+
* fix and needs no API — but it works only when the host's module is a COPY of
|
|
81
|
+
* this one. The motivating host's is not: it exports names this module does not
|
|
82
|
+
* have and lacks names this one exports, so an alias breaks every call site of
|
|
83
|
+
* the difference. `test/claude-hooks-exports.test.mjs` still states the rule for
|
|
84
|
+
* a true copy — share this module, never duplicate it. This seam serves the
|
|
85
|
+
* other case, a host with its own module that must agree with ours on state.
|
|
86
|
+
*
|
|
87
|
+
* Call it before importing any module that reads a slot, for the same reason
|
|
88
|
+
* {@link registerLazyModules} carries that rule: a reader binds at its own
|
|
89
|
+
* module scope.
|
|
90
|
+
*
|
|
91
|
+
* Slots already set on EITHER side survive: this instance's registrations and
|
|
92
|
+
* latches carry over, and a value already present in `state` wins, since it is
|
|
93
|
+
* the adopted root's own choice. Adopting the state this instance already holds
|
|
94
|
+
* is a no-op.
|
|
95
|
+
*
|
|
96
|
+
* CONSTRAINT: every instance must adopt the same root object, and none may adopt
|
|
97
|
+
* a second, different one. `shared` is reassigned, so a later `B.adopt(C)` would
|
|
98
|
+
* leave an earlier `A.adopt(B)` pointing at an abandoned object whose readers
|
|
99
|
+
* nothing reaches. This is not enforced with a throw: callers are bundle entry
|
|
100
|
+
* points, and a throw at their top level kills the hook before it writes a
|
|
101
|
+
* response — a hook that emits nothing reads as non-blocking, which is the
|
|
102
|
+
* fail-OPEN this whole module is built to avoid.
|
|
103
|
+
* @param {HookIoSharedState} state
|
|
104
|
+
* @returns {void}
|
|
105
|
+
*/
|
|
106
|
+
export function adoptHookIoSharedState(state) {
|
|
107
|
+
if (state === shared) return;
|
|
108
|
+
// Fill any slot `state` omits before anything reads it. A host builds this
|
|
109
|
+
// object itself, so one written against an earlier version of this package
|
|
110
|
+
// lacks the slots added since — and every reader below tests `!== null`, which
|
|
111
|
+
// an ABSENT slot satisfies. Unfilled, hookgateMarkerPath returns undefined
|
|
112
|
+
// instead of deriving a path; probeSetupAlive then throws internally on it and
|
|
113
|
+
// reports setup alive forever, so awaitLazyDependency waits out its whole
|
|
114
|
+
// ceiling, the harness kills the hook, and a killed hook is non-blocking — the
|
|
115
|
+
// fail-OPEN this module exists to prevent. `??=` and not `=`: it must not
|
|
116
|
+
// clobber a slot the host deliberately set, including `false`.
|
|
117
|
+
const slots = /** @type {Record<string, any>} */ (state);
|
|
118
|
+
for (const [slot, value] of Object.entries(defaultSharedState()))
|
|
119
|
+
slots[slot] ??= value;
|
|
120
|
+
for (const [specifier, namespace] of Object.entries(shared.lazyModules))
|
|
121
|
+
if (state.lazyModules[specifier] === undefined)
|
|
122
|
+
state.lazyModules[specifier] = namespace;
|
|
123
|
+
if (shared.cliEntryClaimed) state.cliEntryClaimed = true;
|
|
124
|
+
if (
|
|
125
|
+
shared.missingPackageRemedy !== null &&
|
|
126
|
+
state.missingPackageRemedy === null
|
|
127
|
+
)
|
|
128
|
+
state.missingPackageRemedy = shared.missingPackageRemedy;
|
|
129
|
+
if (shared.hookgateMarker !== null && state.hookgateMarker === null)
|
|
130
|
+
state.hookgateMarker = shared.hookgateMarker;
|
|
131
|
+
if (shared.hookgateMarkerResolved) state.hookgateMarkerResolved = true;
|
|
132
|
+
shared = state;
|
|
133
|
+
}
|
|
16
134
|
|
|
17
135
|
/**
|
|
18
136
|
* True when this module is the process entry point (run directly as a CLI, not
|
|
@@ -29,7 +147,7 @@ export function isMain(importMetaUrl) {
|
|
|
29
147
|
// alongside the real entry's and consume its stdin. An entry that claimed the
|
|
30
148
|
// CLI slot (claimCliEntry) therefore makes every later isMain call answer
|
|
31
149
|
// false — module bodies run in dependency order, so the claim lands first.
|
|
32
|
-
if (cliEntryClaimed) return false;
|
|
150
|
+
if (shared.cliEntryClaimed) return false;
|
|
33
151
|
return (
|
|
34
152
|
Boolean(process.argv[1]) &&
|
|
35
153
|
importMetaUrl === pathToFileURL(process.argv[1]).href
|
|
@@ -43,7 +161,7 @@ export function isMain(importMetaUrl) {
|
|
|
43
161
|
* @returns {void}
|
|
44
162
|
*/
|
|
45
163
|
export function claimCliEntry() {
|
|
46
|
-
cliEntryClaimed = true;
|
|
164
|
+
shared.cliEntryClaimed = true;
|
|
47
165
|
}
|
|
48
166
|
|
|
49
167
|
/**
|
|
@@ -121,17 +239,6 @@ export async function readStdinJson(maxBytes = MAX_STDIN_BYTES) {
|
|
|
121
239
|
return JSON.parse((await readAllBounded(process.stdin, maxBytes)).toString());
|
|
122
240
|
}
|
|
123
241
|
|
|
124
|
-
/**
|
|
125
|
-
* Pre-registered module namespaces consulted by {@link lazyImport} before it
|
|
126
|
-
* dials the loader. Empty when the hooks run from source; a build-time BUNDLE
|
|
127
|
-
* (which ships with no node_modules for the runtime `import()` to resolve)
|
|
128
|
-
* statically imports its packages and registers them here before importing the
|
|
129
|
-
* hooks that lazy-load them, so the same hook source runs unchanged in both
|
|
130
|
-
* worlds.
|
|
131
|
-
* @type {Record<string, Record<string, any>>}
|
|
132
|
-
*/
|
|
133
|
-
const registeredLazyModules = Object.create(null);
|
|
134
|
-
|
|
135
242
|
/**
|
|
136
243
|
* Register already-loaded module namespaces for {@link lazyImport} to return in
|
|
137
244
|
* place of a runtime dynamic import. Call before importing any module that
|
|
@@ -140,7 +247,7 @@ const registeredLazyModules = Object.create(null);
|
|
|
140
247
|
* @returns {void}
|
|
141
248
|
*/
|
|
142
249
|
export function registerLazyModules(modules) {
|
|
143
|
-
Object.assign(
|
|
250
|
+
Object.assign(shared.lazyModules, modules);
|
|
144
251
|
}
|
|
145
252
|
|
|
146
253
|
/**
|
|
@@ -153,7 +260,7 @@ export function registerLazyModules(modules) {
|
|
|
153
260
|
* @returns {Record<string, any> | undefined}
|
|
154
261
|
*/
|
|
155
262
|
export function registeredLazyModule(specifier) {
|
|
156
|
-
return
|
|
263
|
+
return shared.lazyModules[specifier];
|
|
157
264
|
}
|
|
158
265
|
|
|
159
266
|
/**
|
|
@@ -178,7 +285,7 @@ const lazyImportErrors = new Map();
|
|
|
178
285
|
* @returns {Promise<Record<string, any>>}
|
|
179
286
|
*/
|
|
180
287
|
export async function lazyImport(specifier) {
|
|
181
|
-
const registered =
|
|
288
|
+
const registered = shared.lazyModules[specifier];
|
|
182
289
|
if (registered) {
|
|
183
290
|
lazyImportErrors.delete(specifier);
|
|
184
291
|
return registered;
|
|
@@ -241,13 +348,6 @@ export function failedLazyPackages() {
|
|
|
241
348
|
export const DEFAULT_MISSING_PACKAGE_REMEDY =
|
|
242
349
|
"reinstall the hook dependencies (pnpm install) and retry.";
|
|
243
350
|
|
|
244
|
-
/**
|
|
245
|
-
* Host-supplied default remedy, replacing DEFAULT_MISSING_PACKAGE_REMEDY. Null
|
|
246
|
-
* (the default) keeps the package's wording.
|
|
247
|
-
* @type {string | null}
|
|
248
|
-
*/
|
|
249
|
-
let missingPackageRemedyOverride = null;
|
|
250
|
-
|
|
251
351
|
/**
|
|
252
352
|
* Adopt a host's own remedy as the default {@link missingPackageMessage} and
|
|
253
353
|
* {@link missingPackageError} state when their caller passes none. This refusal
|
|
@@ -265,7 +365,7 @@ let missingPackageRemedyOverride = null;
|
|
|
265
365
|
* @returns {void}
|
|
266
366
|
*/
|
|
267
367
|
export function configureMissingPackageRemedy(remedy) {
|
|
268
|
-
|
|
368
|
+
shared.missingPackageRemedy = remedy;
|
|
269
369
|
}
|
|
270
370
|
|
|
271
371
|
/**
|
|
@@ -284,7 +384,7 @@ export function configureMissingPackageRemedy(remedy) {
|
|
|
284
384
|
export function missingPackageMessage(
|
|
285
385
|
pkg,
|
|
286
386
|
err = lazyImportErrorFor(pkg),
|
|
287
|
-
remedy =
|
|
387
|
+
remedy = shared.missingPackageRemedy ?? DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
288
388
|
) {
|
|
289
389
|
const prefix = `${pkg} is unavailable: `;
|
|
290
390
|
// 2 for the "; " joiner; 12 for safeErrMessage's own "…[truncated]" marker,
|
|
@@ -314,7 +414,7 @@ export function missingPackageMessage(
|
|
|
314
414
|
export function missingPackageError(
|
|
315
415
|
pkg,
|
|
316
416
|
err = lazyImportErrorFor(pkg),
|
|
317
|
-
remedy =
|
|
417
|
+
remedy = shared.missingPackageRemedy ?? DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
318
418
|
) {
|
|
319
419
|
return Object.assign(new Error(missingPackageMessage(pkg, err, remedy)), {
|
|
320
420
|
code: "DEP_UNAVAILABLE",
|
|
@@ -421,16 +521,6 @@ export function emitHookResponse(hookEventName, fields) {
|
|
|
421
521
|
/** The marker filename stem; the project directory is appended to it. */
|
|
422
522
|
const HOOKGATE_MARKER_STEM = "agent-sanitizer-hookgate-inflight-";
|
|
423
523
|
|
|
424
|
-
/**
|
|
425
|
-
* Host-supplied marker path, replacing the derived one. Null (the default) keeps
|
|
426
|
-
* the derivation below.
|
|
427
|
-
* @type {string | null}
|
|
428
|
-
*/
|
|
429
|
-
let hookgateMarkerOverride = null;
|
|
430
|
-
|
|
431
|
-
/** Whether {@link hookgateMarkerPath} has already handed a path to a caller. */
|
|
432
|
-
let hookgateMarkerResolved = false;
|
|
433
|
-
|
|
434
524
|
/**
|
|
435
525
|
* Adopt a host's own cold-start marker path in place of the derived one, so a
|
|
436
526
|
* host whose setup script already writes a marker under its own convention can
|
|
@@ -449,13 +539,13 @@ let hookgateMarkerResolved = false;
|
|
|
449
539
|
* @returns {void}
|
|
450
540
|
*/
|
|
451
541
|
export function configureHookgateMarker(path) {
|
|
452
|
-
if (hookgateMarkerResolved)
|
|
542
|
+
if (shared.hookgateMarkerResolved)
|
|
453
543
|
process.stderr.write(
|
|
454
544
|
"agent-sanitizer: configureHookgateMarker called after a marker path was " +
|
|
455
545
|
"already resolved; whatever resolved it is using the previous path and " +
|
|
456
546
|
"cannot be re-steered. Call it before importing any hook module.\n",
|
|
457
547
|
);
|
|
458
|
-
|
|
548
|
+
shared.hookgateMarker = path;
|
|
459
549
|
}
|
|
460
550
|
|
|
461
551
|
/**
|
|
@@ -479,8 +569,8 @@ export function hookgateMarkerPath(
|
|
|
479
569
|
projectDir = process.env.CLAUDE_PROJECT_DIR,
|
|
480
570
|
runtimeDir = process.env.XDG_RUNTIME_DIR,
|
|
481
571
|
) {
|
|
482
|
-
hookgateMarkerResolved = true;
|
|
483
|
-
if (
|
|
572
|
+
shared.hookgateMarkerResolved = true;
|
|
573
|
+
if (shared.hookgateMarker !== null) return shared.hookgateMarker;
|
|
484
574
|
if (!projectDir) return null;
|
|
485
575
|
// Prefer the per-user, mode-0700 runtime dir when the harness gives an
|
|
486
576
|
// absolute one; else the world-writable /tmp, where markerIsTrusted() — not
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.14.1",
|
|
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,3 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This instance's state object, for a host to hand to another instance.
|
|
3
|
+
* @returns {HookIoSharedState}
|
|
4
|
+
*/
|
|
5
|
+
export function hookIoSharedState(): HookIoSharedState;
|
|
6
|
+
/**
|
|
7
|
+
* Route every slot of {@link HookIoSharedState} on this instance through `state`.
|
|
8
|
+
*
|
|
9
|
+
* A host that ships its OWN hook-io module beside the packaged hooks ends up
|
|
10
|
+
* with two instances of this file's state in one process, each with its own
|
|
11
|
+
* registry and its own latches. Without this seam the host configures each slot
|
|
12
|
+
* twice, and a slot it sets on only one instance is invisible to the readers on
|
|
13
|
+
* the other. For the registry that means those readers resolve a specifier at
|
|
14
|
+
* RUNTIME, inside a bundle with no node_modules, and the gate fails closed on
|
|
15
|
+
* every call. Adopting one state object removes that failure mode rather than
|
|
16
|
+
* policing it.
|
|
17
|
+
*
|
|
18
|
+
* WHY AN API AND NOT A BUNDLER ALIAS: collapsing the two module records at build
|
|
19
|
+
* time (an esbuild `alias` from the host's module to this one) is the cheaper
|
|
20
|
+
* fix and needs no API — but it works only when the host's module is a COPY of
|
|
21
|
+
* this one. The motivating host's is not: it exports names this module does not
|
|
22
|
+
* have and lacks names this one exports, so an alias breaks every call site of
|
|
23
|
+
* the difference. `test/claude-hooks-exports.test.mjs` still states the rule for
|
|
24
|
+
* a true copy — share this module, never duplicate it. This seam serves the
|
|
25
|
+
* other case, a host with its own module that must agree with ours on state.
|
|
26
|
+
*
|
|
27
|
+
* Call it before importing any module that reads a slot, for the same reason
|
|
28
|
+
* {@link registerLazyModules} carries that rule: a reader binds at its own
|
|
29
|
+
* module scope.
|
|
30
|
+
*
|
|
31
|
+
* Slots already set on EITHER side survive: this instance's registrations and
|
|
32
|
+
* latches carry over, and a value already present in `state` wins, since it is
|
|
33
|
+
* the adopted root's own choice. Adopting the state this instance already holds
|
|
34
|
+
* is a no-op.
|
|
35
|
+
*
|
|
36
|
+
* CONSTRAINT: every instance must adopt the same root object, and none may adopt
|
|
37
|
+
* a second, different one. `shared` is reassigned, so a later `B.adopt(C)` would
|
|
38
|
+
* leave an earlier `A.adopt(B)` pointing at an abandoned object whose readers
|
|
39
|
+
* nothing reaches. This is not enforced with a throw: callers are bundle entry
|
|
40
|
+
* points, and a throw at their top level kills the hook before it writes a
|
|
41
|
+
* response — a hook that emits nothing reads as non-blocking, which is the
|
|
42
|
+
* fail-OPEN this whole module is built to avoid.
|
|
43
|
+
* @param {HookIoSharedState} state
|
|
44
|
+
* @returns {void}
|
|
45
|
+
*/
|
|
46
|
+
export function adoptHookIoSharedState(state: HookIoSharedState): void;
|
|
1
47
|
/**
|
|
2
48
|
* True when this module is the process entry point (run directly as a CLI, not
|
|
3
49
|
* imported). Guards an undefined `process.argv[1]` (e.g. the REPL) before
|
|
@@ -349,3 +395,29 @@ export const MAX_STDIN_BYTES: number;
|
|
|
349
395
|
* command the reader should actually run.
|
|
350
396
|
*/
|
|
351
397
|
export const DEFAULT_MISSING_PACKAGE_REMEDY: "reinstall the hook dependencies (pnpm install) and retry.";
|
|
398
|
+
/**
|
|
399
|
+
* EVERY process-wide slot these helpers keep — the four a host can observe or
|
|
400
|
+
* steer, so a second instance that adopts this object is steered in all four at
|
|
401
|
+
* once. A slot left off this object is one a host must configure per instance,
|
|
402
|
+
* and forgetting the second call fails silently; that is the whole failure class
|
|
403
|
+
* {@link adoptHookIoSharedState} exists to remove, so the object is complete
|
|
404
|
+
* rather than covering only the registry.
|
|
405
|
+
*
|
|
406
|
+
* - `lazyModules` — the namespaces {@link lazyImport} answers from. Empty when
|
|
407
|
+
* the hooks run from source; a build-time BUNDLE (which ships with no
|
|
408
|
+
* node_modules for the runtime `import()` to resolve) statically imports its
|
|
409
|
+
* packages and registers them here before importing the hooks that lazy-load
|
|
410
|
+
* them, so the same hook source runs unchanged in both worlds.
|
|
411
|
+
* - `cliEntryClaimed` — the CLI-entry latch {@link isMain} reads.
|
|
412
|
+
* - `missingPackageRemedy` — the host remedy {@link configureMissingPackageRemedy}
|
|
413
|
+
* sets; null keeps {@link DEFAULT_MISSING_PACKAGE_REMEDY}.
|
|
414
|
+
* - `hookgateMarker` — the marker path {@link configureHookgateMarker} sets, and
|
|
415
|
+
* `hookgateMarkerResolved`, the latch that makes a too-late call say so.
|
|
416
|
+
*/
|
|
417
|
+
export type HookIoSharedState = {
|
|
418
|
+
lazyModules: Record<string, Record<string, any>>;
|
|
419
|
+
cliEntryClaimed: boolean;
|
|
420
|
+
missingPackageRemedy: string | null;
|
|
421
|
+
hookgateMarker: string | null;
|
|
422
|
+
hookgateMarkerResolved: boolean;
|
|
423
|
+
};
|