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/README.md +6 -3
- package/THREAT-MODEL.md +37 -10
- package/claude-hooks/lib/control-plane.mjs +17 -2
- package/claude-hooks/lib/hook-io.mjs +39 -0
- package/claude-hooks/lib/hook-timing.mjs +170 -0
- package/claude-hooks/lib/redactor-client.mjs +11 -2
- package/claude-hooks/sanitize-output.mjs +11 -6
- package/claude-hooks/sanitize-user-prompt.mjs +1 -1
- package/claude-hooks/scan-invisible-chars.mjs +107 -28
- package/package.json +1 -1
- package/src/ansi.mjs +66 -5
- package/src/index.mjs +7 -1
- package/src/invisible.mjs +96 -13
- package/src/layer1.mjs +105 -20
- package/src/output.mjs +21 -10
- package/src/prompt.mjs +16 -26
- package/types/ansi.d.mts +54 -4
- package/types/claude-hooks/lib/control-plane.d.mts +6 -1
- package/types/claude-hooks/lib/hook-timing.d.mts +106 -0
- package/types/claude-hooks/lib/redactor-client.d.mts +2 -1
- package/types/claude-hooks/scan-invisible-chars.d.mts +31 -14
- package/types/index.d.mts +1 -1
- package/types/invisible.d.mts +2 -0
- package/types/layer1.d.mts +54 -2
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
|
|
5
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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(
|
|
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
|
-
*
|
|
94
|
-
*
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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
|
|
106
|
-
*
|
|
107
|
-
* —
|
|
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";
|
package/types/invisible.d.mts
CHANGED
|
@@ -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";
|
package/types/layer1.d.mts
CHANGED
|
@@ -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;
|