agent-sanitizer 2.19.2 → 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.
@@ -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
@@ -1,16 +1,86 @@
1
+ /**
2
+ * Scan every instruction file under the project, ACCOUNTING for every target
3
+ * the finder returned: `scanned + skipped.length === targets.length`, always.
4
+ *
5
+ * The accounting is the point. This scan is the only thing standing between a
6
+ * poisoned `CLAUDE.md` and a session that loads it as instructions, and its
7
+ * caller announces "clean" on the trace channel — the channel that exists so a
8
+ * MISSING announcement is loud. A per-file failure swallowed into an empty
9
+ * findings list turns "we could not read this file" into "this file is fine",
10
+ * which is the one lie this hook must never tell. So a file that cannot be read
11
+ * is REPORTED as unscanned, not dropped.
12
+ *
13
+ * ANY errno is a skip; only a non-filesystem throw propagates. The split is
14
+ * between "this file could not be read" (report it and keep scanning) and "this
15
+ * code is broken" (a TypeError from an unloaded binding — nothing here can be
16
+ * trusted, so it goes to the caller's declared failure posture). Catching only
17
+ * ENOENT would invert the enforcement: one EACCES target would discard the
18
+ * result for EVERY other instruction file, leaving them unscanned and
19
+ * un-auto-cleaned, and under the shipped OPEN posture the hook fault arms
20
+ * nothing — so the SUSPICIOUS failure would get weaker enforcement than the
21
+ * benign glob race, which reaches `partial` and arms the gate. Same errno-vs-bug
22
+ * split {@link autoCleanFindings} uses.
23
+ * @param {string} [dir] project root to scan (injectable for tests)
24
+ * @returns {{
25
+ * targets: string[],
26
+ * scanned: number,
27
+ * findings: Array<{file: string, findings: ReturnType<typeof scanFile>}>,
28
+ * skipped: Array<{file: string, reason: string}>,
29
+ * }}
30
+ */
31
+ export function scanProject(dir?: string): {
32
+ targets: string[];
33
+ scanned: number;
34
+ findings: Array<{
35
+ file: string;
36
+ findings: ReturnType<typeof scanFile>;
37
+ }>;
38
+ skipped: Array<{
39
+ file: string;
40
+ reason: string;
41
+ }>;
42
+ };
43
+ /**
44
+ * The report for targets the scan could not read. Rendered into the alert the
45
+ * PreToolUse gate surfaces, so an incomplete scan reaches the operator as a
46
+ * checkpoint rather than as silence.
47
+ * @param {Array<{file: string, reason: string}>} skipped
48
+ * @returns {string}
49
+ */
50
+ export function formatSkipped(skipped: Array<{
51
+ file: string;
52
+ reason: string;
53
+ }>): string;
1
54
  /**
2
55
  * The hook's CLI: scan the instruction files, auto-clean what it can, persist
3
56
  * the alert for the PreToolUse gate otherwise. Exported so a bundle entry
4
57
  * (which must claim the CLI slot before this module loads) can run the exact
5
58
  * same scan instead of duplicating it.
6
- * @param {{ trace?: import("./lib/trace.mjs").TraceFn }} [opts] `trace` is where
7
- * this scan announces engagement; a host with its own trace channel passes its
8
- * sink so the announcement lands where its detector reads (see lib/trace.mjs).
59
+ * @param {{
60
+ * trace?: import("./lib/trace.mjs").TraceFn,
61
+ * scan?: () => ReturnType<typeof scanProject>,
62
+ * }} [opts] `trace` is where this scan announces engagement; a host with its
63
+ * own trace channel passes its sink so the announcement lands where its
64
+ * detector reads (see lib/trace.mjs). `scan` is the scanner, injectable so the
65
+ * FAULT path below — a scanner that throws something other than an errno, i.e.
66
+ * a bug — is drivable end to end; no filesystem state can force it, and an
67
+ * untested fault path is how a posture goes missing in the first place.
9
68
  * @returns {Promise<void>}
10
69
  */
