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,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
+ }