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/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
- // Raw control introducers that must not survive: 7-bit ESC (U+001B) and the
18
- // entire 8-bit C1 control block (U+0080–U+009F) — which includes CSI (U+009B),
19
- // the string introducers DCS/SOS/OSC/PM/APC (U+0090/0098/009D/009E/009F), and ST
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
- // An OSC (Operating System Command) string is `<introducer> … <terminator>`.
30
- // Introducer: 7-bit `ESC]` or 8-bit C1 OSC (U+009D). Terminator: ST (`ESC\` or
31
- // 8-bit C1 ST U+009C) OR the legacy BEL (U+0007). The body is everything up to
32
- // the terminator — a title, a clickable-hyperlink URL, a clipboard write — i.e.
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.replace(ANSI_RE, "");
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.replace(ANSI_RE, "");
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
- * Removing an invisible character can reconstitute an escape its split hid from
148
- * the ANSI pass (`ESC`<ZWSP>`[32m` → `ESC[32m`), so strip ANSI again after the
149
- * invisible pass — but only when stripInvisible changed something, since
150
- * reconstitution is impossible otherwise and the re-strip is a wasted pass on
151
- * the hot clean path. The ANSI strip still cannot match an *incomplete*
152
- * reconstituted sequence (a lone `ESC[` left when an inner complete sequence is
153
- * removed from a nested split), so a final sweep removes every residual raw
154
- * introducer outright — that sweep, not the regex matching, is the guarantee
155
- * that no control introducer survives. `deAnsi` is the ANSI strip of the
156
- * original (invisible runs intact), the scope a LONG_RUN payload check needs.
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
- // stripInvisibleWithReport returns `found` for exactly the categories it
163
- // removed — so a ZWNJ/ZWJ the carve-out PRESERVES never registers as a strip,
164
- // and the leading-BOM exception is already handled inside it. Pass the ORIGINAL
165
- // `text` so a BOM that was interior before the ANSI strip (`ESC[m + interior U+FEFF`, now at
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 cleaned = afterInvis;
171
- if (afterInvis !== deAnsi) {
172
- const reStripped = stripAnsiFully(afterInvis);
173
- if (reStripped.length !== afterInvis.length) ansiFound = true;
174
- cleaned = reStripped;
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.push(CATEGORY.ANSI);
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 (CONTROL_INTRODUCER_RE) exactly -- no raw C1
38
- // control belongs in a legitimate prompt (the SGR color carve-out is applied
39
- // separately, after SGR removal), so the whole block is gated, not a hand-picked
40
- // subset that lets DCS/SOS/PM/APC through.
41
- // eslint-disable-next-line no-control-regex -- the raw control introducers are exactly what we detect
42
- const ANSI_INTRODUCER = /[\u001b\u0080-\u009f]/;
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
@@ -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