agent-sanitizer 2.63.0 → 2.63.1

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.
@@ -11,9 +11,11 @@
11
11
  * non-ASCII glyphs — see isTokenBoundary). A token folds only when both hold:
12
12
  *
13
13
  * 1. Folding makes it pure ASCII — every non-ASCII code point in it is
14
- * flagged. The bypass requires the folded token to come out byte-equal to
15
- * an ASCII deny-rule target, so a token left holding an unmapped glyph
16
- * could never match one anyway; skipping it costs no enforcement.
14
+ * flagged AND an ASCII pretender (see isAsciiPretender: a letter of a real
15
+ * Latin orthography, `İ` or `í`, is a word's own letter, not a disguise).
16
+ * The bypass requires the folded token to come out byte-equal to an ASCII
17
+ * deny-rule target, so a token left holding an unmapped glyph could never
18
+ * match one anyway; skipping it costs no enforcement.
17
19
  * 2. It is more than a lone non-ASCII glyph standing between two boundaries.
18
20
  * A one-code-point token is a one-letter foreign word — Russian "с", "о",
19
21
  * "у", "а" are among the most frequent words in the language — as readily
@@ -58,6 +60,8 @@
58
60
  */
59
61
  import { createRequire } from "node:module";
60
62
 
63
+ import { GENERAL_CATEGORY_MARK, inSpans } from "./unicode-properties.mjs";
64
+
61
65
  /**
62
66
  * Default path/command fields to fold per tool. Agent-agnostic: the keys are
63
67
  * the conventional Claude/Anthropic tool names, but a caller with a different
@@ -163,6 +167,34 @@ export function hasNonAscii(value) {
163
167
  return false;
164
168
  }
165
169
 
170
+ // The letters of living Latin-script orthographies: Latin-1 Supplement (minus
171
+ // × and ÷, which are symbols), Latin Extended-A/B and Latin Extended Additional
172
+ // (Vietnamese). IPA, phonetic small capitals and fullwidth forms are Latin-script
173
+ // too, but no orthography spells a word with them, so they stay look-alikes.
174
+ const ORTHOGRAPHIC_LATIN =
175
+ /^[\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u024F\u1E00-\u1EFF]$/u;
176
+
177
+ /**
178
+ * True when `glyph` holds a non-ASCII code point that is neither a combining
179
+ * mark nor a letter of a real Latin-script orthography. THE predicate both the
180
+ * host rule's WARNING tier and the input fold gate on, so they cannot disagree.
181
+ * Turkish `ı`, Spanish `í`, Polish `ł` are a word's own letters (`altın.com`,
182
+ * `día.txt`), and flagging them is the worse failure; a cross-script glyph
183
+ * (Cyrillic `а`), an IPA/small-capital one (`ɡ`, `ᴀ`) or anything NFKC folds
184
+ * to ASCII (`a`, `fi`) stays a pretender.
185
+ * @param {string} glyph one or more code points
186
+ * @returns {boolean}
187
+ */
188
+ export function isAsciiPretender(glyph) {
189
+ for (const ch of glyph) {
190
+ const cp = /** @type {number} */ (ch.codePointAt(0));
191
+ if (!hasNonAscii(ch) || inSpans(GENERAL_CATEGORY_MARK, cp)) continue;
192
+ if (!ORTHOGRAPHIC_LATIN.test(ch) || !hasNonAscii(ch.normalize("NFKC")))
193
+ return true;
194
+ }
195
+ return false;
196
+ }
197
+
166
198
  /**
167
199
  * Model-facing note naming the fields whose confusables were folded.
168
200
  * @param {string[]} normalized
@@ -308,12 +340,18 @@ function isTokenBoundary(ch) {
308
340
  export function selectFoldableFindings(text, findings) {
309
341
  for (const finding of findings)
310
342
  assertFinding(finding, bytesAt(text, finding));
343
+ // A same-script orthographic letter (`İ` in `İstanbul`, `í` in `día`) is not
344
+ // a disguise, so it counts as an unmapped glyph below: its token is a word,
345
+ // and none of it folds.
346
+ const pretenders = findings.filter((finding) =>
347
+ isAsciiPretender(finding.char),
348
+ );
311
349
 
312
350
  // Every UTF-16 offset a finding covers, not just its start: a scanner is free
313
351
  // to report a multi-code-point match, and treating only the first offset as
314
352
  // flagged would read the rest as unmapped and reject an otherwise ASCII token.
315
353
  const flagged = new Set();
316
- for (const finding of findings)
354
+ for (const finding of pretenders)
317
355
  for (let i = 0; i < finding.char.length; i++)
318
356
  flagged.add(finding.index + i);
319
357
 
@@ -357,7 +395,7 @@ export function selectFoldableFindings(text, findings) {
357
395
  // units, which is what `foldableAt` is indexed by: an astral glyph occupies
358
396
  // two of them, so a code-point walk would leave the tail of a `char` that
359
397
  // carries one unchecked.
360
- return findings.filter((finding) => {
398
+ return pretenders.filter((finding) => {
361
399
  for (let i = 0; i < finding.char.length; i++)
362
400
  if (foldableAt[finding.index + i] !== 1) return false;
363
401
  return true;
package/src/gates.mjs CHANGED
@@ -7,28 +7,31 @@
7
7
  * `export … from "./html.mjs"` would eagerly evaluate that ~200ms module on
8
8
  * every root import, defeating the lazy-load design. This module imports
9
9
  * nothing, so re-exporting it is free.
10
+ *
11
+ * INVARIANT: every gate is a SUPERSET of its parser (a narrower one silently
12
+ * turns its layer off; a needless fire only costs a parse), checked by
13
+ * test/gates-superset.test.mjs.
10
14
  */
11
15
 
12
16
  // ─── Cheap pre-gates ─────────────────────────────────────────────────────────
13
17
 
14
18
  /**
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 `<!`/`<?` arms carry a comment-only document into the pipeline at all:
19
- * without them it would skip both Layer 2's splice of the comment and Layer 3's
20
- * exfil scan over the comment interior.
21
- * Gate for Layer 2 (HTML sanitization) and the HTML img/a exfil path in
22
- * Layer 3.
19
+ * Matches every point where the HTML tokenizer leaves the data state: `<` then
20
+ * an ASCII letter, `/`, `!` or `?`. No closing `>` on purpose: a comment holding
21
+ * `1 < 2`, or left unterminated, still hides everything after it. Gate for
22
+ * Layer 2 and the HTML img/a exfil path in Layer 3.
23
23
  */
24
- export const HTML_TAG_PRESENT = /<[a-zA-Z/!?][^<>]*>/;
24
+ export const HTML_TAG_PRESENT = /<[a-zA-Z/!?]/;
25
25
 
26
26
  /**
27
27
  * Matches markdown link/image syntax (`](`, `![`) and reference link
28
- * definitions (`[label]: url` at line start). Gate for Layer 3 (markdown
29
- * exfiltration detection).
28
+ * definitions (`[label]: url` at a line start, also nested in a blockquote or
29
+ * list item). The label admits backslash escapes and line endings, as
30
+ * CommonMark labels do, but no unescaped `[`, which also keeps the scan linear.
31
+ * Gate for Layer 3 (markdown exfiltration detection).
30
32
  */
31
- export const MD_LINK_HINT = /\]\(|!\[|^[ \t]*\[[^[\]\n]+\]:\s/m;
33
+ export const MD_LINK_HINT =
34
+ /\]\(|!\[|^[ \t]*(?:(?:>|[-+*]|\d{1,9}[.)])[ \t]*)*\[(?:[^\\[\]]|\\[\s\S])+\]:/m;
32
35
 
33
36
  /**
34
37
  * True when `text` is worth handing to the heavy remark/rehype graph at all:
@@ -45,12 +48,12 @@ export function needsMarkdownPipeline(text) {
45
48
  }
46
49
 
47
50
  /**
48
- * Matches an absolute http(s) URL anywhere in the text. Gate for the Layer-3
49
- * URL detectors, which read the URLs a GFM autolink literal yields and so find
50
- * a look-alike host or an exfil-shaped query in bare prose — no tag and no
51
- * link syntax required.
51
+ * Matches the start of every URL a GFM autolink literal yields (`http://`,
52
+ * `https://`, scheme-less `www.`). Gate for the Layer-3 URL detectors, which
53
+ * find a look-alike host or exfil-shaped query in bare prose. (A bare e-mail
54
+ * becomes a host-less `mailto:` URL that neither detector can flag.)
52
55
  */
53
- export const URL_PRESENT = /https?:\/\//i;
56
+ export const URL_PRESENT = /https?:\/\/|www\./i;
54
57
 
55
58
  /**
56
59
  * True when Layer 3 (exfil + confusable-host detection) can find something.
@@ -65,6 +68,24 @@ export function needsUrlScan(text) {
65
68
  return URL_PRESENT.test(text) || needsMarkdownPipeline(text);
66
69
  }
67
70
 
71
+ // A comment / bogus-comment open: hides everything after it, closed or not.
72
+ const MARKUP_DECLARATION_OPEN = /<[!?]/;
73
+
74
+ /**
75
+ * POLICY, not a gate: true when `text` reads as HTML rather than data that
76
+ * merely holds a `<` (a comment open, or a tag open with a `>` after it). The
77
+ * output hook uses it to decide whether an MCP result earns the Layer-2 splice,
78
+ * so `a<b` stays verbatim; kept apart from {@link HTML_TAG_PRESENT} so widening
79
+ * the gate never widens the policy. Linear, unlike a `<…[^>]*>` regex.
80
+ * @param {string} text
81
+ * @returns {boolean}
82
+ */
83
+ export function isHtmlShaped(text) {
84
+ if (MARKUP_DECLARATION_OPEN.test(text)) return true;
85
+ const open = text.search(HTML_TAG_PRESENT);
86
+ return open !== -1 && text.includes(">", open + 2);
87
+ }
88
+
68
89
  // ─── Secret-shape pre-gate (Layer 3 URL-param reuse) ─────────────────────────
69
90
  // Cheap shape match that decides whether a URL parameter value carries a
70
91
  // credential (Layer 3). This hand-duplicates credential-shape knowledge that
@@ -97,12 +118,23 @@ export const SECRET_HINT_EXT =
97
118
  /(?: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;
98
119
 
99
120
  /**
100
- * True when either pre-gate alternation shape-matches `text`. Split into two
101
- * literals (see SECRET_HINT) and OR'd so neither grows into a
102
- * polynomial-backtracking shape.
121
+ * Either alternation against `text` exactly as written.
103
122
  * @param {string} text
104
123
  * @returns {boolean}
105
124
  */
106
- export function matchesSecretHint(text) {
125
+ function hintMatchesView(text) {
107
126
  return SECRET_HINT.test(text) || SECRET_HINT_EXT.test(text);
108
127
  }
128
+
129
+ /**
130
+ * True when either pre-gate alternation shape-matches `text`, or the same text
131
+ * with every `\n` removed (the view the engine's `_redact_cross_line` scans, so
132
+ * this stays a superset of the redactor). Two literals OR'd so neither grows
133
+ * into a polynomial-backtracking shape (see SECRET_HINT).
134
+ * @param {string} text
135
+ * @returns {boolean}
136
+ */
137
+ export function matchesSecretHint(text) {
138
+ if (hintMatchesView(text)) return true;
139
+ return text.includes("\n") && hintMatchesView(text.replace(/\n/g, ""));
140
+ }
package/src/html.mjs CHANGED
@@ -2451,7 +2451,9 @@ const VALUE_HAS_DIGIT_RE = /\d/;
2451
2451
  // signed-CDN token (imgix `?s=`, KeyCDN `?secure=`) are all a bare digest under
2452
2452
  // a generic name, and every one of them was reported before: hex is a subset of
2453
2453
  // base64's alphabet, so the 40-char base64 arm caught them on charset alone.
2454
- const BLOB_VALUE_B64_RE = /^[A-Za-z0-9+/]{40,}={0,2}$/;
2454
+ // Spelled `X{40}X*`, not the equivalent `X{40,}`: irregexp runs `{40,}` as a
2455
+ // counted loop pushing a backtrack entry per character (4x slower on 64 KB).
2456
+ const BLOB_VALUE_B64_RE = /^[A-Za-z0-9+/]{40}[A-Za-z0-9+/]*={0,2}$/;
2455
2457
  const HEX_ONLY_RE = /^[A-Fa-f0-9]+$/;
2456
2458
  // 16 bytes, the floor the hex arm has always carried.
2457
2459
  const HEX_BLOB_MIN_LEN = 32;
@@ -2473,7 +2475,8 @@ const DIGEST_HEX_LENGTHS = new Set([32, 40, 56, 64, 96, 128]);
2473
2475
  // (`?d=<200-char base64url of cookies>`) routinely dodged it. The mix test keeps
2474
2476
  // the slug benign (no uppercase) while catching the scattered-separator blob the
2475
2477
  // run gate missed. Anchored to the whole value for the same RAW-query reason.
2476
- const BLOB_VALUE_B64URL_RE = /^[A-Za-z0-9_-]{40,}={0,2}$/;
2478
+ // `X{40}X*` for the reason given at BLOB_VALUE_B64_RE.
2479
+ const BLOB_VALUE_B64URL_RE = /^[A-Za-z0-9_-]{40}[A-Za-z0-9_-]*={0,2}$/;
2477
2480
 
2478
2481
  /**
2479
2482
  * True when `value` carries at least one uppercase letter AND one digit — the
@@ -3149,41 +3152,54 @@ function collectUrls(text) {
3149
3152
  * how loudly (see the exfil tier in ./output.mjs).
3150
3153
  * @param {string} text
3151
3154
  * @param {{ flagDigestValues?: boolean }} [options] see {@link checkExfilUrl}
3152
- * @returns {Array<{ isImage: boolean, autoFetched: boolean, reason: string, target: string }> | null}
3155
+ * @returns {Array<{ isImage: boolean, autoFetched: boolean, reason: string, target: string, unscannable?: true }> | null}
3153
3156
  */
3154
3157
  export function detectExfil(text, options = {}) {
3155
3158
  if (!needsUrlScan(text)) return null;
3159
+ return scanUrlsOrSentinel(
3160
+ () => {
3161
+ /** @type {Array<{ isImage: boolean, autoFetched: boolean, reason: string, target: string, unscannable?: true }>} */
3162
+ const threats = [];
3163
+ for (const { url, isImage, autoFetched, context } of collectUrls(text)) {
3164
+ const reason =
3165
+ checkExfilUrl(url, options) ||
3166
+ (context !== "resource" && isOffOrigin(url)
3167
+ ? OFF_ORIGIN_REASON[context]
3168
+ : null);
3169
+ if (!reason) continue;
3170
+ threats.push({ isImage, autoFetched, reason, target: urlHost(url) });
3171
+ }
3172
+ return threats;
3173
+ },
3174
+ {
3175
+ isImage: false,
3176
+ // Fail closed on the severity tier too: an input the scanner could not
3177
+ // read is not evidence that what it hides is harmless.
3178
+ autoFetched: true,
3179
+ reason: "input too deeply nested to scan for exfil URLs",
3180
+ target: "(unparseable HTML)",
3181
+ unscannable: true,
3182
+ },
3183
+ );
3184
+ }
3156
3185
 
3157
- /** @type {Array<{ isImage: boolean, autoFetched: boolean, reason: string, target: string }>} */
3158
- const threats = [];
3159
-
3186
+ /**
3187
+ * Run one URL scan; null when it finds nothing. A stack overflow (pathological
3188
+ * nesting) fails CLOSED as the lone `unscannable` sentinel; any other throw is
3189
+ * a scan bug and propagates. Shared so both detectors have one rethrow arm.
3190
+ * @template T
3191
+ * @param {() => T[]} scan
3192
+ * @param {T} sentinel
3193
+ * @returns {T[] | null}
3194
+ */
3195
+ function scanUrlsOrSentinel(scan, sentinel) {
3196
+ let threats;
3160
3197
  try {
3161
- for (const { url, isImage, autoFetched, context } of collectUrls(text)) {
3162
- const reason =
3163
- checkExfilUrl(url, options) ||
3164
- (context !== "resource" && isOffOrigin(url)
3165
- ? OFF_ORIGIN_REASON[context]
3166
- : null);
3167
- if (!reason) continue;
3168
- threats.push({ isImage, autoFetched, reason, target: urlHost(url) });
3169
- }
3170
- } catch {
3171
- // The parse/visit blew up (stack overflow on pathological nesting). Fail
3172
- // CLOSED so the never-throw contract holds: report one sentinel threat so
3173
- // the caller still warns rather than crashing, since an input too nested to
3174
- // scan could itself be hiding an exfil URL.
3175
- return [
3176
- {
3177
- isImage: false,
3178
- // Fail closed on the severity tier too: an input the scanner could not
3179
- // read is not evidence that what it hides is harmless.
3180
- autoFetched: true,
3181
- reason: "input too deeply nested to scan for exfil URLs",
3182
- target: "(unparseable HTML)",
3183
- },
3184
- ];
3198
+ threats = scan();
3199
+ } catch (err) {
3200
+ if (!(err instanceof RangeError)) throw err;
3201
+ return [sentinel];
3185
3202
  }
3186
-
3187
3203
  return threats.length > 0 ? threats : null;
3188
3204
  }
3189
3205
 
@@ -3196,35 +3212,32 @@ export function detectExfil(text, options = {}) {
3196
3212
  *
3197
3213
  * Fails CLOSED on a parse blow-up for the same reason detectExfil does.
3198
3214
  * @param {string} text
3199
- * @returns {Array<{ severity: string, description: string }> | null}
3215
+ * @returns {Array<{ severity: string, description: string, unscannable?: true }> | null}
3200
3216
  */
3201
3217
  export function detectConfusableHosts(text) {
3202
3218
  if (!needsUrlScan(text)) return null;
3203
-
3204
- /** @type {Array<{ severity: string, description: string }>} */
3205
- const threats = [];
3206
- /** @type {Set<string>} */
3207
- const seen = new Set();
3208
-
3209
- try {
3210
- for (const { url } of collectUrls(text)) {
3211
- const found = confusableHost(url);
3212
- // One host repeated across a document is one deception, not N.
3213
- if (!found || seen.has(found.ascii)) continue;
3214
- seen.add(found.ascii);
3215
- threats.push({
3216
- severity: found.severity,
3217
- description: describeConfusableHost(found),
3218
- });
3219
- }
3220
- } catch {
3221
- return [
3222
- {
3223
- severity: SEVERITY.WARNING,
3224
- description: "input too deeply nested to scan for confusable hosts",
3225
- },
3226
- ];
3227
- }
3228
-
3229
- return threats.length > 0 ? threats : null;
3219
+ return scanUrlsOrSentinel(
3220
+ () => {
3221
+ /** @type {Array<{ severity: string, description: string, unscannable?: true }>} */
3222
+ const threats = [];
3223
+ /** @type {Set<string>} */
3224
+ const seen = new Set();
3225
+ for (const { url } of collectUrls(text)) {
3226
+ const found = confusableHost(url);
3227
+ // One host repeated across a document is one deception, not N.
3228
+ if (!found || seen.has(found.ascii)) continue;
3229
+ seen.add(found.ascii);
3230
+ threats.push({
3231
+ severity: found.severity,
3232
+ description: describeConfusableHost(found),
3233
+ });
3234
+ }
3235
+ return threats;
3236
+ },
3237
+ {
3238
+ severity: SEVERITY.WARNING,
3239
+ description: "input too deeply nested to scan for confusable hosts",
3240
+ unscannable: true,
3241
+ },
3242
+ );
3230
3243
  }
package/src/index.mjs CHANGED
@@ -63,6 +63,7 @@ export {
63
63
  // would eagerly load on every root import.
64
64
  export {
65
65
  HTML_TAG_PRESENT,
66
+ isHtmlShaped,
66
67
  MD_LINK_HINT,
67
68
  SECRET_HINT,
68
69
  SECRET_HINT_EXT,
package/src/invisible.mjs CHANGED
@@ -103,6 +103,7 @@ export const CATEGORY = Object.freeze({
103
103
  HIDDEN_HTML: "hidden-html",
104
104
  EXFIL_URLS: "exfil-urls",
105
105
  CONFUSABLE_HOST: "confusable-host",
106
+ UNSCANNABLE: "unscannable",
106
107
  });
107
108
 
108
109
  // code -> human label, used only to build `warnings` text. Decoupled from
@@ -118,6 +119,7 @@ export const CATEGORY_LABELS = Object.freeze({
118
119
  [CATEGORY.HIDDEN_HTML]: "hidden HTML",
119
120
  [CATEGORY.EXFIL_URLS]: "exfil URLs",
120
121
  [CATEGORY.CONFUSABLE_HOST]: "confusable hosts",
122
+ [CATEGORY.UNSCANNABLE]: "input too deeply nested to scan for URLs",
121
123
  });
122
124
 
123
125
  /** @type {Array<[string, RegExp]>} Each entry pairs a CATEGORY code with its detector. */