agent-sanitizer 2.63.0 → 2.63.1
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 +1 -0
- package/THREAT-MODEL.md +41 -17
- package/claude-hooks/lib/redactor-client.mjs +60 -5
- package/claude-hooks/sanitize-output.mjs +41 -269
- package/package.json +1 -1
- package/src/confusable-host.mjs +18 -14
- package/src/confusables.mjs +43 -5
- package/src/gates.mjs +53 -21
- package/src/html.mjs +72 -59
- package/src/index.mjs +1 -0
- package/src/invisible.mjs +2 -0
- package/src/output.mjs +421 -297
- package/src/severity.mjs +14 -2
- package/src/unicode-properties.mjs +331 -0
- package/src/view-map.mjs +6 -4
- package/src/warnings.mjs +7 -0
- package/types/claude-hooks/lib/redactor-client.d.mts +18 -0
- package/types/claude-hooks/sanitize-output.d.mts +14 -30
- package/types/confusable-host.d.mts +7 -6
- package/types/confusables.d.mts +12 -0
- package/types/gates.d.mts +30 -17
- package/types/html.d.mts +4 -2
- package/types/index.d.mts +1 -1
- package/types/invisible.d.mts +1 -0
- package/types/output.d.mts +131 -39
- package/types/severity.d.mts +0 -4
- package/types/src/invisible.d.mts +1 -0
- package/types/src/unicode-properties.d.mts +2 -0
- package/types/unicode-properties.d.mts +2 -0
- package/types/view-map.d.mts +2 -0
- package/types/warnings.d.mts +5 -0
package/types/invisible.d.mts
CHANGED
|
@@ -212,6 +212,7 @@ export const CATEGORY: Readonly<{
|
|
|
212
212
|
HIDDEN_HTML: "hidden-html";
|
|
213
213
|
EXFIL_URLS: "exfil-urls";
|
|
214
214
|
CONFUSABLE_HOST: "confusable-host";
|
|
215
|
+
UNSCANNABLE: "unscannable";
|
|
215
216
|
}>;
|
|
216
217
|
/** @type {Readonly<Record<string, string>>} */
|
|
217
218
|
export const CATEGORY_LABELS: Readonly<Record<string, string>>;
|
package/types/output.d.mts
CHANGED
|
@@ -71,7 +71,7 @@ export function deleteVerbatimSpans(text: string, spans: string[]): {
|
|
|
71
71
|
* and one that cannot be vetted is WITHHELD (dropped from the array) under the
|
|
72
72
|
* same doctrine — Layer 4 never saw pre-splice text.
|
|
73
73
|
*
|
|
74
|
-
* Every byte mutation goes through {@link
|
|
74
|
+
* Every byte mutation goes through {@link mutate} and every Layer-4 call
|
|
75
75
|
* through {@link runRedact}, so a layer cannot re-establish some of the
|
|
76
76
|
* post-mutation invariants and forget the rest, and every string in the returned
|
|
77
77
|
* object has traversed Layer 4.
|
|
@@ -118,59 +118,68 @@ export function sanitizeText(text: string, options?: SanitizeTextOptions): Promi
|
|
|
118
118
|
*/
|
|
119
119
|
export function isWalkableContainer(value: any): boolean;
|
|
120
120
|
/**
|
|
121
|
-
* Sanitize every string
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
* Fails CLOSED on two hostile shapes that would otherwise throw a `RangeError`
|
|
129
|
-
* as an unhandled async rejection (a DoS that leaves the output un-sanitized):
|
|
130
|
-
* nesting past {@link MAX_DEPTH}, and a reference cycle. Either replaces the
|
|
131
|
-
* offending subtree with a placeholder string + a warning, never passing the
|
|
132
|
-
* raw subtree through. Keys are also screened for hidden chars (see below).
|
|
133
|
-
*
|
|
134
|
-
* `reveals` accumulates each string leaf's pre-Layer-2 text (present only when
|
|
135
|
-
* the HTML splice removed bytes) so a caller can persist what was hidden — the
|
|
136
|
-
* structured-output analogue of {@link sanitizeText}'s `reveal`. Same
|
|
137
|
-
* mutated-accumulator contract as `warnings`.
|
|
138
|
-
*
|
|
139
|
-
* `splices` accumulates each string leaf's Layer-2 placeholder→original pairs
|
|
140
|
-
* (each `original` already vetted, withheld entries dropped — see
|
|
141
|
-
* {@link sanitizeText}) — the per-placeholder twin of `reveals`, so a hook
|
|
142
|
-
* caller gets them for object-shaped tool output too. Same mutated-accumulator
|
|
143
|
-
* contract as `reveals`.
|
|
121
|
+
* Sanitize every string of a tool-output value with `options` — each string
|
|
122
|
+
* leaf AND each object key — preserving its shape. The walk itself, and every
|
|
123
|
+
* rule it applies, is {@link sanitizeValueWith}'s; this binds the leaf and key
|
|
124
|
+
* sanitizers to {@link sanitizeText}. A key runs every layer a leaf does except
|
|
125
|
+
* Layer 5: a span deletion in a NAME can collapse two fields into one, which
|
|
126
|
+
* costs both their values (see the collision rule).
|
|
144
127
|
* @param {any} value
|
|
145
128
|
* @param {SanitizeTextOptions} options
|
|
146
129
|
* @param {string[]} warnings
|
|
147
130
|
* @param {string[]} [reveals]
|
|
148
|
-
* @param {string[]} [notes] the NOTE-severity counterpart of `warnings
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
* @param {Array<{ placeholder: string, original: string }>} [splices] appended
|
|
152
|
-
* after `notes` for the same positional-compatibility reason
|
|
153
|
-
* @returns {Promise<{ value: any, modified: boolean, sgrNote: boolean }>}
|
|
131
|
+
* @param {string[]} [notes] the NOTE-severity counterpart of `warnings`
|
|
132
|
+
* @param {Array<{ placeholder: string, original: string }>} [splices]
|
|
133
|
+
* @returns {Promise<WalkResult>}
|
|
154
134
|
*/
|
|
155
135
|
export function sanitizeValue(value: any, options: SanitizeTextOptions, warnings: string[], reveals?: string[], notes?: string[], splices?: Array<{
|
|
156
136
|
placeholder: string;
|
|
157
137
|
original: string;
|
|
158
|
-
}>): Promise<
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
138
|
+
}>): Promise<WalkResult>;
|
|
139
|
+
/**
|
|
140
|
+
* THE structured-output walk, shared by {@link sanitizeValue} and the output
|
|
141
|
+
* hook so the two cannot disagree about a value's strings. Runs `leaf` on every
|
|
142
|
+
* string leaf and `key` on every object key (a field name is as
|
|
143
|
+
* attacker-controlled as a leaf) and rebuilds the same shape (a harness ignores
|
|
144
|
+
* a reshaped output and shows the RAW one). Each string's findings, `reveal` and
|
|
145
|
+
* `splices` fold into `sinks`, whose caller owns dedup.
|
|
146
|
+
*
|
|
147
|
+
* Keys that sanitize to the same name fail closed: every value under that name
|
|
148
|
+
* becomes {@link COLLISION_WITHHELD_MESSAGE}, the later ones under a
|
|
149
|
+
* disambiguated key so the field count holds. Nesting past {@link MAX_DEPTH}
|
|
150
|
+
* and a reference cycle become a placeholder plus a warning, not a `RangeError`.
|
|
151
|
+
* `sgrNote` holds only when the walk added no warning.
|
|
152
|
+
* @param {any} value
|
|
153
|
+
* @param {StringSanitizers} sanitizers
|
|
154
|
+
* @param {WalkSinks} sinks
|
|
155
|
+
* @returns {Promise<WalkResult>}
|
|
156
|
+
*/
|
|
157
|
+
export function sanitizeValueWith(value: any, sanitizers: StringSanitizers, sinks: WalkSinks): Promise<WalkResult>;
|
|
158
|
+
/**
|
|
159
|
+
* The warning for a post-sanitization key collision, naming where it sits.
|
|
160
|
+
* Exported so tests assert by reference rather than re-typing the prose.
|
|
161
|
+
* @param {string} name the collapsed-to name
|
|
162
|
+
* @param {string} path dotted location of the owning object ("" = top level)
|
|
163
|
+
* @returns {string}
|
|
164
|
+
*/
|
|
165
|
+
export function collisionWarning(name: string, path: string): string;
|
|
163
166
|
/**
|
|
164
167
|
* Compose the model-facing context line for a sanitized/flagged tool output.
|
|
165
168
|
* `injectionAlert` is the caller's optional trailing alert (e.g. appended only
|
|
166
169
|
* for untrusted-ingress tools where a semantic-injection filter actually ran).
|
|
170
|
+
*
|
|
171
|
+
* `notes` follow the WARNING text: a note can carry the instruction a
|
|
172
|
+
* detect-only finding rests on ("do not fetch them"), which an unrelated
|
|
173
|
+
* warning does not make redundant. {@link INERT_ANSI_NOTE} alone is dropped
|
|
174
|
+
* beside a warning (matched by the constant, not its wording).
|
|
167
175
|
* @param {boolean} modified output bytes were changed (vs. flagged only)
|
|
168
176
|
* @param {string[]} warnings
|
|
169
|
-
* @param {{ injectionAlert?: string }} [options]
|
|
177
|
+
* @param {{ injectionAlert?: string, notes?: string[] }} [options]
|
|
170
178
|
* @returns {string}
|
|
171
179
|
*/
|
|
172
|
-
export function composeContext(modified: boolean, warnings: string[], { injectionAlert }?: {
|
|
180
|
+
export function composeContext(modified: boolean, warnings: string[], { injectionAlert, notes }?: {
|
|
173
181
|
injectionAlert?: string;
|
|
182
|
+
notes?: string[];
|
|
174
183
|
}): string;
|
|
175
184
|
/**
|
|
176
185
|
* Replace every string leaf of `value` with `message`, preserving shape so a
|
|
@@ -233,6 +242,8 @@ export { needsMarkdownPipeline };
|
|
|
233
242
|
* caller still emits a sanitized, flagged result instead of crashing.
|
|
234
243
|
*/
|
|
235
244
|
export const MAX_DEPTH: 200;
|
|
245
|
+
/** A colliding field's WHOLE value, so no scalar passes under a legitimate name (the JSON type change is accepted). */
|
|
246
|
+
export const COLLISION_WITHHELD_MESSAGE: "[WITHHELD \u2014 this field's name collided with another after sanitization]";
|
|
236
247
|
/**
|
|
237
248
|
* Layer-4 result: the redacted text, the category labels redacted, and an
|
|
238
249
|
* optional caller-supplied annotation appended to the warning.
|
|
@@ -258,7 +269,7 @@ export type Layer5Result = {
|
|
|
258
269
|
};
|
|
259
270
|
/**
|
|
260
271
|
* The running state of one {@link sanitizeText} call. Layers read `text` and
|
|
261
|
-
* mutate it ONLY through {@link
|
|
272
|
+
* mutate it ONLY through {@link mutate}. Findings carry their own
|
|
262
273
|
* severity (see ./severity.mjs) and are split into `warnings`/`notes` at the
|
|
263
274
|
* single exit, so no layer can push into the wrong list. `found` is the
|
|
264
275
|
* machine-readable twin, unaffected by the split: the {@link CATEGORY} codes
|
|
@@ -269,7 +280,6 @@ export type PipelineState = {
|
|
|
269
280
|
found: string[];
|
|
270
281
|
findings: import("./severity.mjs").Finding[];
|
|
271
282
|
modified: boolean;
|
|
272
|
-
unreportedChange: boolean;
|
|
273
283
|
};
|
|
274
284
|
export type SanitizeTextOptions = {
|
|
275
285
|
html?: boolean;
|
|
@@ -290,6 +300,47 @@ export type SanitizeTextOptions = {
|
|
|
290
300
|
export type Deadline = {
|
|
291
301
|
remainingMs: () => number;
|
|
292
302
|
};
|
|
303
|
+
export type WalkResult = {
|
|
304
|
+
value: any;
|
|
305
|
+
modified: boolean;
|
|
306
|
+
sgrNote: boolean;
|
|
307
|
+
};
|
|
308
|
+
export type StringVerdict = {
|
|
309
|
+
cleaned: string;
|
|
310
|
+
warnings: string[];
|
|
311
|
+
notes: string[];
|
|
312
|
+
modified: boolean;
|
|
313
|
+
sgrNote: boolean;
|
|
314
|
+
reveal?: string;
|
|
315
|
+
splices?: Array<{
|
|
316
|
+
placeholder: string;
|
|
317
|
+
original: string;
|
|
318
|
+
}>;
|
|
319
|
+
};
|
|
320
|
+
export type StringSanitizers = {
|
|
321
|
+
leaf: (text: string) => Promise<StringVerdict>;
|
|
322
|
+
key: (text: string) => Promise<StringVerdict>;
|
|
323
|
+
};
|
|
324
|
+
export type WalkSinks = {
|
|
325
|
+
warnings: string[];
|
|
326
|
+
notes: string[];
|
|
327
|
+
reveals: string[];
|
|
328
|
+
splices: Array<{
|
|
329
|
+
placeholder: string;
|
|
330
|
+
original: string;
|
|
331
|
+
}>;
|
|
332
|
+
};
|
|
333
|
+
/**
|
|
334
|
+
* `seen` holds the ancestors on the active path only (a value shared across
|
|
335
|
+
* sibling branches is not a cycle). `memo` caches each FULLY-PROCESSED result
|
|
336
|
+
* by `(node, depth)` (see {@link depthMemo}); without it a shared-substructure
|
|
337
|
+
* DAG is re-sanitized once per PATH — a ~25-object diamond measured 68 s.
|
|
338
|
+
* Skipping a cached node's duplicate findings is harmless: callers dedup.
|
|
339
|
+
*/
|
|
340
|
+
export type WalkContext = WalkSinks & StringSanitizers & {
|
|
341
|
+
seen: WeakSet<object>;
|
|
342
|
+
memo: ReturnType<typeof depthMemo<WalkResult>>;
|
|
343
|
+
};
|
|
293
344
|
/**
|
|
294
345
|
* a content-block field's value test
|
|
295
346
|
*/
|
|
@@ -299,4 +350,45 @@ export type BlockSchema = {
|
|
|
299
350
|
optional: Record<string, FieldShape>;
|
|
300
351
|
};
|
|
301
352
|
import { needsMarkdownPipeline } from "./gates.mjs";
|
|
353
|
+
/**
|
|
354
|
+
* Cache of a walked subtree keyed by `(node, depth)` — the pair the walk's
|
|
355
|
+
* result actually depends on. Both walkers below truncate past
|
|
356
|
+
* {@link MAX_DEPTH}, so the SAME node yields different output at different
|
|
357
|
+
* depths: withheld on a long path, walked on a short one. Keying by node
|
|
358
|
+
* identity alone therefore caches a path-dependent answer, and a shared node
|
|
359
|
+
* first reached deep withholds real content everywhere it is reached later —
|
|
360
|
+
* the module's own "a node withheld for depth on a long path must still be
|
|
361
|
+
* walked on a shorter one" rule, silently violated.
|
|
362
|
+
*
|
|
363
|
+
* Taking `depth` as a mandatory argument is the point: there is no API here that
|
|
364
|
+
* can key by identity alone. Refusing to cache truncated subtrees instead would
|
|
365
|
+
* NOT work — truncation propagates to every ancestor, so a hostile diamond DAG
|
|
366
|
+
* with a deep tail would go uncached and re-walk once per path, re-opening the
|
|
367
|
+
* exponential blow-up the memo exists to prevent. Work stays bounded at
|
|
368
|
+
* O(nodes × distinct depths), i.e. at most {@link MAX_DEPTH} entries per node.
|
|
369
|
+
*
|
|
370
|
+
* Residual, unchanged from before: the CYCLE placeholder also depends on the
|
|
371
|
+
* ancestor `seen` set, which is not part of the key. Two paths reaching a node
|
|
372
|
+
* at the same depth with different ancestors can therefore share a cached
|
|
373
|
+
* result. Encoding `seen` in the key means storing every node a subtree walked
|
|
374
|
+
* and re-checking it on lookup — the memo's cost becomes the walk it replaces —
|
|
375
|
+
* and refusing to cache cyclic subtrees hits the same exponential wall as
|
|
376
|
+
* above. A cycle is already a fail-closed pathological shape, so this trades a
|
|
377
|
+
* placeholder's exact placement for a hard work bound.
|
|
378
|
+
* @template T
|
|
379
|
+
*/
|
|
380
|
+
declare function depthMemo<T>(): {
|
|
381
|
+
/**
|
|
382
|
+
* @param {object} value
|
|
383
|
+
* @param {number} depth
|
|
384
|
+
* @returns {T | undefined}
|
|
385
|
+
*/
|
|
386
|
+
get(value: object, depth: number): T | undefined;
|
|
387
|
+
/**
|
|
388
|
+
* @param {object} value
|
|
389
|
+
* @param {number} depth
|
|
390
|
+
* @param {T} result
|
|
391
|
+
*/
|
|
392
|
+
set(value: object, depth: number, result: T): void;
|
|
393
|
+
};
|
|
302
394
|
export { describeExfil, describeRemoved, describeWarned } from "./warnings.mjs";
|
package/types/severity.d.mts
CHANGED
|
@@ -212,6 +212,7 @@ export const CATEGORY: Readonly<{
|
|
|
212
212
|
HIDDEN_HTML: "hidden-html";
|
|
213
213
|
EXFIL_URLS: "exfil-urls";
|
|
214
214
|
CONFUSABLE_HOST: "confusable-host";
|
|
215
|
+
UNSCANNABLE: "unscannable";
|
|
215
216
|
}>;
|
|
216
217
|
/** @type {Readonly<Record<string, string>>} */
|
|
217
218
|
export const CATEGORY_LABELS: Readonly<Record<string, string>>;
|
|
@@ -27,3 +27,5 @@ export const UNIFIED_IDEOGRAPH: ReadonlyArray<readonly [number, number]>;
|
|
|
27
27
|
export const SCRIPT_BRAILLE: ReadonlyArray<readonly [number, number]>;
|
|
28
28
|
/** @type {ReadonlyArray<readonly [number, number]>} */
|
|
29
29
|
export const SCRIPT_HANGUL: ReadonlyArray<readonly [number, number]>;
|
|
30
|
+
/** @type {ReadonlyArray<readonly [number, number]>} */
|
|
31
|
+
export const GENERAL_CATEGORY_MARK: ReadonlyArray<readonly [number, number]>;
|
|
@@ -27,3 +27,5 @@ export const UNIFIED_IDEOGRAPH: ReadonlyArray<readonly [number, number]>;
|
|
|
27
27
|
export const SCRIPT_BRAILLE: ReadonlyArray<readonly [number, number]>;
|
|
28
28
|
/** @type {ReadonlyArray<readonly [number, number]>} */
|
|
29
29
|
export const SCRIPT_HANGUL: ReadonlyArray<readonly [number, number]>;
|
|
30
|
+
/** @type {ReadonlyArray<readonly [number, number]>} */
|
|
31
|
+
export const GENERAL_CATEGORY_MARK: ReadonlyArray<readonly [number, number]>;
|
package/types/view-map.d.mts
CHANGED
|
@@ -68,6 +68,8 @@ export function overlapAwareCount(haystack: string, needle: string): number;
|
|
|
68
68
|
* The character runs Layer 1 deleted, located by greedy subsequence alignment
|
|
69
69
|
* (stripping only deletes, so `cleaned` is always a subsequence of `content`).
|
|
70
70
|
* Throws if the subsequence property does not hold — the caller fails closed.
|
|
71
|
+
* Aligned per CODE POINT (Layer 1 deletes whole ones): a code-unit walk matches
|
|
72
|
+
* U+1D173's high surrogate against U+1D11E's and splits both. Offsets stay UTF-16.
|
|
71
73
|
* @param {string} content disk bytes
|
|
72
74
|
* @param {string} cleaned Layer-1 view of the same bytes
|
|
73
75
|
* @returns {{start: number, deleted: string}[]}
|
package/types/warnings.d.mts
CHANGED
|
@@ -74,3 +74,8 @@ export const LONE_SURROGATE_WARNING: "Normalized lone UTF-16 surrogates";
|
|
|
74
74
|
* be false.
|
|
75
75
|
*/
|
|
76
76
|
export const HTML_UNPARSEABLE_WARNING: "HTML unparseable \u2014 the entire output was withheld behind a placeholder; nothing was spliced";
|
|
77
|
+
/**
|
|
78
|
+
* The Layer-3 warning when the URL scan overflowed the stack. It names no URL
|
|
79
|
+
* (none was found), yet stays a warning: unread is not vetted.
|
|
80
|
+
*/
|
|
81
|
+
export const UNSCANNABLE_WARNING: "Input too deeply nested to scan for exfil URLs or look-alike hosts \u2014 any URL in it is unvetted; do not fetch, relay, or embed it";
|