agent-sanitizer 2.12.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.
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
* rather than at module load where a throw would abort before that catch installs
|
|
18
18
|
* and let the harness pass the tool output through UNSANITIZED (fail OPEN).
|
|
19
19
|
*/
|
|
20
|
+
// The package's own subpath, so the bundle resolves it through the pinned
|
|
21
|
+
// engine alias and an external consumer resolves the same specifier.
|
|
22
|
+
import { credentialNameMatcher } from "agent-sanitizer/credential-names-matcher";
|
|
23
|
+
|
|
20
24
|
import credentialNames from "../../python/agent_sanitizer/secrets/data/credential-names.json" with { type: "json" };
|
|
21
25
|
import inferenceKeys from "../config/inference-key-vars.json" with { type: "json" };
|
|
22
26
|
import scrubbed from "../config/scrubbed-env-vars.json" with { type: "json" };
|
|
@@ -127,147 +131,29 @@ function hostExtraSecretVars() {
|
|
|
127
131
|
return vars;
|
|
128
132
|
}
|
|
129
133
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
// list, which would make the match regex accept nothing and leak every forwarded
|
|
133
|
-
// credential) fails closed here instead of silently under-matching. Digits are
|
|
134
|
-
// admitted because a noun part may legitimately carry one (the vocabulary's parts
|
|
135
|
-
// are `[a-z0-9]+`); a digit cannot be a metacharacter, so it is safe to embed.
|
|
136
|
-
const CRED_TOKEN_RE = /^[A-Z0-9_]+$/;
|
|
137
|
-
|
|
138
|
-
const VOCAB_LABEL = "credential-names.json";
|
|
139
|
-
|
|
140
|
-
// Names ending in a credential noun whose value is not a secret. SSH_AUTH_SOCK
|
|
141
|
-
// holds a filesystem path, and redacting it would strip the agent socket out of
|
|
142
|
-
// tool output. Site-independent (anyone running an ssh-agent has it), so it lives
|
|
143
|
-
// with the consumer rather than in the published vocabulary, which describes
|
|
144
|
-
// which WORDS name a secret and cannot know about a specific variable.
|
|
145
|
-
const EXCLUDE_NAMES = ["SSH_AUTH_SOCK"];
|
|
146
|
-
|
|
147
|
-
// Which noun renderings apply to a variable NAME (the other use, `field-value`,
|
|
148
|
-
// feeds the redactor's `field = value` matcher and is not a name matcher).
|
|
149
|
-
const ENV_NAME_USE = "env-name";
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* The whole-name forms of `parts`: underscore-joined and bare-joined, upper-cased.
|
|
153
|
-
* Both are emitted because both spellings occur in the wild (`API_KEY`, `APIKEY`)
|
|
154
|
-
* and a matcher anchored on underscore-delimited segments sees them as different
|
|
155
|
-
* tokens; a single-part noun collapses to one form. Mirrors the Python renderer
|
|
156
|
-
* (`_segment_forms` in agent_sanitizer/secrets/credential_names.py) so the two
|
|
157
|
-
* ecosystems derive the same vocabulary from the same file.
|
|
158
|
-
* @param {string[]} parts
|
|
159
|
-
* @returns {string[]}
|
|
160
|
-
*/
|
|
161
|
-
function segmentForms(parts) {
|
|
162
|
-
return [
|
|
163
|
-
...new Set([parts.join("_").toUpperCase(), parts.join("").toUpperCase()]),
|
|
164
|
-
];
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Render the published credential-noun vocabulary into the name-matcher spec:
|
|
169
|
-
* the credential segments, and the trailing suffixes that mark a
|
|
170
|
-
* credential-shaped name as holding a non-secret.
|
|
171
|
-
*
|
|
172
|
-
* The vocabulary is the SINGLE source both ecosystems read — a curated second
|
|
173
|
-
* copy of these renderings is what let this matcher fall twelve segments behind
|
|
174
|
-
* the engine, so a variable named `…_ACCESS_TOKEN` was never recognized as
|
|
175
|
-
* credential-bearing and its value was never handed to the redactor.
|
|
176
|
-
* @param {Record<string, any>} spec
|
|
177
|
-
* @returns {{ segments: string[], excludeSuffixes: string[], excludeNames: string[] }}
|
|
178
|
-
*/
|
|
179
|
-
export function deriveCredentialVocabulary(spec) {
|
|
180
|
-
const nouns = spec?.nouns;
|
|
181
|
-
const nonSecret = spec?.nonSecretSuffixes;
|
|
182
|
-
if (!Array.isArray(nouns) || !Array.isArray(nonSecret))
|
|
183
|
-
throw new Error(`${VOCAB_LABEL}: nouns/nonSecretSuffixes missing`);
|
|
184
|
-
const segments = [];
|
|
185
|
-
for (const noun of nouns)
|
|
186
|
-
if (Array.isArray(noun?.uses) && noun.uses.includes(ENV_NAME_USE))
|
|
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.
|
|
193
|
-
const excludeSuffixes = nonSecret.flatMap((suffix) =>
|
|
194
|
-
segmentForms(parts(suffix, "nonSecretSuffixes[]")),
|
|
195
|
-
);
|
|
196
|
-
return {
|
|
197
|
-
segments: [...new Set(segments)],
|
|
198
|
-
excludeSuffixes: [...new Set(excludeSuffixes)],
|
|
199
|
-
excludeNames: EXCLUDE_NAMES,
|
|
200
|
-
};
|
|
201
|
-
}
|
|
202
|
-
|
|
134
|
+
/** @type {((name: string) => boolean) | undefined} */
|
|
135
|
+
let _credentialRule;
|
|
203
136
|
/**
|
|
204
|
-
*
|
|
205
|
-
* malformed part must not render into a token that silently under-matches.
|
|
206
|
-
* @param {unknown} value
|
|
207
|
-
* @param {string} field
|
|
208
|
-
* @returns {string[]}
|
|
209
|
-
*/
|
|
210
|
-
function parts(value, field) {
|
|
211
|
-
if (!Array.isArray(value) || value.length === 0)
|
|
212
|
-
throw new Error(`${VOCAB_LABEL}: ${field} is empty or missing`);
|
|
213
|
-
for (const part of value)
|
|
214
|
-
if (typeof part !== "string" || !/^[a-z0-9]+$/u.test(part))
|
|
215
|
-
throw new Error(`${VOCAB_LABEL}: bad part ${part} in ${field}`);
|
|
216
|
-
return value;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* The validated token list under `field`, or throw. An absent, empty, or
|
|
221
|
-
* metacharacter-bearing list must not degrade into a pattern that matches nothing,
|
|
222
|
-
* which would leak every forwarded credential.
|
|
223
|
-
* @param {Record<string, unknown>} spec
|
|
224
|
-
* @param {string} field
|
|
225
|
-
* @returns {string[]}
|
|
226
|
-
*/
|
|
227
|
-
function credentialTokens(spec, field) {
|
|
228
|
-
const group = spec[field];
|
|
229
|
-
if (!Array.isArray(group) || group.length === 0)
|
|
230
|
-
throw new Error(`${VOCAB_LABEL}: ${field} is empty or missing`);
|
|
231
|
-
for (const token of group)
|
|
232
|
-
if (typeof token !== "string" || !CRED_TOKEN_RE.test(token))
|
|
233
|
-
throw new Error(`${VOCAB_LABEL}: bad token ${token} in ${field}`);
|
|
234
|
-
return group;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* Validate a rendered name-matcher spec and build its match/exclude regexes. Pure
|
|
239
|
-
* and exported so the fail-closed paths can be driven directly with a bad spec.
|
|
240
|
-
* @param {Record<string, unknown>} spec
|
|
241
|
-
* @returns {{ match: RegExp, exclude: RegExp }}
|
|
242
|
-
*/
|
|
243
|
-
export function buildCredentialNameRes(spec) {
|
|
244
|
-
const segments = credentialTokens(spec, "segments");
|
|
245
|
-
const excludeSuffixes = credentialTokens(spec, "excludeSuffixes");
|
|
246
|
-
const excludeNames = credentialTokens(spec, "excludeNames");
|
|
247
|
-
return {
|
|
248
|
-
match: new RegExp(`(?:^|_)(?:${segments.join("|")})$`, "i"),
|
|
249
|
-
exclude: new RegExp(
|
|
250
|
-
`(?:^|_)(?:${excludeSuffixes.join("|")})$|^(?:${excludeNames.join("|")})$`,
|
|
251
|
-
"i",
|
|
252
|
-
),
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/** @type {{ match: RegExp, exclude: RegExp } | undefined} */
|
|
257
|
-
let _credentialNameRes;
|
|
258
|
-
/**
|
|
259
|
-
* The credential-var-NAME regexes, memoized after the first build. Matching by
|
|
137
|
+
* The credential-var-NAME predicate, memoized after the first build. Matching by
|
|
260
138
|
* trailing segment lets the redaction set self-populate with any token the
|
|
261
139
|
* process actually holds; a curated list drifts. The curated sets
|
|
262
140
|
* (inferenceKeyVars + scrubbed vars) stay the guaranteed floor; this only ADDS
|
|
263
141
|
* lookalikes.
|
|
264
|
-
*
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
142
|
+
*
|
|
143
|
+
* credentialNameMatcher is the package's one credential-name decision. A second
|
|
144
|
+
* derivation here is how this module came to apply the vocabulary's non-secret
|
|
145
|
+
* runs only when something preceded them, so a variable named exactly
|
|
146
|
+
* `PUBLIC_KEY` was reported credential-bearing and its value cut out of every
|
|
147
|
+
* tool output carrying it. One decision means the two cannot disagree again.
|
|
148
|
+
* @returns {(name: string) => boolean}
|
|
149
|
+
*/
|
|
150
|
+
function credentialRule() {
|
|
151
|
+
if (_credentialRule !== undefined) return _credentialRule;
|
|
152
|
+
return (_credentialRule = credentialNameMatcher({
|
|
153
|
+
spec: credentialNames,
|
|
154
|
+
scope: "trailing",
|
|
155
|
+
declineNonSecret: true,
|
|
156
|
+
}));
|
|
271
157
|
}
|
|
272
158
|
|
|
273
159
|
/**
|
|
@@ -277,8 +163,7 @@ function credentialNameRes() {
|
|
|
277
163
|
* @returns {boolean}
|
|
278
164
|
*/
|
|
279
165
|
export function looksLikeCredentialVar(name) {
|
|
280
|
-
|
|
281
|
-
return res.match.test(name) && !res.exclude.test(name);
|
|
166
|
+
return credentialRule()(name);
|
|
282
167
|
}
|
|
283
168
|
|
|
284
169
|
/**
|
|
@@ -303,9 +188,9 @@ export function dynamicSecretVars(env = process.env) {
|
|
|
303
188
|
// the set without this.
|
|
304
189
|
const EXTRA_SECRET_VARS_ENV = "_AGENT_SANITIZER_EXTRA_SECRET_VARS";
|
|
305
190
|
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
191
|
+
// These are whole variable names an operator typed, and real ones carry digits
|
|
192
|
+
// (`AWS_S3_KEY2`); metacharacters stay excluded so a malformed entry fails
|
|
193
|
+
// closed rather than widening the set.
|
|
309
194
|
const EXTRA_TOKEN_RE = /^[A-Z0-9_]+$/;
|
|
310
195
|
|
|
311
196
|
/**
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
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": {
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
]
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
-
"sanitizer-engine": "npm:agent-sanitizer@2.
|
|
49
|
+
"sanitizer-engine": "npm:agent-sanitizer@2.12.0",
|
|
50
50
|
"@commitlint/cli": "^21.0.1",
|
|
51
51
|
"@commitlint/config-conventional": "^21.0.1",
|
|
52
52
|
"@eslint/js": "10.0.1",
|
|
@@ -34,33 +34,6 @@ export function configureEnvConfigSource(source: {
|
|
|
34
34
|
* @returns {number}
|
|
35
35
|
*/
|
|
36
36
|
export function minEnvSecretLen(): number;
|
|
37
|
-
/**
|
|
38
|
-
* Render the published credential-noun vocabulary into the name-matcher spec:
|
|
39
|
-
* the credential segments, and the trailing suffixes that mark a
|
|
40
|
-
* credential-shaped name as holding a non-secret.
|
|
41
|
-
*
|
|
42
|
-
* The vocabulary is the SINGLE source both ecosystems read — a curated second
|
|
43
|
-
* copy of these renderings is what let this matcher fall twelve segments behind
|
|
44
|
-
* the engine, so a variable named `…_ACCESS_TOKEN` was never recognized as
|
|
45
|
-
* credential-bearing and its value was never handed to the redactor.
|
|
46
|
-
* @param {Record<string, any>} spec
|
|
47
|
-
* @returns {{ segments: string[], excludeSuffixes: string[], excludeNames: string[] }}
|
|
48
|
-
*/
|
|
49
|
-
export function deriveCredentialVocabulary(spec: Record<string, any>): {
|
|
50
|
-
segments: string[];
|
|
51
|
-
excludeSuffixes: string[];
|
|
52
|
-
excludeNames: string[];
|
|
53
|
-
};
|
|
54
|
-
/**
|
|
55
|
-
* Validate a rendered name-matcher spec and build its match/exclude regexes. Pure
|
|
56
|
-
* and exported so the fail-closed paths can be driven directly with a bad spec.
|
|
57
|
-
* @param {Record<string, unknown>} spec
|
|
58
|
-
* @returns {{ match: RegExp, exclude: RegExp }}
|
|
59
|
-
*/
|
|
60
|
-
export function buildCredentialNameRes(spec: Record<string, unknown>): {
|
|
61
|
-
match: RegExp;
|
|
62
|
-
exclude: RegExp;
|
|
63
|
-
};
|
|
64
37
|
/**
|
|
65
38
|
* True when `name` looks like a credential-bearing variable (and isn't a known
|
|
66
39
|
* non-secret lookalike).
|
|
@@ -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
|
+
};
|