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.
- package/LICENSE +202 -0
- package/README.md +217 -0
- package/SECURITY.md +31 -0
- package/THREAT-MODEL.md +173 -0
- package/bin/sanitize-cli.mjs +423 -0
- package/package.json +157 -0
- package/src/cf-charset.mjs +43 -0
- package/src/confusables.mjs +199 -0
- package/src/gates.mjs +71 -0
- package/src/html.mjs +2233 -0
- package/src/index.mjs +166 -0
- package/src/instructions.mjs +530 -0
- package/src/invisible.mjs +976 -0
- package/src/joining-type.mjs +616 -0
- package/src/layer1.mjs +177 -0
- package/src/output.mjs +788 -0
- package/src/prompt.mjs +154 -0
- package/src/rehydrate.mjs +646 -0
- package/src/standardized-variants.mjs +1335 -0
- package/src/view-map.mjs +354 -0
- package/types/cf-charset.d.mts +14 -0
- package/types/confusables.d.mts +85 -0
- package/types/gates.d.mts +37 -0
- package/types/html.d.mts +117 -0
- package/types/index.d.mts +33 -0
- package/types/instructions.d.mts +137 -0
- package/types/invisible.d.mts +98 -0
- package/types/joining-type.d.mts +22 -0
- package/types/layer1.d.mts +35 -0
- package/types/output.d.mts +195 -0
- package/types/prompt.d.mts +28 -0
- package/types/rehydrate.d.mts +54 -0
- package/types/standardized-variants.d.mts +22 -0
- package/types/view-map.d.mts +170 -0
|
@@ -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]>;
|