agent-sanitizer 2.10.1 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -202,6 +202,26 @@ hook module, and every consumer waits on that path instead. `lib/control-plane`
202
202
  resolves the marker at module scope, so a call that lands after that import
203
203
  warns on stderr — it cannot steer the wait that already started.
204
204
 
205
+ **A host's own remedy can replace the packaged one in every fail-closed
206
+ reason.** Deep call sites (`lib/control-plane`'s missing-package throw) take no
207
+ remedy argument, so by default they can only say `pnpm install`. A host whose
208
+ install has one entry point calls `configureMissingPackageRemedy(text)` (from
209
+ `lib/hook-io`) — typically at its bundle entry — and every remedy-less
210
+ `missingPackageMessage`/`missingPackageError` states that text instead. An
211
+ explicit per-call remedy (including a per-gate `MESSAGES.remedy`) still wins,
212
+ and `null` restores the packaged wording. Keep it to a sentence: past ~260
213
+ characters it overruns the 300-char message budget.
214
+
215
+ **A host's own secret registry can drive the env-bound redaction set.**
216
+ `configureEnvConfigSource({ minSecretLen, extraVars })` (from `lib/env-config`)
217
+ replaces the placeholder floor and unions extra `[A-Z0-9_]` variable names into
218
+ `envBoundSecretVars()`, so a host that already declares its forwarded
219
+ credentials (and their length floor) in a registry of its own feeds the packaged
220
+ helpers from it instead of forking the module. Unset fields keep the package
221
+ derivation; `null` restores it entirely. A malformed source — a non-object, a
222
+ key the seam does not read, a bad field — throws on first use, inside the
223
+ consuming hook's fail-closed catch, never at configure time.
224
+
205
225
  Hook internals are tuned by `_AGENT_SANITIZER_*` variables (redactor daemon
206
226
  path/socket/timeouts, sanitize budget, trace channel, Layer-2 reveal dir). The
207
227
  leading underscore marks them unstable — the supported surface is the `--hook=`
@@ -30,13 +30,101 @@ export function inferenceKeyVars() {
30
30
  return inferenceKeys.vars;
31
31
  }
32
32
 
33
+ /**
34
+ * Host-supplied secret-vocabulary source, consulted before the package configs.
35
+ * Null (the default) keeps the package derivation.
36
+ * @type {{ minSecretLen?: number, extraVars?: string[] } | null}
37
+ */
38
+ let hostEnvConfigSource = null;
39
+
40
+ const HOST_SOURCE_LABEL = "configureEnvConfigSource";
41
+
42
+ const HOST_SOURCE_KEYS = ["minSecretLen", "extraVars"];
43
+
44
+ /**
45
+ * Adopt a host's own secret config in place of the package's: `minSecretLen`
46
+ * replaces the placeholder floor, and `extraVars` are unioned into the
47
+ * env-bound redaction set. This seam is what lets a host whose credential
48
+ * registry already names its forwarded secrets (and their length floor) drive
49
+ * the packaged helpers from that registry instead of forking this module —
50
+ * a fork is the drift channel that lets the two redaction sets silently
51
+ * disagree. Unset fields keep their package derivation; `undefined` and `null`
52
+ * both restore it entirely. Like the missing-package remedy seam there is no
53
+ * too-late window: the source is consulted at each helper call, never resolved
54
+ * at module scope, so a later call steers every later answer. A malformed
55
+ * source — a non-object, a key this seam does not read, a bad field — throws
56
+ * on first use, not here (see {@link hostSource}).
57
+ * @param {{ minSecretLen?: number, extraVars?: string[] } | null} source
58
+ * host config, or null to restore the package derivation
59
+ * @returns {void}
60
+ */
61
+ export function configureEnvConfigSource(source) {
62
+ hostEnvConfigSource = source ?? null;
63
+ }
64
+
65
+ /**
66
+ * The stored host source with its shape validated, or null when unconfigured.
67
+ * Shape errors — a non-object source, a key this seam does not read — throw
68
+ * HERE, on first use inside the consuming hook's fail-closed catch, never at
69
+ * configure time: a top-level throw in a bundle entry would kill the hook
70
+ * before that catch installs (fail OPEN), and silently ignoring the problem
71
+ * is worse — a typo'd `minSecretLength: 32` must not leave the helpers
72
+ * running at the package floor while the host believes it configured 32.
73
+ * @returns {{ minSecretLen?: number, extraVars?: string[] } | null}
74
+ */
75
+ function hostSource() {
76
+ const source = hostEnvConfigSource;
77
+ if (source === null) return null;
78
+ if (typeof source !== "object")
79
+ throw new Error(
80
+ `${HOST_SOURCE_LABEL}: source must be an object, got ${typeof source}`,
81
+ );
82
+ for (const key of Object.keys(source))
83
+ if (!HOST_SOURCE_KEYS.includes(key))
84
+ throw new Error(
85
+ `${HOST_SOURCE_LABEL}: unknown key ${JSON.stringify(key)}; it reads ` +
86
+ `${HOST_SOURCE_KEYS.join(" and ")}`,
87
+ );
88
+ return source;
89
+ }
90
+
33
91
  /**
34
92
  * The placeholder floor: a candidate value shorter than this is too short to be a
35
- * real secret and is skipped by the env-bound redaction pre-gate.
93
+ * real secret and is skipped by the env-bound redaction pre-gate. A malformed
94
+ * host floor throws rather than degrading to the package's — a host that set 32
95
+ * must not silently run at a laxer floor, and a non-positive one would admit
96
+ * empty placeholders as secrets.
36
97
  * @returns {number}
37
98
  */
38
99
  export function minEnvSecretLen() {
39
- return inferenceKeys.min_secret_len;
100
+ const hostLen = hostSource()?.minSecretLen;
101
+ if (hostLen === undefined) return inferenceKeys.min_secret_len;
102
+ if (!Number.isInteger(hostLen) || hostLen <= 0)
103
+ throw new Error(
104
+ `${HOST_SOURCE_LABEL}: minSecretLen must be a positive integer, got ${JSON.stringify(hostLen)}`,
105
+ );
106
+ return hostLen;
107
+ }
108
+
109
+ /**
110
+ * The host's declared extra secret variable names, or throw. A malformed entry
111
+ * fails CLOSED, same contract as {@link extraSecretVars}: dropping it silently
112
+ * would leave the host believing a forwarded credential is masked while its
113
+ * value flows to the model verbatim.
114
+ * @returns {string[]}
115
+ */
116
+ function hostExtraSecretVars() {
117
+ const vars = hostSource()?.extraVars;
118
+ if (vars === undefined) return [];
119
+ if (!Array.isArray(vars))
120
+ throw new Error(`${HOST_SOURCE_LABEL}: extraVars must be an array`);
121
+ for (const name of vars)
122
+ if (typeof name !== "string" || !EXTRA_TOKEN_RE.test(name))
123
+ throw new Error(
124
+ `${HOST_SOURCE_LABEL}: ${JSON.stringify(name)} is not a variable name ` +
125
+ "(expected [A-Z0-9_] names)",
126
+ );
127
+ return vars;
40
128
  }
41
129
 
42
130
  // A rendered vocabulary token. Restricting it to A-Z/0-9/_ is what lets the
@@ -97,10 +185,13 @@ export function deriveCredentialVocabulary(spec) {
97
185
  for (const noun of nouns)
98
186
  if (Array.isArray(noun?.uses) && noun.uses.includes(ENV_NAME_USE))
99
187
  segments.push(...segmentForms(parts(noun.parts, "nouns[].parts")));
188
+ // Rendered WITHOUT a leading underscore; the boundary is applied in the regex
189
+ // below, symmetrically with the match pattern. Baking `_` into the token makes
190
+ // the exclusion reachable only when something precedes the run, so a variable
191
+ // named exactly `PUBLIC_KEY` matched on its trailing `KEY` and never reached
192
+ // its exclusion — its value was then cut out of every tool output carrying it.
100
193
  const excludeSuffixes = nonSecret.flatMap((suffix) =>
101
- segmentForms(parts(suffix, "nonSecretSuffixes[]")).map(
102
- (form) => `_${form}`,
103
- ),
194
+ segmentForms(parts(suffix, "nonSecretSuffixes[]")),
104
195
  );
105
196
  return {
106
197
  segments: [...new Set(segments)],
@@ -156,7 +247,7 @@ export function buildCredentialNameRes(spec) {
156
247
  return {
157
248
  match: new RegExp(`(?:^|_)(?:${segments.join("|")})$`, "i"),
158
249
  exclude: new RegExp(
159
- `(?:${excludeSuffixes.join("|")})$|^(?:${excludeNames.join("|")})$`,
250
+ `(?:^|_)(?:${excludeSuffixes.join("|")})$|^(?:${excludeNames.join("|")})$`,
160
251
  "i",
161
252
  ),
162
253
  };
@@ -239,10 +330,11 @@ export function extraSecretVars(env = process.env) {
239
330
 
240
331
  /**
241
332
  * The env-bound redaction set: the UNION of the inference keys, the curated host
242
- * credentials, any credential-shaped var present in the environment, and the
243
- * operator's declared extras. The redactor binds the same union; every consumer
244
- * (the sanitize-output pre-gate, the redactor client's per-request env snapshot)
245
- * must mirror it exactly, else a credential value would never trip the daemon.
333
+ * credentials, any credential-shaped var present in the environment, the
334
+ * operator's declared extras, and the host's configured extras. The redactor
335
+ * binds the same union; every consumer (the sanitize-output pre-gate, the
336
+ * redactor client's per-request env snapshot) must mirror it exactly, else a
337
+ * credential value would never trip the daemon.
246
338
  * @param {Record<string, string | undefined>} [env]
247
339
  * @returns {string[]}
248
340
  */
@@ -253,6 +345,7 @@ export function envBoundSecretVars(env = process.env) {
253
345
  ...scrubbed.vars,
254
346
  ...dynamicSecretVars(env),
255
347
  ...extraSecretVars(env),
348
+ ...hostExtraSecretVars(),
256
349
  ]),
257
350
  ];
258
351
  }
@@ -241,6 +241,33 @@ export function failedLazyPackages() {
241
241
  export const DEFAULT_MISSING_PACKAGE_REMEDY =
242
242
  "reinstall the hook dependencies (pnpm install) and retry.";
243
243
 
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
+ /**
252
+ * Adopt a host's own remedy as the default {@link missingPackageMessage} and
253
+ * {@link missingPackageError} state when their caller passes none. This refusal
254
+ * to hard-code the wording is what prevents a fail-closed reason whose remedy
255
+ * names a command the host doesn't have: deep call sites (controlPlane's
256
+ * missing-package throw) never take a remedy argument, so without this seam
257
+ * they can only ever tell a reader to run `pnpm install` — wrong advice in a
258
+ * host whose one install entry point is its own setup script. An explicit
259
+ * per-call remedy still wins over the configured one. Unlike the hookgate
260
+ * marker there is no too-late window: the remedy is consulted at each throw,
261
+ * never resolved at module scope, so a later call steers every later message.
262
+ * Keep it to a sentence — a remedy beyond ~260 characters overruns the 300-char
263
+ * message budget (see missingPackageMessage).
264
+ * @param {string | null} remedy host remedy text, or null to restore the package default
265
+ * @returns {void}
266
+ */
267
+ export function configureMissingPackageRemedy(remedy) {
268
+ missingPackageRemedyOverride = remedy;
269
+ }
270
+
244
271
  /**
245
272
  * The fail-closed reason for a package a hook could not load: the recorded
246
273
  * loader error plus the remedy. The cause is scrubbed (it is spliced into
@@ -257,7 +284,7 @@ export const DEFAULT_MISSING_PACKAGE_REMEDY =
257
284
  export function missingPackageMessage(
258
285
  pkg,
259
286
  err = lazyImportErrorFor(pkg),
260
- remedy = DEFAULT_MISSING_PACKAGE_REMEDY,
287
+ remedy = missingPackageRemedyOverride ?? DEFAULT_MISSING_PACKAGE_REMEDY,
261
288
  ) {
262
289
  const prefix = `${pkg} is unavailable: `;
263
290
  // 2 for the "; " joiner; 12 for safeErrMessage's own "…[truncated]" marker,
@@ -287,7 +314,7 @@ export function missingPackageMessage(
287
314
  export function missingPackageError(
288
315
  pkg,
289
316
  err = lazyImportErrorFor(pkg),
290
- remedy = DEFAULT_MISSING_PACKAGE_REMEDY,
317
+ remedy = missingPackageRemedyOverride ?? DEFAULT_MISSING_PACKAGE_REMEDY,
291
318
  ) {
292
319
  return Object.assign(new Error(missingPackageMessage(pkg, err, remedy)), {
293
320
  code: "DEP_UNAVAILABLE",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.10.1",
3
+ "version": "2.12.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": {
@@ -4,9 +4,33 @@
4
4
  * @returns {string[]}
5
5
  */
6
6
  export function inferenceKeyVars(): string[];
7
+ /**
8
+ * Adopt a host's own secret config in place of the package's: `minSecretLen`
9
+ * replaces the placeholder floor, and `extraVars` are unioned into the
10
+ * env-bound redaction set. This seam is what lets a host whose credential
11
+ * registry already names its forwarded secrets (and their length floor) drive
12
+ * the packaged helpers from that registry instead of forking this module —
13
+ * a fork is the drift channel that lets the two redaction sets silently
14
+ * disagree. Unset fields keep their package derivation; `undefined` and `null`
15
+ * both restore it entirely. Like the missing-package remedy seam there is no
16
+ * too-late window: the source is consulted at each helper call, never resolved
17
+ * at module scope, so a later call steers every later answer. A malformed
18
+ * source — a non-object, a key this seam does not read, a bad field — throws
19
+ * on first use, not here (see {@link hostSource}).
20
+ * @param {{ minSecretLen?: number, extraVars?: string[] } | null} source
21
+ * host config, or null to restore the package derivation
22
+ * @returns {void}
23
+ */
24
+ export function configureEnvConfigSource(source: {
25
+ minSecretLen?: number;
26
+ extraVars?: string[];
27
+ } | null): void;
7
28
  /**
8
29
  * The placeholder floor: a candidate value shorter than this is too short to be a
9
- * real secret and is skipped by the env-bound redaction pre-gate.
30
+ * real secret and is skipped by the env-bound redaction pre-gate. A malformed
31
+ * host floor throws rather than degrading to the package's — a host that set 32
32
+ * must not silently run at a laxer floor, and a non-positive one would admit
33
+ * empty placeholders as secrets.
10
34
  * @returns {number}
11
35
  */
12
36
  export function minEnvSecretLen(): number;
@@ -63,10 +87,11 @@ export function dynamicSecretVars(env?: Record<string, string | undefined>): str
63
87
  export function extraSecretVars(env?: Record<string, string | undefined>): string[];
64
88
  /**
65
89
  * The env-bound redaction set: the UNION of the inference keys, the curated host
66
- * credentials, any credential-shaped var present in the environment, and the
67
- * operator's declared extras. The redactor binds the same union; every consumer
68
- * (the sanitize-output pre-gate, the redactor client's per-request env snapshot)
69
- * must mirror it exactly, else a credential value would never trip the daemon.
90
+ * credentials, any credential-shaped var present in the environment, the
91
+ * operator's declared extras, and the host's configured extras. The redactor
92
+ * binds the same union; every consumer (the sanitize-output pre-gate, the
93
+ * redactor client's per-request env snapshot) must mirror it exactly, else a
94
+ * credential value would never trip the daemon.
70
95
  * @param {Record<string, string | undefined>} [env]
71
96
  * @returns {string[]}
72
97
  */
@@ -79,6 +79,23 @@ export function lazyImportErrorFor(pkg: string): unknown;
79
79
  * @returns {string[]}
80
80
  */
81
81
  export function failedLazyPackages(): string[];
82
+ /**
83
+ * Adopt a host's own remedy as the default {@link missingPackageMessage} and
84
+ * {@link missingPackageError} state when their caller passes none. This refusal
85
+ * to hard-code the wording is what prevents a fail-closed reason whose remedy
86
+ * names a command the host doesn't have: deep call sites (controlPlane's
87
+ * missing-package throw) never take a remedy argument, so without this seam
88
+ * they can only ever tell a reader to run `pnpm install` — wrong advice in a
89
+ * host whose one install entry point is its own setup script. An explicit
90
+ * per-call remedy still wins over the configured one. Unlike the hookgate
91
+ * marker there is no too-late window: the remedy is consulted at each throw,
92
+ * never resolved at module scope, so a later call steers every later message.
93
+ * Keep it to a sentence — a remedy beyond ~260 characters overruns the 300-char
94
+ * message budget (see missingPackageMessage).
95
+ * @param {string | null} remedy host remedy text, or null to restore the package default
96
+ * @returns {void}
97
+ */
98
+ export function configureMissingPackageRemedy(remedy: string | null): void;
82
99
  /**
83
100
  * The fail-closed reason for a package a hook could not load: the recorded
84
101
  * loader error plus the remedy. The cause is scrubbed (it is spliced into