agent-sanitizer 2.23.1 → 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/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;
@@ -67,10 +67,28 @@ export function formatSkipped(skipped: Array<{
67
67
  * untested fault path is how a posture goes missing in the first place.
68
68
  * @returns {Promise<void>}
69
69
  */
70
- export function cliMain({ trace: sink, scan: runScan }?: {
70
+ export function cliMain(opts?: {
71
71
  trace?: import("./lib/trace.mjs").TraceFn;
72
72
  scan?: () => ReturnType<typeof scanProject>;
73
73
  }): Promise<void>;
74
+ /**
75
+ * The `.claude/` subdirectories whose markdown Claude Code loads as model
76
+ * context. This is a WHITELIST, and that is the point: `.claude/` is also where
77
+ * tooling parks bulk data that is never loaded as context — `worktrees/`
78
+ * (entire checked-out copies of the repo), plus caches, transcripts and
79
+ * snapshots — and globbing `.claude/**` swept all of it in. On a repo with a few
80
+ * populated worktrees that is thousands of files READ at every session start:
81
+ * one report put it at 30 seconds of blocked startup, paid for scanning files
82
+ * that cannot reach the model.
83
+ *
84
+ * A whitelist, not a `worktrees` denylist, because the failure modes are not
85
+ * symmetric: an unlisted context directory costs a scan this hook was never
86
+ * asked for anyway (the PostToolUse sanitizer still cleans those bytes when a
87
+ * tool reads them), while an unlisted BULK directory silently costs every future
88
+ * session its startup. Add an entry here when Claude Code starts loading a new
89
+ * `.claude/` subdirectory as context.
90
+ */
91
+ export const CLAUDE_CONTEXT_SUBDIRS: readonly string[];
74
92
  /**
75
93
  * @param {string} filePath
76
94
  * @returns {Array<{ line: number, charCount: number, method: string, decoded: string }>}
@@ -90,21 +108,20 @@ export function decodeRun(run: string): {
90
108
  decoded: string;
91
109
  };
92
110
  /**
93
- * @param {string} dir
94
- * @returns {string[]}
95
- */
96
- export function findMdFiles(dir: string): string[];
97
- /**
98
- * Every subdirectory instruction file (CLAUDE.md, CLAUDE.local.md, AGENTS.md)
99
- * under `dir`. Claude Code loads these as project instructions on entry to their
100
- * containing directory — a load path that bypasses the PostToolUse sanitizer — so
101
- * a payload planted in e.g. `packages/foo/CLAUDE.md` reaches the model uncleaned
102
- * unless it is scanned here. Skips node_modules.
111
+ * Every file under `dir` that Claude Code loads as model context: the
112
+ * subdirectory instruction files (CLAUDE.md, CLAUDE.local.md, AGENTS.md) and the
113
+ * whitelisted `.claude/` markdown (see {@link CLAUDE_CONTEXT_SUBDIRS}). Claude
114
+ * Code loads these on entry to their containing directory — a load path that
115
+ * bypasses the PostToolUse sanitizer — so a payload planted in e.g.
116
+ * `packages/foo/CLAUDE.md` reaches the model uncleaned unless it is scanned
117
+ * here. Skips node_modules.
103
118
  *
104
119
  * `**` does not descend into dot directories, so NESTED `.claude/` trees need
105
- * their own pattern: the caller scans only the project-root `.claude`, which
106
- * would leave a directory-scoped skill at `packages/foo/.claude/skills/x/SKILL.md`
107
- * — model context by the same load path — never scanned.
120
+ * their own doubled-star-prefixed patterns: without them a directory-scoped skill at
121
+ * `packages/foo/.claude/skills/x/SKILL.md` — model context by the same load
122
+ * path — is never scanned. That same rule is why the root `.claude` needs no
123
+ * separate walk: a leading doubled star matches zero segments, so the nested
124
+ * patterns cover the root tree too.
108
125
  * @param {string} dir
109
126
  * @returns {string[]}
110
127
  */
package/types/index.d.mts CHANGED
@@ -37,6 +37,6 @@ export function sanitize(text: string, options?: {
37
37
  found: string[];
38
38
  warnings: string[];
39
39
  }>;
40
- export { applyLayer1, stripAnsiFully, LONE_SURROGATE_RE } from "./layer1.mjs";
40
+ export { applyLayer1, isBenignAnsi, isBenignAnsiKinds, stripAnsiFully, LONE_SURROGATE_RE } from "./layer1.mjs";
41
41
  export { stripInvisible, stripInvisibleWithReport, isSgrOnly, STRIP, SGR_RE, CHECKS, CATEGORY, CATEGORY_LABELS, LINGUISTIC_SCRIPTS, VS, BLANK_NON_CF, LONG_RUN_RE, LONG_RUN_THRESHOLD, SCATTERED_THRESHOLD } from "./invisible.mjs";
42
42
  export { HTML_TAG_PRESENT, MD_LINK_HINT, SECRET_HINT, SECRET_HINT_EXT, matchesSecretHint } from "./gates.mjs";
@@ -102,5 +102,7 @@ export const CONSECUTIVE_SELECTOR_CAP: 8;
102
102
  export const TOTAL_PRESERVED_JOINER_BUDGET: 16;
103
103
  export const PRESERVED_JOINER_PER_VISIBLE: 8;
104
104
  export const PRESERVE_HARD_CAP: 64;
105
+ export const TOTAL_PRESERVED_BLANK_BUDGET: 16;
106
+ export const PRESERVED_BLANK_PER_ANCHOR: 2;
105
107
  export const LINGUISTIC_SCRIPTS: string[];
106
108
  export { BRAHMIC_CONSONANT_RANGES } from "./joining-type.mjs";
@@ -11,9 +11,54 @@
11
11
  * survives here. Past the bound a reconstituted sequence therefore degrades to
12
12
  * VISIBLE text rather than a hidden control, which is the fail-open direction.
13
13
  * @param {string} input
14
+ * @param {Set<string>} [kinds] see {@link stripAnsiOnce}; accumulates across passes
14
15
  * @returns {string}
15
16
  */
16
- export function stripAnsiFully(input: string): string;
17
+ export function stripAnsiFully(input: string, kinds?: Set<string>): string;
18
+ /**
19
+ * True when the ANSI a Layer-1 strip removed was INERT: every removed sequence
20
+ * was either a display-only SGR colour token or a LONE 7-bit `ESC` that opened
21
+ * nothing at all (a stray byte in a file, a truncated write, a log fragment cut
22
+ * mid-escape).
23
+ *
24
+ * The two other orphan kinds are deliberately NOT inert. A raw C1 orphan
25
+ * (TOKEN_KIND.ORPHAN_C1): legit UTF-8 text does not carry raw C1 bytes, and the
26
+ * block holds the DCS/SOS/PM/APC string introducers this grammar does not
27
+ * consume — so an unrecognized one means a terminal would have eaten the
28
+ * following text as a control payload. An incomplete CSI (TOKEN_KIND.ORPHAN_CSI)
29
+ * for the same reason at 7 bits: the CSI parser keeps consuming until a final
30
+ * byte, so `hello ESC[12 world` hides ` w` from the human while the model reads
31
+ * the whole prompt.
32
+ *
33
+ * This draws a severity line, not a presence line: the bytes are stripped
34
+ * either way, so all that rides on the answer is whether the operator sees a
35
+ * WARNING or a terse note. An orphan introducer cannot move the cursor, erase
36
+ * the screen, relabel a window, or open an OSC string — every one of those needs
37
+ * a COMPLETE token, which {@link scanAnsi} classifies as CSI or OSC and this
38
+ * rejects. Warning on a lone `ESC` is the false positive that costs the most: one
39
+ * pre-existing `ESC` in a markdown file, echoed back in an Edit result, raises
40
+ * the same alarm as a cursor-spoofing payload, and an alarm that fires on inert
41
+ * bytes is the one operators learn to scroll past.
42
+ *
43
+ * It takes the kinds the STRIP recorded, never a fresh scan of the raw text,
44
+ * and that is the whole point: a scan of the raw text answers about sequences
45
+ * that have not been reconstituted yet, so `ESC` + `ESC[m` + `[2J` (a bare ESC,
46
+ * an SGR, then plain text) reads as orphan-only there while the strip's second
47
+ * pass actually removes a CSI erase. Recording what each pass removed reports
48
+ * the sequences that really existed at Layer 1's fixed point.
49
+ * @param {readonly string[] | Set<string>} kinds {@link TOKEN_KIND} values removed
50
+ * @returns {boolean}
51
+ */
52
+ export function isBenignAnsiKinds(kinds: readonly string[] | Set<string>): boolean;
53
+ /**
54
+ * {@link isBenignAnsiKinds} for callers that hold only the text — it runs the
55
+ * full Layer-1 composition to get the fixed-point view. Callers that already
56
+ * ran {@link applyLayer1} must read its `ansiKinds` instead of paying for a
57
+ * second strip.
58
+ * @param {string} text
59
+ * @returns {boolean}
60
+ */
61
+ export function isBenignAnsi(text: string): boolean;
17
62
  /**
18
63
  * Layer 1: ANSI + invisible-char strip with a result guaranteed free of every
19
64
  * raw ANSI control introducer (7-bit ESC U+001B and the whole 8-bit C1 control
@@ -38,12 +83,19 @@ export function stripAnsiFully(input: string): string;
38
83
  *
39
84
  * `deAnsi` is the ANSI strip of the ORIGINAL text (invisible runs intact), the
40
85
  * scope a LONG_RUN payload check needs — not an intermediate of the loop.
86
+ *
87
+ * `ansiKinds` is the {@link TOKEN_KIND} of every ANSI sequence the composition
88
+ * removed, deduped — the severity detail `found`'s single ANSI category cannot
89
+ * carry (see {@link isBenignAnsiKinds}). It is also what DERIVES that category:
90
+ * a kind is recorded exactly when bytes were removed, so "we reported ANSI" and
91
+ * "here is what the ANSI was" can no longer disagree.
41
92
  * @param {string} text
42
- * @returns {{ cleaned: string, deAnsi: string, found: string[] }}
93
+ * @returns {{ cleaned: string, deAnsi: string, found: string[], ansiKinds: string[] }}
43
94
  */
44
95
  export function applyLayer1(text: string): {
45
96
  cleaned: string;
46
97
  deAnsi: string;
47
98
  found: string[];
99
+ ansiKinds: string[];
48
100
  };
49
101
  export const LONE_SURROGATE_RE: RegExp;