agent-sanitizer 2.7.1 → 2.7.2
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 +35 -6
- package/claude-hooks/lib/env-config.mjs +141 -23
- package/claude-hooks/sanitize-output.mjs +159 -33
- package/package.json +24 -4
- package/types/claude-hooks/lib/env-config.d.mts +30 -5
- package/types/claude-hooks/sanitize-output.d.mts +102 -6
- package/claude-hooks/config/credential-var-names.json +0 -23
package/README.md
CHANGED
|
@@ -127,13 +127,42 @@ import {
|
|
|
127
127
|
} from "agent-sanitizer/claude-hooks/lib/hook-io";
|
|
128
128
|
```
|
|
129
129
|
|
|
130
|
-
|
|
130
|
+
The exported set is deliberately small — the four hooks
|
|
131
131
|
(`sanitize-output`, `pretooluse-sanitize`, `sanitize-user-prompt`,
|
|
132
|
-
`scan-invisible-chars`) and `
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
132
|
+
`scan-invisible-chars`) plus `lib/hook-io` and `lib/control-plane`. Everything
|
|
133
|
+
else under `claude-hooks/` stays internal and is refused by the exports map, so
|
|
134
|
+
it never becomes a surface this package owes compatibility on. `lib/hook-io` is
|
|
135
|
+
exported because it must be _shared_ rather than copied: it owns the
|
|
136
|
+
lazy-module registry and the CLI-slot singleton, and two copies in one bundle
|
|
137
|
+
double-fire the inlined CLIs.
|
|
138
|
+
|
|
139
|
+
Importing one runs no CLI and reads no stdin. Same stability posture as the
|
|
140
|
+
`_AGENT_SANITIZER_*` variables below: reachable and typed, but the supported
|
|
141
|
+
surface is the `--hook=` CLI, so these move between minor versions.
|
|
142
|
+
|
|
143
|
+
**`sanitize-output` takes a host-extension bag** — an optional last argument on
|
|
144
|
+
`sanitizeText`, `sanitizeValue`, `evaluateToolOutput`, `judgeSanitizeOutput`, and
|
|
145
|
+
`cliMain`, so a composer that wraps `cliMain` gets the hook's exact fail-closed
|
|
146
|
+
CLI wiring plus its own policy:
|
|
147
|
+
|
|
148
|
+
| Field | Runs | Does |
|
|
149
|
+
| ------------ | -------------------------------------------------------- | ------------------------------------------------------------------------ |
|
|
150
|
+
| `postText` | once per string **value** leaf, after Layers 1–4 | returns `{cleaned?, warning?}`; `cleaned` replaces the model-facing text |
|
|
151
|
+
| `redactNote` | on the pre-redaction text of a leaf that tripped Layer 4 | returns a note appended to that leaf's redaction warning |
|
|
152
|
+
| `audit` | once per judged event carrying a tool response | is handed the output the model will actually see |
|
|
153
|
+
|
|
154
|
+
Omit the bag and every seam is inert — the verdicts are byte-identical to this
|
|
155
|
+
module alone. A callback that throws is **not** caught: it lands in the CLI's
|
|
156
|
+
fail-closed catch and the tool output is suppressed, so a broken extension can
|
|
157
|
+
never degrade into showing unvetted output. `postText` deliberately does not run
|
|
158
|
+
on object field NAMES: a callback sees only the string and the tool, so it cannot
|
|
159
|
+
tell a schema key from content, and rewriting a key can collapse two fields into
|
|
160
|
+
one name — which this hook turns into whole-output suppression.
|
|
161
|
+
|
|
162
|
+
Beyond the credential-shaped names it infers, the env-bound redaction set unions
|
|
163
|
+
`_AGENT_SANITIZER_EXTRA_SECRET_VARS` — a comma-separated list of `[A-Z0-9_]`
|
|
164
|
+
variable names whose values a deployment forwards under names of its own
|
|
165
|
+
choosing. A malformed entry throws rather than being dropped.
|
|
137
166
|
|
|
138
167
|
**Layer 4 needs the Python engine** — `pip install 'agent-sanitizer[secrets]'`,
|
|
139
168
|
version-matched to the npm package. Without it `sanitize-output` fails closed:
|
|
@@ -3,16 +3,21 @@
|
|
|
3
3
|
* share, so the set of variable names whose VALUES get masked has one definition
|
|
4
4
|
* instead of a copy per hook that can silently drift.
|
|
5
5
|
*
|
|
6
|
-
* The
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
6
|
+
* The credential-NAME vocabulary comes from the published
|
|
7
|
+
* `credential-names.json` — the same file `agent_sanitizer.secrets` renders in
|
|
8
|
+
* Python — so the JavaScript pre-gate and the Python redactor recognize the same
|
|
9
|
+
* set of credential-bearing variable names. A rendered second copy is what let
|
|
10
|
+
* this matcher fall twelve segments behind the engine.
|
|
11
|
+
*
|
|
12
|
+
* The JSON configs are imported as modules, not read from disk at call time:
|
|
13
|
+
* esbuild inlines them into the plugin bundle (which ships with no config
|
|
14
|
+
* directory beside it) and Node resolves them natively from the package when the
|
|
15
|
+
* hooks run from source. Their VALIDATION stays lazy — a malformed credential
|
|
16
|
+
* vocabulary throws on first use, inside the consuming hook's fail-closed catch,
|
|
17
|
+
* rather than at module load where a throw would abort before that catch installs
|
|
18
|
+
* and let the harness pass the tool output through UNSANITIZED (fail OPEN).
|
|
14
19
|
*/
|
|
15
|
-
import
|
|
20
|
+
import credentialNames from "../../python/agent_sanitizer/secrets/data/credential-names.json" with { type: "json" };
|
|
16
21
|
import inferenceKeys from "../config/inference-key-vars.json" with { type: "json" };
|
|
17
22
|
import scrubbed from "../config/scrubbed-env-vars.json" with { type: "json" };
|
|
18
23
|
|
|
@@ -34,11 +39,91 @@ export function minEnvSecretLen() {
|
|
|
34
39
|
return inferenceKeys.min_secret_len;
|
|
35
40
|
}
|
|
36
41
|
|
|
37
|
-
// A
|
|
38
|
-
//
|
|
42
|
+
// A rendered vocabulary token. Restricting it to A-Z/0-9/_ is what lets the
|
|
43
|
+
// regexes below interpolate it unescaped: a stray metacharacter (or an empty
|
|
39
44
|
// list, which would make the match regex accept nothing and leak every forwarded
|
|
40
|
-
// credential) fails closed here instead of silently under-matching.
|
|
41
|
-
|
|
45
|
+
// credential) fails closed here instead of silently under-matching. Digits are
|
|
46
|
+
// admitted because a noun part may legitimately carry one (the vocabulary's parts
|
|
47
|
+
// are `[a-z0-9]+`); a digit cannot be a metacharacter, so it is safe to embed.
|
|
48
|
+
const CRED_TOKEN_RE = /^[A-Z0-9_]+$/;
|
|
49
|
+
|
|
50
|
+
const VOCAB_LABEL = "credential-names.json";
|
|
51
|
+
|
|
52
|
+
// Names ending in a credential noun whose value is not a secret. SSH_AUTH_SOCK
|
|
53
|
+
// holds a filesystem path, and redacting it would strip the agent socket out of
|
|
54
|
+
// tool output. Site-independent (anyone running an ssh-agent has it), so it lives
|
|
55
|
+
// with the consumer rather than in the published vocabulary, which describes
|
|
56
|
+
// which WORDS name a secret and cannot know about a specific variable.
|
|
57
|
+
const EXCLUDE_NAMES = ["SSH_AUTH_SOCK"];
|
|
58
|
+
|
|
59
|
+
// Which noun renderings apply to a variable NAME (the other use, `field-value`,
|
|
60
|
+
// feeds the redactor's `field = value` matcher and is not a name matcher).
|
|
61
|
+
const ENV_NAME_USE = "env-name";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The whole-name forms of `parts`: underscore-joined and bare-joined, upper-cased.
|
|
65
|
+
* Both are emitted because both spellings occur in the wild (`API_KEY`, `APIKEY`)
|
|
66
|
+
* and a matcher anchored on underscore-delimited segments sees them as different
|
|
67
|
+
* tokens; a single-part noun collapses to one form. Mirrors the Python renderer
|
|
68
|
+
* (`_segment_forms` in agent_sanitizer/secrets/credential_names.py) so the two
|
|
69
|
+
* ecosystems derive the same vocabulary from the same file.
|
|
70
|
+
* @param {string[]} parts
|
|
71
|
+
* @returns {string[]}
|
|
72
|
+
*/
|
|
73
|
+
function segmentForms(parts) {
|
|
74
|
+
return [
|
|
75
|
+
...new Set([parts.join("_").toUpperCase(), parts.join("").toUpperCase()]),
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Render the published credential-noun vocabulary into the name-matcher spec:
|
|
81
|
+
* the credential segments, and the trailing suffixes that mark a
|
|
82
|
+
* credential-shaped name as holding a non-secret.
|
|
83
|
+
*
|
|
84
|
+
* The vocabulary is the SINGLE source both ecosystems read — a curated second
|
|
85
|
+
* copy of these renderings is what let this matcher fall twelve segments behind
|
|
86
|
+
* the engine, so a variable named `…_ACCESS_TOKEN` was never recognized as
|
|
87
|
+
* credential-bearing and its value was never handed to the redactor.
|
|
88
|
+
* @param {Record<string, any>} spec
|
|
89
|
+
* @returns {{ segments: string[], excludeSuffixes: string[], excludeNames: string[] }}
|
|
90
|
+
*/
|
|
91
|
+
export function deriveCredentialVocabulary(spec) {
|
|
92
|
+
const nouns = spec?.nouns;
|
|
93
|
+
const nonSecret = spec?.nonSecretSuffixes;
|
|
94
|
+
if (!Array.isArray(nouns) || !Array.isArray(nonSecret))
|
|
95
|
+
throw new Error(`${VOCAB_LABEL}: nouns/nonSecretSuffixes missing`);
|
|
96
|
+
const segments = [];
|
|
97
|
+
for (const noun of nouns)
|
|
98
|
+
if (Array.isArray(noun?.uses) && noun.uses.includes(ENV_NAME_USE))
|
|
99
|
+
segments.push(...segmentForms(parts(noun.parts, "nouns[].parts")));
|
|
100
|
+
const excludeSuffixes = nonSecret.flatMap((suffix) =>
|
|
101
|
+
segmentForms(parts(suffix, "nonSecretSuffixes[]")).map(
|
|
102
|
+
(form) => `_${form}`,
|
|
103
|
+
),
|
|
104
|
+
);
|
|
105
|
+
return {
|
|
106
|
+
segments: [...new Set(segments)],
|
|
107
|
+
excludeSuffixes: [...new Set(excludeSuffixes)],
|
|
108
|
+
excludeNames: EXCLUDE_NAMES,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* `value` as a validated non-empty array of lower-case noun parts, or throw. A
|
|
114
|
+
* malformed part must not render into a token that silently under-matches.
|
|
115
|
+
* @param {unknown} value
|
|
116
|
+
* @param {string} field
|
|
117
|
+
* @returns {string[]}
|
|
118
|
+
*/
|
|
119
|
+
function parts(value, field) {
|
|
120
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
121
|
+
throw new Error(`${VOCAB_LABEL}: ${field} is empty or missing`);
|
|
122
|
+
for (const part of value)
|
|
123
|
+
if (typeof part !== "string" || !/^[a-z0-9]+$/u.test(part))
|
|
124
|
+
throw new Error(`${VOCAB_LABEL}: bad part ${part} in ${field}`);
|
|
125
|
+
return value;
|
|
126
|
+
}
|
|
42
127
|
|
|
43
128
|
/**
|
|
44
129
|
* The validated token list under `field`, or throw. An absent, empty, or
|
|
@@ -51,17 +136,15 @@ const CRED_TOKEN_RE = /^[A-Z_]+$/;
|
|
|
51
136
|
function credentialTokens(spec, field) {
|
|
52
137
|
const group = spec[field];
|
|
53
138
|
if (!Array.isArray(group) || group.length === 0)
|
|
54
|
-
throw new Error(
|
|
139
|
+
throw new Error(`${VOCAB_LABEL}: ${field} is empty or missing`);
|
|
55
140
|
for (const token of group)
|
|
56
141
|
if (typeof token !== "string" || !CRED_TOKEN_RE.test(token))
|
|
57
|
-
throw new Error(
|
|
58
|
-
`credential-var-names.json: bad token ${token} in ${field}`,
|
|
59
|
-
);
|
|
142
|
+
throw new Error(`${VOCAB_LABEL}: bad token ${token} in ${field}`);
|
|
60
143
|
return group;
|
|
61
144
|
}
|
|
62
145
|
|
|
63
146
|
/**
|
|
64
|
-
* Validate a
|
|
147
|
+
* Validate a rendered name-matcher spec and build its match/exclude regexes. Pure
|
|
65
148
|
* and exported so the fail-closed paths can be driven directly with a bad spec.
|
|
66
149
|
* @param {Record<string, unknown>} spec
|
|
67
150
|
* @returns {{ match: RegExp, exclude: RegExp }}
|
|
@@ -91,7 +174,9 @@ let _credentialNameRes;
|
|
|
91
174
|
*/
|
|
92
175
|
function credentialNameRes() {
|
|
93
176
|
if (_credentialNameRes !== undefined) return _credentialNameRes;
|
|
94
|
-
return (_credentialNameRes = buildCredentialNameRes(
|
|
177
|
+
return (_credentialNameRes = buildCredentialNameRes(
|
|
178
|
+
deriveCredentialVocabulary(credentialNames),
|
|
179
|
+
));
|
|
95
180
|
}
|
|
96
181
|
|
|
97
182
|
/**
|
|
@@ -120,12 +205,44 @@ export function dynamicSecretVars(env = process.env) {
|
|
|
120
205
|
);
|
|
121
206
|
}
|
|
122
207
|
|
|
208
|
+
// Operator-supplied additions to the env-bound redaction set, comma-separated.
|
|
209
|
+
// The name-shape heuristic above only catches vars whose trailing segment reads
|
|
210
|
+
// as a credential (`…_TOKEN`, `…_KEY`); a deployment that forwards a secret under
|
|
211
|
+
// a name of its own choosing (`GLOVEBOX_ATTESTATION_SEED`) has no way to reach
|
|
212
|
+
// the set without this.
|
|
213
|
+
const EXTRA_SECRET_VARS_ENV = "_AGENT_SANITIZER_EXTRA_SECRET_VARS";
|
|
214
|
+
|
|
215
|
+
// Digits allowed here but not in CRED_TOKEN_RE: that one gates regex-interpolated
|
|
216
|
+
// name SEGMENTS, while these are whole variable names an operator typed, and real
|
|
217
|
+
// ones carry digits (`AWS_S3_KEY2`). Both exclude metacharacters.
|
|
218
|
+
const EXTRA_TOKEN_RE = /^[A-Z0-9_]+$/;
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The operator-declared extra secret variable names, or throw. A malformed entry
|
|
222
|
+
* fails CLOSED — dropping it silently would leave the operator believing a
|
|
223
|
+
* forwarded credential is masked while its value flows to the model verbatim.
|
|
224
|
+
* @param {Record<string, string | undefined>} [env]
|
|
225
|
+
* @returns {string[]}
|
|
226
|
+
*/
|
|
227
|
+
export function extraSecretVars(env = process.env) {
|
|
228
|
+
const raw = env[EXTRA_SECRET_VARS_ENV];
|
|
229
|
+
if (raw === undefined || raw.trim() === "") return [];
|
|
230
|
+
const tokens = raw.split(",").map((token) => token.trim());
|
|
231
|
+
for (const token of tokens)
|
|
232
|
+
if (!EXTRA_TOKEN_RE.test(token))
|
|
233
|
+
throw new Error(
|
|
234
|
+
`${EXTRA_SECRET_VARS_ENV}: ${JSON.stringify(token)} is not a variable name ` +
|
|
235
|
+
"(expected comma-separated [A-Z0-9_] names)",
|
|
236
|
+
);
|
|
237
|
+
return tokens;
|
|
238
|
+
}
|
|
239
|
+
|
|
123
240
|
/**
|
|
124
241
|
* The env-bound redaction set: the UNION of the inference keys, the curated host
|
|
125
|
-
* credentials,
|
|
126
|
-
* redactor binds the same union; every consumer
|
|
127
|
-
* the redactor client's per-request env snapshot)
|
|
128
|
-
* credential value would never trip the daemon.
|
|
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.
|
|
129
246
|
* @param {Record<string, string | undefined>} [env]
|
|
130
247
|
* @returns {string[]}
|
|
131
248
|
*/
|
|
@@ -135,6 +252,7 @@ export function envBoundSecretVars(env = process.env) {
|
|
|
135
252
|
...inferenceKeyVars(),
|
|
136
253
|
...scrubbed.vars,
|
|
137
254
|
...dynamicSecretVars(env),
|
|
255
|
+
...extraSecretVars(env),
|
|
138
256
|
]),
|
|
139
257
|
];
|
|
140
258
|
}
|
|
@@ -160,6 +160,40 @@ async function redactSecrets(text, webIngress = false, deadline) {
|
|
|
160
160
|
);
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Host-supplied extensions to this hook, threaded from {@link cliMain} down to
|
|
165
|
+
* each string leaf. Every field is optional and the bag defaults to `{}`, so a
|
|
166
|
+
* composer that supplies none gets exactly the behavior of this module alone —
|
|
167
|
+
* which is what lets the extension points ship without changing any shipped
|
|
168
|
+
* verdict. The callbacks own all policy: this module decides only WHERE they run,
|
|
169
|
+
* never WHETHER their result is applied.
|
|
170
|
+
*
|
|
171
|
+
* A callback that throws is not caught here. That is deliberate: the throw lands
|
|
172
|
+
* in the CLI's fail-closed catch and the tool output is suppressed, so a broken
|
|
173
|
+
* extension cannot degrade into showing unvetted output.
|
|
174
|
+
*
|
|
175
|
+
* These are NOT the Layer-5 injection-filter seam and do not inherit its
|
|
176
|
+
* delete-only, closed-enum restriction. (Its identifier is deliberately unspoken
|
|
177
|
+
* here: the plugin-bundle suite pins Layer 5 absent by asserting the name appears
|
|
178
|
+
* nowhere in this file, and that absolute check is worth more than the precision
|
|
179
|
+
* of one comment.) That restriction exists because such a
|
|
180
|
+
* filter is a MODEL, so its output is attacker-reachable and must not be able to
|
|
181
|
+
* inject text into the model-facing context. These callbacks are code the
|
|
182
|
+
* composer wrote and linked at build time — the same trust level as the injected
|
|
183
|
+
* redactor — so free-text warnings and arbitrary rewrites are theirs to own.
|
|
184
|
+
*
|
|
185
|
+
* @typedef {object} SanitizeExtensions
|
|
186
|
+
* @property {(cleaned: string, ctx: { toolName: string, webIngress: boolean, deadline: { remainingMs: () => number } }) => Promise<{ cleaned?: string, warning?: string } | null | undefined> | { cleaned?: string, warning?: string } | null | undefined} [postText]
|
|
187
|
+
* Runs once per string leaf, AFTER Layers 1-4. Returning `cleaned` replaces the
|
|
188
|
+
* model-facing text; `warning` joins this leaf's warnings.
|
|
189
|
+
* @property {(raw: string) => string | undefined} [redactNote]
|
|
190
|
+
* Given the pre-redaction text of a leaf that tripped Layer 4, returns a note
|
|
191
|
+
* appended to that leaf's "API keys/secrets redacted: …" warning.
|
|
192
|
+
* @property {(record: { tool: string | null, modified: boolean, output: unknown, context?: string }) => Promise<void> | void} [audit]
|
|
193
|
+
* Awaited once per judged event that carried a tool response, with the output
|
|
194
|
+
* the model will actually see.
|
|
195
|
+
*/
|
|
196
|
+
|
|
163
197
|
/**
|
|
164
198
|
* Run Layers 1-4 over a single text blob, delegated to the package's output seam
|
|
165
199
|
* (sanitizeTextSeam) bound here to this hook's per-tool policy: which tools get
|
|
@@ -171,12 +205,14 @@ async function redactSecrets(text, webIngress = false, deadline) {
|
|
|
171
205
|
* @param {string} toolName gates the SGR carve-out and the untrusted-ingress passes
|
|
172
206
|
* @param {{remainingMs: () => number}} [deadline] shared wall-clock budget across
|
|
173
207
|
* all leaves of one hook run; a direct caller gets a fresh full budget
|
|
208
|
+
* @param {SanitizeExtensions} [ext]
|
|
174
209
|
* @returns {Promise<{ cleaned: string, warnings: string[], modified: boolean, sgrNote: boolean, reveal?: string }>}
|
|
175
210
|
*/
|
|
176
211
|
export async function sanitizeText(
|
|
177
212
|
text,
|
|
178
213
|
toolName,
|
|
179
214
|
deadline = makeDeadline(SANITIZE_BUDGET_MS),
|
|
215
|
+
ext = {},
|
|
180
216
|
) {
|
|
181
217
|
const webIngress = isUntrustedIngress(toolName);
|
|
182
218
|
// Layer 2 (HTML rewrite) runs on WebFetch/WebSearch always, and on MCP output
|
|
@@ -211,12 +247,56 @@ export async function sanitizeText(
|
|
|
211
247
|
);
|
|
212
248
|
throw l4err;
|
|
213
249
|
}
|
|
214
|
-
|
|
250
|
+
if (!secrets) return null;
|
|
251
|
+
// The note is derived from the PRE-redaction text: the caller's reason for
|
|
252
|
+
// annotating (which variable, which provenance) is exactly what redaction
|
|
253
|
+
// is about to remove.
|
|
254
|
+
const note = ext.redactNote?.(content);
|
|
255
|
+
return note
|
|
256
|
+
? { text: secrets.text, found: secrets.found, note }
|
|
257
|
+
: { text: secrets.text, found: secrets.found };
|
|
215
258
|
},
|
|
216
259
|
};
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
260
|
+
const result =
|
|
261
|
+
/** @type {{ cleaned: string, warnings: string[], modified: boolean, sgrNote: boolean, reveal?: string }} */ (
|
|
262
|
+
await sanitizeTextSeam(text, seamOptions)
|
|
263
|
+
);
|
|
264
|
+
return ext.postText
|
|
265
|
+
? applyPostText(
|
|
266
|
+
result,
|
|
267
|
+
await ext.postText(result.cleaned, {
|
|
268
|
+
toolName,
|
|
269
|
+
webIngress,
|
|
270
|
+
deadline,
|
|
271
|
+
}),
|
|
272
|
+
)
|
|
273
|
+
: result;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Fold a `postText` callback's result into the seam's, leaving the seam's result
|
|
278
|
+
* untouched when the callback declined (null/undefined) or returned no `cleaned`.
|
|
279
|
+
* `modified` widens to cover the callback's rewrite, and `sgrNote` is dropped
|
|
280
|
+
* when it does: that flag downgrades the model-facing banner to "display-only
|
|
281
|
+
* ANSI color stripped", which would be a false statement about bytes a callback
|
|
282
|
+
* has since rewritten for its own reasons.
|
|
283
|
+
* @param {{ cleaned: string, warnings: string[], modified: boolean, sgrNote: boolean, reveal?: string }} result
|
|
284
|
+
* @param {{ cleaned?: string, warning?: string } | null | undefined} post
|
|
285
|
+
* @returns {{ cleaned: string, warnings: string[], modified: boolean, sgrNote: boolean, reveal?: string }}
|
|
286
|
+
*/
|
|
287
|
+
function applyPostText(result, post) {
|
|
288
|
+
if (post === null || post === undefined) return result;
|
|
289
|
+
const cleaned = post.cleaned ?? result.cleaned;
|
|
290
|
+
const rewrote = cleaned !== result.cleaned;
|
|
291
|
+
return {
|
|
292
|
+
...result,
|
|
293
|
+
cleaned,
|
|
294
|
+
warnings: post.warning
|
|
295
|
+
? [...result.warnings, post.warning]
|
|
296
|
+
: result.warnings,
|
|
297
|
+
modified: result.modified || rewrote,
|
|
298
|
+
sgrNote: result.sgrNote && !rewrote,
|
|
299
|
+
};
|
|
220
300
|
}
|
|
221
301
|
|
|
222
302
|
/**
|
|
@@ -239,6 +319,7 @@ export async function sanitizeText(
|
|
|
239
319
|
* @param {string[]} [reveals]
|
|
240
320
|
* @param {{remainingMs: () => number}} [deadline] shared wall-clock budget across
|
|
241
321
|
* every leaf of this value (created once by the top-level caller)
|
|
322
|
+
* @param {SanitizeExtensions} [ext]
|
|
242
323
|
* @returns {Promise<{ value: any, modified: boolean, sgrNote: boolean }>}
|
|
243
324
|
*/
|
|
244
325
|
export async function sanitizeValue(
|
|
@@ -247,9 +328,10 @@ export async function sanitizeValue(
|
|
|
247
328
|
warnings,
|
|
248
329
|
reveals = [],
|
|
249
330
|
deadline = makeDeadline(SANITIZE_BUDGET_MS),
|
|
331
|
+
ext = {},
|
|
250
332
|
) {
|
|
251
333
|
if (typeof value === "string") {
|
|
252
|
-
const result = await sanitizeText(value, toolName, deadline);
|
|
334
|
+
const result = await sanitizeText(value, toolName, deadline, ext);
|
|
253
335
|
warnings.push(...result.warnings);
|
|
254
336
|
if (result.reveal !== undefined) reveals.push(result.reveal);
|
|
255
337
|
return {
|
|
@@ -269,6 +351,7 @@ export async function sanitizeValue(
|
|
|
269
351
|
warnings,
|
|
270
352
|
reveals,
|
|
271
353
|
deadline,
|
|
354
|
+
ext,
|
|
272
355
|
);
|
|
273
356
|
out.push(result.value);
|
|
274
357
|
if (result.modified) modified = true;
|
|
@@ -277,7 +360,7 @@ export async function sanitizeValue(
|
|
|
277
360
|
return { value: out, modified, sgrNote };
|
|
278
361
|
}
|
|
279
362
|
if (value !== null && typeof value === "object")
|
|
280
|
-
return sanitizeObject(value, toolName, warnings, reveals, deadline);
|
|
363
|
+
return sanitizeObject(value, toolName, warnings, reveals, deadline, ext);
|
|
281
364
|
return { value, modified: false, sgrNote: false };
|
|
282
365
|
}
|
|
283
366
|
|
|
@@ -291,14 +374,27 @@ export async function sanitizeValue(
|
|
|
291
374
|
* @param {string[]} warnings
|
|
292
375
|
* @param {string[]} reveals
|
|
293
376
|
* @param {{remainingMs: () => number}} deadline shared wall-clock budget
|
|
377
|
+
* @param {SanitizeExtensions} ext
|
|
294
378
|
* @returns {Promise<{ value: Record<string, any>, modified: boolean, sgrNote: boolean }>}
|
|
295
379
|
*/
|
|
296
|
-
async function sanitizeObject(
|
|
380
|
+
async function sanitizeObject(
|
|
381
|
+
value,
|
|
382
|
+
toolName,
|
|
383
|
+
warnings,
|
|
384
|
+
reveals,
|
|
385
|
+
deadline,
|
|
386
|
+
ext,
|
|
387
|
+
) {
|
|
297
388
|
/** @type {Record<string, any>} */
|
|
298
389
|
const out = {};
|
|
299
390
|
let modified = false;
|
|
300
391
|
let sgrNote = false;
|
|
301
392
|
for (const [key, item] of Object.entries(value)) {
|
|
393
|
+
// Layers 1-4 only — `ext` is deliberately NOT passed for a field NAME. A
|
|
394
|
+
// callback sees just the string and the tool, so it cannot tell a schema key
|
|
395
|
+
// from content; a rewrite here can collapse two fields into one name, which
|
|
396
|
+
// the guard below turns into whole-output suppression. Extensions run on
|
|
397
|
+
// value leaves.
|
|
302
398
|
const keyResult = await sanitizeText(key, toolName, deadline);
|
|
303
399
|
warnings.push(...keyResult.warnings);
|
|
304
400
|
if (keyResult.reveal !== undefined) reveals.push(keyResult.reveal);
|
|
@@ -310,6 +406,7 @@ async function sanitizeObject(value, toolName, warnings, reveals, deadline) {
|
|
|
310
406
|
warnings,
|
|
311
407
|
reveals,
|
|
312
408
|
deadline,
|
|
409
|
+
ext,
|
|
313
410
|
);
|
|
314
411
|
// Two distinct raw keys can sanitize to the same name (e.g. `token` and a
|
|
315
412
|
// `token` carrying a zero-width space stripped by Layer 1). Overwriting would
|
|
@@ -458,9 +555,10 @@ export function emitFailClosed(
|
|
|
458
555
|
* fields unchanged. The trace lives here, not in the CLI block below, so it
|
|
459
556
|
* rides the in-process, mutation-tested path.
|
|
460
557
|
* @param {any} input the tool_name / tool_input / tool_response to sanitize
|
|
558
|
+
* @param {SanitizeExtensions} [ext]
|
|
461
559
|
* @returns {Promise<{ mutated_output?: unknown, additional_context?: string } | null>}
|
|
462
560
|
*/
|
|
463
|
-
export async function evaluateToolOutput(input) {
|
|
561
|
+
export async function evaluateToolOutput(input, ext = {}) {
|
|
464
562
|
/**
|
|
465
563
|
* @param {string} outcome noop | clean | flagged | modified
|
|
466
564
|
* @param {{ mutated_output?: unknown, additional_context?: string } | null} fields
|
|
@@ -504,6 +602,7 @@ export async function evaluateToolOutput(input) {
|
|
|
504
602
|
warnings,
|
|
505
603
|
reveals,
|
|
506
604
|
deadline,
|
|
605
|
+
ext,
|
|
507
606
|
);
|
|
508
607
|
// Persist each leaf's pre-Layer-2 text (deduped by content) so the model can
|
|
509
608
|
// Read back what the HTML splice removed; a successful write appends a hint
|
|
@@ -569,9 +668,10 @@ export async function evaluateToolOutput(input) {
|
|
|
569
668
|
* translation. Throws only if a layer engine throws (or on an UNKNOWN event);
|
|
570
669
|
* the CLI fails closed on any throw.
|
|
571
670
|
* @param {import("agent-control-plane-core").ToolCallEvent} event
|
|
671
|
+
* @param {SanitizeExtensions} [ext]
|
|
572
672
|
* @returns {Promise<import("agent-control-plane-core").Verdict>}
|
|
573
673
|
*/
|
|
574
|
-
export async function judgeSanitizeOutput(event) {
|
|
674
|
+
export async function judgeSanitizeOutput(event, ext = {}) {
|
|
575
675
|
const { Decision, EventKind } = controlPlane();
|
|
576
676
|
// Fail closed on a payload the adapter cannot classify (contract/harness
|
|
577
677
|
// drift): this hook only ever receives PostToolUse, so an UNKNOWN event is an
|
|
@@ -584,11 +684,30 @@ export async function judgeSanitizeOutput(event) {
|
|
|
584
684
|
// evaluateToolOutput keys its tool checks on the CANONICAL names (`Read`, the
|
|
585
685
|
// WEB_INGRESS_TOOLS set, `mcp__…`), so it takes `event.tool` — the normalized
|
|
586
686
|
// name — not the raw `meta.native_tool`.
|
|
587
|
-
const fields = await evaluateToolOutput(
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
687
|
+
const fields = await evaluateToolOutput(
|
|
688
|
+
{
|
|
689
|
+
tool_name: event.tool,
|
|
690
|
+
tool_input: event.input,
|
|
691
|
+
tool_response: event.response,
|
|
692
|
+
},
|
|
693
|
+
ext,
|
|
694
|
+
);
|
|
695
|
+
// Audit sees what the MODEL will see, which is the whole point of putting the
|
|
696
|
+
// call here rather than beside the walk: `mutated_output` is present only when
|
|
697
|
+
// the sanitizer actually rewrote bytes, so its absence means the original
|
|
698
|
+
// response IS the model-facing output. Gated on a response existing, so an
|
|
699
|
+
// event with nothing to sanitize records nothing. Awaited (not fired and
|
|
700
|
+
// forgotten) so a recorder that throws fails the hook CLOSED — an audit trail
|
|
701
|
+
// with silent holes is worse than a suppressed tool output.
|
|
702
|
+
if (ext.audit && event.response !== null && event.response !== undefined) {
|
|
703
|
+
const modified = fields !== null && Object.hasOwn(fields, "mutated_output");
|
|
704
|
+
await ext.audit({
|
|
705
|
+
tool: event.tool,
|
|
706
|
+
modified,
|
|
707
|
+
output: modified ? fields?.mutated_output : event.response,
|
|
708
|
+
context: fields?.additional_context,
|
|
709
|
+
});
|
|
710
|
+
}
|
|
592
711
|
/** @type {import("agent-control-plane-core").Verdict} */
|
|
593
712
|
const verdict = { decision: Decision.ALLOW };
|
|
594
713
|
return fields === null ? verdict : { ...verdict, ...fields };
|
|
@@ -627,27 +746,34 @@ export function withPostToolUseDefault(input) {
|
|
|
627
746
|
* The hook's CLI: parse → judge → render, with this hook's fail-closed posture.
|
|
628
747
|
* Exported so a bundle entry (which must claim the CLI slot before this module
|
|
629
748
|
* loads) can run the exact same wiring instead of duplicating the onError
|
|
630
|
-
* posture.
|
|
749
|
+
* posture. That entry is also the only place a host's {@link SanitizeExtensions}
|
|
750
|
+
* can be injected, which is why the bag enters here and not through the
|
|
751
|
+
* environment: a callback is code, and code belongs to the composer.
|
|
752
|
+
* @param {SanitizeExtensions} [ext]
|
|
631
753
|
* @returns {Promise<void>}
|
|
632
754
|
*/
|
|
633
|
-
export async function cliMain() {
|
|
634
|
-
await runJudgeCli(
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
755
|
+
export async function cliMain(ext = {}) {
|
|
756
|
+
await runJudgeCli(
|
|
757
|
+
"sanitize-output",
|
|
758
|
+
(event) => judgeSanitizeOutput(event, ext),
|
|
759
|
+
{
|
|
760
|
+
transformInput: withPostToolUseDefault,
|
|
761
|
+
// Fail closed: replace every string leaf of the original output with the
|
|
762
|
+
// placeholder, preserving shape so the harness honors the suppression
|
|
763
|
+
// instead of falling back to the raw, unvetted output (runJudgeCli hands
|
|
764
|
+
// back the parsed `input` even when the control-plane load failed, so the
|
|
765
|
+
// suppression shape-matches the real tool_response). emitFailClosed itself
|
|
766
|
+
// falls back to a bare string if that shape-matching replacement or its
|
|
767
|
+
// serialization throws, so even a pathological input fails closed.
|
|
768
|
+
onError: (err, input) =>
|
|
769
|
+
emitFailClosed(
|
|
770
|
+
input,
|
|
771
|
+
"[SANITIZATION FAILED — original output suppressed for safety. Hook error: " +
|
|
772
|
+
safeErrMessage(err) +
|
|
773
|
+
"]",
|
|
774
|
+
),
|
|
775
|
+
},
|
|
776
|
+
);
|
|
651
777
|
}
|
|
652
778
|
|
|
653
779
|
// Guard so importing (e.g. property tests) doesn't block on stdin.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.7.
|
|
3
|
+
"version": "2.7.2",
|
|
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": {
|
|
@@ -130,9 +130,29 @@
|
|
|
130
130
|
"types": "./types/claude-hooks/plugin-hooks.d.mts",
|
|
131
131
|
"default": "./claude-hooks/plugin-hooks.mjs"
|
|
132
132
|
},
|
|
133
|
-
"./claude-hooks
|
|
134
|
-
"types": "./types/claude-hooks
|
|
135
|
-
"default": "./claude-hooks
|
|
133
|
+
"./claude-hooks/pretooluse-sanitize": {
|
|
134
|
+
"types": "./types/claude-hooks/pretooluse-sanitize.d.mts",
|
|
135
|
+
"default": "./claude-hooks/pretooluse-sanitize.mjs"
|
|
136
|
+
},
|
|
137
|
+
"./claude-hooks/sanitize-output": {
|
|
138
|
+
"types": "./types/claude-hooks/sanitize-output.d.mts",
|
|
139
|
+
"default": "./claude-hooks/sanitize-output.mjs"
|
|
140
|
+
},
|
|
141
|
+
"./claude-hooks/sanitize-user-prompt": {
|
|
142
|
+
"types": "./types/claude-hooks/sanitize-user-prompt.d.mts",
|
|
143
|
+
"default": "./claude-hooks/sanitize-user-prompt.mjs"
|
|
144
|
+
},
|
|
145
|
+
"./claude-hooks/scan-invisible-chars": {
|
|
146
|
+
"types": "./types/claude-hooks/scan-invisible-chars.d.mts",
|
|
147
|
+
"default": "./claude-hooks/scan-invisible-chars.mjs"
|
|
148
|
+
},
|
|
149
|
+
"./claude-hooks/lib/hook-io": {
|
|
150
|
+
"types": "./types/claude-hooks/lib/hook-io.d.mts",
|
|
151
|
+
"default": "./claude-hooks/lib/hook-io.mjs"
|
|
152
|
+
},
|
|
153
|
+
"./claude-hooks/lib/control-plane": {
|
|
154
|
+
"types": "./types/claude-hooks/lib/control-plane.d.mts",
|
|
155
|
+
"default": "./claude-hooks/lib/control-plane.mjs"
|
|
136
156
|
}
|
|
137
157
|
},
|
|
138
158
|
"files": [
|
|
@@ -11,7 +11,24 @@ export function inferenceKeyVars(): string[];
|
|
|
11
11
|
*/
|
|
12
12
|
export function minEnvSecretLen(): number;
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
14
|
+
* Render the published credential-noun vocabulary into the name-matcher spec:
|
|
15
|
+
* the credential segments, and the trailing suffixes that mark a
|
|
16
|
+
* credential-shaped name as holding a non-secret.
|
|
17
|
+
*
|
|
18
|
+
* The vocabulary is the SINGLE source both ecosystems read — a curated second
|
|
19
|
+
* copy of these renderings is what let this matcher fall twelve segments behind
|
|
20
|
+
* the engine, so a variable named `…_ACCESS_TOKEN` was never recognized as
|
|
21
|
+
* credential-bearing and its value was never handed to the redactor.
|
|
22
|
+
* @param {Record<string, any>} spec
|
|
23
|
+
* @returns {{ segments: string[], excludeSuffixes: string[], excludeNames: string[] }}
|
|
24
|
+
*/
|
|
25
|
+
export function deriveCredentialVocabulary(spec: Record<string, any>): {
|
|
26
|
+
segments: string[];
|
|
27
|
+
excludeSuffixes: string[];
|
|
28
|
+
excludeNames: string[];
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Validate a rendered name-matcher spec and build its match/exclude regexes. Pure
|
|
15
32
|
* and exported so the fail-closed paths can be driven directly with a bad spec.
|
|
16
33
|
* @param {Record<string, unknown>} spec
|
|
17
34
|
* @returns {{ match: RegExp, exclude: RegExp }}
|
|
@@ -36,12 +53,20 @@ export function looksLikeCredentialVar(name: string): boolean;
|
|
|
36
53
|
* @returns {string[]}
|
|
37
54
|
*/
|
|
38
55
|
export function dynamicSecretVars(env?: Record<string, string | undefined>): string[];
|
|
56
|
+
/**
|
|
57
|
+
* The operator-declared extra secret variable names, or throw. A malformed entry
|
|
58
|
+
* fails CLOSED — dropping it silently would leave the operator believing a
|
|
59
|
+
* forwarded credential is masked while its value flows to the model verbatim.
|
|
60
|
+
* @param {Record<string, string | undefined>} [env]
|
|
61
|
+
* @returns {string[]}
|
|
62
|
+
*/
|
|
63
|
+
export function extraSecretVars(env?: Record<string, string | undefined>): string[];
|
|
39
64
|
/**
|
|
40
65
|
* The env-bound redaction set: the UNION of the inference keys, the curated host
|
|
41
|
-
* credentials,
|
|
42
|
-
* redactor binds the same union; every consumer
|
|
43
|
-
* the redactor client's per-request env snapshot)
|
|
44
|
-
* credential value would never trip the daemon.
|
|
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.
|
|
45
70
|
* @param {Record<string, string | undefined>} [env]
|
|
46
71
|
* @returns {string[]}
|
|
47
72
|
*/
|
|
@@ -1,3 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-supplied extensions to this hook, threaded from {@link cliMain} down to
|
|
3
|
+
* each string leaf. Every field is optional and the bag defaults to `{}`, so a
|
|
4
|
+
* composer that supplies none gets exactly the behavior of this module alone —
|
|
5
|
+
* which is what lets the extension points ship without changing any shipped
|
|
6
|
+
* verdict. The callbacks own all policy: this module decides only WHERE they run,
|
|
7
|
+
* never WHETHER their result is applied.
|
|
8
|
+
*
|
|
9
|
+
* A callback that throws is not caught here. That is deliberate: the throw lands
|
|
10
|
+
* in the CLI's fail-closed catch and the tool output is suppressed, so a broken
|
|
11
|
+
* extension cannot degrade into showing unvetted output.
|
|
12
|
+
*
|
|
13
|
+
* These are NOT the Layer-5 injection-filter seam and do not inherit its
|
|
14
|
+
* delete-only, closed-enum restriction. (Its identifier is deliberately unspoken
|
|
15
|
+
* here: the plugin-bundle suite pins Layer 5 absent by asserting the name appears
|
|
16
|
+
* nowhere in this file, and that absolute check is worth more than the precision
|
|
17
|
+
* of one comment.) That restriction exists because such a
|
|
18
|
+
* filter is a MODEL, so its output is attacker-reachable and must not be able to
|
|
19
|
+
* inject text into the model-facing context. These callbacks are code the
|
|
20
|
+
* composer wrote and linked at build time — the same trust level as the injected
|
|
21
|
+
* redactor — so free-text warnings and arbitrary rewrites are theirs to own.
|
|
22
|
+
*
|
|
23
|
+
* @typedef {object} SanitizeExtensions
|
|
24
|
+
* @property {(cleaned: string, ctx: { toolName: string, webIngress: boolean, deadline: { remainingMs: () => number } }) => Promise<{ cleaned?: string, warning?: string } | null | undefined> | { cleaned?: string, warning?: string } | null | undefined} [postText]
|
|
25
|
+
* Runs once per string leaf, AFTER Layers 1-4. Returning `cleaned` replaces the
|
|
26
|
+
* model-facing text; `warning` joins this leaf's warnings.
|
|
27
|
+
* @property {(raw: string) => string | undefined} [redactNote]
|
|
28
|
+
* Given the pre-redaction text of a leaf that tripped Layer 4, returns a note
|
|
29
|
+
* appended to that leaf's "API keys/secrets redacted: …" warning.
|
|
30
|
+
* @property {(record: { tool: string | null, modified: boolean, output: unknown, context?: string }) => Promise<void> | void} [audit]
|
|
31
|
+
* Awaited once per judged event that carried a tool response, with the output
|
|
32
|
+
* the model will actually see.
|
|
33
|
+
*/
|
|
1
34
|
/**
|
|
2
35
|
* Run Layers 1-4 over a single text blob, delegated to the package's output seam
|
|
3
36
|
* (sanitizeTextSeam) bound here to this hook's per-tool policy: which tools get
|
|
@@ -9,11 +42,12 @@
|
|
|
9
42
|
* @param {string} toolName gates the SGR carve-out and the untrusted-ingress passes
|
|
10
43
|
* @param {{remainingMs: () => number}} [deadline] shared wall-clock budget across
|
|
11
44
|
* all leaves of one hook run; a direct caller gets a fresh full budget
|
|
45
|
+
* @param {SanitizeExtensions} [ext]
|
|
12
46
|
* @returns {Promise<{ cleaned: string, warnings: string[], modified: boolean, sgrNote: boolean, reveal?: string }>}
|
|
13
47
|
*/
|
|
14
48
|
export function sanitizeText(text: string, toolName: string, deadline?: {
|
|
15
49
|
remainingMs: () => number;
|
|
16
|
-
}): Promise<{
|
|
50
|
+
}, ext?: SanitizeExtensions): Promise<{
|
|
17
51
|
cleaned: string;
|
|
18
52
|
warnings: string[];
|
|
19
53
|
modified: boolean;
|
|
@@ -40,11 +74,12 @@ export function sanitizeText(text: string, toolName: string, deadline?: {
|
|
|
40
74
|
* @param {string[]} [reveals]
|
|
41
75
|
* @param {{remainingMs: () => number}} [deadline] shared wall-clock budget across
|
|
42
76
|
* every leaf of this value (created once by the top-level caller)
|
|
77
|
+
* @param {SanitizeExtensions} [ext]
|
|
43
78
|
* @returns {Promise<{ value: any, modified: boolean, sgrNote: boolean }>}
|
|
44
79
|
*/
|
|
45
80
|
export function sanitizeValue(value: any, toolName: string, warnings: string[], reveals?: string[], deadline?: {
|
|
46
81
|
remainingMs: () => number;
|
|
47
|
-
}): Promise<{
|
|
82
|
+
}, ext?: SanitizeExtensions): Promise<{
|
|
48
83
|
value: any;
|
|
49
84
|
modified: boolean;
|
|
50
85
|
sgrNote: boolean;
|
|
@@ -117,9 +152,10 @@ export function emitFailClosed(input: any, message: string, emit?: (fields: Reco
|
|
|
117
152
|
* fields unchanged. The trace lives here, not in the CLI block below, so it
|
|
118
153
|
* rides the in-process, mutation-tested path.
|
|
119
154
|
* @param {any} input the tool_name / tool_input / tool_response to sanitize
|
|
155
|
+
* @param {SanitizeExtensions} [ext]
|
|
120
156
|
* @returns {Promise<{ mutated_output?: unknown, additional_context?: string } | null>}
|
|
121
157
|
*/
|
|
122
|
-
export function evaluateToolOutput(input: any): Promise<{
|
|
158
|
+
export function evaluateToolOutput(input: any, ext?: SanitizeExtensions): Promise<{
|
|
123
159
|
mutated_output?: unknown;
|
|
124
160
|
additional_context?: string;
|
|
125
161
|
} | null>;
|
|
@@ -134,9 +170,10 @@ export function evaluateToolOutput(input: any): Promise<{
|
|
|
134
170
|
* translation. Throws only if a layer engine throws (or on an UNKNOWN event);
|
|
135
171
|
* the CLI fails closed on any throw.
|
|
136
172
|
* @param {import("agent-control-plane-core").ToolCallEvent} event
|
|
173
|
+
* @param {SanitizeExtensions} [ext]
|
|
137
174
|
* @returns {Promise<import("agent-control-plane-core").Verdict>}
|
|
138
175
|
*/
|
|
139
|
-
export function judgeSanitizeOutput(event: import("agent-control-plane-core").ToolCallEvent): Promise<import("agent-control-plane-core").Verdict>;
|
|
176
|
+
export function judgeSanitizeOutput(event: import("agent-control-plane-core").ToolCallEvent, ext?: SanitizeExtensions): Promise<import("agent-control-plane-core").Verdict>;
|
|
140
177
|
/**
|
|
141
178
|
* Default a raw payload's `hook_event_name` to PostToolUse when it is absent.
|
|
142
179
|
* sanitize-output is wired ONLY to the PostToolUse event, so a payload that
|
|
@@ -155,10 +192,13 @@ export function withPostToolUseDefault(input: unknown): unknown;
|
|
|
155
192
|
* The hook's CLI: parse → judge → render, with this hook's fail-closed posture.
|
|
156
193
|
* Exported so a bundle entry (which must claim the CLI slot before this module
|
|
157
194
|
* loads) can run the exact same wiring instead of duplicating the onError
|
|
158
|
-
* posture.
|
|
195
|
+
* posture. That entry is also the only place a host's {@link SanitizeExtensions}
|
|
196
|
+
* can be injected, which is why the bag enters here and not through the
|
|
197
|
+
* environment: a callback is code, and code belongs to the composer.
|
|
198
|
+
* @param {SanitizeExtensions} [ext]
|
|
159
199
|
* @returns {Promise<void>}
|
|
160
200
|
*/
|
|
161
|
-
export function cliMain(): Promise<void>;
|
|
201
|
+
export function cliMain(ext?: SanitizeExtensions): Promise<void>;
|
|
162
202
|
export const applyLayer1: typeof import("agent-sanitizer").applyLayer1;
|
|
163
203
|
export const matchesSecretHint: typeof import("agent-sanitizer").matchesSecretHint;
|
|
164
204
|
export const SECRET_HINT: RegExp;
|
|
@@ -166,3 +206,59 @@ export const SECRET_HINT_EXT: RegExp;
|
|
|
166
206
|
export const describeRemoved: typeof import("agent-sanitizer/output").describeRemoved;
|
|
167
207
|
export const describeWarned: typeof import("agent-sanitizer/output").describeWarned;
|
|
168
208
|
export const suppressToolOutput: typeof import("agent-sanitizer/output").suppressToolOutput;
|
|
209
|
+
/**
|
|
210
|
+
* Host-supplied extensions to this hook, threaded from {@link cliMain} down to
|
|
211
|
+
* each string leaf. Every field is optional and the bag defaults to `{}`, so a
|
|
212
|
+
* composer that supplies none gets exactly the behavior of this module alone —
|
|
213
|
+
* which is what lets the extension points ship without changing any shipped
|
|
214
|
+
* verdict. The callbacks own all policy: this module decides only WHERE they run,
|
|
215
|
+
* never WHETHER their result is applied.
|
|
216
|
+
*
|
|
217
|
+
* A callback that throws is not caught here. That is deliberate: the throw lands
|
|
218
|
+
* in the CLI's fail-closed catch and the tool output is suppressed, so a broken
|
|
219
|
+
* extension cannot degrade into showing unvetted output.
|
|
220
|
+
*
|
|
221
|
+
* These are NOT the Layer-5 injection-filter seam and do not inherit its
|
|
222
|
+
* delete-only, closed-enum restriction. (Its identifier is deliberately unspoken
|
|
223
|
+
* here: the plugin-bundle suite pins Layer 5 absent by asserting the name appears
|
|
224
|
+
* nowhere in this file, and that absolute check is worth more than the precision
|
|
225
|
+
* of one comment.) That restriction exists because such a
|
|
226
|
+
* filter is a MODEL, so its output is attacker-reachable and must not be able to
|
|
227
|
+
* inject text into the model-facing context. These callbacks are code the
|
|
228
|
+
* composer wrote and linked at build time — the same trust level as the injected
|
|
229
|
+
* redactor — so free-text warnings and arbitrary rewrites are theirs to own.
|
|
230
|
+
*/
|
|
231
|
+
export type SanitizeExtensions = {
|
|
232
|
+
/**
|
|
233
|
+
* Runs once per string leaf, AFTER Layers 1-4. Returning `cleaned` replaces the
|
|
234
|
+
* model-facing text; `warning` joins this leaf's warnings.
|
|
235
|
+
*/
|
|
236
|
+
postText?: ((cleaned: string, ctx: {
|
|
237
|
+
toolName: string;
|
|
238
|
+
webIngress: boolean;
|
|
239
|
+
deadline: {
|
|
240
|
+
remainingMs: () => number;
|
|
241
|
+
};
|
|
242
|
+
}) => Promise<{
|
|
243
|
+
cleaned?: string;
|
|
244
|
+
warning?: string;
|
|
245
|
+
} | null | undefined> | {
|
|
246
|
+
cleaned?: string;
|
|
247
|
+
warning?: string;
|
|
248
|
+
} | null | undefined) | undefined;
|
|
249
|
+
/**
|
|
250
|
+
* Given the pre-redaction text of a leaf that tripped Layer 4, returns a note
|
|
251
|
+
* appended to that leaf's "API keys/secrets redacted: …" warning.
|
|
252
|
+
*/
|
|
253
|
+
redactNote?: ((raw: string) => string | undefined) | undefined;
|
|
254
|
+
/**
|
|
255
|
+
* Awaited once per judged event that carried a tool response, with the output
|
|
256
|
+
* the model will actually see.
|
|
257
|
+
*/
|
|
258
|
+
audit?: ((record: {
|
|
259
|
+
tool: string | null;
|
|
260
|
+
modified: boolean;
|
|
261
|
+
output: unknown;
|
|
262
|
+
context?: string;
|
|
263
|
+
}) => Promise<void> | void) | undefined;
|
|
264
|
+
};
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"comment": "The credential-shaped ENV-VAR NAME vocabulary the hook-side pre-gate builds its regexes from (looksLikeCredentialVar in lib/env-config.mjs). `segments`: a var whose trailing underscore-delimited segment is one of these is treated as credential-bearing (matched as `(?:^|_)(?:<segment>)$`, case-insensitive). `excludeSuffixes` / `excludeNames`: names that end like a credential but hold a non-secret (an identifier, a public key, the ssh-agent socket path) and must NOT be redacted out of tool output. Every token is restricted to A-Z and _ so it carries no regex metacharacter; the consumer enforces that and fails closed on a violation, an empty list, or a missing field.",
|
|
3
|
-
"segments": [
|
|
4
|
-
"TOKEN",
|
|
5
|
-
"SECRET",
|
|
6
|
-
"SECRETS",
|
|
7
|
-
"PASSWORD",
|
|
8
|
-
"PASSWD",
|
|
9
|
-
"PASSPHRASE",
|
|
10
|
-
"APIKEY",
|
|
11
|
-
"API_KEY",
|
|
12
|
-
"ACCESS_KEY",
|
|
13
|
-
"SECRET_KEY",
|
|
14
|
-
"PRIVATE_KEY",
|
|
15
|
-
"AUTH_TOKEN",
|
|
16
|
-
"PAT",
|
|
17
|
-
"CREDENTIAL",
|
|
18
|
-
"CREDENTIALS",
|
|
19
|
-
"KEY"
|
|
20
|
-
],
|
|
21
|
-
"excludeSuffixes": ["_KEY_ID", "_PUBLIC_KEY"],
|
|
22
|
-
"excludeNames": ["SSH_AUTH_SOCK"]
|
|
23
|
-
}
|