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/README.md
CHANGED
|
@@ -31,16 +31,17 @@ Code](#using-it-with-claude-code) covers each hook and hand-wiring.
|
|
|
31
31
|
import { sanitize } from "agent-sanitizer";
|
|
32
32
|
|
|
33
33
|
// Layer 1 (invisible chars + ANSI), zero heavy deps:
|
|
34
|
-
const { cleaned, found, warnings } = await sanitize(untrustedText);
|
|
34
|
+
const { cleaned, found, warnings, notes } = await sanitize(untrustedText);
|
|
35
35
|
|
|
36
36
|
// Opt into the HTML layers for web ingress (lazy-loads ~200 ms of deps):
|
|
37
37
|
const result = await sanitize(pageSource, { html: true });
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
`sanitize` never throws and never silently drops content—any change comes with
|
|
41
|
-
at least one `warnings` entry. `found` names the neutralized category
|
|
42
|
-
(e.g. `["cf-format", "hidden-html"]`); `cleaned` is the safe text, with
|
|
43
|
-
placeholders where hidden HTML was spliced out.
|
|
41
|
+
at least one `warnings` or `notes` entry. `found` names the neutralized category
|
|
42
|
+
codes (e.g. `["cf-format", "hidden-html"]`); `cleaned` is the safe text, with
|
|
43
|
+
placeholders where hidden HTML was spliced out. See [warnings vs
|
|
44
|
+
notes](#warnings-vs-notes) for which findings land where.
|
|
44
45
|
|
|
45
46
|
## Entry points
|
|
46
47
|
|
|
@@ -79,6 +80,26 @@ without notice.
|
|
|
79
80
|
| `hidden-html` | Elements hidden via CSS/attribute (`display:none`, `hidden`, etc.) spliced out by Layer 2 |
|
|
80
81
|
| `exfil-urls` | Exfil-shaped URLs detected by Layer 3 (reported, not removed) |
|
|
81
82
|
|
|
83
|
+
### warnings vs notes
|
|
84
|
+
|
|
85
|
+
Findings come back at two volumes, on `sanitize` and on `/output` alike:
|
|
86
|
+
|
|
87
|
+
- **`warnings`** — injection-shaped. Something was hidden from a human reader,
|
|
88
|
+
something a payload would have used was removed, or a secret was redacted.
|
|
89
|
+
This is the set to surface.
|
|
90
|
+
- **`notes`** — it happened, and here is how to look at it, but nothing about it
|
|
91
|
+
is attack-shaped: a preserved `<script>` on a fetched page, a plain link whose
|
|
92
|
+
URL merely looks exfil-shaped, or (in `/output`, under `sgrCarveOut`) an
|
|
93
|
+
incidental strip of pasted terminal colour or a stray soft hyphen.
|
|
94
|
+
|
|
95
|
+
The tier changes nothing about what is removed—the same bytes are stripped,
|
|
96
|
+
spliced and redacted either way, and a note is still reported. It exists so the
|
|
97
|
+
banner keeps meaning something. A caller that ignores `notes` is exactly as loud
|
|
98
|
+
as before the split. `/output` also returns `sgrNote: true` when a result is
|
|
99
|
+
note-only, so a caller can pick the quiet line without inspecting the arrays.
|
|
100
|
+
[`THREAT-MODEL.md`](./THREAT-MODEL.md#severity-warnings-vs-notes) lists which
|
|
101
|
+
finding lands at which tier and why.
|
|
102
|
+
|
|
82
103
|
### `FILTER_WARNING` codes (Layer 5)
|
|
83
104
|
|
|
84
105
|
The Layer-5 `filterInjection` seam is deliberately thin: the filter may only
|
|
@@ -428,8 +449,9 @@ field selects the entry point (default `sanitize`); the self-contained ones —
|
|
|
428
449
|
`sanitizeText`, `classifyPrompt`, `scanInstructionFiles`, `cleanFile` — are
|
|
429
450
|
bridged, while entry points taking a JS callback have no wire form. Bridged
|
|
430
451
|
`sanitizeText` runs Layers 1–3 only: no secret redaction (Layer 4), no injection
|
|
431
|
-
filtering (Layer 5), and
|
|
432
|
-
|
|
452
|
+
filtering (Layer 5), and—since the bridge never wires `sgrCarveOut`—Layer 1's
|
|
453
|
+
findings are never downgraded, so `notes` carries only the Layer-2/3 tiers and
|
|
454
|
+
`sgrNote` is `true` only when those were the whole story.
|
|
433
455
|
|
|
434
456
|
```sh
|
|
435
457
|
echo '{"text":"ab"}' | npx sanitize-cli # default op: sanitize
|
package/THREAT-MODEL.md
CHANGED
|
@@ -69,8 +69,9 @@ it exactly—the idempotence the Edit-repair rehydrator's soundness gate assumes
|
|
|
69
69
|
One tokenizer answers every ANSI question (what to splice, and whether what was
|
|
70
70
|
removed was INERT—display-only SGR colour, or a lone 7-bit `ESC` that opened
|
|
71
71
|
nothing at all), so the stripper and the operator warning cannot disagree about
|
|
72
|
-
what a sequence is. That inert/injection-shaped split is
|
|
73
|
-
|
|
72
|
+
what a sequence is. That inert/injection-shaped split is one input to the
|
|
73
|
+
[severity tier](#severity-warnings-vs-notes) that keeps the warning worth
|
|
74
|
+
reading: a stray `ESC` sitting in a file is reported as a terse note, while
|
|
74
75
|
a cursor move, an erase, an OSC string, or a raw C1 introducer (which no
|
|
75
76
|
legitimate UTF-8 text carries, and which includes the DCS/SOS/PM/APC string
|
|
76
77
|
introducers) keeps the WARNING. An `ESC` that _opened_ a CSI it never completed
|
|
@@ -122,7 +123,17 @@ attributes (`src`/`href`/`background`/`srcset`/`ping`, form `action`/`formaction
|
|
|
122
123
|
- `javascript:` / `vbscript:` targets
|
|
123
124
|
|
|
124
125
|
Each threat carries a `reason` and the destination `target` (never the
|
|
125
|
-
payload-bearing query/fragment)
|
|
126
|
+
payload-bearing query/fragment) — the finding is shown to the operator with the
|
|
127
|
+
target named and the payload withheld, since re-presenting the exfil payload in
|
|
128
|
+
the model's context would hand the model the very bytes the finding is about.
|
|
129
|
+
|
|
130
|
+
It also carries `autoFetched`, which is what its [severity](#severity-warnings-vs-notes)
|
|
131
|
+
turns on: an `<img src>`, a stylesheet `<link>`, a `srcset`, a `ping`, a form
|
|
132
|
+
`action` or a `meta refresh` exfiltrates the moment the content renders, with
|
|
133
|
+
nobody deciding anything, while an `<a href>` or a markdown link cannot until
|
|
134
|
+
the model chooses to follow it — and the sentence reporting it is precisely the
|
|
135
|
+
instruction not to. A target whose kind cannot be resolved is treated as
|
|
136
|
+
auto-fetched (fail closed).
|
|
126
137
|
|
|
127
138
|
## Confusable folding (tool input)
|
|
128
139
|
|
|
@@ -309,6 +320,47 @@ positive costs a sentence of context, never a mangled input):
|
|
|
309
320
|
committed content survives, and probing index-vs-worktree state would trade
|
|
310
321
|
precision for recall.
|
|
311
322
|
|
|
323
|
+
## Severity: warnings vs notes
|
|
324
|
+
|
|
325
|
+
Findings come back split into two tiers, and the split is a security property in
|
|
326
|
+
its own right — a detector whose banner fires on every ordinary page teaches its
|
|
327
|
+
reader to skip the banner, and then the one that mattered scrolls past with it.
|
|
328
|
+
|
|
329
|
+
| Tier | Means |
|
|
330
|
+
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
331
|
+
| **WARNING** | This text is injection-shaped: something was hidden from a human reader, something a payload would have used was removed, or a secret was redacted. |
|
|
332
|
+
| **NOTE** | This happened, and here is how to look at it, but nothing about it is attack-shaped: incidental bytes, or content that was PRESERVED and merely described. |
|
|
333
|
+
|
|
334
|
+
The tier never changes what the pipeline **does**. The same bytes are stripped,
|
|
335
|
+
spliced and redacted either way, and a note is still reported — all that rides on
|
|
336
|
+
it is which banner the operator sees. That asymmetry is why a note is the right
|
|
337
|
+
answer whenever the evidence is thin: an under-loud true finding is still
|
|
338
|
+
delivered, while an over-loud false one costs the channel its credibility.
|
|
339
|
+
|
|
340
|
+
Four decisions currently land at NOTE:
|
|
341
|
+
|
|
342
|
+
- **An incidental Layer-1 strip** — inert ANSI (display-only SGR colour, or a
|
|
343
|
+
lone `ESC` that opened nothing) together with too few invisible characters to
|
|
344
|
+
spell anything. Both axes must be incidental; a cursor move, an erase, an OSC,
|
|
345
|
+
a raw C1 introducer or a payload-length run of invisibles keeps the WARNING.
|
|
346
|
+
- **A preserved scripting/resource tag** (Layer 2) — nothing was removed and
|
|
347
|
+
nothing was hidden, and a `<script>` is on essentially every page ever fetched.
|
|
348
|
+
The Layer-2 **splice** stays a WARNING: those bytes were invisible to a human
|
|
349
|
+
reading the rendered page.
|
|
350
|
+
- **An exfil-shaped URL that is not auto-fetched** (Layer 3) — see above.
|
|
351
|
+
- **The prompt gate's inert-escape carve-out**, which predates the tier and is
|
|
352
|
+
the same judgement (see [User-prompt verdict](#user-prompt-verdict)).
|
|
353
|
+
|
|
354
|
+
Layer 4 (a redacted secret) and Layer 5 (a filter finding) are always WARNINGs.
|
|
355
|
+
|
|
356
|
+
The Layer-1 downgrade is gated on the caller asserting first-party ingress
|
|
357
|
+
(`sgrCarveOut` in `./output`, set for local tool output). Without it — the
|
|
358
|
+
`./sanitize` door, a fetched page, an MCP connector — Layer 1 stays loud however
|
|
359
|
+
few the bytes, because that is the channel where a hidden character was _put_
|
|
360
|
+
there. The `sgrNote` flag on a `./output` result means "nothing here rose above a
|
|
361
|
+
note", so a caller can show the quiet line instead of the banner; one warning
|
|
362
|
+
anywhere in the walk clears it.
|
|
363
|
+
|
|
312
364
|
## Failure posture (`AGENT_SANITIZER_FAIL_OPEN`)
|
|
313
365
|
|
|
314
366
|
Installed as Claude Code hooks, these fail **open**: a hook that could not
|
package/bin/sanitize-cli.mjs
CHANGED
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* Protocol — a request is a JSON object with an `op` (default `"sanitize"` so a
|
|
15
15
|
* bare `{ text, html }` keeps working). Per op:
|
|
16
16
|
*
|
|
17
|
-
* sanitize { text, html? } -> { cleaned, found, warnings }
|
|
18
|
-
* sanitizeText { text, html?, exfilScan? } -> { cleaned, warnings, modified, sgrNote }
|
|
17
|
+
* sanitize { text, html? } -> { cleaned, found, warnings, notes }
|
|
18
|
+
* sanitizeText { text, html?, exfilScan? } -> { cleaned, warnings, notes, modified, sgrNote }
|
|
19
19
|
* classifyPrompt { text } -> { action, reason? }
|
|
20
20
|
* scanInstructionFiles { globs, cwd? } -> { findings: [{ file, findings }] }
|
|
21
21
|
* cleanFile { path } -> { changed }
|
|
@@ -109,10 +109,10 @@ const OPS = {
|
|
|
109
109
|
/** @param {Record<string, unknown>} req */
|
|
110
110
|
async sanitize(req) {
|
|
111
111
|
const text = requireString(req, "text");
|
|
112
|
-
const { cleaned, found, warnings } = await sanitize(text, {
|
|
112
|
+
const { cleaned, found, warnings, notes } = await sanitize(text, {
|
|
113
113
|
html: Boolean(req.html),
|
|
114
114
|
});
|
|
115
|
-
return { cleaned, found, warnings };
|
|
115
|
+
return { cleaned, found, warnings, notes };
|
|
116
116
|
},
|
|
117
117
|
|
|
118
118
|
/** @param {Record<string, unknown>} req */
|
|
@@ -121,11 +121,14 @@ const OPS = {
|
|
|
121
121
|
// Layers 1–3 only: redact (Layer 4) and filterInjection (Layer 5) are
|
|
122
122
|
// injected JS callbacks with no wire form, so they're never set here.
|
|
123
123
|
const { sanitizeText } = await import("../src/output.mjs");
|
|
124
|
-
const { cleaned, warnings, modified, sgrNote } = await sanitizeText(
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
124
|
+
const { cleaned, warnings, notes, modified, sgrNote } = await sanitizeText(
|
|
125
|
+
text,
|
|
126
|
+
{
|
|
127
|
+
html: Boolean(req.html),
|
|
128
|
+
exfilScan: Boolean(req.exfilScan),
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
return { cleaned, warnings, notes, modified, sgrNote };
|
|
129
132
|
},
|
|
130
133
|
|
|
131
134
|
/** @param {Record<string, unknown>} req */
|
|
@@ -17,7 +17,11 @@ import {
|
|
|
17
17
|
probeSetupAlive,
|
|
18
18
|
readStdinJson,
|
|
19
19
|
} from "./hook-io.mjs";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
startHookTimer,
|
|
22
|
+
withSlowHookNotice,
|
|
23
|
+
writeSlowHookNotice,
|
|
24
|
+
} from "./hook-timing.mjs";
|
|
21
25
|
|
|
22
26
|
// Loaded via a *caught* dynamic import — never a bare static `import … from`.
|
|
23
27
|
// A static npm import resolves before any try/catch, so a missing node_modules
|
|
@@ -156,12 +160,19 @@ export async function runJudgeCli(
|
|
|
156
160
|
},
|
|
157
161
|
) {
|
|
158
162
|
let input;
|
|
163
|
+
// Hoisted out of the try so the CATCH can read it: a judge that spent thirty
|
|
164
|
+
// seconds and THEN threw is the slow run most worth naming, and reporting only
|
|
165
|
+
// on the success path hides exactly the case where the hook is both slow and
|
|
166
|
+
// broken. Null until stdin arrives — a read that throws measured nothing, and
|
|
167
|
+
// an invented number is worse than none.
|
|
168
|
+
/** @type {(() => number) | null} */
|
|
169
|
+
let elapsed = null;
|
|
159
170
|
try {
|
|
160
171
|
input = await readInput();
|
|
161
172
|
// Timed from HERE, not from process start: the wait for the harness to hand
|
|
162
173
|
// over stdin is not this hook's cost, and blaming it for one would send
|
|
163
174
|
// operators chasing a bug report that is not theirs to fix.
|
|
164
|
-
|
|
175
|
+
elapsed = startHookTimer();
|
|
165
176
|
const { claudeAdapter: adapter } = controlPlane();
|
|
166
177
|
const event = adapter.parse(transformInput(input));
|
|
167
178
|
// Awaited into its own binding first: as an inline argument, `elapsed()`
|
|
@@ -173,6 +184,11 @@ export async function runJudgeCli(
|
|
|
173
184
|
if (out !== null) write(out);
|
|
174
185
|
} catch (err) {
|
|
175
186
|
process.stderr.write(`${hookName} hook error: ${errMessage(err)}\n`);
|
|
187
|
+
// After the error line, before the posture: the fault is what the reader
|
|
188
|
+
// must act on first, and the timing is context for it. stderr only — the
|
|
189
|
+
// model-facing channel here belongs to onError's fail-closed message, and a
|
|
190
|
+
// performance aside must not dilute a "this output was never vetted".
|
|
191
|
+
if (elapsed !== null) writeSlowHookNotice(hookName, elapsed());
|
|
176
192
|
onError(err, input);
|
|
177
193
|
}
|
|
178
194
|
}
|
|
@@ -34,10 +34,41 @@
|
|
|
34
34
|
*/
|
|
35
35
|
export const SLOW_HOOK_THRESHOLD_MS = 1000;
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Wall-clock a ONE-TIME provisioning step may spend before it is reported.
|
|
39
|
+
*
|
|
40
|
+
* Two orders of magnitude above {@link SLOW_HOOK_THRESHOLD_MS}, because it
|
|
41
|
+
* measures something categorically different: a dependency install that a
|
|
42
|
+
* session pays once, not a cost every tool call repeats. A cold `uv` install of
|
|
43
|
+
* the redactor engine is seconds and a cold `pip` one can be tens of them, so a
|
|
44
|
+
* budget anywhere near a second would report every first session — the alert
|
|
45
|
+
* fatigue this whole module exists to avoid. Past a minute, something is
|
|
46
|
+
* actually wrong (a serial pip resolve, a wedged mirror, or an idempotence bug
|
|
47
|
+
* re-provisioning every session), which is worth saying out loud.
|
|
48
|
+
*/
|
|
49
|
+
export const SLOW_PROVISION_THRESHOLD_MS = 60000;
|
|
50
|
+
|
|
37
51
|
/** Where a reader is asked to send the timing. */
|
|
38
52
|
const ISSUE_URL =
|
|
39
53
|
"https://github.com/AlexanderMattTurner/agent-sanitizer/issues/new";
|
|
40
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Milliseconds as the seconds string every notice below prints.
|
|
57
|
+
*
|
|
58
|
+
* Rounds tenths half-UP from an exact integer count of hundredths, rather than
|
|
59
|
+
* `(ms / 1000).toFixed(1)`: the shell port of this module
|
|
60
|
+
* (plugin/scripts/lib/hook-timing.sh) has to produce the byte-identical string
|
|
61
|
+
* with integer arithmetic, and `toFixed` rounds the underlying double — so 1150
|
|
62
|
+
* would print "1.1" here (1.15 is below its decimal value as a double) and "1.2"
|
|
63
|
+
* there. `ms / 100` lands exactly on a half only when `ms` ends in 50, and every
|
|
64
|
+
* such quotient is dyadic, so this rounding is exact for every input.
|
|
65
|
+
* @param {number} ms
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
export function formatSeconds(ms) {
|
|
69
|
+
return (Math.round(ms / 100) / 10).toFixed(1);
|
|
70
|
+
}
|
|
71
|
+
|
|
41
72
|
// Process-wide total of milliseconds spent in one-time provisioning. A running
|
|
42
73
|
// total rather than a flag because a single hook run can pay more than one (a
|
|
43
74
|
// dependency wait AND a cold daemon spawn), and they may not nest.
|
|
@@ -103,12 +134,72 @@ export function slowHookNotice(
|
|
|
103
134
|
if (elapsedMs <= thresholdMs) return null;
|
|
104
135
|
return (
|
|
105
136
|
`agent-sanitizer PERFORMANCE: the ${hookName} hook took ` +
|
|
106
|
-
`${(elapsedMs
|
|
137
|
+
`${formatSeconds(elapsedMs)}s, over its ${formatSeconds(thresholdMs)}s budget — ` +
|
|
107
138
|
"this delay is the sanitizer's, not the model's, and every affected call pays it. " +
|
|
108
139
|
`Tell the user, and suggest they report it at ${ISSUE_URL} with the hook name and timing.`
|
|
109
140
|
);
|
|
110
141
|
}
|
|
111
142
|
|
|
143
|
+
/**
|
|
144
|
+
* The line for a ONE-TIME provisioning step that overran
|
|
145
|
+
* {@link SLOW_PROVISION_THRESHOLD_MS}, or null when it did not.
|
|
146
|
+
*
|
|
147
|
+
* Deliberately NOT {@link slowHookNotice} with a bigger threshold: that message
|
|
148
|
+
* says "every affected call pays it", which is false here and would send the
|
|
149
|
+
* reader hunting a per-call cost that does not exist. What is actionable about a
|
|
150
|
+
* slow install is the installer (uv resolves in a fraction of pip's time) and
|
|
151
|
+
* the fact that a repeat means the idempotence check is broken — so this asks
|
|
152
|
+
* for a report only on the repeat, which is the version of this that is a bug.
|
|
153
|
+
*
|
|
154
|
+
* The one caller is the shell provisioner, whose port of this module
|
|
155
|
+
* (plugin/scripts/lib/hook-timing.sh) must emit this exact string; that port and
|
|
156
|
+
* this definition are pinned to each other by a contract test rather than left
|
|
157
|
+
* as two independently-worded copies.
|
|
158
|
+
* @param {string} stepName
|
|
159
|
+
* @param {number} elapsedMs
|
|
160
|
+
* @param {number} [thresholdMs]
|
|
161
|
+
* @returns {string | null}
|
|
162
|
+
*/
|
|
163
|
+
export function slowProvisionNotice(
|
|
164
|
+
stepName,
|
|
165
|
+
elapsedMs,
|
|
166
|
+
thresholdMs = SLOW_PROVISION_THRESHOLD_MS,
|
|
167
|
+
) {
|
|
168
|
+
if (elapsedMs <= thresholdMs) return null;
|
|
169
|
+
return (
|
|
170
|
+
`agent-sanitizer PERFORMANCE: one-time setup (${stepName}) took ` +
|
|
171
|
+
`${formatSeconds(elapsedMs)}s, over its ${formatSeconds(thresholdMs)}s budget — ` +
|
|
172
|
+
"this is paid once per install, not per tool call, so the session is not slow from here on. " +
|
|
173
|
+
`Installing uv makes it faster; if it happens on EVERY new session, report it at ${ISSUE_URL}.`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Write the slow-hook notice to stderr and return it, or return null when the
|
|
179
|
+
* run was within budget (writing nothing, so the quiet path stays quiet).
|
|
180
|
+
*
|
|
181
|
+
* The one place the notice reaches stderr: every reporter below needs the
|
|
182
|
+
* transcript copy, and a hook whose run ENDED IN AN ERROR has nothing but this —
|
|
183
|
+
* its verdict is the fail-closed one its `onError` composed, and diluting that
|
|
184
|
+
* message with a performance aside would bury the fault. A judge that spent
|
|
185
|
+
* thirty seconds and then threw is exactly the case the timing exists to name,
|
|
186
|
+
* so the error path measures and reports; it just reports on the human channel.
|
|
187
|
+
* @param {string} hookName
|
|
188
|
+
* @param {number} elapsedMs
|
|
189
|
+
* @param {(chunk: string) => void} [writeErr] injectable stderr sink, for tests
|
|
190
|
+
* @returns {string | null}
|
|
191
|
+
*/
|
|
192
|
+
export function writeSlowHookNotice(
|
|
193
|
+
hookName,
|
|
194
|
+
elapsedMs,
|
|
195
|
+
writeErr = (chunk) => process.stderr.write(chunk),
|
|
196
|
+
) {
|
|
197
|
+
const notice = slowHookNotice(hookName, elapsedMs);
|
|
198
|
+
if (notice === null) return null;
|
|
199
|
+
writeErr(notice + "\n");
|
|
200
|
+
return notice;
|
|
201
|
+
}
|
|
202
|
+
|
|
112
203
|
/**
|
|
113
204
|
* `verdict` with the slow-hook notice folded into its `additional_context`, or
|
|
114
205
|
* the verdict untouched when the run was within budget. Also writes the notice
|
|
@@ -130,9 +221,8 @@ export function withSlowHookNotice(
|
|
|
130
221
|
verdict,
|
|
131
222
|
writeErr = (chunk) => process.stderr.write(chunk),
|
|
132
223
|
) {
|
|
133
|
-
const notice =
|
|
224
|
+
const notice = writeSlowHookNotice(hookName, elapsedMs, writeErr);
|
|
134
225
|
if (notice === null) return verdict;
|
|
135
|
-
writeErr(notice + "\n");
|
|
136
226
|
return {
|
|
137
227
|
...verdict,
|
|
138
228
|
additional_context: verdict.additional_context
|
|
@@ -162,9 +252,8 @@ export function reportSlowHook(
|
|
|
162
252
|
emit,
|
|
163
253
|
writeErr = (chunk) => process.stderr.write(chunk),
|
|
164
254
|
) {
|
|
165
|
-
const notice =
|
|
255
|
+
const notice = writeSlowHookNotice(hookName, elapsedMs, writeErr);
|
|
166
256
|
if (notice === null) return false;
|
|
167
|
-
writeErr(notice + "\n");
|
|
168
257
|
emit(hookEventName, { additionalContext: notice });
|
|
169
258
|
return true;
|
|
170
259
|
}
|
|
@@ -98,13 +98,12 @@ const SANITIZE_BUDGET_MS = positiveMsOr(
|
|
|
98
98
|
|
|
99
99
|
// Non-WARNING note for a strip whose only change was INERT ANSI on a local tool:
|
|
100
100
|
// the display-only colour git/pytest/npm/etc. emit by default, and/or a stray
|
|
101
|
-
// escape byte that formed no sequence at all
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
// payloads, redacted secrets).
|
|
101
|
+
// escape byte that formed no sequence at all. The engine now returns this text
|
|
102
|
+
// itself, as a NOTE-severity finding alongside the warnings, so this copy is the
|
|
103
|
+
// FALLBACK for exactly one case: a bundle built against a pinned engine older
|
|
104
|
+
// than that severity split, whose result carries `sgrNote` but no `notes`. Same
|
|
105
|
+
// sentence, so a plugin on the old pin keeps today's wording instead of falling
|
|
106
|
+
// back to a bare "output sanitized".
|
|
108
107
|
const SGR_OUTPUT_NOTE =
|
|
109
108
|
"Inert ANSI stripped (display-only colour and/or a stray escape byte that " +
|
|
110
109
|
"formed no control sequence); pipe through cat -v to inspect raw escapes.";
|
|
@@ -228,7 +227,7 @@ async function redactSecrets(text, webIngress = false, deadline) {
|
|
|
228
227
|
* @param {{remainingMs: () => number}} [deadline] shared wall-clock budget across
|
|
229
228
|
* all leaves of one hook run; a direct caller gets a fresh full budget
|
|
230
229
|
* @param {SanitizeExtensions} [ext]
|
|
231
|
-
* @returns {Promise<{ cleaned: string, warnings: string[], modified: boolean, sgrNote: boolean, reveal?: string }>}
|
|
230
|
+
* @returns {Promise<{ cleaned: string, warnings: string[], notes: string[], modified: boolean, sgrNote: boolean, reveal?: string }>}
|
|
232
231
|
*/
|
|
233
232
|
export async function sanitizeText(
|
|
234
233
|
text,
|
|
@@ -280,10 +279,15 @@ export async function sanitizeText(
|
|
|
280
279
|
: { text: secrets.text, found: secrets.found };
|
|
281
280
|
},
|
|
282
281
|
};
|
|
283
|
-
const
|
|
284
|
-
/** @type {{ cleaned: string, warnings: string[], modified: boolean, sgrNote: boolean, reveal?: string }} */ (
|
|
282
|
+
const seamResult =
|
|
283
|
+
/** @type {{ cleaned: string, warnings: string[], notes?: string[], modified: boolean, sgrNote: boolean, reveal?: string }} */ (
|
|
285
284
|
await sanitizeTextSeam(text, seamOptions)
|
|
286
285
|
);
|
|
286
|
+
// The one place the seam's shape is normalized: `notes` is absent when the
|
|
287
|
+
// engine predates the severity split, which is the shipped plugin's pinned
|
|
288
|
+
// case (see SGR_OUTPUT_NOTE). Defaulting here means nothing downstream has to
|
|
289
|
+
// know that, and the banner composer sees one shape either way.
|
|
290
|
+
const result = { ...seamResult, notes: seamResult.notes ?? [] };
|
|
287
291
|
return ext.postText
|
|
288
292
|
? applyPostText(
|
|
289
293
|
result,
|
|
@@ -300,12 +304,13 @@ export async function sanitizeText(
|
|
|
300
304
|
* Fold a `postText` callback's result into the seam's, leaving the seam's result
|
|
301
305
|
* untouched when the callback declined (null/undefined) or returned no `cleaned`.
|
|
302
306
|
* `modified` widens to cover the callback's rewrite, and `sgrNote` is dropped
|
|
303
|
-
* when it does: that flag downgrades the model-facing banner to
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
+
* when it does: that flag downgrades the model-facing banner to the seam's
|
|
308
|
+
* notes, which would be a false account of bytes a callback has since rewritten
|
|
309
|
+
* for its own reasons. A callback's `warning` is taken at face value as a
|
|
310
|
+
* WARNING — the composer that linked it owns its wording and its volume alike.
|
|
311
|
+
* @param {{ cleaned: string, warnings: string[], notes: string[], modified: boolean, sgrNote: boolean, reveal?: string }} result
|
|
307
312
|
* @param {{ cleaned?: string, warning?: string } | null | undefined} post
|
|
308
|
-
* @returns {{ cleaned: string, warnings: string[], modified: boolean, sgrNote: boolean, reveal?: string }}
|
|
313
|
+
* @returns {{ cleaned: string, warnings: string[], notes: string[], modified: boolean, sgrNote: boolean, reveal?: string }}
|
|
309
314
|
*/
|
|
310
315
|
function applyPostText(result, post) {
|
|
311
316
|
if (post === null || post === undefined) return result;
|
|
@@ -332,10 +337,10 @@ function applyPostText(result, post) {
|
|
|
332
337
|
* too (a connector can hide a secret in a field name); non-string leaves
|
|
333
338
|
* (booleans, numbers, null) pass through untouched, and `warnings` accumulates
|
|
334
339
|
* across leaves.
|
|
335
|
-
* `sgrNote` is the OR across leaves: true when some leaf
|
|
340
|
+
* `sgrNote` is the OR across leaves: true when some leaf came back note-only.
|
|
336
341
|
* `reveals` accumulates each leaf's pre-Layer-2 text (when the HTML splice
|
|
337
|
-
* removed something) for the orchestrator to persist
|
|
338
|
-
* shape as `warnings`.
|
|
342
|
+
* removed something) for the orchestrator to persist, and `notes` the leaves'
|
|
343
|
+
* NOTE-severity findings — same mutated-accumulator shape as `warnings`.
|
|
339
344
|
* @param {any} value
|
|
340
345
|
* @param {string} toolName
|
|
341
346
|
* @param {string[]} warnings
|
|
@@ -343,6 +348,8 @@ function applyPostText(result, post) {
|
|
|
343
348
|
* @param {{remainingMs: () => number}} [deadline] shared wall-clock budget across
|
|
344
349
|
* every leaf of this value (created once by the top-level caller)
|
|
345
350
|
* @param {SanitizeExtensions} [ext]
|
|
351
|
+
* @param {string[]} [notes] appended last so an existing caller's positional
|
|
352
|
+
* arguments keep their meaning
|
|
346
353
|
* @returns {Promise<{ value: any, modified: boolean, sgrNote: boolean }>}
|
|
347
354
|
*/
|
|
348
355
|
export async function sanitizeValue(
|
|
@@ -352,10 +359,12 @@ export async function sanitizeValue(
|
|
|
352
359
|
reveals = [],
|
|
353
360
|
deadline = makeDeadline(SANITIZE_BUDGET_MS),
|
|
354
361
|
ext = {},
|
|
362
|
+
notes = [],
|
|
355
363
|
) {
|
|
356
364
|
if (typeof value === "string") {
|
|
357
365
|
const result = await sanitizeText(value, toolName, deadline, ext);
|
|
358
366
|
warnings.push(...result.warnings);
|
|
367
|
+
notes.push(...result.notes);
|
|
359
368
|
if (result.reveal !== undefined) reveals.push(result.reveal);
|
|
360
369
|
return {
|
|
361
370
|
value: result.cleaned,
|
|
@@ -375,6 +384,7 @@ export async function sanitizeValue(
|
|
|
375
384
|
reveals,
|
|
376
385
|
deadline,
|
|
377
386
|
ext,
|
|
387
|
+
notes,
|
|
378
388
|
);
|
|
379
389
|
out.push(result.value);
|
|
380
390
|
if (result.modified) modified = true;
|
|
@@ -383,7 +393,15 @@ export async function sanitizeValue(
|
|
|
383
393
|
return { value: out, modified, sgrNote };
|
|
384
394
|
}
|
|
385
395
|
if (value !== null && typeof value === "object")
|
|
386
|
-
return sanitizeObject(
|
|
396
|
+
return sanitizeObject(
|
|
397
|
+
value,
|
|
398
|
+
toolName,
|
|
399
|
+
warnings,
|
|
400
|
+
reveals,
|
|
401
|
+
deadline,
|
|
402
|
+
ext,
|
|
403
|
+
notes,
|
|
404
|
+
);
|
|
387
405
|
return { value, modified: false, sgrNote: false };
|
|
388
406
|
}
|
|
389
407
|
|
|
@@ -398,6 +416,7 @@ export async function sanitizeValue(
|
|
|
398
416
|
* @param {string[]} reveals
|
|
399
417
|
* @param {{remainingMs: () => number}} deadline shared wall-clock budget
|
|
400
418
|
* @param {SanitizeExtensions} ext
|
|
419
|
+
* @param {string[]} notes accumulates the leaves' NOTE-severity findings
|
|
401
420
|
* @returns {Promise<{ value: Record<string, any>, modified: boolean, sgrNote: boolean }>}
|
|
402
421
|
*/
|
|
403
422
|
async function sanitizeObject(
|
|
@@ -407,6 +426,7 @@ async function sanitizeObject(
|
|
|
407
426
|
reveals,
|
|
408
427
|
deadline,
|
|
409
428
|
ext,
|
|
429
|
+
notes,
|
|
410
430
|
) {
|
|
411
431
|
/** @type {Record<string, any>} */
|
|
412
432
|
const out = {};
|
|
@@ -420,6 +440,7 @@ async function sanitizeObject(
|
|
|
420
440
|
// value leaves.
|
|
421
441
|
const keyResult = await sanitizeText(key, toolName, deadline);
|
|
422
442
|
warnings.push(...keyResult.warnings);
|
|
443
|
+
notes.push(...keyResult.notes);
|
|
423
444
|
if (keyResult.reveal !== undefined) reveals.push(keyResult.reveal);
|
|
424
445
|
if (keyResult.modified) modified = true;
|
|
425
446
|
if (keyResult.sgrNote) sgrNote = true;
|
|
@@ -430,6 +451,7 @@ async function sanitizeObject(
|
|
|
430
451
|
reveals,
|
|
431
452
|
deadline,
|
|
432
453
|
ext,
|
|
454
|
+
notes,
|
|
433
455
|
);
|
|
434
456
|
// Two distinct raw keys can sanitize to the same name (e.g. `token` and a
|
|
435
457
|
// `token` carrying a zero-width space stripped by Layer 1). Overwriting would
|
|
@@ -725,6 +747,8 @@ export async function evaluateToolOutput(input, ext = {}) {
|
|
|
725
747
|
/** @type {string[]} */
|
|
726
748
|
const warnings = [];
|
|
727
749
|
/** @type {string[]} */
|
|
750
|
+
const notes = [];
|
|
751
|
+
/** @type {string[]} */
|
|
728
752
|
const reveals = [];
|
|
729
753
|
// One shared wall-clock budget for every blocking daemon call this hook makes —
|
|
730
754
|
// across all leaves of the walk AND the reveal-redaction loop below — so their
|
|
@@ -741,6 +765,7 @@ export async function evaluateToolOutput(input, ext = {}) {
|
|
|
741
765
|
reveals,
|
|
742
766
|
deadline,
|
|
743
767
|
ext,
|
|
768
|
+
notes,
|
|
744
769
|
);
|
|
745
770
|
// Persist each leaf's pre-Layer-2 text (deduped by content) so the model can
|
|
746
771
|
// Read back what the HTML splice removed; a successful write appends a hint
|
|
@@ -776,9 +801,13 @@ export async function evaluateToolOutput(input, ext = {}) {
|
|
|
776
801
|
containsPlaceholder(toolOutput)
|
|
777
802
|
)
|
|
778
803
|
warnings.push(ON_DISK_PLACEHOLDER_WARNING);
|
|
779
|
-
//
|
|
780
|
-
//
|
|
781
|
-
|
|
804
|
+
// `notes` is part of the guard, not covered by `modified`. The Layer-1
|
|
805
|
+
// carve-out that used to be the only note DID imply a strip, but the
|
|
806
|
+
// detect-only tiers do not: a preserved `<script>` and a plain-link exfil URL
|
|
807
|
+
// change no bytes and raise no warning, so without this clause the walk would
|
|
808
|
+
// return `clean` and the note would not be quieter — it would be GONE, taking
|
|
809
|
+
// "do not fetch, relay, or embed these URLs" with it.
|
|
810
|
+
if (!modified && warnings.length === 0 && notes.length === 0)
|
|
782
811
|
return revealRead
|
|
783
812
|
? emit("flagged", { additional_context: REVEAL_READ_ENVELOPE })
|
|
784
813
|
: emit("clean", null);
|
|
@@ -790,13 +819,16 @@ export async function evaluateToolOutput(input, ext = {}) {
|
|
|
790
819
|
// the model's view, not the side effects. Detect-only findings (preserved
|
|
791
820
|
// scripting tags, exfil-shaped URLs) carry warnings with no text change; they
|
|
792
821
|
// emit additional_context alone, leaving the output as the tool produced it. A
|
|
793
|
-
//
|
|
794
|
-
//
|
|
795
|
-
//
|
|
796
|
-
//
|
|
822
|
+
// note-only result (some leaf reported, none of it injection-shaped) gets the
|
|
823
|
+
// seam's own note text instead of the WARNING prefix — including the
|
|
824
|
+
// detect-only ones above, which reach here with `modified === false` and land
|
|
825
|
+
// on the `flagged` verdict below; once any real warning
|
|
826
|
+
// exists the WARNING path wins and the notes are dropped (warnings and notes
|
|
827
|
+
// can co-occur across leaves of one tool output, and the reader who has a
|
|
828
|
+
// hidden-HTML splice to read about does not also need the colour codes).
|
|
797
829
|
const baseContext =
|
|
798
830
|
sgrNote && warnings.length === 0
|
|
799
|
-
?
|
|
831
|
+
? noteContext(notes)
|
|
800
832
|
: composeContext(modified, warnings, input.tool_name);
|
|
801
833
|
const additionalContext = revealRead
|
|
802
834
|
? `${REVEAL_READ_ENVELOPE} ${baseContext}`
|
|
@@ -807,6 +839,20 @@ export async function evaluateToolOutput(input, ext = {}) {
|
|
|
807
839
|
return emit(modified ? "modified" : "flagged", fields);
|
|
808
840
|
}
|
|
809
841
|
|
|
842
|
+
/**
|
|
843
|
+
* The model-facing line for a note-only result: the seam's own note text,
|
|
844
|
+
* deduped and joined, with no WARNING prefix.
|
|
845
|
+
*
|
|
846
|
+
* Empty only against a pinned engine that predates the severity split (see
|
|
847
|
+
* SGR_OUTPUT_NOTE): there `sgrNote` still arrives true with no `notes` to go
|
|
848
|
+
* with it, and printing nothing would drop the one thing that run had to say.
|
|
849
|
+
* @param {string[]} notes
|
|
850
|
+
* @returns {string}
|
|
851
|
+
*/
|
|
852
|
+
function noteContext(notes) {
|
|
853
|
+
return notes.length === 0 ? SGR_OUTPUT_NOTE : [...new Set(notes)].join(" ");
|
|
854
|
+
}
|
|
855
|
+
|
|
810
856
|
/**
|
|
811
857
|
* Judge a normalized PostToolUse event: run the sanitization pipeline and
|
|
812
858
|
* express its outcome as a control-plane Verdict. sanitize-output only ever
|