agent-sanitizer 2.60.1 → 2.60.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 +12 -12
- package/THREAT-MODEL.md +21 -9
- package/claude-hooks/lib/layer-pipeline.mjs +87 -59
- package/claude-hooks/lib/placeholder-grammar.mjs +24 -46
- package/claude-hooks/lib/reveal.mjs +39 -12
- package/claude-hooks/lib/secret-annotate.mjs +2 -2
- package/claude-hooks/lib/secret-drop-guard.mjs +27 -9
- package/claude-hooks/pretooluse-sanitize.mjs +141 -108
- package/claude-hooks/sanitize-output.mjs +63 -37
- package/claude-hooks/sanitize-user-prompt.mjs +11 -13
- package/package.json +1 -1
- package/src/html.mjs +9 -52
- package/src/layer2-placeholder.mjs +60 -0
- package/types/claude-hooks/lib/layer-pipeline.d.mts +26 -45
- package/types/claude-hooks/lib/placeholder-grammar.d.mts +3 -23
- package/types/claude-hooks/lib/reveal.d.mts +15 -3
- package/types/claude-hooks/lib/secret-drop-guard.d.mts +9 -5
- package/types/claude-hooks/pretooluse-sanitize.d.mts +41 -28
- package/types/claude-hooks/sanitize-output.d.mts +13 -10
- package/types/claude-hooks/sanitize-user-prompt.d.mts +4 -5
- package/types/html.d.mts +4 -22
- package/types/layer2-placeholder.d.mts +21 -0
- package/types/src/layer2-placeholder.d.mts +21 -0
|
@@ -67,7 +67,7 @@ import { containsPlaceholder, layer2Keys } from "./lib/placeholder-grammar.mjs";
|
|
|
67
67
|
// Layer-1 primitives and the cheap pre-gates, bound via lazyImport (see its
|
|
68
68
|
// doc for the fail-OPEN hazard of a bare static npm import). A load failure
|
|
69
69
|
// leaves the bindings undefined and the dependent call throws into the CLI's
|
|
70
|
-
//
|
|
70
|
+
// catch, which applies this hook's fault posture (emitHookFailure).
|
|
71
71
|
// HTML_TAG_PRESENT (the Layer-2 pre-gate) and the Layer-1 re-exports come from
|
|
72
72
|
// the package ROOT, which exposes them WITHOUT eagerly loading the
|
|
73
73
|
// remark/rehype/unified graph (~120ms of module-load time). Importing `/html`
|
|
@@ -194,8 +194,8 @@ async function redactSecrets(text, webIngress, deadline) {
|
|
|
194
194
|
* never WHETHER their result is applied.
|
|
195
195
|
*
|
|
196
196
|
* A callback that throws is not caught here. That is deliberate: the throw lands
|
|
197
|
-
* in the CLI's
|
|
198
|
-
*
|
|
197
|
+
* in the CLI's catch like any other sanitizer fault, so a broken extension gets
|
|
198
|
+
* exactly the posture the operator chose (emitHookFailure) and no quieter one.
|
|
199
199
|
*
|
|
200
200
|
* These are NOT the Layer-5 injection-filter seam and do not inherit its
|
|
201
201
|
* delete-only, closed-enum restriction. (Its identifier is deliberately unspoken
|
|
@@ -246,6 +246,8 @@ async function redactSecrets(text, webIngress, deadline) {
|
|
|
246
246
|
* @param {{remainingMs: () => number}} [deadline] shared wall-clock budget across
|
|
247
247
|
* all leaves of one hook run; a direct caller gets a fresh full budget
|
|
248
248
|
* @param {SanitizeExtensions} [ext]
|
|
249
|
+
* @param {boolean} [secretsOn] the secret opt-in, decided ONCE per hook run by
|
|
250
|
+
* {@link evaluateToolOutput} and threaded down; a direct caller reads the env
|
|
249
251
|
* @returns {Promise<{ cleaned: string, warnings: string[], notes: string[], modified: boolean, sgrNote: boolean, reveal?: string, splices?: Array<{ placeholder: string, original: string }> }>}
|
|
250
252
|
*/
|
|
251
253
|
export async function sanitizeText(
|
|
@@ -253,6 +255,7 @@ export async function sanitizeText(
|
|
|
253
255
|
toolName,
|
|
254
256
|
deadline = makeDeadline(SANITIZE_BUDGET_MS),
|
|
255
257
|
ext = {},
|
|
258
|
+
secretsOn = secretsEnabled(),
|
|
256
259
|
) {
|
|
257
260
|
const webIngress = isUntrustedIngress(toolName);
|
|
258
261
|
// Layer 2 (HTML rewrite) runs on WebFetch/WebSearch always, and on MCP output
|
|
@@ -275,7 +278,7 @@ export async function sanitizeText(
|
|
|
275
278
|
flagDigestValues: digestFlaggingEnabled(),
|
|
276
279
|
sgrCarveOut: !webIngress,
|
|
277
280
|
deadline,
|
|
278
|
-
// Layer 4 — OPT-IN (
|
|
281
|
+
// Layer 4 — OPT-IN (`secretsOn`): with the knob unset the seam gets no
|
|
279
282
|
// redact callback at all, so plain output never spawns the daemon and no
|
|
280
283
|
// placeholder ever enters the model's view. When it runs, the seam rethrows
|
|
281
284
|
// a redactor throw wrapped, and this hook's fault policy suppresses the
|
|
@@ -283,7 +286,7 @@ export async function sanitizeText(
|
|
|
283
286
|
// operator's terminal here first: the suppression note rides in what only
|
|
284
287
|
// the model sees, so a degraded redactor would otherwise be invisible to
|
|
285
288
|
// the human.
|
|
286
|
-
redact: !
|
|
289
|
+
redact: !secretsOn
|
|
287
290
|
? undefined
|
|
288
291
|
: async (/** @type {string} */ content) => {
|
|
289
292
|
let secrets;
|
|
@@ -389,6 +392,7 @@ function applyPostText(result, post) {
|
|
|
389
392
|
* after `notes` for the same positional-compatibility reason
|
|
390
393
|
* @param {string} [path] dotted location of `value` within the tool output,
|
|
391
394
|
* used only to name a key collision's location in its warning
|
|
395
|
+
* @param {boolean} [secretsOn] the secret opt-in (see {@link sanitizeText})
|
|
392
396
|
* @returns {Promise<{ value: any, modified: boolean, sgrNote: boolean }>}
|
|
393
397
|
*/
|
|
394
398
|
export async function sanitizeValue(
|
|
@@ -401,9 +405,16 @@ export async function sanitizeValue(
|
|
|
401
405
|
notes = [],
|
|
402
406
|
splices = [],
|
|
403
407
|
path = "",
|
|
408
|
+
secretsOn = secretsEnabled(),
|
|
404
409
|
) {
|
|
405
410
|
if (typeof value === "string") {
|
|
406
|
-
const result = await sanitizeText(
|
|
411
|
+
const result = await sanitizeText(
|
|
412
|
+
value,
|
|
413
|
+
toolName,
|
|
414
|
+
deadline,
|
|
415
|
+
ext,
|
|
416
|
+
secretsOn,
|
|
417
|
+
);
|
|
407
418
|
warnings.push(...result.warnings);
|
|
408
419
|
notes.push(...result.notes);
|
|
409
420
|
if (result.reveal !== undefined) reveals.push(result.reveal);
|
|
@@ -429,6 +440,7 @@ export async function sanitizeValue(
|
|
|
429
440
|
notes,
|
|
430
441
|
splices,
|
|
431
442
|
`${path}[${index}]`,
|
|
443
|
+
secretsOn,
|
|
432
444
|
);
|
|
433
445
|
out.push(result.value);
|
|
434
446
|
if (result.modified) modified = true;
|
|
@@ -447,6 +459,7 @@ export async function sanitizeValue(
|
|
|
447
459
|
notes,
|
|
448
460
|
splices,
|
|
449
461
|
path,
|
|
462
|
+
secretsOn,
|
|
450
463
|
);
|
|
451
464
|
return { value, modified: false, sgrNote: false };
|
|
452
465
|
}
|
|
@@ -529,7 +542,8 @@ function withheldKeyFor(out, cleaned) {
|
|
|
529
542
|
* @param {string[]} notes accumulates the leaves' NOTE-severity findings
|
|
530
543
|
* @param {Array<{ placeholder: string, original: string }>} splices accumulates
|
|
531
544
|
* the leaves' Layer-2 placeholder→original pairs
|
|
532
|
-
* @param {string}
|
|
545
|
+
* @param {string} path dotted location of this object in the tool output
|
|
546
|
+
* @param {boolean} secretsOn the secret opt-in (see {@link sanitizeText})
|
|
533
547
|
* @returns {Promise<{ value: Record<string, any>, modified: boolean, sgrNote: boolean }>}
|
|
534
548
|
*/
|
|
535
549
|
async function sanitizeObject(
|
|
@@ -541,7 +555,8 @@ async function sanitizeObject(
|
|
|
541
555
|
ext,
|
|
542
556
|
notes,
|
|
543
557
|
splices,
|
|
544
|
-
path
|
|
558
|
+
path,
|
|
559
|
+
secretsOn,
|
|
545
560
|
) {
|
|
546
561
|
/** @type {Record<string, any>} */
|
|
547
562
|
const out = {};
|
|
@@ -556,7 +571,13 @@ async function sanitizeObject(
|
|
|
556
571
|
// callback sees just the string and the tool, so it cannot tell a schema key
|
|
557
572
|
// from content; a rewrite here can collapse two fields into one name, which
|
|
558
573
|
// costs both of them their values below. Extensions run on value leaves.
|
|
559
|
-
const keyResult = await sanitizeText(
|
|
574
|
+
const keyResult = await sanitizeText(
|
|
575
|
+
key,
|
|
576
|
+
toolName,
|
|
577
|
+
deadline,
|
|
578
|
+
{},
|
|
579
|
+
secretsOn,
|
|
580
|
+
);
|
|
560
581
|
warnings.push(...keyResult.warnings);
|
|
561
582
|
notes.push(...keyResult.notes);
|
|
562
583
|
if (keyResult.reveal !== undefined) reveals.push(keyResult.reveal);
|
|
@@ -573,6 +594,7 @@ async function sanitizeObject(
|
|
|
573
594
|
notes,
|
|
574
595
|
splices,
|
|
575
596
|
path === "" ? keyResult.cleaned : `${path}.${keyResult.cleaned}`,
|
|
597
|
+
secretsOn,
|
|
576
598
|
);
|
|
577
599
|
// Two distinct raw keys can sanitize to the same name (e.g. `token` and a
|
|
578
600
|
// `token` carrying a zero-width space stripped by Layer 1). Overwriting
|
|
@@ -908,6 +930,11 @@ export async function evaluateToolOutput(input, ext = {}) {
|
|
|
908
930
|
const reveals = [];
|
|
909
931
|
/** @type {Array<{ placeholder: string, original: string }>} */
|
|
910
932
|
const splices = [];
|
|
933
|
+
// THE secret opt-in decision for this run. Every secret-touching path below —
|
|
934
|
+
// the walk's Layer 4, the reveal and span persistence, the on-disk tripwire,
|
|
935
|
+
// and the prose that claims redaction — reads this one value, so no path can
|
|
936
|
+
// dial the daemon or promise "redacted" while another treats the layer as off.
|
|
937
|
+
const secretsOn = secretsEnabled();
|
|
911
938
|
// One shared wall-clock budget for every blocking daemon call this hook makes —
|
|
912
939
|
// across all leaves of the walk AND the reveal/span-redaction loops below — so
|
|
913
940
|
// their SUM cannot pile up past the hook kill (see SANITIZE_BUDGET_MS).
|
|
@@ -925,6 +952,8 @@ export async function evaluateToolOutput(input, ext = {}) {
|
|
|
925
952
|
ext,
|
|
926
953
|
notes,
|
|
927
954
|
splices,
|
|
955
|
+
"",
|
|
956
|
+
secretsOn,
|
|
928
957
|
);
|
|
929
958
|
// Persist each leaf's pre-Layer-2 text (deduped by content) so the model can
|
|
930
959
|
// Read back what the HTML splice removed; a successful write appends a hint
|
|
@@ -937,10 +966,10 @@ export async function evaluateToolOutput(input, ext = {}) {
|
|
|
937
966
|
try {
|
|
938
967
|
// Same opt-in as the main pass: with secrets off nothing was redacted
|
|
939
968
|
// out of the primary output either, so the sidecar persists verbatim.
|
|
940
|
-
const
|
|
969
|
+
const redacted = secretsOn
|
|
941
970
|
? await redactSecrets(original, true, deadline)
|
|
942
971
|
: null;
|
|
943
|
-
stored =
|
|
972
|
+
stored = redacted ? redacted.text : original;
|
|
944
973
|
} catch {
|
|
945
974
|
// The pre-splice text carries the spliced hidden-element content, so a
|
|
946
975
|
// secret hidden only inside such an element reaches the redactor here
|
|
@@ -952,16 +981,16 @@ export async function evaluateToolOutput(input, ext = {}) {
|
|
|
952
981
|
warnings.push(REVEAL_WITHHELD_WARNING);
|
|
953
982
|
continue;
|
|
954
983
|
}
|
|
955
|
-
const hint = persistReveal(stored);
|
|
984
|
+
const hint = persistReveal(stored, secretsOn);
|
|
956
985
|
if (hint) warnings.push(hint);
|
|
957
986
|
}
|
|
958
987
|
// Persist each splice's original beside the reveal, keyed by the placeholder's
|
|
959
988
|
// content-addressed key, so the PreToolUse rehydrator can restore a keyed
|
|
960
989
|
// placeholder the model writes back (Edit/Write) to the original bytes. Same
|
|
961
|
-
// re-redaction treatment as the reveals above
|
|
962
|
-
// `original` on its way out, but this loop re-runs
|
|
963
|
-
// redaction so NOTHING lands on disk that did not pass the
|
|
964
|
-
// reveal sidecar. The key is EXTRACTED from the placeholder, never recomputed
|
|
990
|
+
// re-redaction treatment as the reveals above, under the same opt-in — the
|
|
991
|
+
// seam already vetted each `original` on its way out, but this loop re-runs
|
|
992
|
+
// strict web-ingress redaction so NOTHING lands on disk that did not pass the
|
|
993
|
+
// same bar as the reveal sidecar. The key is EXTRACTED from the placeholder, never recomputed
|
|
965
994
|
// from the redacted original: it was minted from the RAW original's sha256,
|
|
966
995
|
// so it is a name, not an integrity check — hashing the redacted bytes would
|
|
967
996
|
// mint a key no placeholder carries. Persistence failure is non-fatal (the
|
|
@@ -973,14 +1002,16 @@ export async function evaluateToolOutput(input, ext = {}) {
|
|
|
973
1002
|
if (key === undefined) continue;
|
|
974
1003
|
let stored;
|
|
975
1004
|
try {
|
|
976
|
-
const
|
|
977
|
-
|
|
1005
|
+
const redacted = secretsOn
|
|
1006
|
+
? await redactSecrets(original, true, deadline)
|
|
1007
|
+
: null;
|
|
1008
|
+
stored = redacted ? redacted.text : original;
|
|
978
1009
|
} catch {
|
|
979
1010
|
// Same doctrine as the reveal loop: never write unvetted text, never
|
|
980
1011
|
// fail the already-safe primary output over a convenience sidecar.
|
|
981
1012
|
continue;
|
|
982
1013
|
}
|
|
983
|
-
if (persistSpan(key, stored)) spanStored = true;
|
|
1014
|
+
if (persistSpan(key, stored, secretsOn)) spanStored = true;
|
|
984
1015
|
}
|
|
985
1016
|
if (spanStored) warnings.push(SPAN_ROUNDTRIP_NOTICE);
|
|
986
1017
|
// On-disk placeholder tripwire (see ON_DISK_PLACEHOLDER_WARNING). Tested on
|
|
@@ -993,7 +1024,7 @@ export async function evaluateToolOutput(input, ext = {}) {
|
|
|
993
1024
|
// inserts placeholders, so placeholder-shaped bytes are ordinary text and
|
|
994
1025
|
// the warning would be pure noise on every doc ABOUT redaction.
|
|
995
1026
|
if (
|
|
996
|
-
|
|
1027
|
+
secretsOn &&
|
|
997
1028
|
input.tool_name === "Read" &&
|
|
998
1029
|
!revealRead &&
|
|
999
1030
|
containsPlaceholder(toolOutput)
|
|
@@ -1045,17 +1076,18 @@ export async function evaluateToolOutput(input, ext = {}) {
|
|
|
1045
1076
|
* {@link evaluateToolOutput} already returns those contract fields (or null),
|
|
1046
1077
|
* so the judge only stamps the `allow` decision onto them — no native-envelope
|
|
1047
1078
|
* translation. Throws only if a layer engine throws (or on an UNKNOWN event);
|
|
1048
|
-
* the CLI
|
|
1079
|
+
* the CLI applies this hook's fault posture to any throw (emitHookFailure).
|
|
1049
1080
|
* @param {import("agent-control-plane-core").ToolCallEvent} event
|
|
1050
1081
|
* @param {SanitizeExtensions} [ext]
|
|
1051
1082
|
* @returns {Promise<import("agent-control-plane-core").Verdict>}
|
|
1052
1083
|
*/
|
|
1053
1084
|
export async function judgeSanitizeOutput(event, ext = {}) {
|
|
1054
1085
|
const { Decision, EventKind } = controlPlane();
|
|
1055
|
-
//
|
|
1056
|
-
//
|
|
1057
|
-
// anomaly, and abstaining would
|
|
1058
|
-
//
|
|
1086
|
+
// Throw on a payload the adapter cannot classify (contract/harness drift):
|
|
1087
|
+
// this hook only ever receives PostToolUse, so an UNKNOWN event is an
|
|
1088
|
+
// anomaly, and abstaining would report a CLEAN run on output nobody vetted.
|
|
1089
|
+
// Throwing lands in the CLI's catch, which applies this hook's registered
|
|
1090
|
+
// fault policy (lib/hook-fault.mjs, rendered by emitHookFailure).
|
|
1059
1091
|
if (event.event === EventKind.UNKNOWN)
|
|
1060
1092
|
throw new Error(
|
|
1061
1093
|
"sanitize-output: unrecognized hook payload (not PostToolUse)",
|
|
@@ -1108,9 +1140,9 @@ export async function judgeSanitizeOutput(event, ext = {}) {
|
|
|
1108
1140
|
* omits the field is a PostToolUse call by construction. The claude adapter
|
|
1109
1141
|
* extracts `tool_response` (this hook's actual input) ONLY for a PostToolUse
|
|
1110
1142
|
* event; without this default a field-less but legitimate payload would parse as
|
|
1111
|
-
* UNKNOWN, {@link judgeSanitizeOutput} would throw, and the CLI would
|
|
1112
|
-
*
|
|
1113
|
-
* left untouched, so the judge's UNKNOWN guard still
|
|
1143
|
+
* UNKNOWN, {@link judgeSanitizeOutput} would throw, and the CLI would take the
|
|
1144
|
+
* fault posture on real tool output. A payload carrying a DIFFERENT event name
|
|
1145
|
+
* is left untouched, so the judge's UNKNOWN guard still fires on a genuinely
|
|
1114
1146
|
* unrecognized event.
|
|
1115
1147
|
* @param {unknown} input the raw stdin payload
|
|
1116
1148
|
* @returns {unknown}
|
|
@@ -1147,15 +1179,9 @@ export async function cliMain(ext = {}) {
|
|
|
1147
1179
|
(event) => judgeSanitizeOutput(event, ext),
|
|
1148
1180
|
{
|
|
1149
1181
|
transformInput: withPostToolUseDefault,
|
|
1150
|
-
//
|
|
1151
|
-
//
|
|
1152
|
-
//
|
|
1153
|
-
// back the parsed `input` even when the control-plane load failed, so the
|
|
1154
|
-
// suppression shape-matches the real tool_response). emitFailClosed itself
|
|
1155
|
-
// falls back to a bare string if that shape-matching replacement or its
|
|
1156
|
-
// serialization throws, so even a pathological input fails closed. Under
|
|
1157
|
-
// the open default with Layer 4 off the caller gets the warning-only
|
|
1158
|
-
// pass-through instead — see emitHookFailure.
|
|
1182
|
+
// The registered fault policy, rendered by emitHookFailure. It needs the
|
|
1183
|
+
// parsed `input`, which runJudgeCli hands back even when the control-plane
|
|
1184
|
+
// load failed, so a suppressing arm can keep the output's shape.
|
|
1159
1185
|
onError: (err, input) =>
|
|
1160
1186
|
emitHookFailure(input, err, undefined, ext.remedy),
|
|
1161
1187
|
},
|
|
@@ -38,10 +38,10 @@ import { hookTrace, TraceEvent } from "./lib/trace.mjs";
|
|
|
38
38
|
// come from the agent-sanitizer package. They are bound by a *caught* dynamic
|
|
39
39
|
// import, never a bare top-level `import … from "…"`: a static npm import
|
|
40
40
|
// resolves before any try/catch, so a missing node_modules would crash this hook
|
|
41
|
-
// at load and let the prompt through UNSANITIZED
|
|
41
|
+
// at load and let the prompt through UNSANITIZED with no warning. A failed load
|
|
42
42
|
// leaves that binding undefined, which the judge's typeof guards turn into a
|
|
43
|
-
//
|
|
44
|
-
// independently.
|
|
43
|
+
// throw that this hook's registered fault policy answers (lib/hook-fault.mjs)
|
|
44
|
+
// — one guard each, since the two loads succeed or fail independently.
|
|
45
45
|
/** @type {typeof import("agent-sanitizer/prompt").classifyPrompt} */
|
|
46
46
|
export let classifyPrompt;
|
|
47
47
|
/** @type {typeof import("agent-sanitizer").stripAnsiFully} */
|
|
@@ -101,7 +101,7 @@ registerFaultPolicy(HOOK_NAME, {
|
|
|
101
101
|
/* c8 ignore start — module-load boundary: the imports resolve in every real
|
|
102
102
|
* run, and their failure (the package absent) can't be simulated in-process, so
|
|
103
103
|
* neither arm is observable to the in-process tests. The judge's typeof guard
|
|
104
|
-
* converts an undefined stripper into a
|
|
104
|
+
* converts an undefined stripper into a fault — that guard IS tested. */
|
|
105
105
|
// Stryker disable all
|
|
106
106
|
// lazyImport rather than a bare `await import` in a try/catch: it RECORDS the
|
|
107
107
|
// loader error, which is the only thing that can tell a missing install apart
|
|
@@ -109,7 +109,7 @@ registerFaultPolicy(HOOK_NAME, {
|
|
|
109
109
|
// missingPackageError with nothing to report but a guess.
|
|
110
110
|
// The cast asserts the loaded SHAPE, not that it loaded: lazyImport yields {} on
|
|
111
111
|
// failure, so either binding can still be undefined here — which is exactly what
|
|
112
|
-
// the judge's typeof guard turns into a
|
|
112
|
+
// the judge's typeof guard turns into a fault (see the posture note above).
|
|
113
113
|
({ classifyPrompt } = /** @type {typeof import("agent-sanitizer/prompt")} */ (
|
|
114
114
|
await lazyImport("agent-sanitizer/prompt")
|
|
115
115
|
));
|
|
@@ -123,12 +123,11 @@ registerFaultPolicy(HOOK_NAME, {
|
|
|
123
123
|
* Judge a normalized prompt-submit event. Agent-agnostic: consumes the
|
|
124
124
|
* control-plane ToolCallEvent and returns a Verdict, so the same prompt gate
|
|
125
125
|
* renders through any agent adapter, not just Claude's. Throws (into the
|
|
126
|
-
* calling hook's catch) when the sanitizer package never loaded
|
|
127
|
-
*
|
|
128
|
-
* never pass through.
|
|
126
|
+
* calling hook's catch) when the sanitizer package never loaded, so a prompt it
|
|
127
|
+
* cannot classify takes the hook's fault posture rather than a silent ALLOW.
|
|
129
128
|
* @param {import("agent-control-plane-core").ToolCallEvent} event
|
|
130
129
|
* @param {((s: string) => string) | null} [strip] the ANSI stripper (defaults
|
|
131
|
-
* to the package's stripAnsiFully; injectable so the
|
|
130
|
+
* to the package's stripAnsiFully; injectable so the fault path is testable)
|
|
132
131
|
* @param {Partial<typeof USER_PROMPT_MESSAGES>} [overrides] reason overrides,
|
|
133
132
|
* merged over the defaults so a partial table can never leave a field unset
|
|
134
133
|
* @returns {import("agent-control-plane-core").Verdict}
|
|
@@ -199,7 +198,7 @@ export function judgeSanitizeUserPrompt(
|
|
|
199
198
|
* env?: NodeJS.ProcessEnv | Record<string, string | undefined>,
|
|
200
199
|
* }} [opts]
|
|
201
200
|
* `strip` is the ANSI stripper (defaults to the package's stripAnsiFully;
|
|
202
|
-
* injectable so the
|
|
201
|
+
* injectable so the fault path is testable); `overrides` are reason
|
|
203
202
|
* overrides, merged over the defaults so a partial table can never leave a field
|
|
204
203
|
* unset; `trace` is where engagement is announced, for a host with its own trace
|
|
205
204
|
* channel (see lib/trace.mjs); `env` is the failure-posture source (see
|
|
@@ -221,9 +220,8 @@ export async function main(read, write, opts = {}) {
|
|
|
221
220
|
// runJudgeCli so this hook doesn't re-implement the control-plane boundary:
|
|
222
221
|
// runJudgeCli reads stdin BEFORE loading the control-plane package, so a
|
|
223
222
|
// load failure fails to this hook's posture (the onError block) instead of
|
|
224
|
-
// leaving stdin unread. onError writes
|
|
225
|
-
//
|
|
226
|
-
// AGENT_SANITIZER_FAIL_OPEN=0 — because the adapter that would render it is
|
|
223
|
+
// leaving stdin unread. onError writes the registered fault policy's envelope
|
|
224
|
+
// (lib/hook-fault.mjs) by hand, because the adapter that would render it is
|
|
227
225
|
// exactly what may have failed to load. Either way this is the HOOK failing;
|
|
228
226
|
// a prompt the working stripper flagged is still blocked in both postures.
|
|
229
227
|
await runJudgeCli(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.60.
|
|
3
|
+
"version": "2.60.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": {
|
package/src/html.mjs
CHANGED
|
@@ -28,7 +28,11 @@
|
|
|
28
28
|
* remark/rehype/unified graph costs ~200ms of module-load time, so the main
|
|
29
29
|
* entry `await import()`s this module only when its cheap regex gates match.
|
|
30
30
|
*/
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
layer2Placeholder,
|
|
33
|
+
LAYER2_PLACEHOLDER_RE,
|
|
34
|
+
UNPARSEABLE_PLACEHOLDER,
|
|
35
|
+
} from "./layer2-placeholder.mjs";
|
|
32
36
|
// Subpath specifiers, never the `css-tree` root: the root builds the default
|
|
33
37
|
// syntax at module scope, that construction reaches the lexer's `lib/data.js`,
|
|
34
38
|
// and `lib/data.js` pulls the whole `mdn-data` CSS table set in through
|
|
@@ -1582,48 +1586,10 @@ export function closingTagName(htmlValue) {
|
|
|
1582
1586
|
* string indices, i.e. UTF-16 code units.
|
|
1583
1587
|
*/
|
|
1584
1588
|
|
|
1585
|
-
// The
|
|
1586
|
-
//
|
|
1587
|
-
//
|
|
1588
|
-
|
|
1589
|
-
const PLACEHOLDER_LABEL = Object.freeze({
|
|
1590
|
-
hidden: "hidden HTML",
|
|
1591
|
-
comment: "HTML comment",
|
|
1592
|
-
});
|
|
1593
|
-
|
|
1594
|
-
// How many lowercase-hex chars of the sha256 make the placeholder key. 48 bits
|
|
1595
|
-
// is far past accidental-collision range for the handful of splices one
|
|
1596
|
-
// document carries, while keeping the placeholder short enough to read.
|
|
1597
|
-
const PLACEHOLDER_KEY_LEN = 12;
|
|
1598
|
-
|
|
1599
|
-
/**
|
|
1600
|
-
* The keyed, content-addressed placeholder for one Layer-2 splice:
|
|
1601
|
-
* `[hidden HTML removed #<key>]` / `[HTML comment removed #<key>]`, where
|
|
1602
|
-
* `<key>` is the first 12 lowercase-hex chars of sha256 over the UTF-8
|
|
1603
|
-
* encoding of the ORIGINAL spliced text. Content-addressed on purpose:
|
|
1604
|
-
* identical spliced content yields the identical placeholder, so a rehydrator
|
|
1605
|
-
* can match placeholder → original by key alone, and duplicated content never
|
|
1606
|
-
* produces conflicting keys.
|
|
1607
|
-
* @param {SpliceKind} kind
|
|
1608
|
-
* @param {string} original the exact text the splice removed
|
|
1609
|
-
* @returns {string}
|
|
1610
|
-
*/
|
|
1611
|
-
export function layer2Placeholder(kind, original) {
|
|
1612
|
-
const key = createHash("sha256")
|
|
1613
|
-
.update(original, "utf8")
|
|
1614
|
-
.digest("hex")
|
|
1615
|
-
.slice(0, PLACEHOLDER_KEY_LEN);
|
|
1616
|
-
return `[${PLACEHOLDER_LABEL[kind]} removed #${key}]`;
|
|
1617
|
-
}
|
|
1618
|
-
|
|
1619
|
-
/**
|
|
1620
|
-
* The single grammar definition for keyed Layer-2 placeholders — the exact
|
|
1621
|
-
* output of {@link layer2Placeholder}, capture group 1 = the key. Global so
|
|
1622
|
-
* callers can scan a document for every placeholder; reset `lastIndex` (or
|
|
1623
|
-
* use `matchAll`) between uses.
|
|
1624
|
-
*/
|
|
1625
|
-
export const LAYER2_PLACEHOLDER_RE =
|
|
1626
|
-
/\[(?:hidden HTML|HTML comment) removed #([0-9a-f]{12})\]/g;
|
|
1589
|
+
// The Layer-2 placeholder grammar lives in the dependency-free
|
|
1590
|
+
// ./layer2-placeholder.mjs so the hooks can parse placeholders without loading
|
|
1591
|
+
// this module's parser graph; it stays part of this subpath's public API.
|
|
1592
|
+
export { layer2Placeholder, LAYER2_PLACEHOLDER_RE, UNPARSEABLE_PLACEHOLDER };
|
|
1627
1593
|
|
|
1628
1594
|
// DEPRECATED un-keyed placeholder PREFIXES, kept exported for callers that
|
|
1629
1595
|
// match "some Layer-2 placeholder of this kind" without knowing the key. The
|
|
@@ -1631,15 +1597,6 @@ export const LAYER2_PLACEHOLDER_RE =
|
|
|
1631
1597
|
// with {@link LAYER2_PLACEHOLDER_RE}.
|
|
1632
1598
|
export const HIDDEN_PLACEHOLDER = "[hidden HTML removed";
|
|
1633
1599
|
export const COMMENT_PLACEHOLDER = "[HTML comment removed";
|
|
1634
|
-
// Shown when the remark/rehype parse itself fails (e.g. pathologically nested
|
|
1635
|
-
// markup overflows the recursive tree walk with a RangeError). The top-level
|
|
1636
|
-
// `sanitize`/`sanitizeText` contract is "never throws, `cleaned` is always a
|
|
1637
|
-
// string", and this module is the only seam those callers own — so the HTML
|
|
1638
|
-
// layer must fail CLOSED here: withhold the whole unparseable input behind one
|
|
1639
|
-
// placeholder rather than let the exception escape and suppress all tool
|
|
1640
|
-
// output. Withholding (not passing through) is the safe choice — content we
|
|
1641
|
-
// could not inspect for hidden payloads is treated as if it were hidden.
|
|
1642
|
-
export const UNPARSEABLE_PLACEHOLDER = "[HTML unparseable — withheld]";
|
|
1643
1600
|
|
|
1644
1601
|
/**
|
|
1645
1602
|
* `ranges` sorted by start with every overlap unioned — one entry per
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Layer-2 placeholder grammar: the one producer of keyed splice
|
|
3
|
+
* placeholders, the regex that parses them, and the un-keyed marker for a
|
|
4
|
+
* withheld unparseable input. `./html.mjs` re-exports all three as its public
|
|
5
|
+
* API; the hooks reach them through claude-hooks/lib/placeholder-grammar.mjs.
|
|
6
|
+
*
|
|
7
|
+
* Split out of `./html.mjs` because that module loads the remark/rehype/unified
|
|
8
|
+
* graph (~200ms) at import, and the hooks that only PARSE placeholders — the
|
|
9
|
+
* PreToolUse rehydrator on every Edit/Write, the PostToolUse span persistence —
|
|
10
|
+
* must not pay for it. This module imports nothing but `node:crypto`.
|
|
11
|
+
*/
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
|
|
14
|
+
// The kind-specific prose of a Layer-2 placeholder. The full placeholder is
|
|
15
|
+
// built by {@link layer2Placeholder} and matched by {@link LAYER2_PLACEHOLDER_RE};
|
|
16
|
+
// keep all three in sync — they are ONE grammar.
|
|
17
|
+
const PLACEHOLDER_LABEL = Object.freeze({
|
|
18
|
+
hidden: "hidden HTML",
|
|
19
|
+
comment: "HTML comment",
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// How many lowercase-hex chars of the sha256 make the placeholder key. 48 bits
|
|
23
|
+
// is far past accidental-collision range for the handful of splices one
|
|
24
|
+
// document carries, while keeping the placeholder short enough to read.
|
|
25
|
+
const PLACEHOLDER_KEY_LEN = 12;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The keyed, content-addressed placeholder for one Layer-2 splice:
|
|
29
|
+
* `[hidden HTML removed #<key>]` / `[HTML comment removed #<key>]`, where
|
|
30
|
+
* `<key>` is the first 12 lowercase-hex chars of sha256 over the UTF-8
|
|
31
|
+
* encoding of the ORIGINAL spliced text. Content-addressed on purpose:
|
|
32
|
+
* identical spliced content yields the identical placeholder, so a rehydrator
|
|
33
|
+
* can match placeholder → original by key alone, and duplicated content never
|
|
34
|
+
* produces conflicting keys.
|
|
35
|
+
* @param {"comment" | "hidden"} kind
|
|
36
|
+
* @param {string} original the exact text the splice removed
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
export function layer2Placeholder(kind, original) {
|
|
40
|
+
const key = createHash("sha256")
|
|
41
|
+
.update(original, "utf8")
|
|
42
|
+
.digest("hex")
|
|
43
|
+
.slice(0, PLACEHOLDER_KEY_LEN);
|
|
44
|
+
return `[${PLACEHOLDER_LABEL[kind]} removed #${key}]`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The single grammar definition for keyed Layer-2 placeholders — the exact
|
|
49
|
+
* output of {@link layer2Placeholder}, capture group 1 = the key. Global so
|
|
50
|
+
* callers can scan a document for every placeholder; reset `lastIndex` (or
|
|
51
|
+
* use `matchAll`) between uses.
|
|
52
|
+
*/
|
|
53
|
+
export const LAYER2_PLACEHOLDER_RE =
|
|
54
|
+
/\[(?:hidden HTML|HTML comment) removed #([0-9a-f]{12})\]/g;
|
|
55
|
+
|
|
56
|
+
// Shown when the remark/rehype parse itself fails (e.g. a RangeError from
|
|
57
|
+
// pathologically nested markup). `sanitize` never throws, so the HTML layer
|
|
58
|
+
// fails CLOSED by withholding the whole input behind this one placeholder:
|
|
59
|
+
// content it could not inspect for hidden payloads is treated as hidden.
|
|
60
|
+
export const UNPARSEABLE_PLACEHOLDER = "[HTML unparseable — withheld]";
|
|
@@ -6,60 +6,40 @@
|
|
|
6
6
|
* @returns {boolean}
|
|
7
7
|
*/
|
|
8
8
|
export function needsFixedPoint(layers: Layer[]): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* The `fault` reason for a body that never settled, naming the stages still
|
|
11
|
+
* rewriting on the last pass so a reader knows which one oscillates.
|
|
12
|
+
* @param {string[]} stillChanging
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
export function nonConvergenceReason(stillChanging: string[]): string;
|
|
9
16
|
/**
|
|
10
17
|
* Run a declared layer chain over one tool input.
|
|
11
18
|
*
|
|
12
|
-
* Returns the final input, whether
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* denies ends the run immediately, with `deny` set.
|
|
19
|
+
* Returns the final input, whether any stage really changed it, and the
|
|
20
|
+
* model-facing notes of those stages, in order and deduplicated (a fixed-point
|
|
21
|
+
* re-run would otherwise repeat a note). A stage
|
|
22
|
+
* that denies ends the run immediately, with `deny` set. A body that never
|
|
23
|
+
* settles sets `fault` (the reason) alongside the last pass's sanitized
|
|
24
|
+
* `updatedInput`, and still runs the terminal stages, so a call that proceeds
|
|
25
|
+
* carries the same rehydration a settled one would. The verdict is the
|
|
26
|
+
* caller's: this driver renders none.
|
|
16
27
|
* @param {string} tool
|
|
17
28
|
* @param {any} toolInput
|
|
18
29
|
* @param {Layer[]} layers
|
|
19
|
-
* @returns {Promise<{ updatedInput: any, changed: boolean, contexts: string[], deny?: string }>}
|
|
30
|
+
* @returns {Promise<{ updatedInput: any, changed: boolean, contexts: string[], deny?: string, fault?: string }>}
|
|
20
31
|
*/
|
|
21
32
|
export function runLayerPipeline(tool: string, toolInput: any, layers: Layer[]): Promise<{
|
|
22
33
|
updatedInput: any;
|
|
23
34
|
changed: boolean;
|
|
24
35
|
contexts: string[];
|
|
25
36
|
deny?: string;
|
|
37
|
+
fault?: string;
|
|
26
38
|
}>;
|
|
27
|
-
/**
|
|
28
|
-
* The PreToolUse layer chain as a DECLARED pipeline instead of a run of
|
|
29
|
-
* sequential statements.
|
|
30
|
-
*
|
|
31
|
-
* The confusable fold (Layer 2) carries a soundness precondition: it deliberately
|
|
32
|
-
* SKIPS a token that still holds an unmapped non-ASCII glyph, because such a
|
|
33
|
-
* token can never come out byte-equal to an ASCII deny-rule target, so folding it
|
|
34
|
-
* would only mangle real foreign-language text. That argument holds only while no
|
|
35
|
-
* later layer ERASES code points from the same field — if it does, the glyph the
|
|
36
|
-
* fold relied on can disappear after the decision was taken.
|
|
37
|
-
*
|
|
38
|
-
* Layer 3 (authored-content stripping) erases exactly that: payload-capable
|
|
39
|
-
* invisible characters. Running it after the fold made the precondition false,
|
|
40
|
-
* and the gap was reachable: `cat /etc/p<CYRILLIC A><12 x ZWSP>sswd` put the
|
|
41
|
-
* zero-width run inside the token, so the fold skipped it, then the strip removed
|
|
42
|
-
* the padding and emitted `cat /etc/p<CYRILLIC A>sswd` — the homoglyph intact and
|
|
43
|
-
* the evidence for skipping it gone. Zero-width padding suppressed the fold and
|
|
44
|
-
* the next layer erased the reason.
|
|
45
|
-
*
|
|
46
|
-
* The eliminator is structural: layers declare whether they ERASE code points and
|
|
47
|
-
* whether their decision is SKIP-BASED (suppressible by code points another layer
|
|
48
|
-
* erases), and this driver enforces the precondition rather than a comment
|
|
49
|
-
* asking a future reader to preserve it. When the table puts an erasing layer
|
|
50
|
-
* after a skip-based one, the driver re-runs the body to a FIXED POINT, so the
|
|
51
|
-
* emitted value is one every skip-based layer has seen in its final form. A table
|
|
52
|
-
* that needs no fixed point runs exactly once, so the cost is paid only by the
|
|
53
|
-
* ordering that creates the hazard.
|
|
54
|
-
*
|
|
55
|
-
* Layers may be `terminal`: run once, after the fixed point, never re-run. That
|
|
56
|
-
* is Layer 4 (rehydration), whose whole contract is that it sees the FINAL
|
|
57
|
-
* authored text and its restored secrets are not re-stripped by Layer 3. Terminal
|
|
58
|
-
* layers must be a suffix of the table, which the driver checks.
|
|
59
|
-
*/
|
|
60
39
|
/**
|
|
61
40
|
* A layer's result: the rewritten input plus the model-facing note, a `deny`
|
|
62
|
-
* verdict that ends the pipeline, or null when the layer changed nothing.
|
|
41
|
+
* verdict that ends the pipeline, or null when the layer changed nothing. A
|
|
42
|
+
* rewrite deep-equal to its input counts as null, whatever the layer reports.
|
|
63
43
|
* @typedef {{ updatedInput: any, context: string } | { deny: string } | null} LayerResult
|
|
64
44
|
*/
|
|
65
45
|
/**
|
|
@@ -79,16 +59,17 @@ export function runLayerPipeline(tool: string, toolInput: any, layers: Layer[]):
|
|
|
79
59
|
*/
|
|
80
60
|
/**
|
|
81
61
|
* Bound on fixed-point passes. Each pass either changes the input or ends the
|
|
82
|
-
* loop, and the
|
|
83
|
-
* shrink the space of remaining findings), so a
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
62
|
+
* loop, and the stages are contractive in practice (folding and stripping both
|
|
63
|
+
* shrink the space of remaining findings), so a body still changing after this
|
|
64
|
+
* many passes holds a stage that oscillates. That yields a `fault` (see
|
|
65
|
+
* {@link runLayerPipeline}); looping forever would instead be killed by the
|
|
66
|
+
* harness, which reads a killed PreToolUse hook as a non-blocking pass.
|
|
87
67
|
*/
|
|
88
68
|
export const MAX_PIPELINE_PASSES: 8;
|
|
89
69
|
/**
|
|
90
70
|
* A layer's result: the rewritten input plus the model-facing note, a `deny`
|
|
91
|
-
* verdict that ends the pipeline, or null when the layer changed nothing.
|
|
71
|
+
* verdict that ends the pipeline, or null when the layer changed nothing. A
|
|
72
|
+
* rewrite deep-equal to its input counts as null, whatever the layer reports.
|
|
92
73
|
*/
|
|
93
74
|
export type LayerResult = {
|
|
94
75
|
updatedInput: any;
|
|
@@ -94,29 +94,6 @@ export const PLACEHOLDER_LABEL_CHARS: "A-Za-z0-9 ()._-";
|
|
|
94
94
|
* `grep "\[REDACTED"`) is not mistaken for a placeholder.
|
|
95
95
|
*/
|
|
96
96
|
export const PLACEHOLDER_RE: RegExp;
|
|
97
|
-
/**
|
|
98
|
-
* The keyed Layer-2 splice placeholder grammar — `[hidden HTML removed #<key>]`
|
|
99
|
-
* / `[HTML comment removed #<key>]`, capture group 1 = the 12-lowercase-hex key
|
|
100
|
-
* (the first 12 hex chars of sha256 over the RAW spliced original). Mirrored
|
|
101
|
-
* from the single producer in the engine (`src/html.mjs`,
|
|
102
|
-
* `LAYER2_PLACEHOLDER_RE`) for the same pinned-registry reason as
|
|
103
|
-
* PLACEHOLDER_RE above.
|
|
104
|
-
*
|
|
105
|
-
* Deliberately DISJOINT from the secret-redaction grammar: a Layer-2
|
|
106
|
-
* placeholder never matches PLACEHOLDER_RE (no `[REDACTED` prefix) and a
|
|
107
|
-
* secret placeholder never matches this — so the secret rehydrator and the
|
|
108
|
-
* Layer-2 rehydrator can compose without either touching the other's tokens.
|
|
109
|
-
*/
|
|
110
|
-
export const LAYER2_PLACEHOLDER_RE: RegExp;
|
|
111
|
-
/**
|
|
112
|
-
* The one Layer-2 marker that carries no key, mirrored from src/html.mjs
|
|
113
|
-
* (UNPARSEABLE_PLACEHOLDER) for the bundle-pin reason above. The fail-closed
|
|
114
|
-
* path withholds the WHOLE output, so there is no per-splice original to key:
|
|
115
|
-
* the pre-splice text lives in the reveal sidecar (lib/reveal.mjs) whose exact
|
|
116
|
-
* path the sanitize-time warning named. Not round-trippable — the advisory
|
|
117
|
-
* points at the sidecar instead of a span file.
|
|
118
|
-
*/
|
|
119
|
-
export const UNPARSEABLE_MARKER: "[HTML unparseable \u2014 withheld]";
|
|
120
97
|
export const REHYDRATED_TOOLS: Set<string>;
|
|
121
98
|
/**
|
|
122
99
|
* One found token: the exact placeholder text and the dotted field path of the
|
|
@@ -126,3 +103,6 @@ export type FoundPlaceholder = {
|
|
|
126
103
|
token: string;
|
|
127
104
|
path: string;
|
|
128
105
|
};
|
|
106
|
+
import { LAYER2_PLACEHOLDER_RE } from "../../src/layer2-placeholder.mjs";
|
|
107
|
+
import { UNPARSEABLE_PLACEHOLDER } from "../../src/layer2-placeholder.mjs";
|
|
108
|
+
export { LAYER2_PLACEHOLDER_RE, UNPARSEABLE_PLACEHOLDER as UNPARSEABLE_MARKER };
|