agent-sanitizer 2.51.0 → 2.53.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 +9 -0
- package/THREAT-MODEL.md +12 -0
- package/bin/sanitize-cli.mjs +2 -1
- package/claude-hooks/lib/hook-timing.mjs +111 -3
- package/package.json +1 -1
- package/src/html.mjs +35 -20
- package/src/index.mjs +12 -2
- package/src/output.mjs +7 -3
- package/types/claude-hooks/lib/hook-timing.d.mts +13 -36
- package/types/html.d.mts +14 -2
- package/types/index.d.mts +7 -1
- package/types/output.d.mts +2 -0
package/README.md
CHANGED
|
@@ -40,6 +40,15 @@ const result = await sanitize(pageSource, { html: true });
|
|
|
40
40
|
// Layer 3 alone: flag exfil-shaped URLs without splicing anything (for text
|
|
41
41
|
// that must stay byte-faithful, e.g. a PR diff). Implied by `html: true`.
|
|
42
42
|
const scanned = await sanitize(diffText, { exfilScan: true });
|
|
43
|
+
|
|
44
|
+
// Layer 3 reads an exact-digest-length hex value under a generic parameter
|
|
45
|
+
// name (`?v=<md5>`, an ETag, a commit id) as a fingerprint. `flagDigestValues`
|
|
46
|
+
// reports it as payload instead — more false positives, no 16-to-64-byte
|
|
47
|
+
// channel under a name the caller picks. For monitors, not for splicing.
|
|
48
|
+
const strict = await sanitize(logText, {
|
|
49
|
+
exfilScan: true,
|
|
50
|
+
flagDigestValues: true,
|
|
51
|
+
});
|
|
43
52
|
```
|
|
44
53
|
|
|
45
54
|
`sanitize` never throws and never silently drops content—any change comes with
|
package/THREAT-MODEL.md
CHANGED
|
@@ -140,6 +140,18 @@ attributes (`src`/`href`/`background`/`srcset`/`ping`, form `action`/`formaction
|
|
|
140
140
|
- off-origin form actions and `meta refresh` redirects
|
|
141
141
|
- `javascript:` / `vbscript:` targets
|
|
142
142
|
|
|
143
|
+
**The digest exemption, and the switch that lifts it.** A value that is
|
|
144
|
+
exactly one digest width of hex (32/40/56/64/96/128) under a generic parameter
|
|
145
|
+
name reads as a fingerprint, not a payload: a cache-buster `?v=<md5>`, an ETag,
|
|
146
|
+
a request id, a git commit, imgix's `?s=`. Under a name that already says
|
|
147
|
+
credential the same characters read as payload and still flag. That leaves a
|
|
148
|
+
residual, because the caller writing the URL picks the name: a payload padded to
|
|
149
|
+
exactly one digest length rides under a generic one, buying 16 to 64 bytes per
|
|
150
|
+
parameter. `flagDigestValues` moves the trade-off — it drops the exemption
|
|
151
|
+
entirely, at the cost of flagging every real fingerprint — for a caller whose
|
|
152
|
+
job is monitoring rather than presenting text to a model. Like every Layer 3
|
|
153
|
+
option it can only ADD detection; there is no switch that turns a report off.
|
|
154
|
+
|
|
143
155
|
Each threat carries a `reason` and the destination `target` (never the
|
|
144
156
|
payload-bearing query/fragment) — the finding is shown to the operator with the
|
|
145
157
|
target named and the payload withheld, since re-presenting the exfil payload in
|
package/bin/sanitize-cli.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* bare `{ text, html }` keeps working). Per op:
|
|
16
16
|
*
|
|
17
17
|
* sanitize { text, html? } -> { cleaned, found, warnings, notes, splices? }
|
|
18
|
-
* sanitizeText { text, html?, exfilScan? } -> { cleaned, warnings, notes, modified, sgrNote }
|
|
18
|
+
* sanitizeText { text, html?, exfilScan?, flagDigestValues? } -> { cleaned, warnings, notes, modified, sgrNote }
|
|
19
19
|
* classifyPrompt { text } -> { action, reason? }
|
|
20
20
|
* scanInstructionFiles { globs, cwd? } -> { findings: [{ file, findings }] }
|
|
21
21
|
* cleanFile { path } -> { changed }
|
|
@@ -135,6 +135,7 @@ export const OPS = {
|
|
|
135
135
|
{
|
|
136
136
|
html: Boolean(req.html),
|
|
137
137
|
exfilScan: Boolean(req.exfilScan),
|
|
138
|
+
flagDigestValues: Boolean(req.flagDigestValues),
|
|
138
139
|
},
|
|
139
140
|
);
|
|
140
141
|
return { cleaned, warnings, notes, modified, sgrNote };
|
|
@@ -30,8 +30,13 @@
|
|
|
30
30
|
* every session cry wolf, which is the alert fatigue this notice fights.
|
|
31
31
|
*
|
|
32
32
|
* Dependency-free on purpose: everything imports this, including hook-io, so a
|
|
33
|
-
* back-import would close a cycle. The one emitter it needs is passed in.
|
|
33
|
+
* back-import would close a cycle. The one emitter it needs is passed in. The
|
|
34
|
+
* node builtins below are not such a dependency — they read one small manifest,
|
|
35
|
+
* once, to name this build's version in a report line.
|
|
34
36
|
*/
|
|
37
|
+
import { readFileSync } from "node:fs";
|
|
38
|
+
import { dirname, join } from "node:path";
|
|
39
|
+
import { fileURLToPath } from "node:url";
|
|
35
40
|
|
|
36
41
|
/**
|
|
37
42
|
* Wall-clock a single hook invocation may spend before it is reported as slow.
|
|
@@ -62,6 +67,103 @@ export const SLOW_PROVISION_THRESHOLD_MS = 60000;
|
|
|
62
67
|
const ISSUE_URL =
|
|
63
68
|
"https://github.com/AlexanderMattTurner/agent-sanitizer/issues/new";
|
|
64
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Where this build's own version sits, relative to the directory this module
|
|
72
|
+
* runs from — each shipped artifact puts its manifest at a fixed offset, so the
|
|
73
|
+
* candidates are enumerated rather than searched for:
|
|
74
|
+
*
|
|
75
|
+
* `../../.claude-plugin/plugin.json` the installed Claude Code plugin,
|
|
76
|
+
* whose bundle ships at
|
|
77
|
+
* `plugin/dist/hooks/`
|
|
78
|
+
* `../../plugin/.claude-plugin/plugin.json` a source checkout, where that same
|
|
79
|
+
* manifest is the accurate version
|
|
80
|
+
* and package.json's is the frozen
|
|
81
|
+
* placeholder npm overwrites at
|
|
82
|
+
* publish
|
|
83
|
+
* `../../package.json` the npm package, which ships this
|
|
84
|
+
* module at `claude-hooks/lib/` and
|
|
85
|
+
* carries the published version
|
|
86
|
+
*
|
|
87
|
+
* First hit wins, and each candidate exists only inside the artifact it belongs
|
|
88
|
+
* to, so no foreign manifest is ever a candidate.
|
|
89
|
+
*/
|
|
90
|
+
const VERSION_MANIFESTS = [
|
|
91
|
+
"../../.claude-plugin/plugin.json",
|
|
92
|
+
"../../plugin/.claude-plugin/plugin.json",
|
|
93
|
+
"../../package.json",
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
/** Strict X.Y.Z, the only shape this project's release tooling ever writes. */
|
|
97
|
+
const SEMVER = /^[0-9]+\.[0-9]+\.[0-9]+$/;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* This build's version for the report line below, or null when nothing here can
|
|
101
|
+
* name it — a compiled hook binary whose `import.meta.url` points inside the
|
|
102
|
+
* executable reads no manifest, and the notice then asks the operator to look
|
|
103
|
+
* the version up rather than printing one nothing confirmed.
|
|
104
|
+
* @returns {string | null}
|
|
105
|
+
*/
|
|
106
|
+
function readVersion() {
|
|
107
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
108
|
+
for (const manifest of VERSION_MANIFESTS) {
|
|
109
|
+
const version = readManifest(join(dir, manifest));
|
|
110
|
+
if (version !== null) return version;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The strict semver `path` carries, or null when it carries none.
|
|
117
|
+
*
|
|
118
|
+
* The read and the parse are caught because neither failure is this function's
|
|
119
|
+
* business: every candidate but one is absent in any given artifact, and a
|
|
120
|
+
* manifest a packager corrupted is not a reason for a PERFORMANCE notice to
|
|
121
|
+
* throw inside the hook it is reporting on.
|
|
122
|
+
* @param {string} path
|
|
123
|
+
* @returns {string | null}
|
|
124
|
+
*/
|
|
125
|
+
function readManifest(path) {
|
|
126
|
+
let manifest;
|
|
127
|
+
try {
|
|
128
|
+
manifest = JSON.parse(readFileSync(path, "utf8"));
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
return SEMVER.test(manifest?.version) ? manifest.version : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** @type {string | null | undefined} */
|
|
136
|
+
let cachedVersion;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* {@link readVersion}, computed once per process — the notice fires on a
|
|
140
|
+
* vanishing fraction of runs, and every hook imports this module on the hot
|
|
141
|
+
* path, so the manifest is read only once something is being reported.
|
|
142
|
+
* @returns {string | null}
|
|
143
|
+
*/
|
|
144
|
+
export function sanitizerVersion() {
|
|
145
|
+
if (cachedVersion === undefined) cachedVersion = readVersion();
|
|
146
|
+
return cachedVersion;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The clause naming the version an issue report should carry: this build's when
|
|
151
|
+
* it knows it, and otherwise an instruction to look it up — never a guess.
|
|
152
|
+
*
|
|
153
|
+
* Resolves the version HERE rather than in a caller's default argument, which
|
|
154
|
+
* would read the manifest on every healthy run too — the notices call this only
|
|
155
|
+
* once they have decided to report.
|
|
156
|
+
* @param {string | null | undefined} version a caller's override; `undefined`
|
|
157
|
+
* asks this build for its own, `null` says nothing could name it
|
|
158
|
+
* @returns {string}
|
|
159
|
+
*/
|
|
160
|
+
function versionClause(version) {
|
|
161
|
+
const resolved = version === undefined ? sanitizerVersion() : version;
|
|
162
|
+
return resolved
|
|
163
|
+
? `agent-sanitizer ${resolved}`
|
|
164
|
+
: "your agent-sanitizer version";
|
|
165
|
+
}
|
|
166
|
+
|
|
65
167
|
/**
|
|
66
168
|
* Milliseconds as the seconds string every notice below prints.
|
|
67
169
|
*
|
|
@@ -439,6 +541,9 @@ function attributeWait(elapsedMs, cpuMs, redactorMs, hostMs) {
|
|
|
439
541
|
* @param {SlowHookContext} [context] known CPU time / payload size /
|
|
440
542
|
* triggering tool, so the notice is self-diagnosing rather than requiring the
|
|
441
543
|
* next reader to reconstruct what was slow by hand
|
|
544
|
+
* @param {string | null} [version] the build to name in the report line;
|
|
545
|
+
* omitted asks {@link sanitizerVersion}, and the shell port passes its own,
|
|
546
|
+
* read from the plugin manifest it ships beside
|
|
442
547
|
* @returns {string | null}
|
|
443
548
|
*/
|
|
444
549
|
export function slowHookNotice(
|
|
@@ -446,6 +551,7 @@ export function slowHookNotice(
|
|
|
446
551
|
elapsedMs,
|
|
447
552
|
thresholdMs = SLOW_HOOK_THRESHOLD_MS,
|
|
448
553
|
context,
|
|
554
|
+
version,
|
|
449
555
|
) {
|
|
450
556
|
if (elapsedMs <= thresholdMs) return null;
|
|
451
557
|
const cpuMs = context?.cpuMs;
|
|
@@ -473,7 +579,7 @@ export function slowHookNotice(
|
|
|
473
579
|
return (
|
|
474
580
|
`agent-sanitizer PERFORMANCE: the ${hookName} hook took ` +
|
|
475
581
|
`${formatSeconds(elapsedMs)}s${formatContextSuffix(context)}, over its ${formatSeconds(thresholdMs)}s budget${attribution} ` +
|
|
476
|
-
`Tell the user, and suggest they report it at ${ISSUE_URL} with the hook name and ${timings}.`
|
|
582
|
+
`Tell the user, and suggest they report it at ${ISSUE_URL} with ${versionClause(version)}, the hook name and ${timings}.`
|
|
477
583
|
);
|
|
478
584
|
}
|
|
479
585
|
|
|
@@ -498,6 +604,7 @@ export function slowHookNotice(
|
|
|
498
604
|
* @param {string} [advice] step-specific speedup advice — the default fits the
|
|
499
605
|
* engine install; the hook-binary download passes its own, because telling a
|
|
500
606
|
* user mid-download that uv would help is advice about the wrong step
|
|
607
|
+
* @param {string | null} [version] see {@link slowHookNotice}
|
|
501
608
|
* @returns {string | null}
|
|
502
609
|
*/
|
|
503
610
|
export function slowProvisionNotice(
|
|
@@ -505,13 +612,14 @@ export function slowProvisionNotice(
|
|
|
505
612
|
elapsedMs,
|
|
506
613
|
thresholdMs = SLOW_PROVISION_THRESHOLD_MS,
|
|
507
614
|
advice = "Installing uv makes it faster",
|
|
615
|
+
version,
|
|
508
616
|
) {
|
|
509
617
|
if (elapsedMs <= thresholdMs) return null;
|
|
510
618
|
return (
|
|
511
619
|
`agent-sanitizer PERFORMANCE: one-time setup (${stepName}) took ` +
|
|
512
620
|
`${formatSeconds(elapsedMs)}s, over its ${formatSeconds(thresholdMs)}s budget — ` +
|
|
513
621
|
"this is paid once per install, not per tool call, so the session is not slow from here on. " +
|
|
514
|
-
`${advice}; if it happens on EVERY new session, report it at ${ISSUE_URL}.`
|
|
622
|
+
`${advice}; if it happens on EVERY new session, report it at ${ISSUE_URL} with ${versionClause(version)}.`
|
|
515
623
|
);
|
|
516
624
|
}
|
|
517
625
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.53.0",
|
|
4
4
|
"description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
package/src/html.mjs
CHANGED
|
@@ -2720,9 +2720,13 @@ function rawParams(qs) {
|
|
|
2720
2720
|
* @param {string} name lowercased parameter name, for the allowlist gate
|
|
2721
2721
|
* @param {string} value RAW (un-decoded) value
|
|
2722
2722
|
* @param {string} rawName RAW (case-preserved, un-decoded) name
|
|
2723
|
+
* @param {boolean} flagDigestValues drop the digest exemption entirely. The
|
|
2724
|
+
* name is the weak half of the test above, because the caller writing the
|
|
2725
|
+
* URL picks it: a payload padded to exactly one digest length otherwise
|
|
2726
|
+
* rides under any generic name.
|
|
2723
2727
|
* @returns {string | null}
|
|
2724
2728
|
*/
|
|
2725
|
-
function paramExfilReason(name, value, rawName) {
|
|
2729
|
+
function paramExfilReason(name, value, rawName, flagDigestValues) {
|
|
2726
2730
|
if (BENIGN_BLOB_PARAM_RE.test(name)) return null;
|
|
2727
2731
|
const publicKeyId =
|
|
2728
2732
|
PUBLIC_KEY_ID_PARAM_RE.test(name) && value.length < BLOB_VALUE_MIN_LEN;
|
|
@@ -2730,9 +2734,9 @@ function paramExfilReason(name, value, rawName) {
|
|
|
2730
2734
|
// — a cache-buster `?v=`, an ETag, imgix's `?s=`, a commit id. Under a name
|
|
2731
2735
|
// that already says credential the same 64 hex characters read as 32 bytes of
|
|
2732
2736
|
// payload, so the exemption stops there.
|
|
2733
|
-
const digestIsBenign =
|
|
2734
|
-
|
|
2735
|
-
|
|
2737
|
+
const digestIsBenign =
|
|
2738
|
+
!flagDigestValues &&
|
|
2739
|
+
!(KEYWORD_PARAM_NAME_RE.test(name) || matchesSecretHint(name));
|
|
2736
2740
|
for (const candidate of [rawName, value]) {
|
|
2737
2741
|
if (!candidate) continue;
|
|
2738
2742
|
// A leaked credential is an OPAQUE, separator-free token. Gate the
|
|
@@ -2784,7 +2788,10 @@ function rawUrlKeywordExfil(url) {
|
|
|
2784
2788
|
for (const segment of url.slice(qIdx + 1).split("#")) {
|
|
2785
2789
|
for (const [name, value, rawName] of rawParams(segment)) {
|
|
2786
2790
|
if (!KEYWORD_PARAM_NAME_RE.test(name)) continue;
|
|
2787
|
-
|
|
2791
|
+
// Only credential-named params reach here, and the digest exemption never
|
|
2792
|
+
// applies to those, so there is no exemption for `flagDigestValues` to
|
|
2793
|
+
// lift on this path.
|
|
2794
|
+
const reason = paramExfilReason(name, value, rawName, false);
|
|
2788
2795
|
if (reason) return reason;
|
|
2789
2796
|
}
|
|
2790
2797
|
}
|
|
@@ -2826,19 +2833,18 @@ function allParamsBenign(parsed) {
|
|
|
2826
2833
|
/**
|
|
2827
2834
|
* Walk the query and fragment parameters of a parsed URL for an exfil reason.
|
|
2828
2835
|
* @param {URL} parsed
|
|
2836
|
+
* @param {boolean} flagDigestValues
|
|
2829
2837
|
* @returns {string | null}
|
|
2830
2838
|
*/
|
|
2831
|
-
function checkUrlParams(parsed) {
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
if (reason) return reason;
|
|
2841
|
-
}
|
|
2839
|
+
function checkUrlParams(parsed, flagDigestValues) {
|
|
2840
|
+
// Query and fragment are one channel: `#token=…` carries what `?token=…` does,
|
|
2841
|
+
// and a bare anchor (`#section-2`) yields one empty-value param that trips
|
|
2842
|
+
// nothing.
|
|
2843
|
+
for (const segment of [parsed.search.slice(1), parsed.hash.slice(1)])
|
|
2844
|
+
for (const [name, value, rawName] of rawParams(segment)) {
|
|
2845
|
+
const reason = paramExfilReason(name, value, rawName, flagDigestValues);
|
|
2846
|
+
if (reason) return reason;
|
|
2847
|
+
}
|
|
2842
2848
|
return null;
|
|
2843
2849
|
}
|
|
2844
2850
|
|
|
@@ -2861,10 +2867,18 @@ function checkUrlPath(parsed) {
|
|
|
2861
2867
|
}
|
|
2862
2868
|
|
|
2863
2869
|
/**
|
|
2870
|
+
* `flagDigestValues` drops the digest exemption: an exact-digest-length hex
|
|
2871
|
+
* value under a generic parameter name is reported as payload rather than read
|
|
2872
|
+
* as a fingerprint. Off by default because the exemption is what keeps a
|
|
2873
|
+
* cache-buster, an ETag and a commit id quiet; on for a caller whose cost of a
|
|
2874
|
+
* missed 16-to-64-byte channel beats its cost of those false positives. Like
|
|
2875
|
+
* every option this module takes, it can only ADD detection.
|
|
2864
2876
|
* @param {string} url
|
|
2877
|
+
* @param {{ flagDigestValues?: boolean }} [options]
|
|
2865
2878
|
* @returns {string | null}
|
|
2866
2879
|
*/
|
|
2867
|
-
export function checkExfilUrl(url) {
|
|
2880
|
+
export function checkExfilUrl(url, options = {}) {
|
|
2881
|
+
const { flagDigestValues = false } = options;
|
|
2868
2882
|
// A browser strips tab/newline/CR ANYWHERE in a URL before resolving its
|
|
2869
2883
|
// scheme, so `java\tscript:alert(1)` navigates as `javascript:`. Strip them
|
|
2870
2884
|
// for the scheme tests (the payload/length checks below keep the raw string).
|
|
@@ -2913,7 +2927,7 @@ export function checkExfilUrl(url) {
|
|
|
2913
2927
|
return "unusually long query string";
|
|
2914
2928
|
if (parsed.hash.length > LONG_QUERY_THRESHOLD)
|
|
2915
2929
|
return "unusually long fragment";
|
|
2916
|
-
return checkUrlParams(parsed) || checkUrlPath(parsed);
|
|
2930
|
+
return checkUrlParams(parsed, flagDigestValues) || checkUrlPath(parsed);
|
|
2917
2931
|
}
|
|
2918
2932
|
|
|
2919
2933
|
/**
|
|
@@ -3177,9 +3191,10 @@ function collectUrls(text) {
|
|
|
3177
3191
|
* link somebody has to follow. Both are reported; the caller uses it to decide
|
|
3178
3192
|
* how loudly (see the exfil tier in ./output.mjs).
|
|
3179
3193
|
* @param {string} text
|
|
3194
|
+
* @param {{ flagDigestValues?: boolean }} [options] see {@link checkExfilUrl}
|
|
3180
3195
|
* @returns {Array<{ isImage: boolean, autoFetched: boolean, reason: string, target: string }> | null}
|
|
3181
3196
|
*/
|
|
3182
|
-
export function detectExfil(text) {
|
|
3197
|
+
export function detectExfil(text, options = {}) {
|
|
3183
3198
|
if (!needsUrlScan(text)) return null;
|
|
3184
3199
|
|
|
3185
3200
|
/** @type {Array<{ isImage: boolean, autoFetched: boolean, reason: string, target: string }>} */
|
|
@@ -3188,7 +3203,7 @@ export function detectExfil(text) {
|
|
|
3188
3203
|
try {
|
|
3189
3204
|
for (const { url, isImage, autoFetched, context } of collectUrls(text)) {
|
|
3190
3205
|
const reason =
|
|
3191
|
-
checkExfilUrl(url) ||
|
|
3206
|
+
checkExfilUrl(url, options) ||
|
|
3192
3207
|
(context !== "resource" && isOffOrigin(url)
|
|
3193
3208
|
? OFF_ORIGIN_REASON[context]
|
|
3194
3209
|
: null);
|
package/src/index.mjs
CHANGED
|
@@ -102,14 +102,23 @@ export {
|
|
|
102
102
|
* legitimate markup — matching the separate flags `sanitizeText` takes for the
|
|
103
103
|
* tool-output pipeline, which needs Layer 3's detection without Layer 2's
|
|
104
104
|
* splice.
|
|
105
|
+
*
|
|
106
|
+
* `flagDigestValues` widens Layer 3 only: it drops the digest exemption, so an
|
|
107
|
+
* exact-digest-length hex value under a generic parameter name is reported as
|
|
108
|
+
* payload rather than read as a cache-buster or an ETag. Off by default, and
|
|
109
|
+
* like `exfilScan` it can only ADD detection.
|
|
105
110
|
* @param {string} text
|
|
106
|
-
* @param {{ html?: boolean, exfilScan?: boolean } | null} [options]
|
|
111
|
+
* @param {{ html?: boolean, exfilScan?: boolean, flagDigestValues?: boolean } | null} [options]
|
|
107
112
|
* @returns {Promise<{ cleaned: string, found: string[], warnings: string[], notes: string[], splices?: Array<{ placeholder: string, original: string }> }>}
|
|
108
113
|
*/
|
|
109
114
|
export async function sanitize(text, options) {
|
|
110
115
|
if (typeof text !== "string")
|
|
111
116
|
throw new TypeError("sanitize(text, options): text must be a string");
|
|
112
|
-
const {
|
|
117
|
+
const {
|
|
118
|
+
html = false,
|
|
119
|
+
exfilScan = false,
|
|
120
|
+
flagDigestValues = false,
|
|
121
|
+
} = options ?? {};
|
|
113
122
|
const { cleaned, found, warnings, notes, splices } = await sanitizeText(
|
|
114
123
|
text,
|
|
115
124
|
{
|
|
@@ -118,6 +127,7 @@ export async function sanitize(text, options) {
|
|
|
118
127
|
// an opt-OUT would make `{ html: true, exfilScan: false }` splice Layer 2
|
|
119
128
|
// while silently dropping Layer 3's report — a fail-open the docs deny.
|
|
120
129
|
exfilScan: exfilScan || html,
|
|
130
|
+
flagDigestValues,
|
|
121
131
|
},
|
|
122
132
|
);
|
|
123
133
|
return {
|
package/src/output.mjs
CHANGED
|
@@ -393,10 +393,13 @@ function processLayer1(text, sgrCarveOut) {
|
|
|
393
393
|
* vet them before they leave. The transform itself stays pure — the caller owns
|
|
394
394
|
* any persistence.
|
|
395
395
|
* @param {PipelineState} state
|
|
396
|
-
* @param {{ html?: boolean, exfilScan?: boolean, deadline?: Deadline }} options
|
|
396
|
+
* @param {{ html?: boolean, exfilScan?: boolean, flagDigestValues?: boolean, deadline?: Deadline }} options
|
|
397
397
|
* @returns {Promise<{ reveal: string | undefined, splices: Array<{ placeholder: string, original: string }> }>}
|
|
398
398
|
*/
|
|
399
|
-
async function applyMarkdownPipeline(
|
|
399
|
+
async function applyMarkdownPipeline(
|
|
400
|
+
state,
|
|
401
|
+
{ html, exfilScan, flagDigestValues, deadline },
|
|
402
|
+
) {
|
|
400
403
|
const inputText = state.text;
|
|
401
404
|
/** @type {string | undefined} */
|
|
402
405
|
let reveal;
|
|
@@ -485,7 +488,7 @@ async function applyMarkdownPipeline(state, { html, exfilScan, deadline }) {
|
|
|
485
488
|
// suspicious, not less, yet Layer 2 has already removed it from `cleaned`.
|
|
486
489
|
if (runLayer3) {
|
|
487
490
|
refuseIfSpent();
|
|
488
|
-
const threats = detectExfil(inputText);
|
|
491
|
+
const threats = detectExfil(inputText, { flagDigestValues });
|
|
489
492
|
// Severity tracks who does the fetching. An auto-fetched target — an image,
|
|
490
493
|
// a stylesheet, a form action, a meta refresh — exfiltrates the moment the
|
|
491
494
|
// content renders, with nobody deciding anything: a WARNING. A plain LINK
|
|
@@ -571,6 +574,7 @@ async function vetStageValue(text, redact, findings, label) {
|
|
|
571
574
|
* @typedef {{
|
|
572
575
|
* html?: boolean,
|
|
573
576
|
* exfilScan?: boolean,
|
|
577
|
+
* flagDigestValues?: boolean,
|
|
574
578
|
* redact?: (text: string) => Promise<RedactResult|null> | (RedactResult|null),
|
|
575
579
|
* filterInjection?: (text: string) => Promise<Layer5Result|null> | (Layer5Result|null),
|
|
576
580
|
* sgrCarveOut?: boolean,
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* {@link readVersion}, computed once per process — the notice fires on a
|
|
3
|
+
* vanishing fraction of runs, and every hook imports this module on the hot
|
|
4
|
+
* path, so the manifest is read only once something is being reported.
|
|
5
|
+
* @returns {string | null}
|
|
6
|
+
*/
|
|
7
|
+
export function sanitizerVersion(): string | null;
|
|
1
8
|
/**
|
|
2
9
|
* Milliseconds as the seconds string every notice below prints.
|
|
3
10
|
*
|
|
@@ -149,9 +156,12 @@ export function startHookTimer(now?: () => number, cpuNow?: () => number): {
|
|
|
149
156
|
* @param {SlowHookContext} [context] known CPU time / payload size /
|
|
150
157
|
* triggering tool, so the notice is self-diagnosing rather than requiring the
|
|
151
158
|
* next reader to reconstruct what was slow by hand
|
|
159
|
+
* @param {string | null} [version] the build to name in the report line;
|
|
160
|
+
* omitted asks {@link sanitizerVersion}, and the shell port passes its own,
|
|
161
|
+
* read from the plugin manifest it ships beside
|
|
152
162
|
* @returns {string | null}
|
|
153
163
|
*/
|
|
154
|
-
export function slowHookNotice(hookName: string, elapsedMs: number, thresholdMs?: number, context?: SlowHookContext): string | null;
|
|
164
|
+
export function slowHookNotice(hookName: string, elapsedMs: number, thresholdMs?: number, context?: SlowHookContext, version?: string | null): string | null;
|
|
155
165
|
/**
|
|
156
166
|
* The line for a ONE-TIME provisioning step that overran
|
|
157
167
|
* {@link SLOW_PROVISION_THRESHOLD_MS}, or null when it did not.
|
|
@@ -173,9 +183,10 @@ export function slowHookNotice(hookName: string, elapsedMs: number, thresholdMs?
|
|
|
173
183
|
* @param {string} [advice] step-specific speedup advice — the default fits the
|
|
174
184
|
* engine install; the hook-binary download passes its own, because telling a
|
|
175
185
|
* user mid-download that uv would help is advice about the wrong step
|
|
186
|
+
* @param {string | null} [version] see {@link slowHookNotice}
|
|
176
187
|
* @returns {string | null}
|
|
177
188
|
*/
|
|
178
|
-
export function slowProvisionNotice(stepName: string, elapsedMs: number, thresholdMs?: number, advice?: string): string | null;
|
|
189
|
+
export function slowProvisionNotice(stepName: string, elapsedMs: number, thresholdMs?: number, advice?: string, version?: string | null): string | null;
|
|
179
190
|
/**
|
|
180
191
|
* Write the slow-hook notice to stderr and return it, or return null when the
|
|
181
192
|
* run was within budget (writing nothing, so the quiet path stays quiet).
|
|
@@ -228,40 +239,6 @@ export function withSlowHookNotice<V extends {
|
|
|
228
239
|
* @returns {boolean} whether a notice was emitted
|
|
229
240
|
*/
|
|
230
241
|
export function reportSlowHook(hookName: string, elapsedMs: number, hookEventName: string, emit: (event: string, fields: Record<string, unknown>) => void, writeErr?: (chunk: string) => void, context?: SlowHookContext): boolean;
|
|
231
|
-
/**
|
|
232
|
-
* The one place a hook's own cost is measured and reported — one threshold, one
|
|
233
|
-
* message, one merge rule, shared by every hook.
|
|
234
|
-
*
|
|
235
|
-
* These hooks sit on the critical path of every tool call, prompt and session
|
|
236
|
-
* start: whatever they spend, the user waits. A slow hook is also the hardest
|
|
237
|
-
* bug to notice from inside — it looks exactly like a slow agent, so it goes
|
|
238
|
-
* unreported for weeks (one SessionStart scan blocked startup for 30 SECONDS
|
|
239
|
-
* before anyone traced it back here). A hook past the budget therefore says so
|
|
240
|
-
* IN BAND, in the model's context, where it can be relayed to the operator.
|
|
241
|
-
*
|
|
242
|
-
* FOUR numbers, because wall-clock alone cannot say whose cost it is: a hook on
|
|
243
|
-
* a contended host waits far longer than it computes (a 1.1 KB payload and a
|
|
244
|
-
* 235 KB one both reported 7.2s on a loaded 2-vCPU box, against 0.3s of work).
|
|
245
|
-
* So the notice prints, beside the clock, the CPU this process burned, the time
|
|
246
|
-
* it spent inside redactor round trips, and the time it spent inside a HOST
|
|
247
|
-
* EXTENSION it called ({@link chargeHostExtension}) — the redactor daemon and a
|
|
248
|
-
* host callback's subprocess or socket peer are separate processes whose CPU this
|
|
249
|
-
* one cannot see (see {@link processCpuMs}), so the call that waits for each is
|
|
250
|
-
* the only measurable stand-in. The notice GATES on none of them: a hook wedged
|
|
251
|
-
* on a dead redactor socket burns no CPU and is exactly the sanitizer's fault.
|
|
252
|
-
*
|
|
253
|
-
* The host-extension window is what turned "blocked on something outside the
|
|
254
|
-
* sanitizer" — a verdict nobody can act on — into a named callee: a composer's
|
|
255
|
-
* best-effort audit POST to an unreachable sink charged every tool call its full
|
|
256
|
-
* 1.0s connect bound, and the notice could name none of it.
|
|
257
|
-
*
|
|
258
|
-
* ONE-TIME PROVISIONING is excluded (see {@link excludeProvisioning}): charging
|
|
259
|
-
* an install to the hook that merely waited it out would make the FIRST call of
|
|
260
|
-
* every session cry wolf, which is the alert fatigue this notice fights.
|
|
261
|
-
*
|
|
262
|
-
* Dependency-free on purpose: everything imports this, including hook-io, so a
|
|
263
|
-
* back-import would close a cycle. The one emitter it needs is passed in.
|
|
264
|
-
*/
|
|
265
242
|
/**
|
|
266
243
|
* Wall-clock a single hook invocation may spend before it is reported as slow.
|
|
267
244
|
*
|
package/types/html.d.mts
CHANGED
|
@@ -111,10 +111,19 @@ export function sanitizeHtml(text: string): {
|
|
|
111
111
|
unparseable?: true;
|
|
112
112
|
} | null;
|
|
113
113
|
/**
|
|
114
|
+
* `flagDigestValues` drops the digest exemption: an exact-digest-length hex
|
|
115
|
+
* value under a generic parameter name is reported as payload rather than read
|
|
116
|
+
* as a fingerprint. Off by default because the exemption is what keeps a
|
|
117
|
+
* cache-buster, an ETag and a commit id quiet; on for a caller whose cost of a
|
|
118
|
+
* missed 16-to-64-byte channel beats its cost of those false positives. Like
|
|
119
|
+
* every option this module takes, it can only ADD detection.
|
|
114
120
|
* @param {string} url
|
|
121
|
+
* @param {{ flagDigestValues?: boolean }} [options]
|
|
115
122
|
* @returns {string | null}
|
|
116
123
|
*/
|
|
117
|
-
export function checkExfilUrl(url: string
|
|
124
|
+
export function checkExfilUrl(url: string, options?: {
|
|
125
|
+
flagDigestValues?: boolean;
|
|
126
|
+
}): string | null;
|
|
118
127
|
/**
|
|
119
128
|
* Host of a flagged URL — enough for the warning to name the destination
|
|
120
129
|
* without echoing the payload-bearing query/fragment.
|
|
@@ -133,9 +142,12 @@ export function urlHost(url: string): string;
|
|
|
133
142
|
* link somebody has to follow. Both are reported; the caller uses it to decide
|
|
134
143
|
* how loudly (see the exfil tier in ./output.mjs).
|
|
135
144
|
* @param {string} text
|
|
145
|
+
* @param {{ flagDigestValues?: boolean }} [options] see {@link checkExfilUrl}
|
|
136
146
|
* @returns {Array<{ isImage: boolean, autoFetched: boolean, reason: string, target: string }> | null}
|
|
137
147
|
*/
|
|
138
|
-
export function detectExfil(text: string
|
|
148
|
+
export function detectExfil(text: string, options?: {
|
|
149
|
+
flagDigestValues?: boolean;
|
|
150
|
+
}): Array<{
|
|
139
151
|
isImage: boolean;
|
|
140
152
|
autoFetched: boolean;
|
|
141
153
|
reason: string;
|
package/types/index.d.mts
CHANGED
|
@@ -35,13 +35,19 @@
|
|
|
35
35
|
* legitimate markup — matching the separate flags `sanitizeText` takes for the
|
|
36
36
|
* tool-output pipeline, which needs Layer 3's detection without Layer 2's
|
|
37
37
|
* splice.
|
|
38
|
+
*
|
|
39
|
+
* `flagDigestValues` widens Layer 3 only: it drops the digest exemption, so an
|
|
40
|
+
* exact-digest-length hex value under a generic parameter name is reported as
|
|
41
|
+
* payload rather than read as a cache-buster or an ETag. Off by default, and
|
|
42
|
+
* like `exfilScan` it can only ADD detection.
|
|
38
43
|
* @param {string} text
|
|
39
|
-
* @param {{ html?: boolean, exfilScan?: boolean } | null} [options]
|
|
44
|
+
* @param {{ html?: boolean, exfilScan?: boolean, flagDigestValues?: boolean } | null} [options]
|
|
40
45
|
* @returns {Promise<{ cleaned: string, found: string[], warnings: string[], notes: string[], splices?: Array<{ placeholder: string, original: string }> }>}
|
|
41
46
|
*/
|
|
42
47
|
export function sanitize(text: string, options?: {
|
|
43
48
|
html?: boolean;
|
|
44
49
|
exfilScan?: boolean;
|
|
50
|
+
flagDigestValues?: boolean;
|
|
45
51
|
} | null): Promise<{
|
|
46
52
|
cleaned: string;
|
|
47
53
|
found: string[];
|
package/types/output.d.mts
CHANGED
|
@@ -38,6 +38,7 @@ export function deleteVerbatimSpans(text: string, spans: string[]): {
|
|
|
38
38
|
* @typedef {{
|
|
39
39
|
* html?: boolean,
|
|
40
40
|
* exfilScan?: boolean,
|
|
41
|
+
* flagDigestValues?: boolean,
|
|
41
42
|
* redact?: (text: string) => Promise<RedactResult|null> | (RedactResult|null),
|
|
42
43
|
* filterInjection?: (text: string) => Promise<Layer5Result|null> | (Layer5Result|null),
|
|
43
44
|
* sgrCarveOut?: boolean,
|
|
@@ -268,6 +269,7 @@ export type PipelineState = {
|
|
|
268
269
|
export type SanitizeTextOptions = {
|
|
269
270
|
html?: boolean;
|
|
270
271
|
exfilScan?: boolean;
|
|
272
|
+
flagDigestValues?: boolean;
|
|
271
273
|
redact?: (text: string) => Promise<RedactResult | null> | (RedactResult | null);
|
|
272
274
|
filterInjection?: (text: string) => Promise<Layer5Result | null> | (Layer5Result | null);
|
|
273
275
|
sgrCarveOut?: boolean;
|