agent-sanitizer 2.13.0 → 2.14.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.
@@ -12,7 +12,106 @@ import { userInfo } from "node:os";
12
12
  import { createHash } from "node:crypto";
13
13
  import { pathToFileURL } from "node:url";
14
14
 
15
- let cliEntryClaimed = false;
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
+ /** @type {HookIoSharedState} */
43
+ let shared = {
44
+ lazyModules: Object.create(null),
45
+ cliEntryClaimed: false,
46
+ missingPackageRemedy: null,
47
+ hookgateMarker: null,
48
+ hookgateMarkerResolved: false,
49
+ };
50
+
51
+ /**
52
+ * This instance's state object, for a host to hand to another instance.
53
+ * @returns {HookIoSharedState}
54
+ */
55
+ export function hookIoSharedState() {
56
+ return shared;
57
+ }
58
+
59
+ /**
60
+ * Route every slot of {@link HookIoSharedState} on this instance through `state`.
61
+ *
62
+ * A host that ships its OWN hook-io module beside the packaged hooks ends up
63
+ * with two instances of this file's state in one process, each with its own
64
+ * registry and its own latches. Without this seam the host configures each slot
65
+ * twice, and a slot it sets on only one instance is invisible to the readers on
66
+ * the other. For the registry that means those readers resolve a specifier at
67
+ * RUNTIME, inside a bundle with no node_modules, and the gate fails closed on
68
+ * every call. Adopting one state object removes that failure mode rather than
69
+ * policing it.
70
+ *
71
+ * WHY AN API AND NOT A BUNDLER ALIAS: collapsing the two module records at build
72
+ * time (an esbuild `alias` from the host's module to this one) is the cheaper
73
+ * fix and needs no API — but it works only when the host's module is a COPY of
74
+ * this one. The motivating host's is not: it exports names this module does not
75
+ * have and lacks names this one exports, so an alias breaks every call site of
76
+ * the difference. `test/claude-hooks-exports.test.mjs` still states the rule for
77
+ * a true copy — share this module, never duplicate it. This seam serves the
78
+ * other case, a host with its own module that must agree with ours on state.
79
+ *
80
+ * Call it before importing any module that reads a slot, for the same reason
81
+ * {@link registerLazyModules} carries that rule: a reader binds at its own
82
+ * module scope.
83
+ *
84
+ * Slots already set on EITHER side survive: this instance's registrations and
85
+ * latches carry over, and a value already present in `state` wins, since it is
86
+ * the adopted root's own choice. Adopting the state this instance already holds
87
+ * is a no-op.
88
+ *
89
+ * CONSTRAINT: every instance must adopt the same root object, and none may adopt
90
+ * a second, different one. `shared` is reassigned, so a later `B.adopt(C)` would
91
+ * leave an earlier `A.adopt(B)` pointing at an abandoned object whose readers
92
+ * nothing reaches. This is not enforced with a throw: callers are bundle entry
93
+ * points, and a throw at their top level kills the hook before it writes a
94
+ * response — a hook that emits nothing reads as non-blocking, which is the
95
+ * fail-OPEN this whole module is built to avoid.
96
+ * @param {HookIoSharedState} state
97
+ * @returns {void}
98
+ */
99
+ export function adoptHookIoSharedState(state) {
100
+ if (state === shared) return;
101
+ for (const [specifier, namespace] of Object.entries(shared.lazyModules))
102
+ if (state.lazyModules[specifier] === undefined)
103
+ state.lazyModules[specifier] = namespace;
104
+ if (shared.cliEntryClaimed) state.cliEntryClaimed = true;
105
+ if (
106
+ shared.missingPackageRemedy !== null &&
107
+ state.missingPackageRemedy === null
108
+ )
109
+ state.missingPackageRemedy = shared.missingPackageRemedy;
110
+ if (shared.hookgateMarker !== null && state.hookgateMarker === null)
111
+ state.hookgateMarker = shared.hookgateMarker;
112
+ if (shared.hookgateMarkerResolved) state.hookgateMarkerResolved = true;
113
+ shared = state;
114
+ }
16
115
 
17
116
  /**
18
117
  * True when this module is the process entry point (run directly as a CLI, not
@@ -29,7 +128,7 @@ export function isMain(importMetaUrl) {
29
128
  // alongside the real entry's and consume its stdin. An entry that claimed the
30
129
  // CLI slot (claimCliEntry) therefore makes every later isMain call answer
31
130
  // false — module bodies run in dependency order, so the claim lands first.
32
- if (cliEntryClaimed) return false;
131
+ if (shared.cliEntryClaimed) return false;
33
132
  return (
34
133
  Boolean(process.argv[1]) &&
35
134
  importMetaUrl === pathToFileURL(process.argv[1]).href
@@ -43,7 +142,7 @@ export function isMain(importMetaUrl) {
43
142
  * @returns {void}
44
143
  */
45
144
  export function claimCliEntry() {
46
- cliEntryClaimed = true;
145
+ shared.cliEntryClaimed = true;
47
146
  }
48
147
 
