agent-sanitizer 2.25.0 → 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 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 codes
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 `sgrNote` is always `false` since the bridge never
432
- wires `sgrCarveOut`.
452
+ filtering (Layer 5), andsince 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":"a​b"}' | 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 what keeps the warning
73
- worth reading: a stray `ESC` sitting in a file is reported as a terse note, while
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), suitable for a warning shown to the operator.
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
@@ -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(text, {
125
- html: Boolean(req.html),
126
- exfilScan: Boolean(req.exfilScan),
127
- });
128
- return { cleaned, warnings, modified, sgrNote };
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 { startHookTimer, withSlowHookNotice } from "./hook-timing.mjs";
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
- const elapsed = startHookTimer();
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 / 1000).toFixed(1)}s, over its ${(thresholdMs / 1000).toFixed(1)}s budget — ` +
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 = slowHookNotice(hookName, elapsedMs);
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 = slowHookNotice(hookName, elapsedMs);
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 (a truncated write, a log fragment,
102
- // a raw ESC sitting in a file the tool echoed back). Neither can move the cursor,
103
- // erase the screen, or open an OSC string those need a complete CSI/OSC token,
104
- // which keeps the WARNING. The note keeps the "escapes were here, and here is how
105
- // to see them" signal without the WARNING prefix, whose constant firing on inert
106
- // bytes would desensitize the reader to the strips that matter (invisible-char
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 result =
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 "display-only
304
- * ANSI color stripped", which would be a false statement about bytes a callback
305
- * has since rewritten for its own reasons.
306
- * @param {{ cleaned: string, warnings: string[], modified: boolean, sgrNote: boolean, reveal?: string }} result
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 was an SGR-only strip.
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 same mutated-accumulator
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(value, toolName, warnings, reveals, deadline, ext);
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
- // sgrNote implies modified (the carve-out lives inside the Layer-1 strip), so
780
- // it never independently survives this guard `modified` covers it.
781
- if (!modified && warnings.length === 0)
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
- // pure display-only-SGR strip (sgrNote, no warning) gets the terse note instead
794
- // of the WARNING prefix; once any real warning exists the WARNING path wins and
795
- // the color note is dropped (warnings and sgrNote can co-occur across leaves of
796
- // one tool output).
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
- ? SGR_OUTPUT_NOTE
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