agent-sanitizer 2.23.2 → 2.24.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/src/ansi.mjs CHANGED
@@ -80,7 +80,7 @@ const ST_C1 = 0x9c;
80
80
  const OSC_C1 = 0x9d;
81
81
  const BEL = 0x07;
82
82
 
83
- /** The four things an introducer can turn out to be. */
83
+ /** The six things an introducer can turn out to be. */
84
84
  export const TOKEN_KIND = Object.freeze({
85
85
  /** A display-only `ESC[…m` / `U+009B…m` colour sequence. */
86
86
  SGR: "sgr",
@@ -88,10 +88,70 @@ export const TOKEN_KIND = Object.freeze({
88
88
  CSI: "csi",
89
89
  /** An OSC string: introducer, body and terminator as one unit. */
90
90
  OSC: "osc",
91
- /** An introducer that starts no sequence the grammar recognizes. */
91
+ /**
92
+ * A 7-bit `ESC` that starts no sequence the grammar recognizes — a truncated
93
+ * write, a log fragment cut mid-escape, a stray byte living in a file.
94
+ */
92
95
  ORPHAN: "orphan-introducer",
96
+ /**
97
+ * A 7-bit `ESC` that OPENS a CSI (`ESC [`) it never completes. Split from
98
+ * {@link TOKEN_KIND.ORPHAN} because a terminal's CSI parser is STATEFUL: it
99
+ * keeps consuming what follows as parameters and intermediates until a final
100
+ * byte (0x40-0x7E) arrives, so `hello ESC[12 world` renders as `hello orld`
101
+ * — the ` w` is eaten as the sequence's intermediate and final. That is the
102
+ * model-sees/human-sees divergence the gate exists for, so consumers that
103
+ * downgrade an inert strip to a note must keep warning on this one; only a
104
+ * lone `ESC` that opens nothing is inert.
105
+ */
106
+ ORPHAN_CSI: "orphan-csi-introducer",
107
+ /**
108
+ * A RAW C1 byte (U+0080-U+009F) that starts no sequence the grammar
109
+ * recognizes. Split from {@link TOKEN_KIND.ORPHAN} because the two carry very
110
+ * different weight: a lone `ESC` is ordinary debris in terminal output, while
111
+ * a raw C1 byte is not something legitimate UTF-8 text produces, and the
112
+ * block includes the string introducers DCS/SOS/PM/APC (U+0090/0098/009E/
113
+ * 009F) — which this grammar does not consume, so an unrecognized one here
114
+ * means a terminal WOULD have swallowed the following text as a control
115
+ * payload. Consumers that downgrade an inert strip to a note (see
116
+ * `isBenignAnsiKinds` in ./layer1.mjs) must keep warning on this one.
117
+ */
118
+ ORPHAN_C1: "orphan-c1-introducer",
93
119
  });
94
120
 
121
+ /**
122
+ * True for either orphan kind — the tokens {@link scanAnsi} emits for an
123
+ * introducer that completes no sequence, which the stripper must leave in place
124
+ * for the residual sweep rather than splice (see stripAnsiOnce).
125
+ * @param {string} kind one of {@link TOKEN_KIND}
126
+ * @returns {boolean}
127
+ */
128
+ export function isOrphanKind(kind) {
129
+ return (
130
+ kind === TOKEN_KIND.ORPHAN ||
131
+ kind === TOKEN_KIND.ORPHAN_CSI ||
132
+ kind === TOKEN_KIND.ORPHAN_C1
133
+ );
134
+ }
135
+
136
+ /**
137
+ * The orphan kind for the introducer character `ch`, given the character `next`
138
+ * that follows it: a raw C1 byte, an `ESC` that opened an incomplete CSI, or a
139
+ * lone `ESC`. The one place that split is decided, shared by the tokenizer and
140
+ * by Layer 1's residual sweep (which sees bare characters, not tokens).
141
+ *
142
+ * `[` is the only lookahead that matters. `ESC ]` is an OSC, which the scanner
143
+ * consumes to the end of input if unterminated (so it never reaches here), and
144
+ * every other second byte — `ESC (`, `ESC #`, `ESC P` — bounds what a terminal
145
+ * swallows to a byte or two rather than running until a final byte arrives.
146
+ * @param {string} ch
147
+ * @param {string} [next] the following character, or undefined at end of input
148
+ * @returns {string}
149
+ */
150
+ export function orphanKindFor(ch, next) {
151
+ if (ch.charCodeAt(0) !== ESC) return TOKEN_KIND.ORPHAN_C1;
152
+ return next === "[" ? TOKEN_KIND.ORPHAN_CSI : TOKEN_KIND.ORPHAN;
153
+ }
154
+
95
155
  /**
96
156
  * @typedef {object} AnsiToken
97
157
  * @property {number} start Index of the introducer.
@@ -168,8 +228,9 @@ const INTRODUCER_SCAN_RE = new RegExp(CONTROL_INTRODUCER_SOURCE, "g");
168
228
  /**
169
229
  * Tokenize every raw control introducer in `text`.
170
230
  *
171
- * Every introducer yields exactly one token — an ORPHAN when it starts nothing
172
- * the grammar recognizes so "which introducers are in this text" and "which
231
+ * Every introducer yields exactly one token — an orphan kind (see
232
+ * {@link orphanKindFor}) when it starts nothing the grammar recognizes so
233
+ * "which introducers are in this text" and "which
173
234
  * sequences are in this text" are answered by the same scan. That is what lets
174
235
  * the stripper (splice every non-orphan token, then sweep) and the SGR-only
175
236
  * predicate (every token is SGR) agree by construction.
@@ -190,7 +251,7 @@ export function scanAnsi(text) {
190
251
  const csiEnd = oscEnd < 0 ? scanCsi(text, start) : -1;
191
252
  let end = start + 1;
192
253
  /** @type {string} */
193
- let kind = TOKEN_KIND.ORPHAN;
254
+ let kind = orphanKindFor(text[start], text[start + 1]);
194
255
  if (oscEnd >= 0) {
195
256
  end = oscEnd;
196
257
  kind = TOKEN_KIND.OSC;
package/src/index.mjs CHANGED
@@ -23,7 +23,13 @@ import { sanitizeText } from "./output.mjs";
23
23
  // Layer 1 lives in the zero-dependency `./layer1.mjs`, shared verbatim with the
24
24
  // tool-output pipeline (`./output`) and the Edit-repair rehydrator
25
25
  // (`./rehydrate`) so every consumer derives the identical model-facing view.
26
- export { applyLayer1, stripAnsiFully, LONE_SURROGATE_RE } from "./layer1.mjs";
26
+ export {
27
+ applyLayer1,
28
+ isBenignAnsi,
29
+ isBenignAnsiKinds,
30
+ stripAnsiFully,
31
+ LONE_SURROGATE_RE,
32
+ } from "./layer1.mjs";
27
33
 
28
34
  export {
29
35
  stripInvisible,
package/src/layer1.mjs CHANGED
@@ -13,7 +13,13 @@
13
13
  * point where a lone surrogate would otherwise corrupt a match or a parse.
14
14
  */
15
15
  import { stripInvisibleWithReport, CATEGORY } from "./invisible.mjs";
16
- import { CONTROL_INTRODUCER_SOURCE, scanAnsi, TOKEN_KIND } from "./ansi.mjs";
16
+ import {
17
+ CONTROL_INTRODUCER_SOURCE,
18
+ isOrphanKind,
19
+ orphanKindFor,
20
+ scanAnsi,
21
+ TOKEN_KIND,
22
+ } from "./ansi.mjs";
17
23
 
18
24
  // The ANSI grammar and the introducer charset live in ./ansi.mjs so this module
19
25
  // and invisible.mjs (which owns the public isSgrOnly / SGR_RE and cannot import
@@ -24,6 +30,23 @@ import { CONTROL_INTRODUCER_SOURCE, scanAnsi, TOKEN_KIND } from "./ansi.mjs";
24
30
  // survives Layer 1.
25
31
  const CONTROL_INTRODUCER_RE = new RegExp(CONTROL_INTRODUCER_SOURCE, "g");
26
32
 
33
+ /**
34
+ * Run the residual sweep, recording the orphan kind of every introducer it
35
+ * removes. The sweep sees bare characters rather than tokens, so the kind comes
36
+ * from {@link orphanKindFor} — the same decision the tokenizer makes, not a
37
+ * second spelling of it — fed the following character from the text being
38
+ * swept, which is the context a terminal reading this introducer would have.
39
+ * @param {string} text
40
+ * @param {Set<string>} kinds
41
+ * @returns {string}
42
+ */
43
+ function sweepIntroducers(text, kinds) {
44
+ return text.replace(CONTROL_INTRODUCER_RE, (ch, offset) => {
45
+ kinds.add(orphanKindFor(ch, text[offset + 1]));
46
+ return "";
47
+ });
48
+ }
49
+
27
50
  // Unpaired UTF-16 surrogates (high not followed by low, or low not preceded by
28
51
  // high). Normalized before any HTML parser, which throws on a stray byte —
29
52
  // which would otherwise let a single malformed code unit suppress all output.
@@ -41,13 +64,18 @@ const MAX_ANSI_PASSES = 3;
41
64
  * view. Orphans are removed by applyLayer1's residual sweep, once no
42
65
  * reconstitution is possible.
43
66
  * @param {string} text
67
+ * @param {Set<string>} [kinds] collects the {@link TOKEN_KIND} of every
68
+ * sequence this pass actually removed, so a caller can tell a display-only
69
+ * colour strip from a cursor/erase/OSC one without re-scanning (see
70
+ * {@link isBenignAnsiKinds}).
44
71
  * @returns {string}
45
72
  */
46
- function stripAnsiOnce(text) {
73
+ function stripAnsiOnce(text, kinds) {
47
74
  let out = "";
48
75
  let last = 0;
49
76
  for (const token of scanAnsi(text)) {
50
- if (token.kind === TOKEN_KIND.ORPHAN) continue;
77
+ if (isOrphanKind(token.kind)) continue;
78
+ kinds?.add(token.kind);
51
79
  out += text.slice(last, token.start);
52
80
  last = token.end;
53
81
  }
@@ -68,18 +96,71 @@ function stripAnsiOnce(text) {
68
96
  * survives here. Past the bound a reconstituted sequence therefore degrades to
69
97
  * VISIBLE text rather than a hidden control, which is the fail-open direction.
70
98
  * @param {string} input
99
+ * @param {Set<string>} [kinds] see {@link stripAnsiOnce}; accumulates across passes
71
100
  * @returns {string}
72
101
  */
73
- export function stripAnsiFully(input) {
102
+ export function stripAnsiFully(input, kinds) {
74
103
  let prev = input;
75
- let out = stripAnsiOnce(prev);
104
+ let out = stripAnsiOnce(prev, kinds);
76
105
  for (let pass = 1; pass < MAX_ANSI_PASSES && out !== prev; pass++) {
77
106
  prev = out;
78
- out = stripAnsiOnce(prev);
107
+ out = stripAnsiOnce(prev, kinds);
79
108
  }
80
109
  return out;
81
110
  }
82
111
 
112
+ /**
113
+ * True when the ANSI a Layer-1 strip removed was INERT: every removed sequence
114
+ * was either a display-only SGR colour token or a LONE 7-bit `ESC` that opened
115
+ * nothing at all (a stray byte in a file, a truncated write, a log fragment cut
116
+ * mid-escape).
117
+ *
118
+ * The two other orphan kinds are deliberately NOT inert. A raw C1 orphan
119
+ * (TOKEN_KIND.ORPHAN_C1): legit UTF-8 text does not carry raw C1 bytes, and the
120
+ * block holds the DCS/SOS/PM/APC string introducers this grammar does not
121
+ * consume — so an unrecognized one means a terminal would have eaten the
122
+ * following text as a control payload. An incomplete CSI (TOKEN_KIND.ORPHAN_CSI)
123
+ * for the same reason at 7 bits: the CSI parser keeps consuming until a final
124
+ * byte, so `hello ESC[12 world` hides ` w` from the human while the model reads
125
+ * the whole prompt.
126
+ *
127
+ * This draws a severity line, not a presence line: the bytes are stripped
128
+ * either way, so all that rides on the answer is whether the operator sees a
129
+ * WARNING or a terse note. An orphan introducer cannot move the cursor, erase
130
+ * the screen, relabel a window, or open an OSC string — every one of those needs
131
+ * a COMPLETE token, which {@link scanAnsi} classifies as CSI or OSC and this
132
+ * rejects. Warning on a lone `ESC` is the false positive that costs the most: one
133
+ * pre-existing `ESC` in a markdown file, echoed back in an Edit result, raises
134
+ * the same alarm as a cursor-spoofing payload, and an alarm that fires on inert
135
+ * bytes is the one operators learn to scroll past.
136
+ *
137
+ * It takes the kinds the STRIP recorded, never a fresh scan of the raw text,
138
+ * and that is the whole point: a scan of the raw text answers about sequences
139
+ * that have not been reconstituted yet, so `ESC` + `ESC[m` + `[2J` (a bare ESC,
140
+ * an SGR, then plain text) reads as orphan-only there while the strip's second
141
+ * pass actually removes a CSI erase. Recording what each pass removed reports
142
+ * the sequences that really existed at Layer 1's fixed point.
143
+ * @param {readonly string[] | Set<string>} kinds {@link TOKEN_KIND} values removed
144
+ * @returns {boolean}
145
+ */
146
+ export function isBenignAnsiKinds(kinds) {
147
+ return [...kinds].every(
148
+ (kind) => kind === TOKEN_KIND.SGR || kind === TOKEN_KIND.ORPHAN,
149
+ );
150
+ }
151
+
152
+ /**
153
+ * {@link isBenignAnsiKinds} for callers that hold only the text — it runs the
154
+ * full Layer-1 composition to get the fixed-point view. Callers that already
155
+ * ran {@link applyLayer1} must read its `ansiKinds` instead of paying for a
156
+ * second strip.
157
+ * @param {string} text
158
+ * @returns {boolean}
159
+ */
160
+ export function isBenignAnsi(text) {
161
+ return isBenignAnsiKinds(applyLayer1(text).ansiKinds);
162
+ }
163
+
83
164
  // How many times the {ANSI strip, invisible strip} composition may re-run before
84
165
  // the sweep is forced. Each iteration deletes at least one character, so the
85
166
  // loop terminates on its own; the bound is a DoS guard on the same quadratic
@@ -119,19 +200,25 @@ const MAX_LAYER1_PASSES = 4;
119
200
  *
120
201
  * `deAnsi` is the ANSI strip of the ORIGINAL text (invisible runs intact), the
121
202
  * scope a LONG_RUN payload check needs — not an intermediate of the loop.
203
+ *
204
+ * `ansiKinds` is the {@link TOKEN_KIND} of every ANSI sequence the composition
205
+ * removed, deduped — the severity detail `found`'s single ANSI category cannot
206
+ * carry (see {@link isBenignAnsiKinds}). It is also what DERIVES that category:
207
+ * a kind is recorded exactly when bytes were removed, so "we reported ANSI" and
208
+ * "here is what the ANSI was" can no longer disagree.
122
209
  * @param {string} text
123
- * @returns {{ cleaned: string, deAnsi: string, found: string[] }}
210
+ * @returns {{ cleaned: string, deAnsi: string, found: string[], ansiKinds: string[] }}
124
211
  */
125
212
  export function applyLayer1(text) {
126
- const deAnsi = stripAnsiFully(text);
213
+ /** @type {Set<string>} TOKEN_KINDs removed by every ANSI pass below. */
214
+ const ansiKinds = new Set();
215
+ const deAnsi = stripAnsiFully(text, ansiKinds);
127
216
  /** @type {Set<string>} Union of the categories every iteration reported. */
128
217
  const found = new Set();
129
218
  let cleaned = text;
130
- let ansiFound = false;
131
219
 
132
220
  for (let pass = 0; pass < MAX_LAYER1_PASSES; pass++) {
133
- const afterAnsi = pass === 0 ? deAnsi : stripAnsiFully(cleaned);
134
- if (afterAnsi !== cleaned) ansiFound = true;
221
+ const afterAnsi = pass === 0 ? deAnsi : stripAnsiFully(cleaned, ansiKinds);
135
222
  // stripInvisibleWithReport returns `found` for exactly the categories it
136
223
  // removed — so a ZWNJ/ZWJ the carve-out PRESERVES never registers as a
137
224
  // strip. The second argument stays the ORIGINAL `text` on every iteration:
@@ -152,18 +239,16 @@ export function applyLayer1(text) {
152
239
  // pass. A sweep that changes the text feeds one more round (removing an
153
240
  // introducer can make invisibles adjacent, exactly as removing a sequence
154
241
  // can); one that changes nothing means the whole composition has converged.
155
- const swept = cleaned.replace(CONTROL_INTRODUCER_RE, "");
242
+ const swept = sweepIntroducers(cleaned, ansiKinds);
156
243
  if (swept === cleaned) break;
157
244
  cleaned = swept;
158
- ansiFound = true;
159
245
  }
160
246
 
161
- const swept = cleaned.replace(CONTROL_INTRODUCER_RE, "");
162
- if (swept !== cleaned) {
163
- cleaned = swept;
164
- ansiFound = true;
165
- }
247
+ cleaned = sweepIntroducers(cleaned, ansiKinds);
166
248
 
167
- if (ansiFound) found.add(CATEGORY.ANSI);
168
- return { cleaned, deAnsi, found: [...found] };
249
+ // Derived, not tracked in parallel: a kind is recorded exactly when an ANSI
250
+ // pass or the sweep removed bytes, so the category and the severity detail
251
+ // are two readings of one fact.
252
+ if (ansiKinds.size > 0) found.add(CATEGORY.ANSI);
253
+ return { cleaned, deAnsi, found: [...found], ansiKinds: [...ansiKinds] };
169
254
  }
package/src/output.mjs CHANGED
@@ -24,9 +24,13 @@
24
24
  * actually removes something, so a secret that a deletion reconstitutes is
25
25
  * still caught before this function returns.
26
26
  */
27
- import { CATEGORY, describeStripped, isSgrOnly } from "./invisible.mjs";
27
+ import { CATEGORY, describeStripped } from "./invisible.mjs";
28
28
  import { needsMarkdownPipeline } from "./gates.mjs";
29
- import { applyLayer1, LONE_SURROGATE_RE } from "./layer1.mjs";
29
+ import {
30
+ applyLayer1,
31
+ isBenignAnsiKinds,
32
+ LONE_SURROGATE_RE,
33
+ } from "./layer1.mjs";
30
34
  import {
31
35
  describeExfil,
32
36
  describeHtmlSanitized,
@@ -283,9 +287,10 @@ export function deleteVerbatimSpans(text, spans) {
283
287
 
284
288
  /**
285
289
  * Layer 1 + surrogate normalisation: invisible chars, ANSI, lone surrogates.
286
- * `sgrNote` is true when the ONLY change was display-only SGR color AND the
287
- * caller opted into the carve-out (`sgrCarveOut`) the caller reports that
288
- * with a terse note, not the WARNING prefix.
290
+ * `sgrNote` is true when the ONLY change was INERT ANSI — display-only SGR
291
+ * colour and/or a stray orphan introducer that formed no sequence — AND the
292
+ * caller opted into the carve-out (`sgrCarveOut`); the caller reports that with
293
+ * a terse note, not the WARNING prefix.
289
294
  * @param {string} text
290
295
  * @param {boolean} sgrCarveOut
291
296
  * @returns {{ cleaned: string, found: string[], warnings: string[], modified: boolean, sgrNote: boolean }}
@@ -297,18 +302,24 @@ function processLayer1(text, sgrCarveOut) {
297
302
  const found = [];
298
303
  let modified = false;
299
304
  let sgrNote = false;
300
- const { cleaned: layer1, deAnsi, found: invisFound } = applyLayer1(text);
305
+ const {
306
+ cleaned: layer1,
307
+ deAnsi,
308
+ found: invisFound,
309
+ ansiKinds,
310
+ } = applyLayer1(text);
301
311
  let cleaned = layer1;
302
312
  if (invisFound.length > 0) {
303
313
  found.push(...invisFound);
304
314
  modified = true;
305
- // Display-only color with the carve-out enabled: the strip removed cosmetic
306
- // styling and nothing else (found is exactly [ANSI], so zero invisible
307
- // chars were present, making isSgrOnly exact). Report it as a note.
315
+ // Inert ANSI with the carve-out enabled: the strip removed cosmetic styling
316
+ // and/or a stray escape byte, and nothing else (found is exactly [ANSI], so
317
+ // zero invisible chars were present). Report it as a note — a cursor-move,
318
+ // erase or OSC token lands in ansiKinds as CSI/OSC and keeps the WARNING.
308
319
  sgrNote =
309
320
  invisFound.length === 1 &&
310
321
  invisFound[0] === CATEGORY.ANSI &&
311
- isSgrOnly(text) &&
322
+ isBenignAnsiKinds(ansiKinds) &&
312
323
  sgrCarveOut;
313
324
  if (!sgrNote) warnings.push(describeStripped(invisFound, deAnsi));
314
325
  }
package/src/prompt.mjs CHANGED
@@ -8,12 +8,14 @@
8
8
  * the only way to neutralize a payload is to block. This is the pure decision;
9
9
  * a host wraps it in whatever its agent's prompt-submission hook expects.
10
10
  *
11
- * One carve-out: a prompt whose only escape content is SGR color/style codes
12
- * (`ESC [ params m`) passes with a note instead of blocking. Pasting colored
13
- * terminal output (test runs, build logs) is the single most common debugging
14
- * action, and SGR is display-only by the ECMA-48 grammar it cannot move the
15
- * cursor, erase the screen, or carry an OSC payload. Anything beyond SGR still
16
- * blocks, as do the invisible-char thresholds.
11
+ * One carve-out: a prompt whose only escape content is INERT — SGR color/style
12
+ * codes (`ESC [ params m`) and/or an orphan introducer that completes no
13
+ * sequence passes with a note instead of blocking. Pasting colored terminal
14
+ * output (test runs, build logs) is the single most common debugging action,
15
+ * and SGR is display-only by the ECMA-48 grammar; an orphan `ESC` is not a
16
+ * sequence at all. Neither can move the cursor, erase the screen, or carry an
17
+ * OSC payload. Anything that IS a complete CSI/OSC token still blocks, as do
18
+ * the invisible-char thresholds.
17
19
  */
18
20
  import {
19
21
  CHECKS,
@@ -24,9 +26,8 @@ import {
24
26
  SCATTERED_THRESHOLD,
25
27
  countPayloadInvisible,
26
28
  stripInvisible,
27
- isSgrOnly,
28
29
  } from "./invisible.mjs";
29
- import { stripAnsiFully } from "./layer1.mjs";
30
+ import { isBenignAnsi, stripAnsiFully } from "./layer1.mjs";
30
31
  import { CONTROL_INTRODUCER_SOURCE } from "./ansi.mjs";
31
32
 
32
33
  // Every raw ANSI control a prompt can carry: 7-bit ESC (U+001B) and the entire
@@ -44,21 +45,6 @@ import { CONTROL_INTRODUCER_SOURCE } from "./ansi.mjs";
44
45
  // differently, so even a grep-based drift check would have missed a divergence.
45
46
  const ANSI_INTRODUCER = new RegExp(CONTROL_INTRODUCER_SOURCE);
46
47
 
47
- /**
48
- * True when every ANSI introducer in `prompt` belongs to a display-only SGR
49
- * color sequence -- the note carve-out's precondition. `isSgrOnly` already tests
50
- * the SGR-stripped prompt against the WHOLE C1 control block (U+0080-U+009F,
51
- * which includes the C1 OSC introducer U+009D and DCS/SOS/PM/APC) plus the 7-bit
52
- * ESC, so a residual C1-OSC or any non-SGR escape already denies it — no
53
- * separate re-check is needed (an earlier `&& !ANSI_INTRODUCER.test(...)` here
54
- * was an exact duplicate of that gate over the same stripped string).
55
- * @param {string} prompt
56
- * @returns {boolean}
57
- */
58
- function isSgrColorOnly(prompt) {
59
- return isSgrOnly(prompt);
60
- }
61
-
62
48
  /**
63
49
  * Human-facing block reason: what was detected, the thresholds, a code-point
64
50
  * sample of the long run (if any), and how to recover.
@@ -139,9 +125,13 @@ export function classifyPrompt(prompt, strip = stripAnsiFully) {
139
125
 
140
126
  if (!hasAnsi && invisiblesBelowThreshold) return { action: "pass" };
141
127
 
142
- // Display-only color codes in an otherwise clean prompt: pass with a note
143
- // instead of blocking, so pasted colored logs remain usable.
144
- if (hasAnsi && invisiblesBelowThreshold && isSgrColorOnly(prompt))
128
+ // Inert escapes in an otherwise clean prompt display-only colour and/or an
129
+ // orphan introducer that forms no sequence: pass with a note instead of
130
+ // blocking, so pasted colored logs and log fragments cut mid-escape remain
131
+ // usable. isBenignAnsi judges from what Layer 1's strip actually removed, so
132
+ // it covers the whole C1 block and any sequence that only reconstitutes
133
+ // during the strip; a complete CSI/OSC token falls through to the block.
134
+ if (hasAnsi && invisiblesBelowThreshold && isBenignAnsi(prompt))
145
135
  return { action: "note" };
146
136
 
147
137
  // CHECKS pairs a machine-readable category code with its detector; map each
package/types/ansi.d.mts CHANGED
@@ -1,8 +1,32 @@
1
+ /**
2
+ * True for either orphan kind — the tokens {@link scanAnsi} emits for an
3
+ * introducer that completes no sequence, which the stripper must leave in place
4
+ * for the residual sweep rather than splice (see stripAnsiOnce).
5
+ * @param {string} kind one of {@link TOKEN_KIND}
6
+ * @returns {boolean}
7
+ */
8
+ export function isOrphanKind(kind: string): boolean;
9
+ /**
10
+ * The orphan kind for the introducer character `ch`, given the character `next`
11
+ * that follows it: a raw C1 byte, an `ESC` that opened an incomplete CSI, or a
12
+ * lone `ESC`. The one place that split is decided, shared by the tokenizer and
13
+ * by Layer 1's residual sweep (which sees bare characters, not tokens).
14
+ *
15
+ * `[` is the only lookahead that matters. `ESC ]` is an OSC, which the scanner
16
+ * consumes to the end of input if unterminated (so it never reaches here), and
17
+ * every other second byte — `ESC (`, `ESC #`, `ESC P` — bounds what a terminal
18
+ * swallows to a byte or two rather than running until a final byte arrives.
19
+ * @param {string} ch
20
+ * @param {string} [next] the following character, or undefined at end of input
21
+ * @returns {string}
22
+ */
23
+ export function orphanKindFor(ch: string, next?: string): string;
1
24
  /**
2
25
  * Tokenize every raw control introducer in `text`.
3
26
  *
4
- * Every introducer yields exactly one token — an ORPHAN when it starts nothing
5
- * the grammar recognizes so "which introducers are in this text" and "which
27
+ * Every introducer yields exactly one token — an orphan kind (see
28
+ * {@link orphanKindFor}) when it starts nothing the grammar recognizes so
29
+ * "which introducers are in this text" and "which
6
30
  * sequences are in this text" are answered by the same scan. That is what lets
7
31
  * the stripper (splice every non-orphan token, then sweep) and the SGR-only
8
32
  * predicate (every token is SGR) agree by construction.
@@ -39,7 +63,7 @@ export const CONTROL_INTRODUCER_SOURCE: "[\\u001b\\u0080-\\u009f]";
39
63
  * and the regex can no longer describe different languages.
40
64
  */
41
65
  export const SGR_RE: RegExp;
42
- /** The four things an introducer can turn out to be. */
66
+ /** The six things an introducer can turn out to be. */
43
67
  export const TOKEN_KIND: Readonly<{
44
68
  /** A display-only `ESC[…m` / `U+009B…m` colour sequence. */
45
69
  SGR: "sgr";
@@ -47,8 +71,34 @@ export const TOKEN_KIND: Readonly<{
47
71
  CSI: "csi";
48
72
  /** An OSC string: introducer, body and terminator as one unit. */
49
73
  OSC: "osc";
50
- /** An introducer that starts no sequence the grammar recognizes. */
74
+ /**
75
+ * A 7-bit `ESC` that starts no sequence the grammar recognizes — a truncated
76
+ * write, a log fragment cut mid-escape, a stray byte living in a file.
77
+ */
51
78
  ORPHAN: "orphan-introducer";
79
+ /**
80
+ * A 7-bit `ESC` that OPENS a CSI (`ESC [`) it never completes. Split from
81
+ * {@link TOKEN_KIND.ORPHAN} because a terminal's CSI parser is STATEFUL: it
82
+ * keeps consuming what follows as parameters and intermediates until a final
83
+ * byte (0x40-0x7E) arrives, so `hello ESC[12 world` renders as `hello orld`
84
+ * — the ` w` is eaten as the sequence's intermediate and final. That is the
85
+ * model-sees/human-sees divergence the gate exists for, so consumers that
86
+ * downgrade an inert strip to a note must keep warning on this one; only a
87
+ * lone `ESC` that opens nothing is inert.
88
+ */
89
+ ORPHAN_CSI: "orphan-csi-introducer";
90
+ /**
91
+ * A RAW C1 byte (U+0080-U+009F) that starts no sequence the grammar
92
+ * recognizes. Split from {@link TOKEN_KIND.ORPHAN} because the two carry very
93
+ * different weight: a lone `ESC` is ordinary debris in terminal output, while
94
+ * a raw C1 byte is not something legitimate UTF-8 text produces, and the
95
+ * block includes the string introducers DCS/SOS/PM/APC (U+0090/0098/009E/
96
+ * 009F) — which this grammar does not consume, so an unrecognized one here
97
+ * means a terminal WOULD have swallowed the following text as a control
98
+ * payload. Consumers that downgrade an inert strip to a note (see
99
+ * `isBenignAnsiKinds` in ./layer1.mjs) must keep warning on this one.
100
+ */
101
+ ORPHAN_C1: "orphan-c1-introducer";
52
102
  }>;
53
103
  export type AnsiToken = {
54
104
  /**
@@ -43,7 +43,12 @@ export function nativeStdout(response: {
43
43
  * unparsable stdin, missing package, a judge error — is reported on stderr and
44
44
  * routed to `onError(err, input)` (`input` undefined when stdin never parsed),
45
45
  * where the hook applies its declared fail posture.
46
- * @param {string} hookName prefix for the stderr diagnostic
46
+ *
47
+ * It is also where every judge hook is TIMED: the verdict picks up a
48
+ * performance note when the judge overran the hook budget (see
49
+ * lib/hook-timing.mjs), so no hook has to remember to measure itself.
50
+ * @param {string} hookName prefix for the stderr diagnostic, and the hook name
51
+ * a slow-run notice reports
47
52
  * @param {(event: import("agent-control-plane-core").ToolCallEvent) =>
48
53
  * import("agent-control-plane-core").Verdict |
49
54
  * Promise<import("agent-control-plane-core").Verdict>} judge
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Run `work`, charging its whole duration to provisioning so no timer running
3
+ * across it counts that time. Charged in a `finally`, so a provisioning step
4
+ * that FAILS is still excluded — the wait happened either way, and a hook that
5
+ * then fails is reported through its fault posture, not as "slow".
6
+ *
7
+ * Wrap only genuinely one-time, per-session setup: waiting out a dependency
8
+ * install, waiting for a cold redactor daemon to bind. Never wrap the hook's
9
+ * actual work — that is exactly what this measurement is for.
10
+ * @template T
11
+ * @param {() => Promise<T>} work
12
+ * @param {() => number} [now] injectable clock, for tests
13
+ * @returns {Promise<T>}
14
+ */
15
+ export function excludeProvisioning<T>(work: () => Promise<T>, now?: () => number): Promise<T>;
16
+ /**
17
+ * Start measuring; the returned function reports the milliseconds elapsed so
18
+ * far MINUS any provisioning charged in the meantime, and may be called more
19
+ * than once.
20
+ *
21
+ * Only provisioning charged since this timer started is subtracted, so an
22
+ * earlier run's cold start cannot pay down a later run's real cost. A
23
+ * provisioning window that straddles the timer's start would otherwise be able
24
+ * to subtract more than the timer has measured, so the result is floored at 0.
25
+ * @param {() => number} [now] injectable clock, for tests
26
+ * @returns {() => number}
27
+ */
28
+ export function startHookTimer(now?: () => number): () => number;
29
+ /**
30
+ * The model-facing line for a hook that overran the budget, or null when it did
31
+ * not. Addressed to the model because the model is the only party that reliably
32
+ * reads this channel — stderr from a non-blocking hook is easy to miss — and it
33
+ * is asked to relay the number, since the operator is the one who can file it.
34
+ * @param {string} hookName
35
+ * @param {number} elapsedMs
36
+ * @param {number} [thresholdMs]
37
+ * @returns {string | null}
38
+ */
39
+ export function slowHookNotice(hookName: string, elapsedMs: number, thresholdMs?: number): string | null;
40
+ /**
41
+ * `verdict` with the slow-hook notice folded into its `additional_context`, or
42
+ * the verdict untouched when the run was within budget. Also writes the notice
43
+ * to stderr, so the timing survives in the transcript even for a hook whose
44
+ * verdict carries no context channel to the model.
45
+ *
46
+ * Appended, never substituted: the context slot is how a hook reports a REDACTED
47
+ * secret or a stripped payload, and a timing note must not displace that.
48
+ * @template {{ additional_context?: string }} V
49
+ * @param {string} hookName
50
+ * @param {number} elapsedMs
51
+ * @param {V} verdict
52
+ * @param {(chunk: string) => void} [writeErr] injectable stderr sink, for tests
53
+ * @returns {V}
54
+ */
55
+ export function withSlowHookNotice<V extends {
56
+ additional_context?: string;
57
+ }>(hookName: string, elapsedMs: number, verdict: V, writeErr?: (chunk: string) => void): V;
58
+ /**
59
+ * Report a slow run for a hook that answers with a bare `hookSpecificOutput`
60
+ * envelope rather than a control-plane verdict — SessionStart, which has no
61
+ * verdict channel at all. A within-budget run emits nothing, so the quiet path
62
+ * stays quiet (and the hook's silent-success contract is unchanged).
63
+ * @param {string} hookName
64
+ * @param {number} elapsedMs
65
+ * @param {string} hookEventName
66
+ * @param {(event: string, fields: Record<string, unknown>) => void} emit the
67
+ * stdout envelope writer (hook-io's emitHookResponse); passed in rather than
68
+ * imported so this module stays dependency-free — see the module doc
69
+ * @param {(chunk: string) => void} [writeErr] injectable stderr sink, for tests
70
+ * @returns {boolean} whether a notice was emitted
71
+ */
72
+ export function reportSlowHook(hookName: string, elapsedMs: number, hookEventName: string, emit: (event: string, fields: Record<string, unknown>) => void, writeErr?: (chunk: string) => void): boolean;
73
+ /**
74
+ * The one place a hook's own wall-clock cost is measured and reported.
75
+ *
76
+ * These hooks sit on the critical path of every tool call, every prompt and
77
+ * every session start: whatever they spend, the user waits. That cost is also
78
+ * the hardest kind of bug to notice from inside — a hook that got slow looks
79
+ * exactly like an agent that got slow, so it goes unreported for weeks (one
80
+ * SessionStart scan blocked startup for 30 SECONDS before anyone traced it back
81
+ * here). A hook past the budget therefore says so IN BAND, in the model's
82
+ * context, where it cannot be missed and can be relayed to the operator.
83
+ *
84
+ * One threshold, one message, one merge rule, shared by every hook — the
85
+ * measurement is worthless if each hook words it differently or picks its own
86
+ * bar for "slow".
87
+ *
88
+ * What it deliberately does NOT count is ONE-TIME PROVISIONING (see
89
+ * {@link excludeProvisioning}). A dependency-install wait or a cold redactor
90
+ * spawn is wall-clock the user really waits, but it is not a cost this hook
91
+ * pays per call and it is not a bug worth a report — charging it would make the
92
+ * FIRST call of every session cry wolf, which is precisely the alert fatigue
93
+ * this notice exists to avoid.
94
+ *
95
+ * Dependency-free on purpose: everything imports this, including hook-io, so a
96
+ * back-import would close a cycle. The one emitter it needs is passed in.
97
+ */
98
+ /**
99
+ * Wall-clock a single hook invocation may spend before it is reported as slow.
100
+ *
101
+ * A second is far above anything these hooks do when healthy (Layer 1 is a few
102
+ * regex passes; the redactor daemon answers in tens of milliseconds once warm)
103
+ * and far below the point where a human is merely impatient — so crossing it
104
+ * means something is actually wrong, not that the machine is busy.
105
+ */
106
+ export const SLOW_HOOK_THRESHOLD_MS: 1000;
@@ -100,7 +100,7 @@ export function waitForSocket(socketPath: string, { deadlineMs, stepMs }?: {
100
100
  * @param {{map?: boolean, webIngress?: boolean, socketPath?: string,
101
101
  * deadline?: {remainingMs: () => number},
102
102
  * connect?: typeof connectAndRequest, spawn?: typeof spawnDaemon,
103
- * waitForSocket?: typeof waitForSocket}} [opts]
103
+ * waitForSocket?: typeof waitForSocket, now?: () => number}} [opts]
104
104
  * @returns {Promise<RedactResponse|null>}
105
105
  */
106
106
  export function redactViaDaemon(text: string, opts?: {
@@ -113,6 +113,7 @@ export function redactViaDaemon(text: string, opts?: {
113
113
  connect?: typeof connectAndRequest;
114
114
  spawn?: typeof spawnDaemon;
115
115
  waitForSocket?: typeof waitForSocket;
116
+ now?: () => number;
116
117
  }): Promise<RedactResponse | null>;
117
118
  export const FRAME_CAP: number;
118
119
  export const DEFAULT_SOCKET_PATH: string;