11
- export function cliMain({ trace: sink }?: {
70
+ export function cliMain({ trace: sink, scan: runScan }?: {
12
71
  trace?: import("./lib/trace.mjs").TraceFn;
72
+ scan?: () => ReturnType<typeof scanProject>;
13
73
  }): Promise<void>;
74
+ /**
75
+ * @param {string} filePath
76
+ * @returns {Array<{ line: number, charCount: number, method: string, decoded: string }>}
77
+ */
78
+ export function scanFile(filePath: string): Array<{
79
+ line: number;
80
+ charCount: number;
81
+ method: string;
82
+ decoded: string;
83
+ }>;
14
84
  /**
15
85
  * @param {string} run
16
86
  * @returns {{ method: string, decoded: string }}
@@ -39,16 +109,6 @@ export function findMdFiles(dir: string): string[];
39
109
  * @returns {string[]}
40
110
  */
41
111
  export function findInstructionFiles(dir: string): string[];
42
- /**
43
- * @param {string} filePath
44
- * @returns {Array<{ line: number, charCount: number, method: string, decoded: string }>}
45
- */
46
- export function scanFile(filePath: string): Array<{
47
- line: number;
48
- charCount: number;
49
- method: string;
50
- decoded: string;
51
- }>;
52
112
  import { ALERT_FILE } from "./lib/invisible-alert.mjs";
53
113
  import { ALERT_ACK_FILE } from "./lib/invisible-alert.mjs";
54
114
  export let LONG_RUN_RE: RegExp;
@@ -113,8 +113,12 @@ export function normalizeConfusables(tool: string, toolInput: any, { scan, field
113
113
  *
114
114
  * ORDERING: the soundness argument assumes no later layer erases code points
115
115
  * from the same field, which would let an unmapped glyph the gate relied on
116
- * disappear after the decision. Layer 4 runs before sanitizeAuthoredContent on
117
- * Bash.command; keep it there.
116
+ * disappear after the decision — a zero-width run padded into a token suppresses
117
+ * the fold, and the erasing layer then removes the very evidence for skipping it.
118
+ * This fold does NOT run last: on Bash.command the invisible-char strip follows
119
+ * it. A caller that composes the two is therefore responsible for re-running
120
+ * this fold on the post-erasure text until it reports nothing, which is what the
121
+ * hook driver in claude-hooks/lib/layer-pipeline.mjs does.
118
122
  *
119
123
  * Genuine non-confusable non-ASCII (accented Latin, CJK, emoji) is untouched
120
124
  * regardless, since a faithful scanner does not flag it.
@@ -1,8 +1,15 @@
1
1
  /**
2
2
  * True when every ANSI control introducer in `text` belongs to a display-only
3
- * SGR color sequence (so stripping the ANSI removed only cosmetic styling,
3
+ * SGR color sequence (so stripping the ANSI removes only cosmetic styling,
4
4
  * nothing that could move the cursor, erase, or carry a payload). Recognizes
5
5
  * both the 7-bit `ESC[…m` and 8-bit C1 (`U+009B…m`) SGR encodings.
6
+ *
7
+ * Answered by the STRIPPER'S OWN tokenizer: scanAnsi emits one token per raw
8
+ * introducer — 7-bit ESC and the whole C1 block, so a C1 cursor-move
9
+ * (`U+009B 2J`), a C1-OSC string (`U+009D … BEL`), a C1-DCS/APC payload and a
10
+ * lone or partial escape each yield a non-SGR token — and the predicate is
11
+ * "every token is SGR". Because the same scan decides what Layer 1 splices,
12
+ * this can no longer report "colour only" for bytes the stripper leaves behind.
6
13
  * @param {string} text
7
14
  * @returns {boolean}
8
15
  */
@@ -84,7 +91,7 @@ export const CATEGORY_LABELS: Readonly<Record<string, string>>;
84
91
  /** @type {Array<[string, RegExp]>} Each entry pairs a CATEGORY code with its detector. */
85
92
  export const CHECKS: Array<[string, RegExp]>;
86
93
  export const STRIP: RegExp;
87
- export const SGR_RE: RegExp;
94
+ export { SGR_RE } from "./ansi.mjs";
88
95
  export const LONG_RUN_THRESHOLD: 10;
89
96
  /** Total invisible-char count above which a file/prompt is treated as
90
97
  * payload-capable even without a long run (threshold-evasion catch). */
@@ -96,3 +103,5 @@ export const TOTAL_PRESERVED_JOINER_BUDGET: 16;
96
103
  export const PRESERVED_JOINER_PER_VISIBLE: 8;
97
104
  export const PRESERVE_HARD_CAP: 64;
98
105
  export const LINGUISTIC_SCRIPTS: string[];
106
+ /** @type {ReadonlyArray<readonly [string, number, number]>} */
107
+ export const BRAHMIC_CONSONANT_RANGES: ReadonlyArray<readonly [string, number, number]>;
@@ -19,16 +19,25 @@ export function stripAnsiFully(input: string): string;
19
19
  * raw ANSI control introducer (7-bit ESC U+001B and the whole 8-bit C1 control
20
20
  * block U+0080–U+009F: CSI, the DCS/SOS/OSC/PM/APC string introducers, and ST).
21
21
  *
22
- * Removing an invisible character can reconstitute an escape its split hid from
23
- * the ANSI pass (`ESC`<ZWSP>`[32m` → `ESC[32m`), so strip ANSI again after the
24
- * invisible pass — but only when stripInvisible changed something, since
25
- * reconstitution is impossible otherwise and the re-strip is a wasted pass on
26
- * the hot clean path. The ANSI strip still cannot match an *incomplete*
27
- * reconstituted sequence (a lone `ESC[` left when an inner complete sequence is
28
- * removed from a nested split), so a final sweep removes every residual raw
29
- * introducer outright — that sweep, not the regex matching, is the guarantee
30
- * that no control introducer survives. `deAnsi` is the ANSI strip of the
31
- * original (invisible runs intact), the scope a LONG_RUN payload check needs.
22
+ * The two passes FEED each other in both directions, so neither ordering is
23
+ * enough on its own and the composition is iterated to a fixed point instead:
24
+ * removing an invisible char reconstitutes an escape its split hid
25
+ * (`ESC`<ZWSP>`[32m` → `ESC[32m`), and removing an escape makes two invisibles
26
+ * ADJACENT that were not (`م ZWJ ESC[m ZWJ م` → a joiner run the invisible pass
27
+ * classifies as a payload channel rather than linguistic). A pipeline with a
28
+ * fixed number of alternations always leaves one of those unanswered — this used
29
+ * to re-strip ANSI after the invisible pass and stop, so `applyLayer1` was not
30
+ * idempotent and the rehydrator's "re-cleaning reproduces the view" assumption
31
+ * did not hold.
32
+ *
33
+ * The residual sweep runs only once the composition is STABLE, because sweeping
34
+ * an introducer early destroys the sequence a later ANSI pass would have removed
35
+ * whole, promoting a hidden control to visible text. A final UNCONDITIONAL sweep
36
+ * follows the loop so the no-raw-introducer guarantee does not depend on the
37
+ * pass bound.
38
+ *
39
+ * `deAnsi` is the ANSI strip of the ORIGINAL text (invisible runs intact), the
40
+ * scope a LONG_RUN payload check needs — not an intermediate of the loop.
32
41
  * @param {string} text
33
42
  * @returns {{ cleaned: string, deAnsi: string, found: string[] }}
34
43
  */
@@ -26,8 +26,21 @@ export function describeWarned(warned: {
26
26
  /**
27
27
  * Delete each verbatim span in `spans` from `text`. The secure Layer-5
28
28
  * primitive: a filter can only ask for deletions, so this can never inject
29
- * bytes. Returns the new text and how many distinct span-occurrences were
30
- * removed (0 when no span was present).
29
+ * bytes. Returns the new text and how many span occurrences were removed (0
30
+ * when no span was present).
31
+ *
32
+ * Every occurrence is located in the ORIGINAL `text` and the deletions applied
33
+ * in one ordered pass ({@link spliceOrdered}), so every removed byte lies inside
34
+ * a match some span had in the INPUT. Deleting span-by-span with a
35
+ * chained `split`/`join` would not hold that line: an earlier deletion joins the
36
+ * bytes on either side of it and can CREATE a match for a later span that never
37
+ * occurred in the input — `deleteVerbatimSpans("PRE-XX-POST", ["-XX-", "PREPOST"])`
38
+ * then deletes the whole document. That would widen the Layer-5 seam's blast
39
+ * radius (see the module doc) from "a compromised filter can at most remove the
40
+ * content it named" to "it can remove content it never named".
41
+ *
42
+ * Overlapping spans are resolved first-match-wins, so `removed` counts the
43
+ * occurrences actually spliced out, never a double-count of the same bytes.
31
44
  * @param {string} text
32
45
  * @param {string[]} spans
33
46
  * @returns {{ text: string, removed: number }}
@@ -106,9 +106,15 @@ export function resolveSpan(content: string, cleaned: string, view: {
106
106
  }[];
107
107
  } | null;
108
108
  /**
109
- * All occurrences of any needle in `text`, ordered by position. Placeholder
110
- * texts never substring-overlap one another (each ends in "]" right after its
111
- * distinguishing label), so the sorted matches are non-overlapping.
109
+ * All occurrences of any needle in `text`, ordered by position. Every index is
110
+ * computed against the ORIGINAL `text`, so the caller can splice them in one
111
+ * pass ({@link spliceOrdered}). Redaction placeholder texts never
112
+ * substring-overlap one another (each ends in "]" right after its
113
+ * distinguishing label), so for that caller the sorted matches are also
114
+ * non-overlapping; needles from an untrusted source (a Layer-5 filter's
115
+ * removeSpans) can overlap, which spliceOrdered resolves first-match-wins.
116
+ * Distinct needles matching at the SAME index keep `needles` order (Array#sort
117
+ * is stable), so first-match-wins is deterministic.
112
118
  * @param {string} text
113
119
  * @param {string[]} needles
114
120
  * @returns {{text: string, index: number}[]}
@@ -117,6 +123,44 @@ export function orderedMatches(text: string, needles: string[]): {
117
123
  text: string;
118
124
  index: number;
119
125
  }[];
126
+ /**
127
+ * Replace every match in `matches` with `replacementFor(match, i)` in a SINGLE
128
+ * ordered pass over `text`. THE splice primitive for this codebase — the sole
129
+ * sound way to substitute several needles at once.
130
+ *
131
+ * A chained `text.split(needle).join(value)` per needle is unsound in both
132
+ * directions, which is why no caller may hand-roll one:
133
+ * - substitution: an inserted value whose bytes contain a LATER needle is
134
+ * re-matched by the next split and corrupted (or partially exposed);
135
+ * - deletion: an earlier deletion joins the bytes on either side of it and
136
+ * can CREATE a later needle's match, deleting text that needle never
137
+ * matched in the input ("PRE-XX-POST" minus "-XX-" yields "PREPOST").
138
+ * Because every index in `matches` is measured against the original `text`,
139
+ * this pass only ever touches bytes the caller actually matched.
140
+ *
141
+ * Overlapping matches are resolved first-match-wins: a match starting before
142
+ * the previous one ended is skipped, never spliced at a shifted offset.
143
+ * `i` is the match's index in `matches` (stable across skips) so a caller
144
+ * pairing matches positionally with its own array stays aligned.
145
+ * @param {string} text
146
+ * @param {{text: string, index: number}[]} matches ordered by index, indices into `text`
147
+ * @param {(match: {text: string, index: number}, i: number) => string} replacementFor
148
+ * @returns {{text: string, spans: {start: number, end: number}[]}} spliced text
149
+ * and the [start, end) range each replacement occupies in it
150
+ */
151
+ export function spliceOrdered(text: string, matches: {
152
+ text: string;
153
+ index: number;
154
+ }[], replacementFor: (match: {
155
+ text: string;
156
+ index: number;
157
+ }, i: number) => string): {
158
+ text: string;
159
+ spans: {
160
+ start: number;
161
+ end: number;
162
+ }[];
163
+ };
120
164
  /**
121
165
  * On-disk [start, end) span of every redaction pair, mapped from its view
122
166
  * offset through placeholder expansion (view → cleaned) and stripped invisible