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,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Confusable / homoglyph folding for tool-call INPUT fields.
|
|
3
|
+
*
|
|
4
|
+
* Folding look-alike glyphs to their ASCII canon narrows the steganographic
|
|
5
|
+
* channel a model-to-model paste can open and closes the cross-script deny-rule
|
|
6
|
+
* bypass of CVE-2025-54794: a Cyrillic "а" dressed as ASCII "a" would not match
|
|
7
|
+
* an ASCII deny rule, so an attacker could slip a denied path/command past a
|
|
8
|
+
* filter by spelling it in look-alike code points.
|
|
9
|
+
*
|
|
10
|
+
* Folding is per-character and context-free: every glyph the injected scanner
|
|
11
|
+
* flags is replaced with its ASCII (latin) equivalent regardless of its
|
|
12
|
+
* neighbours. This deliberately catches an ISOLATED confusable with no ASCII
|
|
13
|
+
* anchor (a lone Cyrillic "а" in "/а") that a context-SENSITIVE canonicaliser
|
|
14
|
+
* would leave untouched — exactly the bypass to close — while leaving genuine
|
|
15
|
+
* non-confusable non-ASCII (accented Latin, CJK, emoji) alone, since a faithful
|
|
16
|
+
* scanner does not flag those.
|
|
17
|
+
*
|
|
18
|
+
* The confusable scanner is INJECTED, never imported: the canonical engine
|
|
19
|
+
* (namespace-guard's vision-weighted map) is a heavy, separately-owned peer.
|
|
20
|
+
* Pass `{ scan }` where `scan(text)` returns `{ findings: [{ index, char,
|
|
21
|
+
* latinEquivalent }] }` — `index` a UTF-16 offset, `char` the matched glyph
|
|
22
|
+
* (possibly a 2-unit astral char), `latinEquivalent` its ASCII canon.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Default path/command fields to fold per tool. Agent-agnostic: the keys are
|
|
27
|
+
* the conventional Claude/Anthropic tool names, but a caller with a different
|
|
28
|
+
* tool surface passes its own `fields` map.
|
|
29
|
+
* @type {Record<string, string[]>}
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_FIELDS = {
|
|
32
|
+
Bash: ["command"],
|
|
33
|
+
Edit: ["file_path"],
|
|
34
|
+
Write: ["file_path"],
|
|
35
|
+
Read: ["file_path"],
|
|
36
|
+
MultiEdit: ["file_path"],
|
|
37
|
+
NotebookEdit: ["notebook_path"],
|
|
38
|
+
Grep: ["pattern", "path"],
|
|
39
|
+
Glob: ["pattern", "path"],
|
|
40
|
+
LS: ["path"],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* True iff any UTF-16 code unit is outside ASCII (> 0x7F). Surrogates (astral
|
|
45
|
+
* chars) are >= 0xD800 so they count; ASCII control chars (tab, newline) stay
|
|
46
|
+
* ASCII. A plain loop, not a regex, to avoid a control char in the pattern.
|
|
47
|
+
* @param {string} value
|
|
48
|
+
* @returns {boolean}
|
|
49
|
+
*/
|
|
50
|
+
export function hasNonAscii(value) {
|
|
51
|
+
for (let i = 0; i < value.length; i++) {
|
|
52
|
+
if (value.charCodeAt(i) > 0x7f) return true;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Model-facing note naming the fields whose confusables were folded.
|
|
59
|
+
* @param {string[]} normalized
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
export function normalizeContext(normalized) {
|
|
63
|
+
return `Confusable characters normalized in: ${normalized.join(", ")}. If a path now fails to resolve, the on-disk name itself contains the look-alike glyph shown.`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Cap the per-field fold list so a glyph-stuffed input can't bloat the context.
|
|
67
|
+
const MAX_REPORTED_FOLDS = 8;
|
|
68
|
+
|
|
69
|
+
/** @param {Array<{ char: string, latinEquivalent: string }>} findings */
|
|
70
|
+
function describeFolds(findings) {
|
|
71
|
+
const folds = [
|
|
72
|
+
...new Set(
|
|
73
|
+
findings.map(
|
|
74
|
+
(finding) =>
|
|
75
|
+
// char is always a non-empty confusable glyph, so codePointAt(0) is
|
|
76
|
+
// defined; the cast avoids an unreachable `?? 0` fallback branch.
|
|
77
|
+
`U+${
|
|
78
|
+
/** @type {number} */ (finding.char.codePointAt(0))
|
|
79
|
+
.toString(16)
|
|
80
|
+
.toUpperCase()
|
|
81
|
+
.padStart(4, "0")
|
|
82
|
+
} → "${finding.latinEquivalent}"`,
|
|
83
|
+
),
|
|
84
|
+
),
|
|
85
|
+
];
|
|
86
|
+
const shown = folds.slice(0, MAX_REPORTED_FOLDS).join(", ");
|
|
87
|
+
return folds.length > MAX_REPORTED_FOLDS ? `${shown}, …` : shown;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Replace every scan-flagged confusable with its ASCII (latin) equivalent.
|
|
92
|
+
* `index` is a UTF-16 offset into `text` and `char` is the matched glyph (which
|
|
93
|
+
* may be an astral, 2-unit char); splice highest-index first so a
|
|
94
|
+
* length-changing fold never shifts the offsets of earlier findings.
|
|
95
|
+
* @param {string} text
|
|
96
|
+
* @param {Array<{ index: number, char: string, latinEquivalent: string }>} findings
|
|
97
|
+
* @returns {string}
|
|
98
|
+
*/
|
|
99
|
+
export function foldConfusables(text, findings) {
|
|
100
|
+
let folded = text;
|
|
101
|
+
for (const finding of [...findings].sort(
|
|
102
|
+
(lhs, rhs) => rhs.index - lhs.index,
|
|
103
|
+
)) {
|
|
104
|
+
// Fail loud on a finding that does not match the actual bytes at its offset:
|
|
105
|
+
// a buggy/adversarial scanner reporting a wrong char/index would otherwise
|
|
106
|
+
// silently corrupt the path/command, defeating the deny-rule protection.
|
|
107
|
+
// A negative index is the gap the startsWith guard alone misses: when `char`
|
|
108
|
+
// is a prefix of the text, `startsWith(char, -1)` is true (the offset is
|
|
109
|
+
// clamped to 0), and the slice math below then mangles the string instead of
|
|
110
|
+
// throwing — so range-check the index explicitly first.
|
|
111
|
+
if (!Number.isInteger(finding.index) || finding.index < 0)
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Confusable finding has an out-of-range index ${finding.index}`,
|
|
114
|
+
);
|
|
115
|
+
// An empty `char` makes startsWith("", i) vacuously true for ANY index, so
|
|
116
|
+
// the slice below inserts latinEquivalent without consuming a code point —
|
|
117
|
+
// silent insertion-corruption — and describeFolds then crashes on
|
|
118
|
+
// "".codePointAt(0). A finding must name a real matched glyph, so reject it
|
|
119
|
+
// loudly rather than let a buggy/adversarial scanner corrupt the input.
|
|
120
|
+
if (finding.char === "")
|
|
121
|
+
throw new Error(
|
|
122
|
+
`Confusable finding at index ${finding.index} has an empty char`,
|
|
123
|
+
);
|
|
124
|
+
if (!folded.startsWith(finding.char, finding.index))
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Confusable finding does not match input at index ${finding.index}: expected ${JSON.stringify(finding.char)}`,
|
|
127
|
+
);
|
|
128
|
+
// An empty `latinEquivalent` slips past the ASCII loop below (it never
|
|
129
|
+
// iterates) and would splice the glyph to nothing — silently DELETING a
|
|
130
|
+
// character from a path/command. That is the same class of silent corruption
|
|
131
|
+
// the non-ASCII guard rejects, so fail loud here too rather than let a
|
|
132
|
+
// buggy/adversarial scanner erase input.
|
|
133
|
+
if (finding.latinEquivalent === "")
|
|
134
|
+
throw new Error(
|
|
135
|
+
`Confusable finding for ${JSON.stringify(
|
|
136
|
+
finding.char,
|
|
137
|
+
)} at index ${finding.index} has an empty latinEquivalent`,
|
|
138
|
+
);
|
|
139
|
+
// The replacement must be the ASCII canon the contract promises. A non-ASCII
|
|
140
|
+
// `latinEquivalent` would fold one confusable into ANOTHER look-alike (e.g.
|
|
141
|
+
// Cyrillic а → Cyrillic е), defeating the whole point — the cross-script
|
|
142
|
+
// deny-rule bypass would survive — and silently break the fold-to-ASCII
|
|
143
|
+
// invariant callers rely on, so reject it loudly.
|
|
144
|
+
for (const ch of finding.latinEquivalent)
|
|
145
|
+
if (/** @type {number} */ (ch.codePointAt(0)) > 0x7f)
|
|
146
|
+
throw new Error(
|
|
147
|
+
`Confusable latinEquivalent ${JSON.stringify(
|
|
148
|
+
finding.latinEquivalent,
|
|
149
|
+
)} is not ASCII`,
|
|
150
|
+
);
|
|
151
|
+
folded =
|
|
152
|
+
folded.slice(0, finding.index) +
|
|
153
|
+
finding.latinEquivalent +
|
|
154
|
+
folded.slice(finding.index + finding.char.length);
|
|
155
|
+
}
|
|
156
|
+
return folded;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Normalize confusable/homoglyph chars in the path/command fields of a tool
|
|
161
|
+
* call. Returns the updated input plus the fields touched, or null when nothing
|
|
162
|
+
* changed. Throws if the injected scanner fails (the caller fails closed: an
|
|
163
|
+
* un-normalized confusable could slip past a deny rule).
|
|
164
|
+
*
|
|
165
|
+
* `scan` is the injected confusable engine: `scan(text)` → `{ findings }` (an
|
|
166
|
+
* empty `findings` means no confusables). `fields` maps a tool name to the
|
|
167
|
+
* input keys to fold; defaults to {@link DEFAULT_FIELDS}.
|
|
168
|
+
* @param {string} tool
|
|
169
|
+
* @param {any} toolInput
|
|
170
|
+
* @param {{ scan: (text: string) => { findings: Array<{ index: number, char: string, latinEquivalent: string }> }, fields?: Record<string, string[]> }} options
|
|
171
|
+
* @returns {{ updatedInput: any, normalized: string[] } | null}
|
|
172
|
+
*/
|
|
173
|
+
export function normalizeConfusables(
|
|
174
|
+
tool,
|
|
175
|
+
toolInput,
|
|
176
|
+
{ scan, fields = DEFAULT_FIELDS },
|
|
177
|
+
) {
|
|
178
|
+
const keys = Object.hasOwn(fields, tool) ? fields[tool] : undefined;
|
|
179
|
+
if (!keys || toolInput === null || toolInput === undefined) return null;
|
|
180
|
+
|
|
181
|
+
// ASCII fast-path: only a field carrying a non-ASCII code unit can hold a
|
|
182
|
+
// confusable, so all-ASCII input never invokes the (heavy) scanner.
|
|
183
|
+
const candidates = keys.filter(
|
|
184
|
+
(k) => typeof toolInput[k] === "string" && hasNonAscii(toolInput[k]),
|
|
185
|
+
);
|
|
186
|
+
if (candidates.length === 0) return null;
|
|
187
|
+
|
|
188
|
+
const normalized = [];
|
|
189
|
+
const updatedInput = { ...toolInput };
|
|
190
|
+
for (const k of candidates) {
|
|
191
|
+
const { findings } = scan(toolInput[k]);
|
|
192
|
+
if (findings.length === 0) continue;
|
|
193
|
+
updatedInput[k] = foldConfusables(toolInput[k], findings);
|
|
194
|
+
normalized.push(`${k} (${describeFolds(findings)})`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (normalized.length === 0) return null;
|
|
198
|
+
return { updatedInput, normalized };
|
|
199
|
+
}
|
package/src/gates.mjs
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cheap, dependency-free pre-gates shared by the HTML layer (Layers 2 & 3) and
|
|
3
|
+
* re-exported from both the package root and the `./html` subpath.
|
|
4
|
+
*
|
|
5
|
+
* These are pulled out of `html.mjs` so the package root can re-export them
|
|
6
|
+
* without dragging in the heavy remark/rehype/unified graph: a static
|
|
7
|
+
* `export … from "./html.mjs"` would eagerly evaluate that ~200ms module on
|
|
8
|
+
* every root import, defeating the lazy-load design. This module imports
|
|
9
|
+
* nothing, so re-exporting it is free.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ─── Cheap pre-gates ─────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Matches any HTML tag-like construct: opening tags, closing tags (`</`),
|
|
16
|
+
* comments and bogus declarations (`<!`), and processing instructions / bogus
|
|
17
|
+
* comments (`<?…?>`, which the HTML tokenizer hides exactly like a comment).
|
|
18
|
+
* The `<?` arm is what lets a PI-only document reach Layer 2's bogus-comment
|
|
19
|
+
* splice; without it such a document would skip the pipeline entirely. Gate for
|
|
20
|
+
* Layer 2 (HTML sanitization) and the HTML img/a exfil path in Layer 3.
|
|
21
|
+
*/
|
|
22
|
+
export const HTML_TAG_PRESENT = /<[a-zA-Z/!?][^<>]*>/;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Matches markdown link/image syntax (`](`, `![`) and reference link
|
|
26
|
+
* definitions (`[label]: url` at line start). Gate for Layer 3 (markdown
|
|
27
|
+
* exfiltration detection).
|
|
28
|
+
*/
|
|
29
|
+
export const MD_LINK_HINT = /\]\(|!\[|^[ \t]*\[[^[\]\n]+\]:\s/m;
|
|
30
|
+
|
|
31
|
+
// ─── Secret-shape pre-gate (Layer 3 URL-param reuse) ─────────────────────────
|
|
32
|
+
// Cheap shape match that decides whether a URL parameter value carries a
|
|
33
|
+
// credential (Layer 3). This hand-duplicates credential-shape knowledge that
|
|
34
|
+
// also lives in the Python detect-secrets detectors (python/.../secret-detectors.json)
|
|
35
|
+
// — a deliberately BROADER, shorter-run superset that adds keyword and
|
|
36
|
+
// non-detector shapes (AWS `AKIA…`, JWT `eyJ…`, Slack `xox…`, …) and trims each
|
|
37
|
+
// opaque run for ReDoS-safety. It is NOT derivable from that JSON: inlining the
|
|
38
|
+
// detector regexes would reintroduce the cross-arm polynomial backtracking the
|
|
39
|
+
// two-alternation split below exists to prevent, so this is a distinct
|
|
40
|
+
// representation for a distinct constraint, not a copy. That duplication can't
|
|
41
|
+
// be collapsed to one source, so it is instead DRIFT-GUARDED: the test in
|
|
42
|
+
// test/secret-detectors-portability.test.mjs drives from the JSON and fails the
|
|
43
|
+
// moment a detector is added/changed without a matching arm here — extend
|
|
44
|
+
// SECRET_HINT when that fires.
|
|
45
|
+
// Split across TWO regexes, combined by matchesSecretHint:
|
|
46
|
+
// one alternation of every arm makes a redos analyzer see cross-arm polynomial
|
|
47
|
+
// backtracking (each arm is linear alone, but the union was a 3rd-degree
|
|
48
|
+
// polynomial on a long alnum run). Testing two independently-safe literals with
|
|
49
|
+
// || is linear and keeps each under the analyzer's bar. The `(?<!...)`
|
|
50
|
+
// lookbehinds on the EXT run-matching arms pin them to a token boundary so they
|
|
51
|
+
// can't be retried at every offset; the atlasv1 arm in SECRET_HINT does the same.
|
|
52
|
+
/** @type {RegExp} */
|
|
53
|
+
export const SECRET_HINT =
|
|
54
|
+
/secret|token|password|passwd|pwd|bearer|credential|authorization|contrase[nñ]a|-----BEGIN|(?:api|auth|service|account|db|database|priv|private|client|access)[_-]?key|(?:db|database|key)[_-]?pass|(?:A3T|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}|gh[pousr]_[A-Za-z0-9]|github_pat_|gl[a-z]{2,12}-[0-9A-Za-z_-]{20}|sk-ant-|AIza[0-9A-Za-z_-]{35}|sk_live_|sk_test_|rk_live_|rk_test_|xox[bpasr]-|eyJ[A-Za-z0-9]|do[opr]_v1_[a-f0-9]{16}|v1\.0-[a-f0-9]{24}-|hv[sb]\.[A-Za-z0-9_-]{20}|(?<![a-z0-9])[a-z0-9]{14}\.atlasv1\.|sk-or-v1-[0-9a-f]{16}|gsk_[A-Za-z0-9]{16}|xai-[A-Za-z0-9]{16}|r8_[A-Za-z0-9]{16}/i;
|
|
55
|
+
|
|
56
|
+
// Second alternation (see SECRET_HINT): kept a separate literal so a redos
|
|
57
|
+
// analyzer vets each alternation in isolation.
|
|
58
|
+
/** @type {RegExp} */
|
|
59
|
+
export const SECRET_HINT_EXT =
|
|
60
|
+
/(?:AC|SK)[a-z0-9]{32}|SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}|sq0csp-[0-9A-Za-z_-]{43}|(?<![0-9])[0-9]{8,10}:[0-9A-Za-z_-]{35}|(?<![0-9a-z])[0-9a-z]{32}-us[0-9]{1,2}|(?<![A-Za-z0-9_-])[MNO][A-Za-z0-9_-]{23,25}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27}|T3BlbkFJ|pypi-AgE|(?<![A-Za-z0-9])AKC[A-Za-z0-9]{10}|(?<![A-Za-z0-9])AP[0-9A-Fa-f][A-Za-z0-9]{8}|:\/\/[^\s:/@]{1,64}:[^\s:/@]{1,64}@|(?:key|pw|pass)["']?[\s:=>]+["']?[A-Za-z0-9_/+-]{20}/i;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* True when either pre-gate alternation shape-matches `text`. Split into two
|
|
64
|
+
* literals (see SECRET_HINT) and OR'd so neither grows into a
|
|
65
|
+
* polynomial-backtracking shape.
|
|
66
|
+
* @param {string} text
|
|
67
|
+
* @returns {boolean}
|
|
68
|
+
*/
|
|
69
|
+
export function matchesSecretHint(text) {
|
|
70
|
+
return SECRET_HINT.test(text) || SECRET_HINT_EXT.test(text);
|
|
71
|
+
}
|