49
148
  /**
@@ -121,17 +220,6 @@ export async function readStdinJson(maxBytes = MAX_STDIN_BYTES) {
121
220
  return JSON.parse((await readAllBounded(process.stdin, maxBytes)).toString());
122
221
  }
123
222
 
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
223
  /**
136
224
  * Register already-loaded module namespaces for {@link lazyImport} to return in
137
225
  * place of a runtime dynamic import. Call before importing any module that
@@ -140,7 +228,7 @@ const registeredLazyModules = Object.create(null);
140
228
  * @returns {void}
141
229
  */
142
230
  export function registerLazyModules(modules) {
143
- Object.assign(registeredLazyModules, modules);
231
+ Object.assign(shared.lazyModules, modules);
144
232
  }
145
233
 
146
234
  /**
@@ -153,7 +241,7 @@ export function registerLazyModules(modules) {
153
241
  * @returns {Record<string, any> | undefined}
154
242
  */
155
243
  export function registeredLazyModule(specifier) {
156
- return registeredLazyModules[specifier];
244
+ return shared.lazyModules[specifier];
157
245
  }
158
246
 
159
247
  /**
@@ -178,7 +266,7 @@ const lazyImportErrors = new Map();
178
266
  * @returns {Promise<Record<string, any>>}
179
267
  */
180
268
  export async function lazyImport(specifier) {
181
- const registered = registeredLazyModules[specifier];
269
+ const registered = shared.lazyModules[specifier];
182
270
  if (registered) {
183
271
  lazyImportErrors.delete(specifier);
184
272
  return registered;
@@ -241,13 +329,6 @@ export function failedLazyPackages() {
241
329
  export const DEFAULT_MISSING_PACKAGE_REMEDY =
242
330
  "reinstall the hook dependencies (pnpm install) and retry.";
243
331
 
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
332
  /**
252
333
  * Adopt a host's own remedy as the default {@link missingPackageMessage} and
253
334
  * {@link missingPackageError} state when their caller passes none. This refusal
@@ -265,7 +346,7 @@ let missingPackageRemedyOverride = null;
265
346
  * @returns {void}
266
347
  */
267
348
  export function configureMissingPackageRemedy(remedy) {
268
- missingPackageRemedyOverride = remedy;
349
+ shared.missingPackageRemedy = remedy;
269
350
  }
270
351
 
271
352
  /**
@@ -284,7 +365,7 @@ export function configureMissingPackageRemedy(remedy) {
284
365
  export function missingPackageMessage(
285
366
  pkg,
286
367
  err = lazyImportErrorFor(pkg),
287
- remedy = missingPackageRemedyOverride ?? DEFAULT_MISSING_PACKAGE_REMEDY,
368
+ remedy = shared.missingPackageRemedy ?? DEFAULT_MISSING_PACKAGE_REMEDY,
288
369
  ) {
289
370
  const prefix = `${pkg} is unavailable: `;
290
371
  // 2 for the "; " joiner; 12 for safeErrMessage's own "…[truncated]" marker,
@@ -314,7 +395,7 @@ export function missingPackageMessage(
314
395
  export function missingPackageError(
315
396
  pkg,
316
397
  err = lazyImportErrorFor(pkg),
317
- remedy = missingPackageRemedyOverride ?? DEFAULT_MISSING_PACKAGE_REMEDY,
398
+ remedy = shared.missingPackageRemedy ?? DEFAULT_MISSING_PACKAGE_REMEDY,
318
399
  ) {
319
400
  return Object.assign(new Error(missingPackageMessage(pkg, err, remedy)), {
320
401
  code: "DEP_UNAVAILABLE",
@@ -421,16 +502,6 @@ export function emitHookResponse(hookEventName, fields) {
421
502
  /** The marker filename stem; the project directory is appended to it. */
422
503
  const HOOKGATE_MARKER_STEM = "agent-sanitizer-hookgate-inflight-";
423
504
 
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
505
  /**
435
506
  * Adopt a host's own cold-start marker path in place of the derived one, so a
436
507
  * host whose setup script already writes a marker under its own convention can
@@ -449,13 +520,13 @@ let hookgateMarkerResolved = false;
449
520
  * @returns {void}
450
521
  */
451
522
  export function configureHookgateMarker(path) {
452
- if (hookgateMarkerResolved)
523
+ if (shared.hookgateMarkerResolved)
453
524
  process.stderr.write(
454
525
  "agent-sanitizer: configureHookgateMarker called after a marker path was " +
455
526
  "already resolved; whatever resolved it is using the previous path and " +
456
527
  "cannot be re-steered. Call it before importing any hook module.\n",
457
528
  );
458
- hookgateMarkerOverride = path;
529
+ shared.hookgateMarker = path;
459
530
  }
460
531
 
461
532
  /**
@@ -479,8 +550,8 @@ export function hookgateMarkerPath(
479
550
  projectDir = process.env.CLAUDE_PROJECT_DIR,
480
551
  runtimeDir = process.env.XDG_RUNTIME_DIR,
481
552
  ) {
482
- hookgateMarkerResolved = true;
483
- if (hookgateMarkerOverride !== null) return hookgateMarkerOverride;
553
+ shared.hookgateMarkerResolved = true;
554
+ if (shared.hookgateMarker !== null) return shared.hookgateMarker;
484
555
  if (!projectDir) return null;
485
556
  // Prefer the per-user, mode-0700 runtime dir when the harness gives an
486
557
  // 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.13.0",
3
+ "version": "2.14.0",
4
4
  "description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -1,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
+ };