agent-sanitizer 2.24.2 → 2.26.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 +28 -6
- package/THREAT-MODEL.md +55 -3
- package/bin/sanitize-cli.mjs +12 -9
- package/claude-hooks/lib/control-plane.mjs +18 -2
- package/claude-hooks/lib/hook-timing.mjs +94 -5
- package/claude-hooks/sanitize-output.mjs +73 -27
- package/claude-hooks/scan-invisible-chars.mjs +31 -86
- package/package.json +1 -1
- package/src/claude-context.mjs +125 -0
- package/src/html.mjs +48 -10
- package/src/index.mjs +6 -5
- package/src/instructions.mjs +36 -6
- package/src/invisible.mjs +85 -5
- package/src/layer1.mjs +15 -0
- package/src/output.mjs +159 -54
- package/src/prompt.mjs +11 -28
- package/src/severity.mjs +97 -0
- package/types/claude-context.d.mts +88 -0
- package/types/claude-hooks/lib/hook-timing.d.mts +64 -0
- package/types/claude-hooks/sanitize-output.d.mts +8 -5
- package/types/claude-hooks/scan-invisible-chars.d.mts +13 -31
- package/types/html.d.mts +7 -1
- package/types/index.d.mts +5 -3
- package/types/instructions.d.mts +14 -4
- package/types/invisible.d.mts +66 -0
- package/types/layer1.d.mts +12 -0
- package/types/output.d.mts +29 -14
- package/types/severity.d.mts +83 -0
- package/types/src/claude-context.d.mts +88 -0
package/src/output.mjs
CHANGED
|
@@ -24,10 +24,15 @@
|
|
|
24
24
|
* actually removes something, so a secret that a deletion reconstitutes is
|
|
25
25
|
* still caught before this function returns.
|
|
26
26
|
*/
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
CATEGORY,
|
|
29
|
+
describeStripped,
|
|
30
|
+
isIncidentalInvisible,
|
|
31
|
+
} from "./invisible.mjs";
|
|
28
32
|
import { needsMarkdownPipeline } from "./gates.mjs";
|
|
29
33
|
import {
|
|
30
34
|
applyLayer1,
|
|
35
|
+
INERT_ANSI_NOTE,
|
|
31
36
|
isBenignAnsiKinds,
|
|
32
37
|
LONE_SURROGATE_RE,
|
|
33
38
|
} from "./layer1.mjs";
|
|
@@ -37,6 +42,13 @@ import {
|
|
|
37
42
|
describeWarned,
|
|
38
43
|
LONE_SURROGATE_WARNING,
|
|
39
44
|
} from "./warnings.mjs";
|
|
45
|
+
import {
|
|
46
|
+
finding,
|
|
47
|
+
note,
|
|
48
|
+
noteMessages,
|
|
49
|
+
warning,
|
|
50
|
+
warningMessages,
|
|
51
|
+
} from "./severity.mjs";
|
|
40
52
|
import { orderedMatches, spliceOrdered } from "./view-map.mjs";
|
|
41
53
|
|
|
42
54
|
/**
|
|
@@ -145,11 +157,13 @@ function normalizeLoneSurrogates(text) {
|
|
|
145
157
|
}
|
|
146
158
|
|
|
147
159
|
/**
|
|
148
|
-
* @typedef {{ text: string, found: string[],
|
|
160
|
+
* @typedef {{ text: string, found: string[], findings: import("./severity.mjs").Finding[], modified: boolean, unreportedChange: boolean }} PipelineState
|
|
149
161
|
* The running state of one {@link sanitizeText} call. Layers read `text` and
|
|
150
|
-
* mutate it ONLY through {@link applyMutation}.
|
|
151
|
-
*
|
|
152
|
-
*
|
|
162
|
+
* mutate it ONLY through {@link applyMutation}. Findings carry their own
|
|
163
|
+
* severity (see ./severity.mjs) and are split into `warnings`/`notes` at the
|
|
164
|
+
* single exit, so no layer can push into the wrong list. `found` is the
|
|
165
|
+
* machine-readable twin, unaffected by the split: the {@link CATEGORY} codes
|
|
166
|
+
* for whatever Layers 1-3 neutralized or flagged.
|
|
153
167
|
*/
|
|
154
168
|
|
|
155
169
|
/**
|
|
@@ -166,10 +180,12 @@ function normalizeLoneSurrogates(text) {
|
|
|
166
180
|
* configured) makes an invariant of Layer 1 conditional on an unrelated
|
|
167
181
|
* option.
|
|
168
182
|
* 2. `modified` is set — the caller's "bytes changed" banner.
|
|
169
|
-
* 3. `
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
183
|
+
* 3. `unreportedChange` is set. A mutation that pushed no finding — a Layer-5
|
|
184
|
+
* span deletion whose filter returned no warning code — is a change the
|
|
185
|
+
* caller was never told about, and the returned `sgrNote` ("nothing here
|
|
186
|
+
* rose above a note") must not invite a quiet banner over one. A mutation
|
|
187
|
+
* that DID report itself is covered by its own WARNING, so this flag only
|
|
188
|
+
* ever costs a note that would have been misleading.
|
|
173
189
|
*
|
|
174
190
|
* Callers decide WHETHER a mutation happened (each layer already knows: a
|
|
175
191
|
* changed splice output, a redactor finding, a removed span) and call this with
|
|
@@ -186,7 +202,7 @@ function normalizeLoneSurrogates(text) {
|
|
|
186
202
|
function applyMutation(state, nextText) {
|
|
187
203
|
state.text = normalizeLoneSurrogates(nextText);
|
|
188
204
|
state.modified = true;
|
|
189
|
-
state.
|
|
205
|
+
state.unreportedChange = true;
|
|
190
206
|
}
|
|
191
207
|
|
|
192
208
|
/**
|
|
@@ -224,8 +240,12 @@ async function runRedact(state, redact) {
|
|
|
224
240
|
}
|
|
225
241
|
if (!secrets) return;
|
|
226
242
|
applyMutation(state, secrets.text);
|
|
227
|
-
|
|
228
|
-
|
|
243
|
+
// Always a WARNING: a secret reached this output, and the caller-supplied
|
|
244
|
+
// `note` (which credential, from where) is the part an operator acts on.
|
|
245
|
+
state.findings.push(
|
|
246
|
+
warning(
|
|
247
|
+
`API keys/secrets redacted: ${secrets.found.join(", ")}${secrets.note ?? ""}${REDACTION_DOCTRINE}`,
|
|
248
|
+
),
|
|
229
249
|
);
|
|
230
250
|
}
|
|
231
251
|
|
|
@@ -246,7 +266,7 @@ export const REDACTION_DOCTRINE =
|
|
|
246
266
|
// (./index.mjs), which runs the same layers; re-exported here because both were
|
|
247
267
|
// part of this module's public surface before they moved.
|
|
248
268
|
export { needsMarkdownPipeline };
|
|
249
|
-
export { describeRemoved, describeWarned } from "./warnings.mjs";
|
|
269
|
+
export { describeExfil, describeRemoved, describeWarned } from "./warnings.mjs";
|
|
250
270
|
|
|
251
271
|
/**
|
|
252
272
|
* Delete each verbatim span in `spans` from `text`. The secure Layer-5
|
|
@@ -285,23 +305,54 @@ export function deleteVerbatimSpans(text, spans) {
|
|
|
285
305
|
return { text: spliced.text, removed: spliced.spans.length };
|
|
286
306
|
}
|
|
287
307
|
|
|
308
|
+
/**
|
|
309
|
+
* The tier for what Layer 1 removed.
|
|
310
|
+
*
|
|
311
|
+
* INCIDENTAL means both axes are: the ANSI was display-only (or a stray escape
|
|
312
|
+
* that opened nothing) AND there were too few invisible characters to spell
|
|
313
|
+
* anything. Either axis alone keeps the WARNING — a cursor move beside one soft
|
|
314
|
+
* hyphen is still a cursor move, and ten tag characters beside a colour code are
|
|
315
|
+
* still ten ASCII letters.
|
|
316
|
+
*
|
|
317
|
+
* The whole downgrade is gated on `sgrCarveOut`, the caller asserting local,
|
|
318
|
+
* first-party output. Without it — a fetched page, an MCP connector — a single
|
|
319
|
+
* hidden character is not incidental at all; that is the channel where one gets
|
|
320
|
+
* PUT there.
|
|
321
|
+
*
|
|
322
|
+
* When the strip was inert AND nothing but ANSI was found, the note says so in
|
|
323
|
+
* its own words ({@link INERT_ANSI_NOTE}) rather than reciting a stripped
|
|
324
|
+
* category, because "we removed some colour codes" is the whole finding.
|
|
325
|
+
* @param {string[]} invisFound
|
|
326
|
+
* @param {string[]} ansiKinds
|
|
327
|
+
* @param {string} deAnsi
|
|
328
|
+
* @param {boolean} sgrCarveOut
|
|
329
|
+
* @returns {import("./severity.mjs").Finding}
|
|
330
|
+
*/
|
|
331
|
+
function layer1Finding(invisFound, ansiKinds, deAnsi, sgrCarveOut) {
|
|
332
|
+
const incidental =
|
|
333
|
+
sgrCarveOut &&
|
|
334
|
+
isBenignAnsiKinds(ansiKinds) &&
|
|
335
|
+
isIncidentalInvisible(deAnsi);
|
|
336
|
+
const ansiOnly = invisFound.length === 1 && invisFound[0] === CATEGORY.ANSI;
|
|
337
|
+
if (incidental && ansiOnly) return note(INERT_ANSI_NOTE);
|
|
338
|
+
return finding(!incidental, describeStripped(invisFound, deAnsi));
|
|
339
|
+
}
|
|
340
|
+
|
|
288
341
|
/**
|
|
289
342
|
* Layer 1 + surrogate normalisation: invisible chars, ANSI, lone surrogates.
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
* a terse note, not the WARNING prefix.
|
|
343
|
+
* Findings carry their own tier (see {@link layer1Finding}); a lone-surrogate
|
|
344
|
+
* normalisation is always loud, since splitting a secret across one is how a
|
|
345
|
+
* redactor is evaded.
|
|
294
346
|
* @param {string} text
|
|
295
347
|
* @param {boolean} sgrCarveOut
|
|
296
|
-
* @returns {{ cleaned: string, found: string[],
|
|
348
|
+
* @returns {{ cleaned: string, found: string[], findings: import("./severity.mjs").Finding[], modified: boolean }}
|
|
297
349
|
*/
|
|
298
350
|
function processLayer1(text, sgrCarveOut) {
|
|
299
|
-
/** @type {
|
|
300
|
-
const
|
|
351
|
+
/** @type {import("./severity.mjs").Finding[]} */
|
|
352
|
+
const findings = [];
|
|
301
353
|
/** @type {string[]} */
|
|
302
354
|
const found = [];
|
|
303
355
|
let modified = false;
|
|
304
|
-
let sgrNote = false;
|
|
305
356
|
const {
|
|
306
357
|
cleaned: layer1,
|
|
307
358
|
deAnsi,
|
|
@@ -312,16 +363,7 @@ function processLayer1(text, sgrCarveOut) {
|
|
|
312
363
|
if (invisFound.length > 0) {
|
|
313
364
|
found.push(...invisFound);
|
|
314
365
|
modified = true;
|
|
315
|
-
|
|
316
|
-
// and/or a stray escape byte, and nothing else (found is exactly [ANSI], so
|
|
317
|
-
// zero invisible chars were present). Report it as a note — a cursor-move,
|
|
318
|
-
// erase or OSC token lands in ansiKinds as CSI/OSC and keeps the WARNING.
|
|
319
|
-
sgrNote =
|
|
320
|
-
invisFound.length === 1 &&
|
|
321
|
-
invisFound[0] === CATEGORY.ANSI &&
|
|
322
|
-
isBenignAnsiKinds(ansiKinds) &&
|
|
323
|
-
sgrCarveOut;
|
|
324
|
-
if (!sgrNote) warnings.push(describeStripped(invisFound, deAnsi));
|
|
366
|
+
findings.push(layer1Finding(invisFound, ansiKinds, deAnsi, sgrCarveOut));
|
|
325
367
|
}
|
|
326
368
|
// Normalize lone UTF-16 surrogates for ALL output: a secret split by an
|
|
327
369
|
// interposed lone surrogate reads as adjacent to a model rendering its own
|
|
@@ -332,11 +374,10 @@ function processLayer1(text, sgrCarveOut) {
|
|
|
332
374
|
if (wellFormed !== cleaned) {
|
|
333
375
|
cleaned = wellFormed;
|
|
334
376
|
modified = true;
|
|
335
|
-
sgrNote = false;
|
|
336
377
|
found.push(CATEGORY.LONE_SURROGATES);
|
|
337
|
-
|
|
378
|
+
findings.push(warning(LONE_SURROGATE_WARNING));
|
|
338
379
|
}
|
|
339
|
-
return { cleaned, found,
|
|
380
|
+
return { cleaned, found, findings, modified };
|
|
340
381
|
}
|
|
341
382
|
|
|
342
383
|
/**
|
|
@@ -385,10 +426,18 @@ async function applyMarkdownPipeline(state, { html, exfilScan }) {
|
|
|
385
426
|
if (layer2.removed.comments > 0)
|
|
386
427
|
state.found.push(CATEGORY.HTML_COMMENTS);
|
|
387
428
|
if (layer2.removed.hidden > 0) state.found.push(CATEGORY.HIDDEN_HTML);
|
|
388
|
-
|
|
429
|
+
// A WARNING: these bytes were invisible to a human reading the rendered
|
|
430
|
+
// page and are now gone from the model's view too — the exact shape of
|
|
431
|
+
// a hidden-instruction payload, and the model cannot check what it was
|
|
432
|
+
// without the reveal sidecar.
|
|
433
|
+
state.findings.push(warning(describeHtmlSanitized(layer2.removed)));
|
|
389
434
|
}
|
|
435
|
+
// A NOTE: nothing was removed and nothing was hidden. This line says "the
|
|
436
|
+
// page had scripts, treat their contents as data", which is true of nearly
|
|
437
|
+
// every page fetched — at WARNING volume it trained the reader to skip the
|
|
438
|
+
// banner Layer 2's actual splice needs.
|
|
390
439
|
const preserved = describeWarned(layer2.warned);
|
|
391
|
-
if (preserved) state.
|
|
440
|
+
if (preserved) state.findings.push(note(preserved));
|
|
392
441
|
}
|
|
393
442
|
}
|
|
394
443
|
// Layer 3 — detection only: the URLs stay intact, the model is told not to
|
|
@@ -397,9 +446,21 @@ async function applyMarkdownPipeline(state, { html, exfilScan }) {
|
|
|
397
446
|
// suspicious, not less, yet Layer 2 has already removed it from `cleaned`.
|
|
398
447
|
if (exfilScan) {
|
|
399
448
|
const threats = detectExfil(inputText);
|
|
449
|
+
// Severity tracks who does the fetching. An auto-fetched target — an image,
|
|
450
|
+
// a stylesheet, a form action, a meta refresh — exfiltrates the moment the
|
|
451
|
+
// content renders, with nobody deciding anything: a WARNING. A plain LINK
|
|
452
|
+
// cannot exfiltrate unless the model chooses to follow it, and the sentence
|
|
453
|
+
// it is reported in is precisely the instruction not to, so it is a NOTE.
|
|
454
|
+
// One auto-fetched threat raises the whole finding — the loudest member
|
|
455
|
+
// wins, since they share one line.
|
|
400
456
|
if (threats) {
|
|
401
457
|
state.found.push(CATEGORY.EXFIL_URLS);
|
|
402
|
-
state.
|
|
458
|
+
state.findings.push(
|
|
459
|
+
finding(
|
|
460
|
+
threats.some((threat) => threat.autoFetched),
|
|
461
|
+
describeExfil(threats),
|
|
462
|
+
),
|
|
463
|
+
);
|
|
403
464
|
}
|
|
404
465
|
}
|
|
405
466
|
return reveal;
|
|
@@ -432,17 +493,22 @@ async function applyMarkdownPipeline(state, { html, exfilScan }) {
|
|
|
432
493
|
* no-finding paths are already well-formed.
|
|
433
494
|
* @param {string} text
|
|
434
495
|
* @param {SanitizeTextOptions["redact"]} redact
|
|
435
|
-
* @param {
|
|
496
|
+
* @param {import("./severity.mjs").Finding[]} findings
|
|
436
497
|
* @param {string} label
|
|
437
498
|
* @returns {Promise<string | undefined>} vetted text, or undefined if withheld
|
|
438
499
|
*/
|
|
439
|
-
async function vetStageValue(text, redact,
|
|
500
|
+
async function vetStageValue(text, redact, findings, label) {
|
|
440
501
|
if (!redact) return text;
|
|
441
502
|
try {
|
|
442
503
|
const secrets = await redact(text);
|
|
443
504
|
return secrets ? normalizeLoneSurrogates(secrets.text) : text;
|
|
444
505
|
} catch {
|
|
445
|
-
|
|
506
|
+
// A WARNING: the caller asked for this field and is not getting it, and the
|
|
507
|
+
// reason is an unrunnable redactor — the same fault that fails `cleaned`
|
|
508
|
+
// closed, just with a survivable remedy here.
|
|
509
|
+
findings.push(
|
|
510
|
+
warning(`Withheld the ${label}: it could not be vetted for secrets`),
|
|
511
|
+
);
|
|
446
512
|
return undefined;
|
|
447
513
|
}
|
|
448
514
|
}
|
|
@@ -474,22 +540,35 @@ async function vetStageValue(text, redact, warnings, label) {
|
|
|
474
540
|
* post-mutation invariants and forget the rest, and every string in the returned
|
|
475
541
|
* object has traversed Layer 4.
|
|
476
542
|
*
|
|
477
|
-
*
|
|
478
|
-
*
|
|
479
|
-
*
|
|
480
|
-
*
|
|
543
|
+
* Findings come back SPLIT BY SEVERITY (see ./severity.mjs): `warnings` holds
|
|
544
|
+
* everything injection-shaped — the banner a caller must show — and `notes`
|
|
545
|
+
* holds what happened but is not alarming. `warnings` therefore keeps exactly
|
|
546
|
+
* the meaning it always had, and a caller that ignores `notes` is no louder
|
|
547
|
+
* than before, just quieter about incidental bytes.
|
|
548
|
+
*
|
|
549
|
+
* `found` is the machine-readable twin, and the severity split does NOT reach
|
|
550
|
+
* it — the {@link CATEGORY} codes for what Layers 1-3 neutralized or flagged,
|
|
551
|
+
* in the order the layers ran, whichever tier described them. Layers 4 and 5
|
|
552
|
+
* have no category codes (their findings are the injected seam's own
|
|
553
|
+
* vocabulary), so they contribute findings only.
|
|
481
554
|
* @param {string} text
|
|
482
555
|
* @param {SanitizeTextOptions} [options]
|
|
483
|
-
* @returns {Promise<{ cleaned: string, found: string[], warnings: string[], modified: boolean, sgrNote: boolean, reveal?: string }>}
|
|
556
|
+
* @returns {Promise<{ cleaned: string, found: string[], warnings: string[], notes: string[], modified: boolean, sgrNote: boolean, reveal?: string }>}
|
|
484
557
|
*/
|
|
485
558
|
export async function sanitizeText(text, options = {}) {
|
|
486
559
|
const { redact, filterInjection, sgrCarveOut = false } = options;
|
|
487
|
-
const {
|
|
560
|
+
const { findings, found, cleaned, modified } = processLayer1(
|
|
488
561
|
text,
|
|
489
562
|
sgrCarveOut,
|
|
490
563
|
);
|
|
491
564
|
/** @type {PipelineState} */
|
|
492
|
-
const state = {
|
|
565
|
+
const state = {
|
|
566
|
+
text: cleaned,
|
|
567
|
+
found,
|
|
568
|
+
findings,
|
|
569
|
+
modified,
|
|
570
|
+
unreportedChange: false,
|
|
571
|
+
};
|
|
493
572
|
|
|
494
573
|
const revealText = await applyMarkdownPipeline(state, options);
|
|
495
574
|
|
|
@@ -520,7 +599,7 @@ export async function sanitizeText(text, options = {}) {
|
|
|
520
599
|
// message; free text is refused (throws) so no filter-supplied byte ever
|
|
521
600
|
// reaches the model-facing context. `null`/`undefined` means no warning.
|
|
522
601
|
if (res.warning != null)
|
|
523
|
-
state.
|
|
602
|
+
state.findings.push(warning(mapFilterWarning(res.warning)));
|
|
524
603
|
}
|
|
525
604
|
}
|
|
526
605
|
|
|
@@ -535,15 +614,24 @@ export async function sanitizeText(text, options = {}) {
|
|
|
535
614
|
: await vetStageValue(
|
|
536
615
|
revealText,
|
|
537
616
|
redact,
|
|
538
|
-
state.
|
|
617
|
+
state.findings,
|
|
539
618
|
"pre-splice copy of the removed HTML",
|
|
540
619
|
);
|
|
620
|
+
const warnings = warningMessages(state.findings);
|
|
621
|
+
const notes = noteMessages(state.findings);
|
|
541
622
|
return {
|
|
542
623
|
cleaned: state.text,
|
|
543
624
|
found: state.found,
|
|
544
|
-
warnings
|
|
625
|
+
warnings,
|
|
626
|
+
notes,
|
|
545
627
|
modified: state.modified,
|
|
546
|
-
|
|
628
|
+
// Kept under its original name (it is a published field, and renaming a
|
|
629
|
+
// published field for a wording win is a breaking change) but now derived
|
|
630
|
+
// rather than tracked: "nothing here rose above a note". That is a strict
|
|
631
|
+
// generalization of what it used to mean — the inert-ANSI strip that set it
|
|
632
|
+
// before is now simply the most common way to end up note-only.
|
|
633
|
+
sgrNote:
|
|
634
|
+
notes.length > 0 && warnings.length === 0 && !state.unreportedChange,
|
|
547
635
|
...(reveal !== undefined && { reveal }),
|
|
548
636
|
};
|
|
549
637
|
}
|
|
@@ -637,8 +725,10 @@ function depthMemo() {
|
|
|
637
725
|
/**
|
|
638
726
|
* Sanitize every string leaf of a tool-output value, preserving its shape (a
|
|
639
727
|
* structured tool output whose shape changes would be ignored by a harness,
|
|
640
|
-
* leaking the raw value). Non-string leaves pass through; `warnings`
|
|
641
|
-
*
|
|
728
|
+
* leaking the raw value). Non-string leaves pass through; `warnings` and
|
|
729
|
+
* `notes` accumulate across leaves, split by severity (see ./severity.mjs).
|
|
730
|
+
* `sgrNote` is the OR across leaves — true when SOME leaf was note-only — so a
|
|
731
|
+
* caller can still pick the quiet banner when no leaf raised a warning.
|
|
642
732
|
*
|
|
643
733
|
* Fails CLOSED on two hostile shapes that would otherwise throw a `RangeError`
|
|
644
734
|
* as an unhandled async rejection (a DoS that leaves the output un-sanitized):
|
|
@@ -654,13 +744,23 @@ function depthMemo() {
|
|
|
654
744
|
* @param {SanitizeTextOptions} options
|
|
655
745
|
* @param {string[]} warnings
|
|
656
746
|
* @param {string[]} [reveals]
|
|
747
|
+
* @param {string[]} [notes] the NOTE-severity counterpart of `warnings`;
|
|
748
|
+
* appended last so an existing positional caller keeps working (it simply
|
|
749
|
+
* discards the notes, which is exactly as loud as before the split)
|
|
657
750
|
* @returns {Promise<{ value: any, modified: boolean, sgrNote: boolean }>}
|
|
658
751
|
*/
|
|
659
|
-
export async function sanitizeValue(
|
|
752
|
+
export async function sanitizeValue(
|
|
753
|
+
value,
|
|
754
|
+
options,
|
|
755
|
+
warnings,
|
|
756
|
+
reveals = [],
|
|
757
|
+
notes = [],
|
|
758
|
+
) {
|
|
660
759
|
return sanitizeValueAt(
|
|
661
760
|
value,
|
|
662
761
|
options,
|
|
663
762
|
warnings,
|
|
763
|
+
notes,
|
|
664
764
|
reveals,
|
|
665
765
|
0,
|
|
666
766
|
new WeakSet(),
|
|
@@ -677,6 +777,7 @@ export async function sanitizeValue(value, options, warnings, reveals = []) {
|
|
|
677
777
|
* @param {any} value
|
|
678
778
|
* @param {SanitizeTextOptions} options
|
|
679
779
|
* @param {string[]} warnings
|
|
780
|
+
* @param {string[]} notes accumulates each string leaf's NOTE-severity findings
|
|
680
781
|
* @param {string[]} reveals accumulates each string leaf's pre-Layer-2 text
|
|
681
782
|
* @param {number} depth
|
|
682
783
|
* @param {WeakSet<object>} seen
|
|
@@ -696,6 +797,7 @@ async function sanitizeValueAt(
|
|
|
696
797
|
value,
|
|
697
798
|
options,
|
|
698
799
|
warnings,
|
|
800
|
+
notes,
|
|
699
801
|
reveals,
|
|
700
802
|
depth,
|
|
701
803
|
seen,
|
|
@@ -704,6 +806,7 @@ async function sanitizeValueAt(
|
|
|
704
806
|
if (typeof value === "string") {
|
|
705
807
|
const result = await sanitizeText(value, options);
|
|
706
808
|
warnings.push(...result.warnings);
|
|
809
|
+
notes.push(...result.notes);
|
|
707
810
|
if (result.reveal !== undefined) reveals.push(result.reveal);
|
|
708
811
|
return {
|
|
709
812
|
value: result.cleaned,
|
|
@@ -792,6 +895,7 @@ async function sanitizeValueAt(
|
|
|
792
895
|
item,
|
|
793
896
|
options,
|
|
794
897
|
warnings,
|
|
898
|
+
notes,
|
|
795
899
|
reveals,
|
|
796
900
|
depth + 1,
|
|
797
901
|
seen,
|
|
@@ -827,6 +931,7 @@ async function sanitizeValueAt(
|
|
|
827
931
|
item,
|
|
828
932
|
options,
|
|
829
933
|
warnings,
|
|
934
|
+
notes,
|
|
830
935
|
reveals,
|
|
831
936
|
depth + 1,
|
|
832
937
|
seen,
|
package/src/prompt.mjs
CHANGED
|
@@ -21,11 +21,10 @@ import {
|
|
|
21
21
|
CHECKS,
|
|
22
22
|
CATEGORY,
|
|
23
23
|
CATEGORY_LABELS,
|
|
24
|
-
LONG_RUN_RE,
|
|
25
24
|
LONG_RUN_THRESHOLD,
|
|
26
25
|
SCATTERED_THRESHOLD,
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
countEffectiveInvisible,
|
|
27
|
+
payloadLongRunSample,
|
|
29
28
|
} from "./invisible.mjs";
|
|
30
29
|
import { isBenignAnsi, stripAnsiFully } from "./layer1.mjs";
|
|
31
30
|
import { CONTROL_INTRODUCER_SOURCE } from "./ansi.mjs";
|
|
@@ -95,31 +94,15 @@ export function classifyPrompt(prompt, strip = stripAnsiFully) {
|
|
|
95
94
|
const hasAnsi = ANSI_INTRODUCER.test(prompt);
|
|
96
95
|
const deAnsi = strip(prompt);
|
|
97
96
|
|
|
98
|
-
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
// built entirely from MEANINGFUL joiners — an attacker alternates
|
|
108
|
-
// `letter joiner letter joiner …` so every joiner sits between two cursive
|
|
109
|
-
// letters — counts as ZERO here and would pass, even though the strip layer
|
|
110
|
-
// (carveStrip) only PRESERVES joiners up to a per-document budget
|
|
111
|
-
// (TOTAL_PRESERVED_JOINER_BUDGET / CONSECUTIVE_JOINER_CAP) and strips the
|
|
112
|
-
// surplus as payload. A prompt channel cannot strip, only block, so mirror
|
|
113
|
-
// that budget by counting the joiners the strip layer WOULD remove — delegated
|
|
114
|
-
// to stripInvisible (the SSOT) rather than re-deriving the budget here, which
|
|
115
|
-
// would risk drift — and fold that surplus into the count the scatter gate
|
|
116
|
-
// sees. A leading BOM is preserved by the strip but counted by
|
|
117
|
-
// countPayloadInvisible, so the difference can go slightly negative; clamp it.
|
|
118
|
-
const surplusPreservedJoiners = Math.max(
|
|
119
|
-
0,
|
|
120
|
-
[...deAnsi].length - [...stripInvisible(deAnsi)].length - payloadInvisible,
|
|
121
|
-
);
|
|
122
|
-
const invisibleCount = payloadInvisible + surplusPreservedJoiners;
|
|
97
|
+
// Both gates read the SHARED definitions in ./invisible.mjs rather than
|
|
98
|
+
// spelling their own: what counts as a hidden run, and how many invisibles a
|
|
99
|
+
// text really carries once the carve-out's preserved joiners and its
|
|
100
|
+
// over-budget surplus are accounted for. See payloadLongRunSample /
|
|
101
|
+
// countEffectiveInvisible — including why the run probe masks the invisibles
|
|
102
|
+
// the strip layer would PRESERVE, so a legitimate emoji sequence cannot block
|
|
103
|
+
// a prompt that the strip layer would not even have flagged.
|
|
104
|
+
const longRunSample = payloadLongRunSample(deAnsi);
|
|
105
|
+
const invisibleCount = countEffectiveInvisible(deAnsi);
|
|
123
106
|
const invisiblesBelowThreshold =
|
|
124
107
|
longRunSample === null && invisibleCount < SCATTERED_THRESHOLD;
|
|
125
108
|
|
package/src/severity.mjs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place a finding's LOUDNESS is decided, and the vocabulary every layer
|
|
3
|
+
* reports in.
|
|
4
|
+
*
|
|
5
|
+
* Every layer of this pipeline used to have exactly one volume. A cursor-spoofing
|
|
6
|
+
* ANSI payload and a single stray escape byte in a README produced the same
|
|
7
|
+
* `WARNING: Tool output sanitized` banner; so did one soft hyphen, and so did
|
|
8
|
+
* the `<script>` tag that every fetched web page carries. That is the failure
|
|
9
|
+
* mode a detector dies of: a banner that fires on every ordinary page teaches its
|
|
10
|
+
* reader to skip the banner, and then the one that mattered scrolls past too.
|
|
11
|
+
*
|
|
12
|
+
* So a finding carries a SEVERITY, and the two tiers mean specific things:
|
|
13
|
+
*
|
|
14
|
+
* WARNING — this text is injection-shaped. Something was hidden from a human
|
|
15
|
+
* reader, something was removed that a payload would have used, or a
|
|
16
|
+
* secret was redacted. Worth interrupting the reader for.
|
|
17
|
+
* NOTE — this happened, and here is how to look at it, but nothing about it
|
|
18
|
+
* is attack-shaped. Incidental bytes, or content that was PRESERVED
|
|
19
|
+
* and merely described.
|
|
20
|
+
*
|
|
21
|
+
* The tier never changes what the pipeline DOES: the same bytes are stripped,
|
|
22
|
+
* spliced and redacted either way, and a note is still reported. All that rides
|
|
23
|
+
* on it is which banner the operator sees, which is why a note is the right
|
|
24
|
+
* answer whenever the evidence is thin — an under-loud true finding is still
|
|
25
|
+
* delivered, while an over-loud false one costs the channel its credibility.
|
|
26
|
+
*
|
|
27
|
+
* Mechanism only, deliberately: WHICH findings qualify is each layer's own
|
|
28
|
+
* judgement, made where that layer's evidence lives (see isBenignAnsiKinds in
|
|
29
|
+
* ./layer1.mjs, isIncidentalInvisible in ./invisible.mjs, and the exfil/HTML
|
|
30
|
+
* tiers in ./output.mjs and ./index.mjs). This module owns the enum, the constructors and the
|
|
31
|
+
* queries so nobody spells `severity === "warning"` by hand.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The closed severity vocabulary. Stable, machine-readable values: branch on
|
|
36
|
+
* these, not on the prose.
|
|
37
|
+
*/
|
|
38
|
+
export const SEVERITY = Object.freeze({
|
|
39
|
+
NOTE: "note",
|
|
40
|
+
WARNING: "warning",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @typedef {{ severity: string, message: string }} Finding
|
|
45
|
+
* A single reportable outcome and how loudly to report it.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A WARNING-severity finding: injection-shaped, worth the banner.
|
|
50
|
+
* @param {string} message
|
|
51
|
+
* @returns {Finding}
|
|
52
|
+
*/
|
|
53
|
+
export function warning(message) {
|
|
54
|
+
return { severity: SEVERITY.WARNING, message };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A NOTE-severity finding: reported, not alarming.
|
|
59
|
+
* @param {string} message
|
|
60
|
+
* @returns {Finding}
|
|
61
|
+
*/
|
|
62
|
+
export function note(message) {
|
|
63
|
+
return { severity: SEVERITY.NOTE, message };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A finding at `severity` — the constructor for a caller that has already
|
|
68
|
+
* computed the tier as a boolean and would otherwise write the ternary itself.
|
|
69
|
+
* @param {boolean} isWarning
|
|
70
|
+
* @param {string} message
|
|
71
|
+
* @returns {Finding}
|
|
72
|
+
*/
|
|
73
|
+
export function finding(isWarning, message) {
|
|
74
|
+
return isWarning ? warning(message) : note(message);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The messages of every WARNING-severity finding, in order.
|
|
79
|
+
* @param {readonly Finding[]} findings
|
|
80
|
+
* @returns {string[]}
|
|
81
|
+
*/
|
|
82
|
+
export function warningMessages(findings) {
|
|
83
|
+
return findings
|
|
84
|
+
.filter((entry) => entry.severity === SEVERITY.WARNING)
|
|
85
|
+
.map((entry) => entry.message);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The messages of every NOTE-severity finding, in order.
|
|
90
|
+
* @param {readonly Finding[]} findings
|
|
91
|
+
* @returns {string[]}
|
|
92
|
+
*/
|
|
93
|
+
export function noteMessages(findings) {
|
|
94
|
+
return findings
|
|
95
|
+
.filter((entry) => entry.severity === SEVERITY.NOTE)
|
|
96
|
+
.map((entry) => entry.message);
|
|
97
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one directory no instruction-file walk ever descends into. Its own
|
|
3
|
+
* function so the name is spelled once, and so the two predicates that need it
|
|
4
|
+
* (a plain glob walk, and {@link excludeFromContextScan}) cannot disagree.
|
|
5
|
+
* @param {string} entry a bare entry name or a path relative to the scan root
|
|
6
|
+
* @returns {boolean}
|
|
7
|
+
*/
|
|
8
|
+
export function excludeNodeModules(entry: string): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Entries a context scan must not descend into or return: `node_modules`, and
|
|
11
|
+
* every child of a `.claude` directory that is not whitelisted context.
|
|
12
|
+
*
|
|
13
|
+
* The globs alone would already refuse to MATCH those files, but a glob walker
|
|
14
|
+
* calls this on directories as it walks and prunes the ones it rejects — which
|
|
15
|
+
* is where the cost actually is. Without the prune, a `.claude/worktrees/`
|
|
16
|
+
* holding a few repo checkouts is walked in full on every session start (and,
|
|
17
|
+
* because a doubled-star segment does cross into a dot directory when the
|
|
18
|
+
* pattern names one, a `.claude` NESTED inside a worktree was matched and
|
|
19
|
+
* scanned as if it were this session's context).
|
|
20
|
+
*
|
|
21
|
+
* A walker calls this with both bare names and root-relative paths, so it must
|
|
22
|
+
* answer for either; a bare name carries no `.claude` context and is judged only
|
|
23
|
+
* against `node_modules`.
|
|
24
|
+
* @param {string} entry a bare entry name or a path relative to the scan root
|
|
25
|
+
* @returns {boolean}
|
|
26
|
+
*/
|
|
27
|
+
export function excludeFromContextScan(entry: string): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* WHICH files an agent loads as model context, as data: the glob set and the
|
|
30
|
+
* walk-pruning predicate that together define "everything Claude Code reads as
|
|
31
|
+
* instructions, and nothing else".
|
|
32
|
+
*
|
|
33
|
+
* This is the SINGLE SOURCE for that scope. It used to live inside
|
|
34
|
+
* `claude-hooks/scan-invisible-chars.mjs`, which meant the SessionStart hook
|
|
35
|
+
* knew the answer and nobody else did: `src/instructions.mjs` takes
|
|
36
|
+
* caller-supplied globs by design (no agent's convention is baked into the
|
|
37
|
+
* engine), so the CLI, the Python port and every downstream fork spelled their
|
|
38
|
+
* own approximation of this list — and an approximation that drifts either
|
|
39
|
+
* scans bulk data that can never reach the model (the 30-second session start
|
|
40
|
+
* this whitelist exists to fix) or MISSES a context directory entirely, which
|
|
41
|
+
* is a silent hole in the one scan standing between a poisoned instruction file
|
|
42
|
+
* and a session that loads it.
|
|
43
|
+
*
|
|
44
|
+
* It is a standalone, dependency-free DATA module (like ./cf-charset.mjs) for
|
|
45
|
+
* two reasons: `src/instructions.mjs` re-exports it as the library's public
|
|
46
|
+
* door, and the hook imports it RELATIVELY — deliberately not through the
|
|
47
|
+
* `agent-sanitizer` specifier the plugin bundle pins to a published engine.
|
|
48
|
+
* This scope is hook POLICY, not engine behavior: it must ship and move with the
|
|
49
|
+
* hook that walks it, or a plugin built against an older pin would prune the
|
|
50
|
+
* wrong directories while believing it had scanned everything.
|
|
51
|
+
*/
|
|
52
|
+
/**
|
|
53
|
+
* The `.claude/` subdirectories whose markdown Claude Code loads as model
|
|
54
|
+
* context. This is a WHITELIST, and that is the point: `.claude/` is also where
|
|
55
|
+
* tooling parks bulk data that is never loaded as context — `worktrees/`
|
|
56
|
+
* (entire checked-out copies of the repo), plus caches, transcripts and
|
|
57
|
+
* snapshots — and globbing `.claude/**` swept all of it in. On a repo with a few
|
|
58
|
+
* populated worktrees that is thousands of files READ at every session start:
|
|
59
|
+
* one report put it at 30 seconds of blocked startup, paid for scanning files
|
|
60
|
+
* that cannot reach the model.
|
|
61
|
+
*
|
|
62
|
+
* A whitelist, not a `worktrees` denylist, because the failure modes are not
|
|
63
|
+
* symmetric: an unlisted context directory costs a scan nobody asked for anyway
|
|
64
|
+
* (the PostToolUse sanitizer still cleans those bytes when a tool reads them),
|
|
65
|
+
* while an unlisted BULK directory silently costs every future session its
|
|
66
|
+
* startup. Add an entry here when Claude Code starts loading a new `.claude/`
|
|
67
|
+
* subdirectory as context.
|
|
68
|
+
*/
|
|
69
|
+
export const CLAUDE_CONTEXT_SUBDIRS: readonly string[];
|
|
70
|
+
/**
|
|
71
|
+
* Every glob whose matches Claude Code loads as model context: the
|
|
72
|
+
* per-directory instruction files (CLAUDE.md, CLAUDE.local.md, AGENTS.md) and
|
|
73
|
+
* the whitelisted `.claude/` markdown. Claude Code loads these on entry to their
|
|
74
|
+
* containing directory — a load path that bypasses the PostToolUse sanitizer —
|
|
75
|
+
* so a payload planted in e.g. `packages/foo/CLAUDE.md` reaches the model
|
|
76
|
+
* uncleaned unless something scans it here.
|
|
77
|
+
*
|
|
78
|
+
* `**` does not descend into dot directories, so NESTED `.claude/` trees need
|
|
79
|
+
* their own doubled-star-prefixed patterns: without them a directory-scoped
|
|
80
|
+
* skill at `packages/foo/.claude/skills/x/SKILL.md` — model context by the same
|
|
81
|
+
* load path — is never matched. That same rule is why the root `.claude` needs
|
|
82
|
+
* no separate entry: a leading doubled star matches zero segments, so the
|
|
83
|
+
* nested patterns cover the root tree too.
|
|
84
|
+
*
|
|
85
|
+
* Pair with {@link excludeFromContextScan}: the patterns alone already refuse to
|
|
86
|
+
* MATCH a bulk directory, but only pruning the WALK avoids paying to read it.
|
|
87
|
+
*/
|
|
88
|
+
export const CLAUDE_INSTRUCTION_GLOBS: readonly string[];
|