agent-sanitizer 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Decode a run of invisible characters to its likely payload. Recognizes the
3
+ * two common smuggling encodings — Unicode tag characters (U+E0001–U+E007F map
4
+ * directly to ASCII) and zero-width binary (ZWSP=0, ZWNJ=1, ZWJ=separator) —
5
+ * and otherwise reports the raw code points. The tag-character payload is
6
+ * rendered as a neutral, quoted/escaped `untrusted data, not instructions: "…"`
7
+ * string (see {@link neutralizeTagBytes}) so the report can never re-inject the
8
+ * hidden instruction, and only U+E0020–U+E007E map to raw printable ASCII.
9
+ * @param {string} run
10
+ * @returns {{ method: string, decoded: string }}
11
+ */
12
+ export function decodeRun(run: string): {
13
+ method: string;
14
+ decoded: string;
15
+ };
16
+ /**
17
+ * Scan a file's text for hidden-Unicode injection. Reports each long invisible
18
+ * run (with its decoded payload) plus a single scattered-chars finding when the
19
+ * non-run invisible count crosses the threshold-evasion floor.
20
+ * @param {string} content
21
+ * @returns {Array<{ line: number | null, charCount: number, method: string, decoded: string }>}
22
+ * `line` is the 1-based line of a long-run finding, or `null` for the
23
+ * whole-file scattered-chars finding (not tied to a single line).
24
+ */
25
+ export function scanText(content: string): Array<{
26
+ line: number | null;
27
+ charCount: number;
28
+ method: string;
29
+ decoded: string;
30
+ }>;
31
+ /**
32
+ * Expand `globs` (relative to `cwd`) to absolute file paths, skipping
33
+ * `node_modules`. The glob set is the caller's instruction-file convention.
34
+ *
35
+ * Containment is enforced per match (see {@link keepContained}): a match whose
36
+ * glob pattern itself escapes `cwd` — via `..` or an absolute-path glob
37
+ * outside the tree — THROWS, since reaching outside the tree is a caller
38
+ * misconfiguration. A match that lexically stays inside `cwd` but resolves
39
+ * (via an in-tree symlink) to a target outside the tree, or that simply
40
+ * cannot be resolved (a dangling symlink or unreadable entry inside the
41
+ * tree), is SKIPPED, so one bad symlink never aborts scanning the rest of the
42
+ * project.
43
+ * @param {string[]} globs
44
+ * @param {{ cwd?: string }} [options]
45
+ * @returns {string[]}
46
+ */
47
+ export function findInstructionFiles(globs: string[], { cwd }?: {
48
+ cwd?: string;
49
+ }): string[];
50
+ /**
51
+ * Scan every instruction file matched by `globs` and return only those with
52
+ * findings, each path reported relative to `cwd`. Unreadable/missing files are
53
+ * skipped. Pure scan — no mutation; pair with {@link cleanFile} to strip.
54
+ * @param {string[]} globs
55
+ * @param {{ cwd?: string }} [options]
56
+ * @returns {Array<{ file: string, findings: ReturnType<typeof scanText> }>}
57
+ */
58
+ export function scanInstructionFiles(globs: string[], { cwd }?: {
59
+ cwd?: string;
60
+ }): Array<{
61
+ file: string;
62
+ findings: ReturnType<typeof scanText>;
63
+ }>;
64
+ /**
65
+ * Atomically replace `absPath`'s contents with `data`, preserving `mode`.
66
+ *
67
+ * Writes to a sibling temp in the same directory, then `rename`s it over the
68
+ * original (same dir => same filesystem => the rename is atomic, not a
69
+ * cross-device copy). The temp name is UNPREDICTABLE (`tmpName()` defaults to
70
+ * crypto-random) and the temp is created exclusively (O_CREAT|O_EXCL): if the
71
+ * path already exists — including an attacker-planted symlink at a guessable
72
+ * temp name — the open fails (EEXIST) and does NOT follow the link to clobber
73
+ * its target. On the rare collision we fail loud rather than retry into a
74
+ * different attacker-controlled path.
75
+ *
76
+ * Crash-safety (matching the doc claim): the temp fd is `fsync`ed before the
77
+ * rename and the directory fd is `fsync`ed after it, so a power loss can't leave
78
+ * the renamed name pointing at unflushed/empty data or lose the rename itself.
79
+ * The EXACT `mode` is applied with `fchmod` (openSync's create mode is
80
+ * umask-masked, so it alone would drop bits), and a failed write/sync `unlink`s
81
+ * the temp before rethrowing so no partial temp leaks. `tmpName` and `remove`
82
+ * are injectable fault-injection seams for tests (force a known temp path; drive
83
+ * a cleanup-unlink failure); production callers never pass them.
84
+ * @param {string} absPath
85
+ * @param {string} data
86
+ * @param {number} mode
87
+ * @param {() => string} [tmpName]
88
+ * @param {(path: string) => void} [remove]
89
+ */
90
+ export function atomicReplaceFile(absPath: string, data: string, mode: number, tmpName?: () => string, remove?: (path: string) => void): void;
91
+ /**
92
+ * Strip payload-capable invisible characters from `absPath` in place. Returns
93
+ * `true` when the file's bytes actually changed (a payload {@link scanText}
94
+ * flags was removed), `false` when {@link scanText} reports nothing, and `null`
95
+ * when scan flagged a payload but {@link stripInvisible} removes nothing — a
96
+ * fail-closed signal that the flagged run was PRESERVED (e.g. a well-formed
97
+ * emoji-tag sequence the stripper keeps), so the caller must not treat it as
98
+ * cleaned. `true` means and only means "bytes changed".
99
+ *
100
+ * Contract (scan/clean coherence): clean strips exactly what scan flags. A
101
+ * write happens ONLY when `scanText` reports a finding, so the "scan, then
102
+ * clean what scan flagged" workflow never silently rewrites a file scan called
103
+ * clean. A handful of sub-threshold invisible chars (which scan ignores) are
104
+ * left untouched — by design, the scanner's definition of a payload is the
105
+ * single source of truth for what gets removed.
106
+ *
107
+ * Refuses to follow symlinks: instruction files must be regular files. The read
108
+ * fd is opened with `O_NOFOLLOW`, so a symlinked path (which could redirect the
109
+ * read/write to a target outside the tree) makes the OPEN itself fail — closing
110
+ * the lstat→open TOCTOU window a separate stat would leave, in which the path
111
+ * could be swapped to a symlink between the check and the read.
112
+ *
113
+ * Non-UTF-8 safety (O9): the file is read as raw BYTES and required to round-trip
114
+ * losslessly through UTF-8 before any rewrite. `readFileSync(…, "utf-8")`
115
+ * silently maps invalid bytes to U+FFFD, which a naive strip-and-rewrite would
116
+ * then persist file-wide — so a non-UTF-8 file fails loud and is left untouched.
117
+ *
118
+ * Lost-update / TOCTOU guard: the on-path file is re-checked against the fstat
119
+ * snapshot taken right after open (inode, size, mtime, and not-a-symlink) before
120
+ * the rename; a concurrent write or symlink swap between our read and our write
121
+ * fails loud rather than silently clobbering the other writer.
122
+ *
123
+ * The write is atomic (see {@link atomicReplaceFile}): stripped content goes to
124
+ * a temp file in the same directory which is then `rename`d over the original
125
+ * (preserving the original file mode), fsync'd for crash-safety.
126
+ *
127
+ * Throws if the file cannot be read or written (the caller decides whether an
128
+ * unwritable contaminated file is fatal or falls back to alerting).
129
+ * @param {string} absPath
130
+ * @param {(path: string) => import("node:fs").Stats} [lstat] injectable
131
+ * pre-rename recheck stat (fault-injection seam, mirrors
132
+ * {@link atomicReplaceFile}'s `tmpName`): lets a test drive the concurrent
133
+ * write/symlink-swap that the TOCTOU guard exists to catch, which is otherwise
134
+ * unreachable from this fully-synchronous path. Defaults to `lstatSync`.
135
+ * @returns {boolean}
136
+ */
137
+ export function cleanFile(absPath: string, lstat?: (path: string) => import("node:fs").Stats): boolean;
@@ -0,0 +1,98 @@
1
+ /**
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,
4
+ * nothing that could move the cursor, erase, or carry a payload). Recognizes
5
+ * both the 7-bit `ESC[…m` and 8-bit C1 (`U+009B…m`) SGR encodings.
6
+ * @param {string} text
7
+ * @returns {boolean}
8
+ */
9
+ export function isSgrOnly(text: string): boolean;
10
+ /**
11
+ * The agent-facing "Stripped: …" note for a Layer-1 strip: the removed category
12
+ * labels, the LONG RUN marker when the de-ANSI'd text still holds a
13
+ * payload-length invisible run, and a pointer to recover the bytes — a hex dump
14
+ * is ASCII, so it passes through sanitization untouched. The single source of
15
+ * this note, shared by the `sanitize` convenience entry and the tool-output
16
+ * pipeline so the two can't drift.
17
+ * @param {string[]} invisFound CATEGORY codes applyLayer1 reported removing
18
+ * @param {string} deAnsi ANSI-stripped text (invisible runs intact), for the LONG_RUN probe
19
+ * @returns {string}
20
+ */
21
+ export function describeStripped(invisFound: string[], deAnsi: string): string;
22
+ /**
23
+ * Count the PAYLOAD invisible code points in `text`: those the carve-out would
24
+ * strip, excluding ZWNJ/ZWJ (and emoji VS16) that do real rendering work.
25
+ * Consumers that gate on invisible density (e.g. the prompt classifier's scatter
26
+ * threshold) use this so legitimate dense multilingual prose is not mistaken for
27
+ * a hidden channel.
28
+ * @param {string} text
29
+ * @returns {number}
30
+ */
31
+ export function countPayloadInvisible(text: string): number;
32
+ /**
33
+ * The `text` with every carve-out-PRESERVABLE invisible (joiners/selectors/tags/
34
+ * blank fillers doing real rendering work) replaced by a space, leaving only the
35
+ * PAYLOAD invisibles in place. The LONG_RUN injection probe runs over this so a
36
+ * legitimate emoji/flag/variation sequence never trips the "possible injection
37
+ * payload" marker (alert fatigue), while a genuine hidden run still surfaces.
38
+ * @param {string} text
39
+ * @returns {string}
40
+ */
41
+ export function payloadInvisibleView(text: string): string;
42
+ /**
43
+ * Strip payload-capable invisible chars and report which categories were
44
+ * removed. A single leading U+FEFF (BOM) is preserved as a legitimate marker;
45
+ * interior BOMs and all soft hyphens (U+00AD) are stripped, since either can
46
+ * encode hidden instructions. ZWNJ/ZWJ survive only in a linguistic context
47
+ * (see the carve-out above). `found` names exactly the categories stripped, so
48
+ * a caller never warns about a strip the carve-out skipped.
49
+ *
50
+ * `originalText` is the pre-processing text (before any ANSI strip) used ONLY to
51
+ * decide whether a leading BOM is genuinely leading: an interior BOM that an
52
+ * ANSI-strip left at index 0 of `text` (e.g. `ESC[m + interior U+FEFF`) must NOT be treated as a
53
+ * legitimate leading marker. Defaults to `text` for the common single-arg call.
54
+ * @param {string} text
55
+ * @param {string} [originalText]
56
+ * @returns {{ cleaned: string, found: string[] }}
57
+ */
58
+ export function stripInvisibleWithReport(text: string, originalText?: string): {
59
+ cleaned: string;
60
+ found: string[];
61
+ };
62
+ /**
63
+ * Strip payload-capable invisible chars (cleaned text only). See
64
+ * stripInvisibleWithReport for the BOM and ZWNJ/ZWJ carve-out semantics.
65
+ * @param {string} text
66
+ * @returns {string}
67
+ */
68
+ export function stripInvisible(text: string): string;
69
+ export const VS: string;
70
+ export const ZERO_WIDTH_MN: "\u034F\u17B4\u17B5";
71
+ export const BLANK_NON_CF: string;
72
+ export const CATEGORY: Readonly<{
73
+ CF: "cf-format";
74
+ VARIATION_SELECTORS: "variation-selectors";
75
+ BLANK_FILLERS: "blank-fillers";
76
+ ANSI: "ansi";
77
+ LONE_SURROGATES: "lone-surrogates";
78
+ HTML_COMMENTS: "html-comments";
79
+ HIDDEN_HTML: "hidden-html";
80
+ EXFIL_URLS: "exfil-urls";
81
+ }>;
82
+ /** @type {Readonly<Record<string, string>>} */
83
+ export const CATEGORY_LABELS: Readonly<Record<string, string>>;
84
+ /** @type {Array<[string, RegExp]>} Each entry pairs a CATEGORY code with its detector. */
85
+ export const CHECKS: Array<[string, RegExp]>;
86
+ export const STRIP: RegExp;
87
+ export const SGR_RE: RegExp;
88
+ export const LONG_RUN_THRESHOLD: 10;
89
+ /** Total invisible-char count above which a file/prompt is treated as
90
+ * payload-capable even without a long run (threshold-evasion catch). */
91
+ export const SCATTERED_THRESHOLD: 30;
92
+ export const LONG_RUN_RE: RegExp;
93
+ export const CONSECUTIVE_JOINER_CAP: 8;
94
+ export const CONSECUTIVE_SELECTOR_CAP: 8;
95
+ export const TOTAL_PRESERVED_JOINER_BUDGET: 16;
96
+ export const PRESERVED_JOINER_PER_VISIBLE: 8;
97
+ export const PRESERVE_HARD_CAP: 64;
98
+ export const LINGUISTIC_SCRIPTS: string[];
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The Unicode Joining_Type of a code point: "C", "D", "R", "L", "T", or "U"
3
+ * (the non-joining default) for anything not in the cursive tables.
4
+ * @param {number} cp
5
+ * @returns {string}
6
+ */
7
+ export function joiningType(cp: number): string;
8
+ /**
9
+ * True when `cp` is an Indic virama (the only position where a ZWNJ/ZWJ is
10
+ * linguistically meaningful in Brahmic scripts).
11
+ * @param {number} cp
12
+ * @returns {boolean}
13
+ */
14
+ export function isVirama(cp: number): boolean;
15
+ /**
16
+ * GENERATED by scripts/gen-joining-type.mjs from ucd-full@17.0.0 — DO NOT EDIT.
17
+ *
18
+ * Unicode Joining_Type and Indic virama range tables backing the ZWNJ/ZWJ
19
+ * carve-out in invisible.mjs. Regenerate with `pnpm gen:joining-type`;
20
+ * test/joining-type.test.mjs fails if this drifts from the pinned UCD.
21
+ */
22
+ export const UNICODE_VERSION: "17.0.0";
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Strip ANSI escape sequences to a fixed point. Removing one sequence can
3
+ * reconstitute another around it (a lone ESC left of `ESC[32m[0m` gains the
4
+ * trailing `[0m` once the inner sequence is removed, forming a brand-new valid
5
+ * sequence the single pass would miss), so iterate until stable: every changed
6
+ * pass consumes at least one ESC introducer, so the pass count is bounded by
7
+ * the input's ESC count, and ANSI-free text exits after one pass.
8
+ * @param {string} input
9
+ * @returns {string}
10
+ */
11
+ export function stripAnsiFully(input: string): string;
12
+ /**
13
+ * Layer 1: ANSI + invisible-char strip with a result guaranteed free of every
14
+ * raw ANSI control introducer (7-bit ESC U+001B and the whole 8-bit C1 control
15
+ * block U+0080–U+009F: CSI, the DCS/SOS/OSC/PM/APC string introducers, and ST).
16
+ *
17
+ * Removing an invisible character can reconstitute an escape its split hid from
18
+ * the ANSI pass (`ESC`<ZWSP>`[32m` → `ESC[32m`), so strip ANSI again after the
19
+ * invisible pass — but only when stripInvisible changed something, since
20
+ * reconstitution is impossible otherwise and the re-strip is a wasted pass on
21
+ * the hot clean path. The ANSI strip still cannot match an *incomplete*
22
+ * reconstituted sequence (a lone `ESC[` left when an inner complete sequence is
23
+ * removed from a nested split), so a final sweep removes every residual raw
24
+ * introducer outright — that sweep, not the regex matching, is the guarantee
25
+ * that no control introducer survives. `deAnsi` is the ANSI strip of the
26
+ * original (invisible runs intact), the scope a LONG_RUN payload check needs.
27
+ * @param {string} text
28
+ * @returns {{ cleaned: string, deAnsi: string, found: string[] }}
29
+ */
30
+ export function applyLayer1(text: string): {
31
+ cleaned: string;
32
+ deAnsi: string;
33
+ found: string[];
34
+ };
35
+ export const LONE_SURROGATE_RE: RegExp;
@@ -0,0 +1,195 @@
1
+ /**
2
+ * @param {string} text
3
+ * @returns {boolean}
4
+ */
5
+ export function needsMarkdownPipeline(text: string): boolean;
6
+ /**
7
+ * Warning fragment for Layer 2's stripped content — counts only, never the
8
+ * content itself (which would re-inject what was just removed).
9
+ * @param {{ comments: number, hidden: number }} removed
10
+ * @returns {string}
11
+ */
12
+ export function describeRemoved(removed: {
13
+ comments: number;
14
+ hidden: number;
15
+ }): string;
16
+ /**
17
+ * Full warning for Layer 2's preserved-but-reported content (scripting and
18
+ * resource tags, data: URIs), or "" when there is nothing to report.
19
+ * @param {{ tags: Record<string, number>, dataSrc: number }} warned
20
+ * @returns {string}
21
+ */
22
+ export function describeWarned(warned: {
23
+ tags: Record<string, number>;
24
+ dataSrc: number;
25
+ }): string;
26
+ /**
27
+ * Delete each verbatim span in `spans` from `text`. The secure Layer-5
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).
31
+ * @param {string} text
32
+ * @param {string[]} spans
33
+ * @returns {{ text: string, removed: number }}
34
+ */
35
+ export function deleteVerbatimSpans(text: string, spans: string[]): {
36
+ text: string;
37
+ removed: number;
38
+ };
39
+ /**
40
+ * @typedef {{
41
+ * html?: boolean,
42
+ * exfilScan?: boolean,
43
+ * redact?: (text: string) => Promise<RedactResult|null> | (RedactResult|null),
44
+ * filterInjection?: (text: string) => Promise<Layer5Result|null> | (Layer5Result|null),
45
+ * sgrCarveOut?: boolean,
46
+ * }} SanitizeTextOptions
47
+ */
48
+ /**
49
+ * Run the configured layers over a single text blob. Layer 1 always runs; the
50
+ * rest are opt-in via `options`. Layer 4 (`redact`) is the fail-closed path: a
51
+ * redactor that throws is rethrown wrapped, so the caller suppresses the
52
+ * output rather than emitting an unvetted value. That fail-closed behavior
53
+ * also applies to Layer 4's re-scan after a Layer-5 span deletion (see Layer
54
+ * 5, below) — a redactor failure there fails the whole call closed too.
55
+ * `reveal` is the pre-Layer-2 text, present only when the HTML splice removed
56
+ * bytes, so a caller can persist what was hidden for later inspection (see
57
+ * {@link applyMarkdownPipeline}); the field is omitted otherwise.
58
+ * @param {string} text
59
+ * @param {SanitizeTextOptions} [options]
60
+ * @returns {Promise<{ cleaned: string, warnings: string[], modified: boolean, sgrNote: boolean, reveal?: string }>}
61
+ */
62
+ export function sanitizeText(text: string, options?: SanitizeTextOptions): Promise<{
63
+ cleaned: string;
64
+ warnings: string[];
65
+ modified: boolean;
66
+ sgrNote: boolean;
67
+ reveal?: string;
68
+ }>;
69
+ /**
70
+ * True only for arrays and PLAIN objects — the two shapes whose contents are
71
+ * safe to walk via `Object.entries` without silently dropping data. An exotic
72
+ * object (Map/Set/Date/RegExp/typed array/class instance) carries its data in
73
+ * internal slots that `Object.entries` does not enumerate, so descending into
74
+ * one and rebuilding it from its entries corrupts it to `{}` (or an empty
75
+ * clone). Those pass through as OPAQUE LEAVES instead — unchanged — preserving
76
+ * the tool-output shape a harness matches on. A null-prototype object is treated
77
+ * as plain (its own enumerable string keys are the whole story).
78
+ * @param {any} value
79
+ * @returns {boolean}
80
+ */
81
+ export function isWalkableContainer(value: any): boolean;
82
+ /**
83
+ * Sanitize every string leaf of a tool-output value, preserving its shape (a
84
+ * structured tool output whose shape changes would be ignored by a harness,
85
+ * leaking the raw value). Non-string leaves pass through; `warnings`
86
+ * accumulates across leaves. `sgrNote` is the OR across leaves.
87
+ *
88
+ * Fails CLOSED on two hostile shapes that would otherwise throw a `RangeError`
89
+ * as an unhandled async rejection (a DoS that leaves the output un-sanitized):
90
+ * nesting past {@link MAX_DEPTH}, and a reference cycle. Either replaces the
91
+ * offending subtree with a placeholder string + a warning, never passing the
92
+ * raw subtree through. Keys are also screened for hidden chars (see below).
93
+ *
94
+ * `reveals` accumulates each string leaf's pre-Layer-2 text (present only when
95
+ * the HTML splice removed bytes) so a caller can persist what was hidden — the
96
+ * structured-output analogue of {@link sanitizeText}'s `reveal`. Same
97
+ * mutated-accumulator contract as `warnings`.
98
+ * @param {any} value
99
+ * @param {SanitizeTextOptions} options
100
+ * @param {string[]} warnings
101
+ * @param {string[]} [reveals]
102
+ * @returns {Promise<{ value: any, modified: boolean, sgrNote: boolean }>}
103
+ */
104
+ export function sanitizeValue(value: any, options: SanitizeTextOptions, warnings: string[], reveals?: string[]): Promise<{
105
+ value: any;
106
+ modified: boolean;
107
+ sgrNote: boolean;
108
+ }>;
109
+ /**
110
+ * Compose the model-facing context line for a sanitized/flagged tool output.
111
+ * `injectionAlert` is the caller's optional trailing alert (e.g. appended only
112
+ * for untrusted-ingress tools where a semantic-injection filter actually ran).
113
+ * @param {boolean} modified output bytes were changed (vs. flagged only)
114
+ * @param {string[]} warnings
115
+ * @param {{ injectionAlert?: string }} [options]
116
+ * @returns {string}
117
+ */
118
+ export function composeContext(modified: boolean, warnings: string[], { injectionAlert }?: {
119
+ injectionAlert?: string;
120
+ }): string;
121
+ /**
122
+ * Replace every string leaf of `value` with `message`, preserving shape so a
123
+ * fail-closed placeholder matches the tool's output schema. Non-string leaves
124
+ * pass through.
125
+ *
126
+ * Shares {@link sanitizeValue}'s depth/cycle guard for the same reason: this
127
+ * runs on the fail-closed path (an already-suspect output), so a 200k-deep or
128
+ * self-referential value must NOT blow the stack here — that would re-open the
129
+ * very hole suppression exists to close. Past {@link MAX_DEPTH} or on a cycle it
130
+ * substitutes `message` for the offending subtree (already the suppression
131
+ * sentinel, so the placeholder is consistent with the rest of the output).
132
+ * @param {any} value
133
+ * @param {string} message
134
+ * @returns {any}
135
+ */
136
+ export function suppressToolOutput(value: any, message: string): any;
137
+ /**
138
+ * Closed enum of LIBRARY-OWNED Layer-5 warning codes — the ONLY warning values
139
+ * the injected `filterInjection` seam may return. This mirrors the `found`-code
140
+ * contract (`CATEGORY` in ./invisible.mjs): the seam speaks a fixed vocabulary
141
+ * of codes, and the LIBRARY owns the human-readable string each maps to. Free
142
+ * text from the filter is REFUSED (see `mapFilterWarning`), because the filter
143
+ * runs on attacker-influenced content and its output is concatenated into the
144
+ * model-facing context WITHOUT passing back through Layer 1 — so a compromised
145
+ * or prompt-injected filter that could emit arbitrary `warning` text would
146
+ * defeat the "a compromised filter can only remove bytes, never inject" seam
147
+ * contract. Branch on these codes; the prose below is not part of the contract.
148
+ * @type {Readonly<{ SPANS_REMOVED: "spans-removed", FILTER_FLAGGED: "filter-flagged", FILTER_ERROR: "filter-error" }>}
149
+ */
150
+ export const FILTER_WARNING: Readonly<{
151
+ SPANS_REMOVED: "spans-removed";
152
+ FILTER_FLAGGED: "filter-flagged";
153
+ FILTER_ERROR: "filter-error";
154
+ }>;
155
+ /**
156
+ * Maximum container nesting `sanitizeValue` / `suppressToolOutput` will descend
157
+ * before failing closed. The JS engine's own call-stack limit is many thousands
158
+ * of frames deep, so 200 is a wide safety margin below it: a real tool output
159
+ * never nests this far, while a hostile 200k-deep array (or a self-referential
160
+ * cycle) would otherwise blow the stack as an UNHANDLED async rejection — the
161
+ * output then escapes sanitization entirely (fail-open DoS). Past this depth the
162
+ * subtree is replaced with a placeholder and a warning is recorded, so the
163
+ * caller still emits a sanitized, flagged result instead of crashing.
164
+ */
165
+ export const MAX_DEPTH: 200;
166
+ /**
167
+ * Layer-4 result: the redacted text, the category labels redacted, and an
168
+ * optional caller-supplied annotation appended to the warning.
169
+ */
170
+ export type RedactResult = {
171
+ text: string;
172
+ found: string[];
173
+ note?: string;
174
+ };
175
+ /**
176
+ * A {@link FILTER_WARNING} enum code — the closed vocabulary the Layer-5 seam
177
+ * may return in `warning`. See FILTER_WARNING for the meanings.
178
+ */
179
+ export type FilterWarningCode = "spans-removed" | "filter-flagged" | "filter-error";
180
+ /**
181
+ * Layer-5 result: verbatim spans to delete (the only mutation a filter may
182
+ * request) and/or a warning CODE (never free text — the library owns the
183
+ * message). Null means the filter made no finding.
184
+ */
185
+ export type Layer5Result = {
186
+ removeSpans?: string[];
187
+ warning?: FilterWarningCode;
188
+ };
189
+ export type SanitizeTextOptions = {
190
+ html?: boolean;
191
+ exfilScan?: boolean;
192
+ redact?: (text: string) => Promise<RedactResult | null> | (RedactResult | null);
193
+ filterInjection?: (text: string) => Promise<Layer5Result | null> | (Layer5Result | null);
194
+ sgrCarveOut?: boolean;
195
+ };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Human-facing block reason: what was detected, the thresholds, a code-point
3
+ * sample of the long run (if any), and how to recover.
4
+ * @param {string[]} categories
5
+ * @param {number} invisibleCount
6
+ * @param {string | null} longRunSample
7
+ * @returns {string}
8
+ */
9
+ export function formatReason(categories: string[], invisibleCount: number, longRunSample: string | null): string;
10
+ /**
11
+ * Pure verdict for a user prompt: pass through, pass with an SGR note, or
12
+ * block. `strip` (the ANSI stripper, defaulting to the package's
13
+ * {@link stripAnsiFully}) runs on every prompt so invisibles smuggled *inside*
14
+ * an ANSI sequence (an OSC string) are stripped before the invisible-char
15
+ * thresholds are counted; it is injectable so a host can substitute its own
16
+ * stripper or exercise the fail-closed path.
17
+ * @param {string} prompt
18
+ * @param {(s: string) => string} [strip]
19
+ * @returns {{action:"pass"} | {action:"note"} | {action:"block", reason:string}}
20
+ */
21
+ export function classifyPrompt(prompt: string, strip?: (s: string) => string): {
22
+ action: "pass";
23
+ } | {
24
+ action: "note";
25
+ } | {
26
+ action: "block";
27
+ reason: string;
28
+ };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Re-anchor an Edit/Write input composed from a sanitized file view back onto
3
+ * the on-disk bytes (secrets rehydrated, stripped invisible runs re-attached).
4
+ * Returns the rewritten input plus a model-facing context line, a deny with an
5
+ * instructive reason when the input is unresolvable or would expose a secret,
6
+ * or null when there is nothing to do. Throws only on internal error (the
7
+ * caller fails closed).
8
+ *
9
+ * `io` is the injected I/O (file read + redactor map/plain). `hint` is the
10
+ * redaction-placeholder prefix (defaults to {@link DEFAULT_HINT}); override it
11
+ * only if the injected redactor emits a different placeholder shape.
12
+ * @param {string} tool
13
+ * @param {any} toolInput
14
+ * @param {RehydrateIo} io
15
+ * @param {{ hint?: string }} [options]
16
+ * @returns {Promise<{updatedInput: any, context: string} | {deny: string} | null>}
17
+ */
18
+ export function rehydrateRedacted(tool: string, toolInput: any, io: RehydrateIo, { hint }?: {
19
+ hint?: string;
20
+ }): Promise<{
21
+ updatedInput: any;
22
+ context: string;
23
+ } | {
24
+ deny: string;
25
+ } | null>;
26
+ export const DEFAULT_HINT: "[REDACTED";
27
+ /**
28
+ * Map-mode response from the redactor: either the mappable view (text + ordered
29
+ * (placeholder, original, start) pairs) or an unmappable verdict carrying its
30
+ * reason — a discriminated pair.
31
+ */
32
+ export type RedactMapView = {
33
+ text: string;
34
+ pairs: {
35
+ placeholder: string;
36
+ original: string;
37
+ start: number;
38
+ }[];
39
+ } | {
40
+ unmappable: string;
41
+ };
42
+ /**
43
+ * Injected I/O. `readFile` returns the file's bytes (throwing on a missing or
44
+ * unreadable path). `redactMap` returns the redacted view of (Layer-1-cleaned)
45
+ * file text plus the ordered (placeholder, original, start) pairs, or an
46
+ * `{unmappable}` verdict. `redact` returns the plain redacted text, or null
47
+ * when nothing was redacted. `redactMap`/`redact` are the only secret-engine
48
+ * seam; they may be async and are awaited.
49
+ */
50
+ export type RehydrateIo = {
51
+ readFile: (path: string) => string;
52
+ redactMap: (text: string) => Promise<RedactMapView> | RedactMapView;
53
+ redact: (text: string) => Promise<string | null> | (string | null);
54
+ };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * True when `base` followed by `selector` (a U+FE00–U+FE0D code point) is a
3
+ * registered standardized variation sequence.
4
+ * @param {number} base
5
+ * @param {number} selector
6
+ * @returns {boolean}
7
+ */
8
+ export function isStandardizedVariant(base: number, selector: number): boolean;
9
+ /**
10
+ * GENERATED by scripts/gen-standardized-variants.mjs from UCD
11
+ * StandardizedVariants.txt (Unicode 17.0.0) — DO NOT EDIT.
12
+ *
13
+ * Registered standardized variation sequences: a base code point followed by a
14
+ * variation selector in U+FE00–U+FE0D. invisible.mjs preserves such a selector
15
+ * only when the immediately preceding base appears here; every other FE00–FE0D
16
+ * selector is stripped as a hidden-payload byte. Regenerate with
17
+ * `node scripts/gen-standardized-variants.mjs`; test/invisible-charset.test.mjs
18
+ * fails if this drifts from the pinned UCD slice.
19
+ */
20
+ export const UNICODE_VERSION: "17.0.0";
21
+ /** @type {Array<[number, number]>} [base, selector], sorted by base then selector. */
22
+ export const STANDARDIZED_VARIANTS: Array<[number, number]>;