agent-sanitizer 2.19.3 → 2.19.4
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/THREAT-MODEL.md +18 -6
- package/claude-hooks/lib/hook-fault.mjs +224 -0
- package/claude-hooks/lib/layer-pipeline.mjs +147 -0
- package/claude-hooks/plugin-hooks.mjs +45 -9
- package/claude-hooks/pretooluse-sanitize.mjs +116 -41
- package/claude-hooks/sanitize-output.mjs +66 -19
- package/claude-hooks/sanitize-user-prompt.mjs +31 -23
- package/claude-hooks/scan-invisible-chars.mjs +235 -47
- package/package.json +1 -1
- package/src/ansi.mjs +207 -0
- package/src/confusables.mjs +6 -2
- package/src/html.mjs +130 -48
- package/src/invisible.mjs +202 -159
- package/src/layer1.mjs +101 -116
- package/src/prompt.mjs +9 -6
- package/types/ansi.d.mts +66 -0
- package/types/claude-hooks/lib/hook-fault.d.mts +104 -0
- package/types/claude-hooks/lib/layer-pipeline.d.mts +113 -0
- package/types/claude-hooks/pretooluse-sanitize.d.mts +16 -0
- package/types/claude-hooks/scan-invisible-chars.d.mts +74 -14
- package/types/confusables.d.mts +6 -2
- package/types/invisible.d.mts +11 -2
- package/types/layer1.d.mts +19 -10
package/src/layer1.mjs
CHANGED
|
@@ -13,98 +13,16 @@
|
|
|
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
17
|
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// (U+009C). All are category Cc, so the invisible-char pass (which targets Cf /
|
|
21
|
-
// variation / blank fillers) never removes them; this residual sweep is the
|
|
22
|
-
// guarantee that none survives. Sweeping the whole C1 block — not just the
|
|
23
|
-
// introducers the ANSI grammar above names — fails closed: a DCS/SOS/PM/APC
|
|
24
|
-
// string the grammar does not consume still loses its introducer and terminator
|
|
25
|
-
// here, so no terminal can hide-render its body as a control payload.
|
|
26
|
-
// eslint-disable-next-line no-control-regex -- matching the raw introducers is the point
|
|
27
|
-
const CONTROL_INTRODUCER_RE = /[\u001b\u0080-\u009f]/g;
|
|
18
|
+
// The ANSI grammar and the introducer charset live in ./ansi.mjs so this module
|
|
19
|
+
// and invisible.mjs (which owns the public isSgrOnly / SGR_RE and cannot import
|
|
20
|
+
// this one) scan with the SAME tokenizer.
|
|
28
21
|
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
// attacker-controlled PAYLOAD TEXT. Matching the introducer alone (leaving the
|
|
34
|
-
// body) would let that payload survive into the model's view, so the OSC branch
|
|
35
|
-
// consumes the introducer, the whole body, AND the terminator as one unit.
|
|
36
|
-
//
|
|
37
|
-
// Three alternatives, tried in order:
|
|
38
|
-
// 1. a properly TERMINATED string — a body of bytes that no terminator can
|
|
39
|
-
// start with (the negated class makes that run unambiguous and
|
|
40
|
-
// backtrack-free), then a terminator (ST or BEL).
|
|
41
|
-
// 2. an ABORTED string — the body runs up to (but does NOT consume) an
|
|
42
|
-
// interior bare ESC or a nested C1-OSC introducer (U+009D) that is not
|
|
43
|
-
// itself part of a valid terminator. Per ECMA-48/xterm, a bare ESC (one
|
|
44
|
-
// not immediately followed by `\` to form ST) aborts the OSC string in
|
|
45
|
-
// progress — the terminal drops back to processing that ESC as the start
|
|
46
|
-
// of a NEW sequence, and everything after it is normal text/escapes, not
|
|
47
|
-
// part of this OSC's payload. Consuming only up to the lookahead (not
|
|
48
|
-
// the ESC/U+009D itself) leaves it for the next position in this same
|
|
49
|
-
// `.replace(ANSI_RE, ...)` scan to match as its own sequence (or, if it
|
|
50
|
-
// doesn't complete one, for the residual C1 sweep in applyLayer1 to
|
|
51
|
-
// remove just that one introducer byte) — so an interior ESC can no
|
|
52
|
-
// longer delete the rest of the document (it used to fall through to
|
|
53
|
-
// alternative 3 below and consume everything to EOS).
|
|
54
|
-
// 3. anything else from the introducer to END-OF-STRING (`[\s\S]*$`) — the
|
|
55
|
-
// fail-closed catch-all for a GENUINELY unterminated string: no ST, BEL,
|
|
56
|
-
// interior ESC, or nested OSC intro anywhere in the remainder, so there
|
|
57
|
-
// is truly nothing left to hand to a later position. Reached only when
|
|
58
|
-
// alternatives 1 and 2 both fail to find their respective triggers.
|
|
59
|
-
// All three are linear (bounded lookahead / no nested quantifiers), so the
|
|
60
|
-
// branch stays linear.
|
|
61
|
-
const OSC_INTRO = "(?:\\u001b\\]|\\u009d)";
|
|
62
|
-
const OSC_TERM = "(?:\\u001b\\\\|\\u009c|\\u0007)";
|
|
63
|
-
const OSC_BODY = "[^\\u0007\\u001b\\u009c\\u009d]";
|
|
64
|
-
const OSC_BRANCH = `${OSC_INTRO}(?:${OSC_BODY}*${OSC_TERM}|${OSC_BODY}*(?=[\\u001b\\u009d])|${OSC_BODY}*$)`;
|
|
65
|
-
|
|
66
|
-
// CSI / two-byte ESC sequences (cursor moves, erase, SGR color, charset/DEC
|
|
67
|
-
// selectors): an introducer, a bounded private-intro run, optional numeric
|
|
68
|
-
// params, and a single final byte. Not an enforcement boundary on its own — any
|
|
69
|
-
// introducer this declines to match is still removed by the residual sweep in
|
|
70
|
-
// applyLayer1 — but matching the whole sequence keeps the common case one clean
|
|
71
|
-
// deletion (and avoids a lone-ESC residual on every styled line).
|
|
72
|
-
//
|
|
73
|
-
// The private-intro class is BOUNDED ({0,12}, not *) on purpose: ; and # live
|
|
74
|
-
// in both this class and the parameter group that follows, so an unbounded *
|
|
75
|
-
// here lets a ;#;#... run be split between the two quantifiers — O(n^2)
|
|
76
|
-
// backtracking on an ESC;#;#... string that never completes a sequence
|
|
77
|
-
// (CodeQL js/polynomial-redos). A constant bound makes the intro a constant
|
|
78
|
-
// factor, so the whole match is linear; a real sequence never carries more than
|
|
79
|
-
// a couple of intro bytes.
|
|
80
|
-
//
|
|
81
|
-
// Params allow BOTH `;` (standard parameter separator) and `:` (ITU T.416
|
|
82
|
-
// colon-separated SGR sub-parameters, e.g. truecolor `ESC[38:2:255:0:0m` as
|
|
83
|
-
// emitted by tmux/kitty/mintty) — legitimate, display-only ANSI that must not
|
|
84
|
-
// leave a colon-parameter residue behind.
|
|
85
|
-
//
|
|
86
|
-
// The final-byte class deliberately excludes `\d`: per ECMA-48, CSI final
|
|
87
|
-
// bytes occupy 0x40–0x7E (letters and a handful of punctuation) while digits
|
|
88
|
-
// are PARAMETER bytes and can never terminate a sequence — an unterminated
|
|
89
|
-
// `ESC[` therefore must not be allowed to eat trailing visible digits (e.g.
|
|
90
|
-
// `ESC[2024 report` is NOT `ESC[` + final-byte `2` + literal `024 report`; it
|
|
91
|
-
// is an incomplete CSI intro that the residual sweep in applyLayer1 cleans up,
|
|
92
|
-
// leaving "2024 report" intact). `=`, `<`, `>` are excluded for the same
|
|
93
|
-
// reason: 0x3C–0x3F (`<=>?`) are private PARAMETER-prefix bytes, not final
|
|
94
|
-
// bytes, per ECMA-48 § 5.4 — `?` already lives in the private-intro class
|
|
95
|
-
// above; `<=>` were never valid finals and including them let a private-marker
|
|
96
|
-
// sequence terminate one byte too early. `~` (0x7E) IS a real final byte (vt220
|
|
97
|
-
// function-key sequences, e.g. `ESC[3~` for Delete) and is kept.
|
|
98
|
-
const CSI_BRANCH =
|
|
99
|
-
"[\\u001b\\u009b][[()#;?]{0,12}(?:(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[A-PR-TZcf-ntqry~])";
|
|
100
|
-
|
|
101
|
-
// Full ANSI escape grammar (OSC first so `ESC]` / C1-OSC is consumed as a whole
|
|
102
|
-
// string, not split by the CSI branch), not just SGR: the Layer-1 guarantee is
|
|
103
|
-
// that no control introducer and no OSC payload survives, and a cursor-move or
|
|
104
|
-
// erase sequence is as much a display-spoofing hazard as a color one. Built from
|
|
105
|
-
// `\uXXXX`-escaped string parts via `new RegExp`, so no raw control byte sits in
|
|
106
|
-
// the source (no no-control-regex disable needed).
|
|
107
|
-
const ANSI_RE = new RegExp(`(?:${OSC_BRANCH}|${CSI_BRANCH})`, "gu");
|
|
22
|
+
// The residual sweep: every raw control introducer, whatever the grammar made of
|
|
23
|
+
// it. This — not the sequence matching — is the guarantee that no introducer
|
|
24
|
+
// survives Layer 1.
|
|
25
|
+
const CONTROL_INTRODUCER_RE = new RegExp(CONTROL_INTRODUCER_SOURCE, "g");
|
|
108
26
|
|
|
109
27
|
// Unpaired UTF-16 surrogates (high not followed by low, or low not preceded by
|
|
110
28
|
// high). Normalized before any HTML parser, which throws on a stray byte —
|
|
@@ -114,6 +32,29 @@ export const LONE_SURROGATE_RE =
|
|
|
114
32
|
|
|
115
33
|
const MAX_ANSI_PASSES = 3;
|
|
116
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Splice out every complete ANSI sequence {@link scanAnsi} finds — SGR, other
|
|
37
|
+
* CSI/two-byte escapes, and whole OSC strings. An ORPHAN introducer (one that
|
|
38
|
+
* starts no sequence) is deliberately LEFT: deleting it here would drop the
|
|
39
|
+
* `ESC` out of `ESC`<ZWSP>`[32m` before the invisible pass could reconstitute
|
|
40
|
+
* the sequence, turning a hidden control into visible `[32m` in the model's
|
|
41
|
+
* view. Orphans are removed by applyLayer1's residual sweep, once no
|
|
42
|
+
* reconstitution is possible.
|
|
43
|
+
* @param {string} text
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
function stripAnsiOnce(text) {
|
|
47
|
+
let out = "";
|
|
48
|
+
let last = 0;
|
|
49
|
+
for (const token of scanAnsi(text)) {
|
|
50
|
+
if (token.kind === TOKEN_KIND.ORPHAN) continue;
|
|
51
|
+
out += text.slice(last, token.start);
|
|
52
|
+
last = token.end;
|
|
53
|
+
}
|
|
54
|
+
if (last === 0) return text;
|
|
55
|
+
return out + text.slice(last);
|
|
56
|
+
}
|
|
57
|
+
|
|
117
58
|
/**
|
|
118
59
|
* Strip ANSI escape sequences to a fixed point. Removing one sequence can
|
|
119
60
|
* reconstitute another around it (a lone ESC left of `ESC[32m[0m` gains the
|
|
@@ -131,54 +72,98 @@ const MAX_ANSI_PASSES = 3;
|
|
|
131
72
|
*/
|
|
132
73
|
export function stripAnsiFully(input) {
|
|
133
74
|
let prev = input;
|
|
134
|
-
let out = prev
|
|
75
|
+
let out = stripAnsiOnce(prev);
|
|
135
76
|
for (let pass = 1; pass < MAX_ANSI_PASSES && out !== prev; pass++) {
|
|
136
77
|
prev = out;
|
|
137
|
-
out = prev
|
|
78
|
+
out = stripAnsiOnce(prev);
|
|
138
79
|
}
|
|
139
80
|
return out;
|
|
140
81
|
}
|
|
141
82
|
|
|
83
|
+
// How many times the {ANSI strip, invisible strip} composition may re-run before
|
|
84
|
+
// the sweep is forced. Each iteration deletes at least one character, so the
|
|
85
|
+
// loop terminates on its own; the bound is a DoS guard on the same quadratic
|
|
86
|
+
// argument as MAX_ANSI_PASSES (n rounds of O(n) scans on adversarial input).
|
|
87
|
+
// Three rounds is what the deepest REAL reconstitution needs — strip
|
|
88
|
+
// invisibles, remove the ANSI they hid, strip the joiners that ANSI removal
|
|
89
|
+
// made adjacent, confirm — so four leaves a round of margin. Deeper nesting is
|
|
90
|
+
// not reachable through the carve-out: preserving a joiner requires cursive or
|
|
91
|
+
// emoji neighbours on BOTH sides, so a preserved joiner can never sit inside an
|
|
92
|
+
// escape sequence, and every joiner that hides one is therefore stripped by the
|
|
93
|
+
// FIRST invisible pass. Past the bound the composition simply stops early — the
|
|
94
|
+
// final sweep still holds the no-introducer guarantee, and what is left degrades
|
|
95
|
+
// to visible text (the fail-open direction), exactly as with MAX_ANSI_PASSES.
|
|
96
|
+
const MAX_LAYER1_PASSES = 4;
|
|
97
|
+
|
|
142
98
|
/**
|
|
143
99
|
* Layer 1: ANSI + invisible-char strip with a result guaranteed free of every
|
|
144
100
|
* raw ANSI control introducer (7-bit ESC U+001B and the whole 8-bit C1 control
|
|
145
101
|
* block U+0080–U+009F: CSI, the DCS/SOS/OSC/PM/APC string introducers, and ST).
|
|
146
102
|
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
103
|
+
* The two passes FEED each other in both directions, so neither ordering is
|
|
104
|
+
* enough on its own and the composition is iterated to a fixed point instead:
|
|
105
|
+
* removing an invisible char reconstitutes an escape its split hid
|
|
106
|
+
* (`ESC`<ZWSP>`[32m` → `ESC[32m`), and removing an escape makes two invisibles
|
|
107
|
+
* ADJACENT that were not (`م ZWJ ESC[m ZWJ م` → a joiner run the invisible pass
|
|
108
|
+
* classifies as a payload channel rather than linguistic). A pipeline with a
|
|
109
|
+
* fixed number of alternations always leaves one of those unanswered — this used
|
|
110
|
+
* to re-strip ANSI after the invisible pass and stop, so `applyLayer1` was not
|
|
111
|
+
* idempotent and the rehydrator's "re-cleaning reproduces the view" assumption
|
|
112
|
+
* did not hold.
|
|
113
|
+
*
|
|
114
|
+
* The residual sweep runs only once the composition is STABLE, because sweeping
|
|
115
|
+
* an introducer early destroys the sequence a later ANSI pass would have removed
|
|
116
|
+
* whole, promoting a hidden control to visible text. A final UNCONDITIONAL sweep
|
|
117
|
+
* follows the loop so the no-raw-introducer guarantee does not depend on the
|
|
118
|
+
* pass bound.
|
|
119
|
+
*
|
|
120
|
+
* `deAnsi` is the ANSI strip of the ORIGINAL text (invisible runs intact), the
|
|
121
|
+
* scope a LONG_RUN payload check needs — not an intermediate of the loop.
|
|
157
122
|
* @param {string} text
|
|
158
123
|
* @returns {{ cleaned: string, deAnsi: string, found: string[] }}
|
|
159
124
|
*/
|
|
160
125
|
export function applyLayer1(text) {
|
|
161
126
|
const deAnsi = stripAnsiFully(text);
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
// index 0 of `deAnsi`) is treated as interior and stripped, not preserved.
|
|
167
|
-
const { cleaned: afterInvis, found } = stripInvisibleWithReport(deAnsi, text);
|
|
168
|
-
let ansiFound = deAnsi.length !== text.length;
|
|
127
|
+
/** @type {Set<string>} Union of the categories every iteration reported. */
|
|
128
|
+
const found = new Set();
|
|
129
|
+
let cleaned = text;
|
|
130
|
+
let ansiFound = false;
|
|
169
131
|
|
|
170
|
-
let
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
132
|
+
for (let pass = 0; pass < MAX_LAYER1_PASSES; pass++) {
|
|
133
|
+
const afterAnsi = pass === 0 ? deAnsi : stripAnsiFully(cleaned);
|
|
134
|
+
if (afterAnsi !== cleaned) ansiFound = true;
|
|
135
|
+
// stripInvisibleWithReport returns `found` for exactly the categories it
|
|
136
|
+
// removed — so a ZWNJ/ZWJ the carve-out PRESERVES never registers as a
|
|
137
|
+
// strip. The second argument stays the ORIGINAL `text` on every iteration:
|
|
138
|
+
// the leading-BOM exception is defined against what the user actually sent,
|
|
139
|
+
// so a BOM that was interior before an ANSI strip shifted it to index 0 is
|
|
140
|
+
// treated as interior and stripped, not preserved.
|
|
141
|
+
const { cleaned: afterInvis, found: passFound } = stripInvisibleWithReport(
|
|
142
|
+
afterAnsi,
|
|
143
|
+
text,
|
|
144
|
+
);
|
|
145
|
+
for (const category of passFound) found.add(category);
|
|
146
|
+
if (afterInvis !== cleaned) {
|
|
147
|
+
cleaned = afterInvis;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
// {ANSI, invisible} is stable: nothing left can reconstitute, so the
|
|
151
|
+
// residual introducers can be swept without hiding a sequence from a later
|
|
152
|
+
// pass. A sweep that changes the text feeds one more round (removing an
|
|
153
|
+
// introducer can make invisibles adjacent, exactly as removing a sequence
|
|
154
|
+
// can); one that changes nothing means the whole composition has converged.
|
|
155
|
+
const swept = cleaned.replace(CONTROL_INTRODUCER_RE, "");
|
|
156
|
+
if (swept === cleaned) break;
|
|
157
|
+
cleaned = swept;
|
|
158
|
+
ansiFound = true;
|
|
175
159
|
}
|
|
160
|
+
|
|
176
161
|
const swept = cleaned.replace(CONTROL_INTRODUCER_RE, "");
|
|
177
162
|
if (swept !== cleaned) {
|
|
178
163
|
cleaned = swept;
|
|
179
164
|
ansiFound = true;
|
|
180
165
|
}
|
|
181
166
|
|
|
182
|
-
if (ansiFound) found.
|
|
183
|
-
return { cleaned, deAnsi, found };
|
|
167
|
+
if (ansiFound) found.add(CATEGORY.ANSI);
|
|
168
|
+
return { cleaned, deAnsi, found: [...found] };
|
|
184
169
|
}
|
package/src/prompt.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
isSgrOnly,
|
|
28
28
|
} from "./invisible.mjs";
|
|
29
29
|
import { stripAnsiFully } from "./layer1.mjs";
|
|
30
|
+
import { CONTROL_INTRODUCER_SOURCE } from "./ansi.mjs";
|
|
30
31
|
|
|
31
32
|
// Every raw ANSI control a prompt can carry: 7-bit ESC (U+001B) and the entire
|
|
32
33
|
// 8-bit C1 block (U+0080-U+009F). Gating on ESC alone -- or on only CSI/OSC --
|
|
@@ -34,12 +35,14 @@ import { stripAnsiFully } from "./layer1.mjs";
|
|
|
34
35
|
// (CSI erase), `U+009D 0;...BEL` (OSC), or the string introducers DCS (U+0090),
|
|
35
36
|
// SOS (U+0098), PM (U+009E), APC (U+009F): Layer 1 strips it, dropping the
|
|
36
37
|
// invisible count to zero, so the prompt reads clean and passes. This gate must
|
|
37
|
-
// match Layer 1's residual sweep
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
38
|
+
// match Layer 1's residual sweep exactly -- no raw C1 control belongs in a
|
|
39
|
+
// legitimate prompt (the SGR color carve-out is applied separately, after SGR
|
|
40
|
+
// removal), so the whole block is gated, not a hand-picked subset that lets
|
|
41
|
+
// DCS/SOS/PM/APC through. Built from the SHARED charset source instead of a
|
|
42
|
+
// third hand-written copy: keeping the copies in step used to be a prose
|
|
43
|
+
// obligation recorded in this very comment, and two of the three spelled ESC
|
|
44
|
+
// differently, so even a grep-based drift check would have missed a divergence.
|
|
45
|
+
const ANSI_INTRODUCER = new RegExp(CONTROL_INTRODUCER_SOURCE);
|
|
43
46
|
|
|
44
47
|
/**
|
|
45
48
|
* True when every ANSI introducer in `prompt` belongs to a display-only SGR
|
package/types/ansi.d.mts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tokenize every raw control introducer in `text`.
|
|
3
|
+
*
|
|
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
|
|
6
|
+
* sequences are in this text" are answered by the same scan. That is what lets
|
|
7
|
+
* the stripper (splice every non-orphan token, then sweep) and the SGR-only
|
|
8
|
+
* predicate (every token is SGR) agree by construction.
|
|
9
|
+
*
|
|
10
|
+
* Tokens are disjoint and ordered by `start`; each `end` is strictly greater
|
|
11
|
+
* than its `start`, so the scan always advances.
|
|
12
|
+
* @param {string} text
|
|
13
|
+
* @returns {AnsiToken[]}
|
|
14
|
+
*/
|
|
15
|
+
export function scanAnsi(text: string): AnsiToken[];
|
|
16
|
+
/**
|
|
17
|
+
* The ONE ANSI grammar: the raw control-introducer charset and the tokenizer
|
|
18
|
+
* every consumer scans with.
|
|
19
|
+
*
|
|
20
|
+
* Two modules need this grammar and they cannot import each other —
|
|
21
|
+
* `layer1.mjs` imports `invisible.mjs`, so `invisible.mjs` (which owns the
|
|
22
|
+
* public `isSgrOnly` / `SGR_RE`) must not import back. Before this module the
|
|
23
|
+
* grammar was therefore written out twice with DIFFERENT param rules
|
|
24
|
+
* (`invisible.mjs`'s SGR regex accepted any digit run, `layer1.mjs`'s CSI
|
|
25
|
+
* branch capped each parameter at four digits), and the introducer charset
|
|
26
|
+
* three times. The looser copy suppressed the operator warning for a sequence
|
|
27
|
+
* the stripper could not match: `ESC[12345m` read as "display-only colour"
|
|
28
|
+
* while `[12345m` was spliced into the model's view as visible text. One
|
|
29
|
+
* tokenizer, one charset, consumed by both — the disagreement cannot recur.
|
|
30
|
+
*
|
|
31
|
+
* Same precedent (and same reason) as `cf-charset.mjs`: a dependency-free leaf
|
|
32
|
+
* module both layers read from.
|
|
33
|
+
*/
|
|
34
|
+
export const CONTROL_INTRODUCER_SOURCE: "[\\u001b\\u0080-\\u009f]";
|
|
35
|
+
/**
|
|
36
|
+
* Public alias kept for compatibility (re-exported by `invisible.mjs` and the
|
|
37
|
+
* package root). It is now DERIVED: {@link scanAnsi} classifies a token as SGR
|
|
38
|
+
* by testing the token's own text against this exact source, so the predicate
|
|
39
|
+
* and the regex can no longer describe different languages.
|
|
40
|
+
*/
|
|
41
|
+
export const SGR_RE: RegExp;
|
|
42
|
+
/** The four things an introducer can turn out to be. */
|
|
43
|
+
export const TOKEN_KIND: Readonly<{
|
|
44
|
+
/** A display-only `ESC[…m` / `U+009B…m` colour sequence. */
|
|
45
|
+
SGR: "sgr";
|
|
46
|
+
/** Any other complete CSI / two-byte escape (cursor move, erase, charset). */
|
|
47
|
+
CSI: "csi";
|
|
48
|
+
/** An OSC string: introducer, body and terminator as one unit. */
|
|
49
|
+
OSC: "osc";
|
|
50
|
+
/** An introducer that starts no sequence the grammar recognizes. */
|
|
51
|
+
ORPHAN: "orphan-introducer";
|
|
52
|
+
}>;
|
|
53
|
+
export type AnsiToken = {
|
|
54
|
+
/**
|
|
55
|
+
* Index of the introducer.
|
|
56
|
+
*/
|
|
57
|
+
start: number;
|
|
58
|
+
/**
|
|
59
|
+
* Index one past the last character of the token.
|
|
60
|
+
*/
|
|
61
|
+
end: number;
|
|
62
|
+
/**
|
|
63
|
+
* One of {@link TOKEN_KIND}.
|
|
64
|
+
*/
|
|
65
|
+
kind: string;
|
|
66
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declare a hook's failure posture. Called at module scope by each hook, so
|
|
3
|
+
* importing the hook is what makes its posture reachable.
|
|
4
|
+
* @param {string} hook a member of {@link FAULT_POLICY_HOOKS}
|
|
5
|
+
* @param {FaultPolicy} policy
|
|
6
|
+
* @returns {void}
|
|
7
|
+
*/
|
|
8
|
+
export function registerFaultPolicy(hook: string, policy: FaultPolicy): void;
|
|
9
|
+
/**
|
|
10
|
+
* The registered policy for `hook`. Throws rather than defaulting: a hook whose
|
|
11
|
+
* posture nobody declared has no defensible default — guessing OPEN would let
|
|
12
|
+
* the guarded action through on a hook an operator believed was strict, and
|
|
13
|
+
* guessing CLOSED would block a session on a wiring bug.
|
|
14
|
+
* @param {string} hook
|
|
15
|
+
* @returns {FaultPolicy}
|
|
16
|
+
*/
|
|
17
|
+
export function faultPolicy(hook: string): FaultPolicy;
|
|
18
|
+
/**
|
|
19
|
+
* Resolve `hook`'s response to its own failure under the caller's posture. This
|
|
20
|
+
* is the ONLY place {@link failOpenEnabled} is consulted on a hook fault, so the
|
|
21
|
+
* knob cannot be honored in three hooks and skipped in the fourth.
|
|
22
|
+
* @param {string} hook
|
|
23
|
+
* @param {unknown} err
|
|
24
|
+
* @param {Record<string, any> & {
|
|
25
|
+
* env?: NodeJS.ProcessEnv | Record<string, string | undefined>,
|
|
26
|
+
* }} [ctx] hook-specific inputs threaded to the builders (a message table, the
|
|
27
|
+
* parsed input, a remedy). `env` selects the posture; it is threaded to the
|
|
28
|
+
* builders along with the rest, though none reads it — the posture is resolved
|
|
29
|
+
* here precisely so a builder never has to.
|
|
30
|
+
* @returns {FaultOutcome}
|
|
31
|
+
*/
|
|
32
|
+
export function hookFaultOutcome(hook: string, err: unknown, ctx?: Record<string, any> & {
|
|
33
|
+
env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
|
|
34
|
+
}): FaultOutcome;
|
|
35
|
+
/**
|
|
36
|
+
* Render an outcome's stdout/stderr halves and return its exit code. The caller
|
|
37
|
+
* decides what to do with the code (a hook that must keep running ignores it),
|
|
38
|
+
* and performs `armAlert` itself — persisting the alert needs the hook's own
|
|
39
|
+
* report text, which this module has no view of.
|
|
40
|
+
* @param {FaultOutcome} outcome
|
|
41
|
+
* @param {(chunk: string) => void} [write]
|
|
42
|
+
* @param {(chunk: string) => void} [writeErr]
|
|
43
|
+
* @returns {number}
|
|
44
|
+
*/
|
|
45
|
+
export function writeFaultOutcome(outcome: FaultOutcome, write?: (chunk: string) => void, writeErr?: (chunk: string) => void): number;
|
|
46
|
+
/**
|
|
47
|
+
* The hook modules that must register a fault policy — every CLI entry point in
|
|
48
|
+
* `claude-hooks/*.mjs`. Declared here rather than discovered so registering an
|
|
49
|
+
* unknown name is an error instead of a typo nobody notices.
|
|
50
|
+
* @type {readonly string[]}
|
|
51
|
+
*/
|
|
52
|
+
export const FAULT_POLICY_HOOKS: readonly string[];
|
|
53
|
+
/**
|
|
54
|
+
* What a hook's fault renders to. `fields`/`envelope` are the stdout response
|
|
55
|
+
* (`fields` is the `hookSpecificOutput` body, wrapped with the policy's event;
|
|
56
|
+
* `envelope` is a hook that answers with a top-level shape instead, like
|
|
57
|
+
* UserPromptSubmit's `{decision:"block"}`); `stderr` and `exitCode` are the
|
|
58
|
+
* process-level halves a hook with no stdout channel uses; `armAlert` asks the
|
|
59
|
+
* caller to persist its cross-hook alert so a LATER gate carries the closed
|
|
60
|
+
* posture the faulting hook could not express itself.
|
|
61
|
+
*/
|
|
62
|
+
export type FaultOutcome = {
|
|
63
|
+
posture: "open" | "closed";
|
|
64
|
+
fields: Record<string, unknown> | null;
|
|
65
|
+
fallbackFields: Record<string, unknown> | null;
|
|
66
|
+
envelope: object | null;
|
|
67
|
+
stderr: string | null;
|
|
68
|
+
exitCode: number;
|
|
69
|
+
armAlert: boolean;
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* What a policy's `open`/`closed` builder returns: any subset of a
|
|
73
|
+
* {@link FaultOutcome}'s renderable slots. Everything omitted takes its default
|
|
74
|
+
* (no output, exit 0, no alert).
|
|
75
|
+
*/
|
|
76
|
+
export type FaultParts = {
|
|
77
|
+
fields?: Record<string, unknown>;
|
|
78
|
+
fallbackFields?: Record<string, unknown>;
|
|
79
|
+
envelope?: object;
|
|
80
|
+
stderr?: string;
|
|
81
|
+
exitCode?: number;
|
|
82
|
+
armAlert?: boolean;
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* The context a builder is handed: the caller's own inputs (whatever it passed
|
|
86
|
+
* to {@link hookFaultOutcome}) plus the three values every hook derived by hand
|
|
87
|
+
* before — the hook name, the scrubbed error message, and the model-facing
|
|
88
|
+
* open-posture warning.
|
|
89
|
+
*/
|
|
90
|
+
export type FaultContext = Record<string, any> & {
|
|
91
|
+
hook: string;
|
|
92
|
+
err: unknown;
|
|
93
|
+
message: string;
|
|
94
|
+
openContext: string;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* A hook's declared posture.
|
|
98
|
+
*/
|
|
99
|
+
export type FaultPolicy = {
|
|
100
|
+
event: string | null;
|
|
101
|
+
guarded: string;
|
|
102
|
+
open?: (ctx: FaultContext) => FaultParts;
|
|
103
|
+
closed: (ctx: FaultContext) => FaultParts;
|
|
104
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether `layers` places an erasing layer after a skip-based one — the ordering
|
|
3
|
+
* whose soundness needs a fixed point. Exported so the property is assertable
|
|
4
|
+
* about a table directly, not only through a run.
|
|
5
|
+
* @param {Layer[]} layers
|
|
6
|
+
* @returns {boolean}
|
|
7
|
+
*/
|
|
8
|
+
export function needsFixedPoint(layers: Layer[]): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Run a declared layer chain over one tool input.
|
|
11
|
+
*
|
|
12
|
+
* Returns the final input, whether anything changed, and the model-facing notes
|
|
13
|
+
* in the order they were produced (deduplicated: a fixed-point re-run that
|
|
14
|
+
* repeats a layer's note would otherwise say the same thing twice). A layer that
|
|
15
|
+
* denies ends the run immediately, with `deny` set.
|
|
16
|
+
* @param {string} tool
|
|
17
|
+
* @param {any} toolInput
|
|
18
|
+
* @param {Layer[]} layers
|
|
19
|
+
* @returns {Promise<{ updatedInput: any, changed: boolean, contexts: string[], deny?: string }>}
|
|
20
|
+
*/
|
|
21
|
+
export function runLayerPipeline(tool: string, toolInput: any, layers: Layer[]): Promise<{
|
|
22
|
+
updatedInput: any;
|
|
23
|
+
changed: boolean;
|
|
24
|
+
contexts: string[];
|
|
25
|
+
deny?: string;
|
|
26
|
+
}>;
|
|
27
|
+
/**
|
|
28
|
+
* The PreToolUse layer chain as a DECLARED pipeline instead of a run of
|
|
29
|
+
* sequential statements.
|
|
30
|
+
*
|
|
31
|
+
* The confusable fold (Layer 2) carries a soundness precondition: it deliberately
|
|
32
|
+
* SKIPS a token that still holds an unmapped non-ASCII glyph, because such a
|
|
33
|
+
* token can never come out byte-equal to an ASCII deny-rule target, so folding it
|
|
34
|
+
* would only mangle real foreign-language text. That argument holds only while no
|
|
35
|
+
* later layer ERASES code points from the same field — if it does, the glyph the
|
|
36
|
+
* fold relied on can disappear after the decision was taken.
|
|
37
|
+
*
|
|
38
|
+
* Layer 3 (authored-content stripping) erases exactly that: payload-capable
|
|
39
|
+
* invisible characters. Running it after the fold made the precondition false,
|
|
40
|
+
* and the gap was reachable: `cat /etc/p<CYRILLIC A><12 x ZWSP>sswd` put the
|
|
41
|
+
* zero-width run inside the token, so the fold skipped it, then the strip removed
|
|
42
|
+
* the padding and emitted `cat /etc/p<CYRILLIC A>sswd` — the homoglyph intact and
|
|
43
|
+
* the evidence for skipping it gone. Zero-width padding suppressed the fold and
|
|
44
|
+
* the next layer erased the reason.
|
|
45
|
+
*
|
|
46
|
+
* The eliminator is structural: layers declare whether they ERASE code points and
|
|
47
|
+
* whether their decision is SKIP-BASED (suppressible by code points another layer
|
|
48
|
+
* erases), and this driver enforces the precondition rather than a comment
|
|
49
|
+
* asking a future reader to preserve it. When the table puts an erasing layer
|
|
50
|
+
* after a skip-based one, the driver re-runs the body to a FIXED POINT, so the
|
|
51
|
+
* emitted value is one every skip-based layer has seen in its final form. A table
|
|
52
|
+
* that needs no fixed point runs exactly once, so the cost is paid only by the
|
|
53
|
+
* ordering that creates the hazard.
|
|
54
|
+
*
|
|
55
|
+
* Layers may be `terminal`: run once, after the fixed point, never re-run. That
|
|
56
|
+
* is Layer 4 (rehydration), whose whole contract is that it sees the FINAL
|
|
57
|
+
* authored text and its restored secrets are not re-stripped by Layer 3. Terminal
|
|
58
|
+
* layers must be a suffix of the table, which the driver checks.
|
|
59
|
+
*/
|
|
60
|
+
/**
|
|
61
|
+
* A layer's result: the rewritten input plus the model-facing note, a `deny`
|
|
62
|
+
* verdict that ends the pipeline, or null when the layer changed nothing.
|
|
63
|
+
* @typedef {{ updatedInput: any, context: string } | { deny: string } | null} LayerResult
|
|
64
|
+
*/
|
|
65
|
+
/**
|
|
66
|
+
* One declared layer.
|
|
67
|
+
* - `erases` — may REMOVE code points from a field another layer reads. This is
|
|
68
|
+
* the property that can invalidate a skip-based decision taken earlier.
|
|
69
|
+
* - `skipBased` — its decision can be suppressed by code points present at the
|
|
70
|
+
* time it ran. Such a layer must be re-run after any erasure.
|
|
71
|
+
* - `terminal` — runs once after the fixed point and is never re-run.
|
|
72
|
+
* @typedef {{
|
|
73
|
+
* name: string,
|
|
74
|
+
* erases: boolean,
|
|
75
|
+
* skipBased: boolean,
|
|
76
|
+
* terminal?: boolean,
|
|
77
|
+
* run: (tool: string, input: any) => LayerResult | Promise<LayerResult>,
|
|
78
|
+
* }} Layer
|
|
79
|
+
*/
|
|
80
|
+
/**
|
|
81
|
+
* Bound on fixed-point passes. Each pass either changes the input or ends the
|
|
82
|
+
* loop, and the layers are contractive in practice (folding and stripping both
|
|
83
|
+
* shrink the space of remaining findings), so a run that is still changing after
|
|
84
|
+
* this many passes is a layer that oscillates — a bug. Throwing hands it to the
|
|
85
|
+
* hook's fail-closed catch, which is the loud outcome; looping forever would be
|
|
86
|
+
* silently killed by the harness and read as a non-blocking pass (fail OPEN).
|
|
87
|
+
*/
|
|
88
|
+
export const MAX_PIPELINE_PASSES: 8;
|
|
89
|
+
/**
|
|
90
|
+
* A layer's result: the rewritten input plus the model-facing note, a `deny`
|
|
91
|
+
* verdict that ends the pipeline, or null when the layer changed nothing.
|
|
92
|
+
*/
|
|
93
|
+
export type LayerResult = {
|
|
94
|
+
updatedInput: any;
|
|
95
|
+
context: string;
|
|
96
|
+
} | {
|
|
97
|
+
deny: string;
|
|
98
|
+
} | null;
|
|
99
|
+
/**
|
|
100
|
+
* One declared layer.
|
|
101
|
+
* - `erases` — may REMOVE code points from a field another layer reads. This is
|
|
102
|
+
* the property that can invalidate a skip-based decision taken earlier.
|
|
103
|
+
* - `skipBased` — its decision can be suppressed by code points present at the
|
|
104
|
+
* time it ran. Such a layer must be re-run after any erasure.
|
|
105
|
+
* - `terminal` — runs once after the fixed point and is never re-run.
|
|
106
|
+
*/
|
|
107
|
+
export type Layer = {
|
|
108
|
+
name: string;
|
|
109
|
+
erases: boolean;
|
|
110
|
+
skipBased: boolean;
|
|
111
|
+
terminal?: boolean;
|
|
112
|
+
run: (tool: string, input: any) => LayerResult | Promise<LayerResult>;
|
|
113
|
+
};
|
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The declared layer chain, layers 2-4. Every entry states the two properties
|
|
3
|
+
* the driver reasons about — whether it ERASES code points another layer reads,
|
|
4
|
+
* and whether its own decision is SKIP-BASED and therefore invalidated by such
|
|
5
|
+
* an erasure — so the confusable fold's ordering precondition is enforced by the
|
|
6
|
+
* table instead of restated as a comment.
|
|
7
|
+
*
|
|
8
|
+
* Layer 3 is dropped from the table (not merely skipped at run time) when
|
|
9
|
+
* AGENT_SANITIZER_OUTPUT_DISABLED=1, so the driver sees the chain that will
|
|
10
|
+
* actually run: with no erasing layer left after the fold there is no fixed
|
|
11
|
+
* point to reach and no extra pass to pay for.
|
|
12
|
+
* @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} rehydrate
|
|
13
|
+
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
|
|
14
|
+
* @returns {import("./lib/layer-pipeline.mjs").Layer[]}
|
|
15
|
+
*/
|
|
16
|
+
export function preToolUseLayers(rehydrate: (tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>, env?: NodeJS.ProcessEnv | Record<string, string | undefined>): import("./lib/layer-pipeline.mjs").Layer[];
|
|
1
17
|
/**
|
|
2
18
|
* Compose the four protections. Returns the `hookSpecificOutput` fields to
|
|
3
19
|
* emit, or null for a clean no-op. Throws only if a layer's engine throws; the
|