agent-sanitizer 2.30.0 → 2.31.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 +2 -2
- package/THREAT-MODEL.md +40 -19
- package/claude-hooks/lib/placeholder-grammar.mjs +162 -84
- package/claude-hooks/lib/reveal.mjs +129 -5
- package/claude-hooks/plugin-hooks.mjs +1 -0
- package/claude-hooks/pretooluse-sanitize.mjs +159 -2
- package/claude-hooks/sanitize-output.mjs +76 -23
- package/package.json +1 -1
- package/src/gates.mjs +5 -3
- package/src/html.mjs +123 -30
- package/src/index.mjs +32 -18
- package/src/output.mjs +73 -17
- package/types/claude-hooks/lib/placeholder-grammar.d.mts +75 -29
- package/types/claude-hooks/lib/reveal.d.mts +46 -0
- package/types/claude-hooks/pretooluse-sanitize.d.mts +35 -0
- package/types/claude-hooks/sanitize-output.d.mts +17 -3
- package/types/gates.d.mts +5 -3
- package/types/html.d.mts +74 -24
- package/types/index.d.mts +19 -10
- package/types/output.d.mts +24 -3
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* PreToolUse content-protection orchestrator. Runs
|
|
2
|
+
* PreToolUse content-protection orchestrator. Runs five layers in ONE process:
|
|
3
3
|
*
|
|
4
4
|
* 1. Invisible-char injection gate (lib/invisible-alert.mjs)
|
|
5
5
|
* 2. Confusable/homoglyph normalization of paths & commands
|
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* (lib/authored-content.mjs)
|
|
9
9
|
* 4. Rehydration of secret-redaction placeholders in Edit/Write inputs
|
|
10
10
|
* (agent-sanitizer/rehydrate, redactor-daemon io injected)
|
|
11
|
+
* 5. Rehydration of keyed Layer-2 splice placeholders in Edit/Write inputs
|
|
12
|
+
* ({@link rehydrateLayer2}, span store in lib/reveal.mjs) — disjoint
|
|
13
|
+
* grammar from step 4, run after it
|
|
11
14
|
*
|
|
12
15
|
* WHY ONE PROCESS: Claude Code runs PreToolUse hooks in parallel and does NOT
|
|
13
16
|
* chain their `updatedInput` — each hook sees the original input and the last to
|
|
@@ -60,7 +63,13 @@ import {
|
|
|
60
63
|
import { redactViaDaemon } from "./lib/redactor-client.mjs";
|
|
61
64
|
import { secretsEnabled } from "./lib/env-config.mjs";
|
|
62
65
|
import { withSecretDropGuard } from "./lib/secret-drop-guard.mjs";
|
|
63
|
-
import {
|
|
66
|
+
import {
|
|
67
|
+
placeholderNotice,
|
|
68
|
+
layer2PlaceholderNotice,
|
|
69
|
+
layer2Keys,
|
|
70
|
+
LAYER2_PLACEHOLDER_RE,
|
|
71
|
+
} from "./lib/placeholder-grammar.mjs";
|
|
72
|
+
import { readSpan, spanPath } from "./lib/reveal.mjs";
|
|
64
73
|
import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
|
|
65
74
|
|
|
66
75
|
const HOOK_NAME = "pretooluse-sanitize";
|
|
@@ -110,6 +119,14 @@ const { rehydrateRedacted } =
|
|
|
110
119
|
/** @type {typeof import("agent-sanitizer/rehydrate")} */ (
|
|
111
120
|
await lazyImport("agent-sanitizer/rehydrate")
|
|
112
121
|
);
|
|
122
|
+
// The one sound multi-needle splice primitive (see its doc in the engine): the
|
|
123
|
+
// Layer-2 rehydrator below substitutes every keyed placeholder in one ordered
|
|
124
|
+
// pass, so a stored original whose bytes happen to contain another placeholder
|
|
125
|
+
// is never re-matched and re-expanded.
|
|
126
|
+
const { spliceOrdered } =
|
|
127
|
+
/** @type {typeof import("agent-sanitizer/view-map")} */ (
|
|
128
|
+
await lazyImport("agent-sanitizer/view-map")
|
|
129
|
+
);
|
|
113
130
|
|
|
114
131
|
// Injection seams binding the peer dependencies into the provider-agnostic
|
|
115
132
|
// package functions. namespace-guard (the confusable vision map) and the
|
|
@@ -157,6 +174,130 @@ const guardedRehydrate = withSecretDropGuard(
|
|
|
157
174
|
redactorIo,
|
|
158
175
|
);
|
|
159
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Substitute every keyed Layer-2 placeholder in `text` with the stored original
|
|
179
|
+
* from the reveal store's span files, in ONE ordered pass (spliceOrdered).
|
|
180
|
+
* A key with NO stored span fails CLOSED — the placeholder stands for real
|
|
181
|
+
* content this store cannot produce, so writing it through literally would
|
|
182
|
+
* silently persist the loss the keyed grammar exists to prevent.
|
|
183
|
+
* @param {string} text
|
|
184
|
+
* @param {string} field the tool-input field being rehydrated, for the deny prose
|
|
185
|
+
* @returns {{ text: string, restored: number } | { deny: string } | null}
|
|
186
|
+
*/
|
|
187
|
+
function substituteLayer2(text, field) {
|
|
188
|
+
const matches = [...text.matchAll(LAYER2_PLACEHOLDER_RE)].map((match) => ({
|
|
189
|
+
text: match[0],
|
|
190
|
+
index: /** @type {number} */ (match.index),
|
|
191
|
+
key: match[1],
|
|
192
|
+
}));
|
|
193
|
+
if (matches.length === 0) return null;
|
|
194
|
+
/** @type {Map<string, string>} */
|
|
195
|
+
const byKey = new Map();
|
|
196
|
+
/** @type {string[]} */
|
|
197
|
+
const missing = [];
|
|
198
|
+
for (const key of new Set(matches.map((match) => match.key))) {
|
|
199
|
+
const stored = readSpan(key);
|
|
200
|
+
if (stored === null) missing.push(key);
|
|
201
|
+
else byKey.set(key, stored);
|
|
202
|
+
}
|
|
203
|
+
if (missing.length > 0)
|
|
204
|
+
return {
|
|
205
|
+
deny:
|
|
206
|
+
`${field} contains Layer-2 removed-content placeholder(s) whose original is not ` +
|
|
207
|
+
`in the span store (missing key(s): ${missing.join(", ")}; expected file(s): ` +
|
|
208
|
+
`${missing.map((key) => spanPath(key)).join(", ")}), so the removed content ` +
|
|
209
|
+
`cannot be restored automatically. Reconstruct that content yourself, ` +
|
|
210
|
+
`deliberately drop the placeholder(s) if the removed content should stay ` +
|
|
211
|
+
`removed, or ask the user to make this change`,
|
|
212
|
+
};
|
|
213
|
+
// `i` is the match's index in `matches` (stable across spliceOrdered's
|
|
214
|
+
// overlap skips — impossible here, distinct non-overlapping literals), so the
|
|
215
|
+
// key rides positionally.
|
|
216
|
+
const spliced = spliceOrdered(
|
|
217
|
+
text,
|
|
218
|
+
matches,
|
|
219
|
+
(_match, i) => /** @type {string} */ (byKey.get(matches[i].key)),
|
|
220
|
+
);
|
|
221
|
+
return { text: spliced.text, restored: matches.length };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Layer-2 placeholder rehydration on the write path: an Edit `new_string` or a
|
|
226
|
+
* Write `content` carrying `[hidden HTML removed #<key>]` / `[HTML comment
|
|
227
|
+
* removed #<key>]` placeholders (copied from sanitized tool output) has each
|
|
228
|
+
* one restored to the stored original bytes from the reveal store's span files.
|
|
229
|
+
*
|
|
230
|
+
* SECURITY invariant: the stored span content was REDACTED before persistence
|
|
231
|
+
* (sanitize-output runs strict web-ingress redaction on each splice original
|
|
232
|
+
* before persistSpan), so this rehydration can never write a raw secret — the
|
|
233
|
+
* worst it can restore is `[REDACTED…]` placeholder text standing where the
|
|
234
|
+
* secret was, which the on-disk tripwire then flags on later Reads.
|
|
235
|
+
*
|
|
236
|
+
* Composition with the secret rehydrator: this runs AFTER it (a later terminal
|
|
237
|
+
* layer), and the two grammars are disjoint — a Layer-2 placeholder never
|
|
238
|
+
* matches the `[REDACTED…]` grammar and vice versa — so neither can touch the
|
|
239
|
+
* other's tokens. Running second also means a restored original that contains
|
|
240
|
+
* `[REDACTED…]` text is never re-fed to the secret resolver (which would deny
|
|
241
|
+
* it as a foreign placeholder).
|
|
242
|
+
*
|
|
243
|
+
* `old_string` is deliberately NOT rehydrated: a Layer-2 placeholder there
|
|
244
|
+
* exists in the model's view of PRIOR TOOL OUTPUT, not on disk, so unless the
|
|
245
|
+
* file literally contains the placeholder text, Edit's ordinary no-match
|
|
246
|
+
* failure is the right outcome — no re-anchoring. MultiEdit/NotebookEdit with a
|
|
247
|
+
* Layer-2 placeholder are denied (parity with the secret path: sequential
|
|
248
|
+
* edits / notebook JSON cannot be rehydrated).
|
|
249
|
+
* @param {string} tool
|
|
250
|
+
* @param {any} toolInput
|
|
251
|
+
* @returns {{ updatedInput: any, context: string } | { deny: string } | null}
|
|
252
|
+
*/
|
|
253
|
+
export function rehydrateLayer2(tool, toolInput) {
|
|
254
|
+
const hasL2 = (/** @type {unknown} */ text) =>
|
|
255
|
+
typeof text === "string" && layer2Keys(text).length > 0;
|
|
256
|
+
if (
|
|
257
|
+
tool === "MultiEdit" &&
|
|
258
|
+
Array.isArray(toolInput?.edits) &&
|
|
259
|
+
toolInput.edits.some(
|
|
260
|
+
(/** @type {any} */ edit) =>
|
|
261
|
+
hasL2(edit?.old_string) || hasL2(edit?.new_string),
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
return {
|
|
265
|
+
deny:
|
|
266
|
+
`the edits carry [hidden HTML removed #…]/[HTML comment removed #…] ` +
|
|
267
|
+
`placeholders, which stand for content spliced out of earlier tool output; ` +
|
|
268
|
+
`MultiEdit's sequential edits cannot be rehydrated. Use single Edit calls — ` +
|
|
269
|
+
`each restores the stored original individually — or ask the user to make ` +
|
|
270
|
+
`this change`,
|
|
271
|
+
};
|
|
272
|
+
if (tool === "NotebookEdit" && hasL2(toolInput?.new_source))
|
|
273
|
+
return {
|
|
274
|
+
deny:
|
|
275
|
+
`new_source contains a [hidden HTML removed #…]/[HTML comment removed #…] ` +
|
|
276
|
+
`placeholder, which stands for content spliced out of earlier tool output; ` +
|
|
277
|
+
`rehydration is not supported for notebooks. Reconstruct the content, ` +
|
|
278
|
+
`deliberately drop the placeholder if the removed content should stay ` +
|
|
279
|
+
`removed, or ask the user to edit the cell`,
|
|
280
|
+
};
|
|
281
|
+
/** @type {"new_string" | "content" | null} */
|
|
282
|
+
const field =
|
|
283
|
+
tool === "Edit" && typeof toolInput?.new_string === "string"
|
|
284
|
+
? "new_string"
|
|
285
|
+
: tool === "Write" && typeof toolInput?.content === "string"
|
|
286
|
+
? "content"
|
|
287
|
+
: null;
|
|
288
|
+
if (field === null) return null;
|
|
289
|
+
const result = substituteLayer2(toolInput[field], field);
|
|
290
|
+
if (result === null) return null;
|
|
291
|
+
if ("deny" in result) return result;
|
|
292
|
+
return {
|
|
293
|
+
updatedInput: { ...toolInput, [field]: result.text },
|
|
294
|
+
context:
|
|
295
|
+
`${result.restored} Layer-2 removed-content placeholder(s) in ${field} ` +
|
|
296
|
+
`were restored to the stored original content (secrets inside were ` +
|
|
297
|
+
`redacted before storage, so no raw secret is written).`,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
160
301
|
/**
|
|
161
302
|
* The wired default gates the whole rehydration layer on the secret opt-in:
|
|
162
303
|
* with secrets off the output hook never inserts placeholders, so there is
|
|
@@ -263,6 +404,17 @@ export function preToolUseLayers(rehydrate, env = process.env) {
|
|
|
263
404
|
};
|
|
264
405
|
},
|
|
265
406
|
},
|
|
407
|
+
{
|
|
408
|
+
name: "layer2-rehydrate",
|
|
409
|
+
erases: true,
|
|
410
|
+
skipBased: false,
|
|
411
|
+
// Terminal, AFTER the secret rehydrator: the grammars are disjoint (see
|
|
412
|
+
// rehydrateLayer2's doc), and the stored bytes it restores — which may
|
|
413
|
+
// legitimately contain [REDACTED…] text — must not be re-fed to the
|
|
414
|
+
// secret resolver or re-stripped by an earlier layer.
|
|
415
|
+
terminal: true,
|
|
416
|
+
run: (tool, toolInput) => rehydrateLayer2(tool, toolInput),
|
|
417
|
+
},
|
|
266
418
|
];
|
|
267
419
|
return env.AGENT_SANITIZER_OUTPUT_DISABLED === "1"
|
|
268
420
|
? layers.filter((layer) => layer.name !== "authored-content")
|
|
@@ -334,6 +486,11 @@ export async function buildPreToolUseResponse(
|
|
|
334
486
|
// placeholder-shaped text is ordinary prose and the advisory is noise.
|
|
335
487
|
const notice = secretsEnabled() ? placeholderNotice(tool, current) : null;
|
|
336
488
|
if (notice !== null) contexts.push(notice);
|
|
489
|
+
// Same advisory for keyed Layer-2 splice placeholders (disjoint grammar, own
|
|
490
|
+
// store): a Bash/MCP write path would persist them literally too, and the
|
|
491
|
+
// note names the span file(s) where the original bytes live.
|
|
492
|
+
const layer2Notice = layer2PlaceholderNotice(tool, current);
|
|
493
|
+
if (layer2Notice !== null) contexts.push(layer2Notice);
|
|
337
494
|
|
|
338
495
|
return emitTraced(
|
|
339
496
|
emitTrace,
|
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
* PostToolUse: sanitize tool output before the model sees it.
|
|
3
3
|
*
|
|
4
4
|
* Layer 1: Strip payload-capable invisible chars + ANSI escapes.
|
|
5
|
-
* Layer 2: Splice out hidden
|
|
6
|
-
* ingress
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Layer 2: Splice out hidden-styled/hidden-attribute elements and HTML
|
|
6
|
+
* comments from web ingress, each replaced by a keyed,
|
|
7
|
+
* content-addressed placeholder; report preserved scripting/resource
|
|
8
|
+
* tags. The pre-splice text is stashed in an ephemeral sidecar file
|
|
9
|
+
* the model may Read back (behind an untrusted-content envelope), and
|
|
10
|
+
* each splice's original is persisted beside it under the
|
|
11
|
+
* placeholder's key so Edit/Write can round-trip the placeholder back
|
|
12
|
+
* to the original bytes — see lib/reveal.mjs.
|
|
9
13
|
* Layer 3: Report data-exfil-shaped URLs in web ingress (detection only).
|
|
10
14
|
* Layer 4: Redact API keys/secrets via detect-secrets, served by the long-lived
|
|
11
15
|
* redactor daemon — see lib/redactor-client.mjs.
|
|
@@ -39,10 +43,12 @@ import { hasEnvBoundSecret } from "./lib/secret-annotate.mjs";
|
|
|
39
43
|
import { secretsEnabled } from "./lib/env-config.mjs";
|
|
40
44
|
import {
|
|
41
45
|
persistReveal,
|
|
46
|
+
persistSpan,
|
|
47
|
+
SPAN_ROUNDTRIP_NOTICE,
|
|
42
48
|
isRevealRead,
|
|
43
49
|
REVEAL_READ_ENVELOPE,
|
|
44
50
|
} from "./lib/reveal.mjs";
|
|
45
|
-
import { containsPlaceholder } from "./lib/placeholder-grammar.mjs";
|
|
51
|
+
import { containsPlaceholder, layer2Keys } from "./lib/placeholder-grammar.mjs";
|
|
46
52
|
|
|
47
53
|
// Layer-1 primitives and the cheap pre-gates, bound via lazyImport (see its
|
|
48
54
|
// doc for the fail-OPEN hazard of a bare static npm import). A load failure
|
|
@@ -124,7 +130,7 @@ const SGR_OUTPUT_NOTE =
|
|
|
124
130
|
|
|
125
131
|
// Web-ingress tools always get the Layer 2 HTML rewrite; local tools — Read,
|
|
126
132
|
// Bash, Grep, gh — never do. A local HTML/markdown pass either rewrites bytes the
|
|
127
|
-
// model is about to edit or deletes content (
|
|
133
|
+
// model is about to edit or deletes content (diffs, PR bodies, page
|
|
128
134
|
// source fetched with curl) the task legitimately needs. (MCP output gets Layer 2
|
|
129
135
|
// only when HTML-shaped — see the `html` gate in sanitizeText.) Layers 1
|
|
130
136
|
// (invisible chars) and 4 (secret redaction) still run on every tool.
|
|
@@ -235,13 +241,15 @@ async function redactSecrets(text, webIngress = false, deadline) {
|
|
|
235
241
|
* the HTML rewrite (Layer 2) and the exfil-URL scan (Layer 3), the injected
|
|
236
242
|
* secret redactor (Layer 4), and the display-only-SGR carve-out. `reveal` carries
|
|
237
243
|
* the seam's pre-Layer-2 text when the HTML splice removed anything, for the
|
|
238
|
-
* orchestrator to persist
|
|
244
|
+
* orchestrator to persist; `splices` is its per-placeholder twin (each
|
|
245
|
+
* `original` already vetted by the seam's exit redaction, withheld entries
|
|
246
|
+
* dropped there), for the orchestrator's per-key span persistence.
|
|
239
247
|
* @param {string} text
|
|
240
248
|
* @param {string} toolName gates the SGR carve-out and the untrusted-ingress passes
|
|
241
249
|
* @param {{remainingMs: () => number}} [deadline] shared wall-clock budget across
|
|
242
250
|
* all leaves of one hook run; a direct caller gets a fresh full budget
|
|
243
251
|
* @param {SanitizeExtensions} [ext]
|
|
244
|
-
* @returns {Promise<{ cleaned: string, warnings: string[], notes: string[], modified: boolean, sgrNote: boolean, reveal?: string }>}
|
|
252
|
+
* @returns {Promise<{ cleaned: string, warnings: string[], notes: string[], modified: boolean, sgrNote: boolean, reveal?: string, splices?: Array<{ placeholder: string, original: string }> }>}
|
|
245
253
|
*/
|
|
246
254
|
export async function sanitizeText(
|
|
247
255
|
text,
|
|
@@ -298,7 +306,7 @@ export async function sanitizeText(
|
|
|
298
306
|
},
|
|
299
307
|
};
|
|
300
308
|
const seamResult =
|
|
301
|
-
/** @type {{ cleaned: string, warnings: string[], notes?: string[], modified: boolean, sgrNote: boolean, reveal?: string }} */ (
|
|
309
|
+
/** @type {{ cleaned: string, warnings: string[], notes?: string[], modified: boolean, sgrNote: boolean, reveal?: string, splices?: Array<{ placeholder: string, original: string }> }} */ (
|
|
302
310
|
await sanitizeTextSeam(text, seamOptions)
|
|
303
311
|
);
|
|
304
312
|
// The one place the seam's shape is normalized: `notes` is absent when the
|
|
@@ -326,9 +334,9 @@ export async function sanitizeText(
|
|
|
326
334
|
* notes, which would be a false account of bytes a callback has since rewritten
|
|
327
335
|
* for its own reasons. A callback's `warning` is taken at face value as a
|
|
328
336
|
* WARNING — the composer that linked it owns its wording and its volume alike.
|
|
329
|
-
* @param {{ cleaned: string, warnings: string[], notes: string[], modified: boolean, sgrNote: boolean, reveal?: string }} result
|
|
337
|
+
* @param {{ cleaned: string, warnings: string[], notes: string[], modified: boolean, sgrNote: boolean, reveal?: string, splices?: Array<{ placeholder: string, original: string }> }} result
|
|
330
338
|
* @param {{ cleaned?: string, warning?: string } | null | undefined} post
|
|
331
|
-
* @returns {{ cleaned: string, warnings: string[], notes: string[], modified: boolean, sgrNote: boolean, reveal?: string }}
|
|
339
|
+
* @returns {{ cleaned: string, warnings: string[], notes: string[], modified: boolean, sgrNote: boolean, reveal?: string, splices?: Array<{ placeholder: string, original: string }> }}
|
|
332
340
|
*/
|
|
333
341
|
function applyPostText(result, post) {
|
|
334
342
|
if (post === null || post === undefined) return result;
|
|
@@ -359,6 +367,9 @@ function applyPostText(result, post) {
|
|
|
359
367
|
* `reveals` accumulates each leaf's pre-Layer-2 text (when the HTML splice
|
|
360
368
|
* removed something) for the orchestrator to persist, and `notes` the leaves'
|
|
361
369
|
* NOTE-severity findings — same mutated-accumulator shape as `warnings`.
|
|
370
|
+
* `splices` accumulates each leaf's Layer-2 placeholder→original pairs (the
|
|
371
|
+
* per-key twin of `reveals`, already vetted by the seam) for the orchestrator's
|
|
372
|
+
* span persistence — same mutated-accumulator shape again.
|
|
362
373
|
* @param {any} value
|
|
363
374
|
* @param {string} toolName
|
|
364
375
|
* @param {string[]} warnings
|
|
@@ -368,6 +379,8 @@ function applyPostText(result, post) {
|
|
|
368
379
|
* @param {SanitizeExtensions} [ext]
|
|
369
380
|
* @param {string[]} [notes] appended last so an existing caller's positional
|
|
370
381
|
* arguments keep their meaning
|
|
382
|
+
* @param {Array<{ placeholder: string, original: string }>} [splices] appended
|
|
383
|
+
* after `notes` for the same positional-compatibility reason
|
|
371
384
|
* @param {string} [path] dotted location of `value` within the tool output,
|
|
372
385
|
* used only to name a key collision's location in its warning
|
|
373
386
|
* @returns {Promise<{ value: any, modified: boolean, sgrNote: boolean }>}
|
|
@@ -380,6 +393,7 @@ export async function sanitizeValue(
|
|
|
380
393
|
deadline = makeDeadline(SANITIZE_BUDGET_MS),
|
|
381
394
|
ext = {},
|
|
382
395
|
notes = [],
|
|
396
|
+
splices = [],
|
|
383
397
|
path = "",
|
|
384
398
|
) {
|
|
385
399
|
if (typeof value === "string") {
|
|
@@ -387,6 +401,7 @@ export async function sanitizeValue(
|
|
|
387
401
|
warnings.push(...result.warnings);
|
|
388
402
|
notes.push(...result.notes);
|
|
389
403
|
if (result.reveal !== undefined) reveals.push(result.reveal);
|
|
404
|
+
if (result.splices !== undefined) splices.push(...result.splices);
|
|
390
405
|
return {
|
|
391
406
|
value: result.cleaned,
|
|
392
407
|
modified: result.modified,
|
|
@@ -406,6 +421,7 @@ export async function sanitizeValue(
|
|
|
406
421
|
deadline,
|
|
407
422
|
ext,
|
|
408
423
|
notes,
|
|
424
|
+
splices,
|
|
409
425
|
`${path}[${index}]`,
|
|
410
426
|
);
|
|
411
427
|
out.push(result.value);
|
|
@@ -423,6 +439,7 @@ export async function sanitizeValue(
|
|
|
423
439
|
deadline,
|
|
424
440
|
ext,
|
|
425
441
|
notes,
|
|
442
|
+
splices,
|
|
426
443
|
path,
|
|
427
444
|
);
|
|
428
445
|
return { value, modified: false, sgrNote: false };
|
|
@@ -504,6 +521,8 @@ function withheldKeyFor(out, cleaned) {
|
|
|
504
521
|
* @param {{remainingMs: () => number}} deadline shared wall-clock budget
|
|
505
522
|
* @param {SanitizeExtensions} ext
|
|
506
523
|
* @param {string[]} notes accumulates the leaves' NOTE-severity findings
|
|
524
|
+
* @param {Array<{ placeholder: string, original: string }>} splices accumulates
|
|
525
|
+
* the leaves' Layer-2 placeholder→original pairs
|
|
507
526
|
* @param {string} [path] dotted location of this object in the tool output
|
|
508
527
|
* @returns {Promise<{ value: Record<string, any>, modified: boolean, sgrNote: boolean }>}
|
|
509
528
|
*/
|
|
@@ -515,6 +534,7 @@ async function sanitizeObject(
|
|
|
515
534
|
deadline,
|
|
516
535
|
ext,
|
|
517
536
|
notes,
|
|
537
|
+
splices,
|
|
518
538
|
path = "",
|
|
519
539
|
) {
|
|
520
540
|
/** @type {Record<string, any>} */
|
|
@@ -534,6 +554,7 @@ async function sanitizeObject(
|
|
|
534
554
|
warnings.push(...keyResult.warnings);
|
|
535
555
|
notes.push(...keyResult.notes);
|
|
536
556
|
if (keyResult.reveal !== undefined) reveals.push(keyResult.reveal);
|
|
557
|
+
if (keyResult.splices !== undefined) splices.push(...keyResult.splices);
|
|
537
558
|
if (keyResult.modified) modified = true;
|
|
538
559
|
if (keyResult.sgrNote) sgrNote = true;
|
|
539
560
|
const result = await sanitizeValue(
|
|
@@ -544,6 +565,7 @@ async function sanitizeObject(
|
|
|
544
565
|
deadline,
|
|
545
566
|
ext,
|
|
546
567
|
notes,
|
|
568
|
+
splices,
|
|
547
569
|
path === "" ? keyResult.cleaned : `${path}.${keyResult.cleaned}`,
|
|
548
570
|
);
|
|
549
571
|
// Two distinct raw keys can sanitize to the same name (e.g. `token` and a
|
|
@@ -864,9 +886,11 @@ export async function evaluateToolOutput(input, ext = {}) {
|
|
|
864
886
|
const notes = [];
|
|
865
887
|
/** @type {string[]} */
|
|
866
888
|
const reveals = [];
|
|
889
|
+
/** @type {Array<{ placeholder: string, original: string }>} */
|
|
890
|
+
const splices = [];
|
|
867
891
|
// One shared wall-clock budget for every blocking daemon call this hook makes —
|
|
868
|
-
// across all leaves of the walk AND the reveal-redaction
|
|
869
|
-
// SUM cannot pile up past the hook kill (see SANITIZE_BUDGET_MS).
|
|
892
|
+
// across all leaves of the walk AND the reveal/span-redaction loops below — so
|
|
893
|
+
// their SUM cannot pile up past the hook kill (see SANITIZE_BUDGET_MS).
|
|
870
894
|
const deadline = makeDeadline(SANITIZE_BUDGET_MS);
|
|
871
895
|
const {
|
|
872
896
|
value: sanitized,
|
|
@@ -880,13 +904,14 @@ export async function evaluateToolOutput(input, ext = {}) {
|
|
|
880
904
|
deadline,
|
|
881
905
|
ext,
|
|
882
906
|
notes,
|
|
907
|
+
splices,
|
|
883
908
|
);
|
|
884
909
|
// Persist each leaf's pre-Layer-2 text (deduped by content) so the model can
|
|
885
910
|
// Read back what the HTML splice removed; a successful write appends a hint
|
|
886
911
|
// naming the file. Redact BEFORE writing — never put an unredacted secret on
|
|
887
|
-
// disk, including one
|
|
888
|
-
// arise when Layer 2 modified the output, so this never
|
|
889
|
-
// early-return below.
|
|
912
|
+
// disk, including one carried inside the spliced hidden element itself.
|
|
913
|
+
// Reveals only arise when Layer 2 modified the output, so this never
|
|
914
|
+
// resurrects the `clean` early-return below.
|
|
890
915
|
for (const original of reveals) {
|
|
891
916
|
let stored;
|
|
892
917
|
try {
|
|
@@ -897,19 +922,47 @@ export async function evaluateToolOutput(input, ext = {}) {
|
|
|
897
922
|
: null;
|
|
898
923
|
stored = secrets ? secrets.text : original;
|
|
899
924
|
} catch {
|
|
900
|
-
// The pre-splice text carries the spliced
|
|
901
|
-
// hidden only inside
|
|
902
|
-
// time (the post-splice scan never saw it). If the daemon
|
|
903
|
-
// we must neither write that unvetted text nor suppress
|
|
904
|
-
// primary output — drop this one convenience reveal,
|
|
905
|
-
// splice warning has just promised the model a reveal it
|
|
906
|
-
// and a silent drop leaves that promise dangling.
|
|
925
|
+
// The pre-splice text carries the spliced hidden-element content, so a
|
|
926
|
+
// secret hidden only inside such an element reaches the redactor here
|
|
927
|
+
// for the first time (the post-splice scan never saw it). If the daemon
|
|
928
|
+
// is unreachable we must neither write that unvetted text nor suppress
|
|
929
|
+
// the already-safe primary output — drop this one convenience reveal,
|
|
930
|
+
// but SAY so: the splice warning has just promised the model a reveal it
|
|
931
|
+
// can Read back, and a silent drop leaves that promise dangling.
|
|
907
932
|
warnings.push(REVEAL_WITHHELD_WARNING);
|
|
908
933
|
continue;
|
|
909
934
|
}
|
|
910
935
|
const hint = persistReveal(stored);
|
|
911
936
|
if (hint) warnings.push(hint);
|
|
912
937
|
}
|
|
938
|
+
// Persist each splice's original beside the reveal, keyed by the placeholder's
|
|
939
|
+
// content-addressed key, so the PreToolUse rehydrator can restore a keyed
|
|
940
|
+
// placeholder the model writes back (Edit/Write) to the original bytes. Same
|
|
941
|
+
// re-redaction treatment as the reveals above — the seam already vetted each
|
|
942
|
+
// `original` on its way out, but this loop re-runs strict web-ingress
|
|
943
|
+
// redaction so NOTHING lands on disk that did not pass the same bar as the
|
|
944
|
+
// reveal sidecar. The key is EXTRACTED from the placeholder, never recomputed
|
|
945
|
+
// from the redacted original: it was minted from the RAW original's sha256,
|
|
946
|
+
// so it is a name, not an integrity check — hashing the redacted bytes would
|
|
947
|
+
// mint a key no placeholder carries. Persistence failure is non-fatal (the
|
|
948
|
+
// splice already protected the output); a missing span later fails the
|
|
949
|
+
// rehydration CLOSED with a deny naming the key.
|
|
950
|
+
let spanStored = false;
|
|
951
|
+
for (const { placeholder, original } of splices) {
|
|
952
|
+
const [key] = layer2Keys(placeholder);
|
|
953
|
+
if (key === undefined) continue;
|
|
954
|
+
let stored;
|
|
955
|
+
try {
|
|
956
|
+
const secrets = await redactSecrets(original, true, deadline);
|
|
957
|
+
stored = secrets ? secrets.text : original;
|
|
958
|
+
} catch {
|
|
959
|
+
// Same doctrine as the reveal loop: never write unvetted text, never
|
|
960
|
+
// fail the already-safe primary output over a convenience sidecar.
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
if (persistSpan(key, stored)) spanStored = true;
|
|
964
|
+
}
|
|
965
|
+
if (spanStored) warnings.push(SPAN_ROUNDTRIP_NOTICE);
|
|
913
966
|
// On-disk placeholder tripwire (see ON_DISK_PLACEHOLDER_WARNING). Tested on
|
|
914
967
|
// the RAW tool_response — post-sanitization text carries placeholders this
|
|
915
968
|
// hook itself just inserted. Reads only: file bytes are where a clobbered
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.31.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": {
|
package/src/gates.mjs
CHANGED
|
@@ -15,9 +15,11 @@
|
|
|
15
15
|
* Matches any HTML tag-like construct: opening tags, closing tags (`</`),
|
|
16
16
|
* comments and bogus declarations (`<!`), and processing instructions / bogus
|
|
17
17
|
* comments (`<?…?>`, which the HTML tokenizer hides exactly like a comment).
|
|
18
|
-
* The
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* The `<!`/`<?` arms carry a comment-only document into the pipeline at all:
|
|
19
|
+
* without them it would skip both Layer 2's splice of the comment and Layer 3's
|
|
20
|
+
* exfil scan over the comment interior.
|
|
21
|
+
* Gate for Layer 2 (HTML sanitization) and the HTML img/a exfil path in
|
|
22
|
+
* Layer 3.
|
|
21
23
|
*/
|
|
22
24
|
export const HTML_TAG_PRESENT = /<[a-zA-Z/!?][^<>]*>/;
|
|
23
25
|
|