agent-sanitizer 2.19.4 → 2.19.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.19.4",
3
+ "version": "2.19.5",
4
4
  "description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
5
5
  "type": "module",
6
6
  "repository": {
package/src/html.mjs CHANGED
@@ -1219,12 +1219,26 @@ function hasDataSrc(el) {
1219
1219
  );
1220
1220
  }
1221
1221
 
1222
+ // One shared fragment parser for every HTML parse in this module (mirroring
1223
+ // `mdParser` below): all of them must agree on the tokenizer's verdict, so
1224
+ // there is exactly one parser configuration to reason about.
1225
+ const htmlParser = unified().use(rehypeParse, { fragment: true });
1226
+
1227
+ /**
1228
+ * Parse `html` as an HTML fragment with the real tokenizer (parse5, via rehype).
1229
+ * @param {string} html
1230
+ * @returns {any}
1231
+ */
1232
+ function parseFragment(html) {
1233
+ return htmlParser.parse(html);
1234
+ }
1235
+
1222
1236
  /**
1223
1237
  * @param {string} htmlValue
1224
1238
  * @returns {any}
1225
1239
  */
1226
1240
  function parseHtmlTag(htmlValue) {
1227
- const tree = unified().use(rehypeParse, { fragment: true }).parse(htmlValue);
1241
+ const tree = parseFragment(htmlValue);
1228
1242
  /** @type {any} */
1229
1243
  let firstElement = null;
1230
1244
  visit(tree, "element", (node) => {
@@ -1358,7 +1372,19 @@ function hasWarned(warned) {
1358
1372
  * @returns {{ ranges: Array<{start: number, end: number, kind: "comment" | "hidden"}>, warned: ReturnType<typeof newWarned> }}
1359
1373
  */
1360
1374
  export function scanHtmlFragment(html) {
1361
- const tree = unified().use(rehypeParse, { fragment: true }).parse(html);
1375
+ return scanFragmentTree(html, parseFragment(html));
1376
+ }
1377
+
1378
+ /**
1379
+ * `scanHtmlFragment` for a caller that already has the fragment tree — the
1380
+ * dispatch in `sanitizeHtml` parses to decide the branch, so re-parsing there
1381
+ * would tokenize the same input twice. `tree` MUST be the parse of `html`;
1382
+ * the ranges are offsets into `html`, read from that tree's positions.
1383
+ * @param {string} html
1384
+ * @param {any} tree
1385
+ * @returns {{ ranges: Array<{start: number, end: number, kind: "comment" | "hidden"}>, warned: ReturnType<typeof newWarned> }}
1386
+ */
1387
+ function scanFragmentTree(html, tree) {
1362
1388
  /** @type {Array<{start: number, end: number, kind: "comment" | "hidden"}>} */
1363
1389
  const ranges = [];
1364
1390
  const warned = newWarned();
@@ -1443,7 +1469,7 @@ function foldAbsorb(absorbing, raw) {
1443
1469
  * @returns {Map<number, number>}
1444
1470
  */
1445
1471
  function commentSpans(value) {
1446
- const tree = unified().use(rehypeParse, { fragment: true }).parse(value);
1472
+ const tree = parseFragment(value);
1447
1473
  /** @type {Map<number, number>} */
1448
1474
  const spans = new Map();
1449
1475
  visit(tree, "comment", (/** @type {any} */ node) => {
@@ -1711,20 +1737,77 @@ function scanMarkdown(text) {
1711
1737
  return { ranges, warned };
1712
1738
  }
1713
1739
 
1714
- // 30%-of-lines heuristic: HTML *source* gets scanned as one rehype fragment;
1715
- // inline tags scattered in prose go through the markdown branch instead.
1716
1740
  /**
1741
+ * True when remark finds a code block — fenced or indented — in `text`.
1742
+ *
1743
+ * Asked before the HTML tokenizer because "no character data outside the
1744
+ * markup" cannot see an INDENTED code block: its four leading spaces are
1745
+ * whitespace, so a document that is nothing but one indented block
1746
+ * (`" <div hidden>x</div>\n"`) satisfies the rule and takes the source
1747
+ * branch, and the hidden element gets spliced out of a block the renderer
1748
+ * displays as literal text. A fence escapes only incidentally, because the
1749
+ * backticks are non-whitespace character data. Code blocks are markdown-ONLY
1750
+ * syntax, so their presence settles the question the same way the tokenizer
1751
+ * does — by parsing, not by counting.
1717
1752
  * @param {string} text
1718
1753
  * @returns {boolean}
1719
1754
  */
1720
- export function looksLikeHtmlSource(text) {
1721
- const lines = text.split("\n");
1722
- if (lines.length < 5) return false;
1723
- let htmlLines = 0;
1724
- for (const line of lines) {
1725
- if (/<\/?[a-zA-Z][^<>]*>/.test(line)) htmlLines++;
1755
+ function hasMarkdownCode(text) {
1756
+ let found = false;
1757
+ visit(mdParser.parse(text), "code", () => {
1758
+ found = true;
1759
+ return EXIT;
1760
+ });
1761
+ return found;
1762
+ }
1763
+
1764
+ /**
1765
+ * The parsed fragment tree for `text` when `text` is HTML *source*, else null.
1766
+ *
1767
+ * "HTML source" means the markup accounts for the WHOLE document: the real
1768
+ * tokenizer (parse5, via rehype) places every element there is, and the only
1769
+ * character data it leaves OUTSIDE all of them is whitespace. That is exactly
1770
+ * the property the source branch needs — it hands the whole input to
1771
+ * `scanHtmlFragment` as one fragment, which is faithful only when there is no
1772
+ * non-HTML syntax around the markup for that parse to misread.
1773
+ *
1774
+ * Everything else fails OPEN to the markdown branch, which parses with remark
1775
+ * and scans only the spans remark itself calls HTML. That is the conservative
1776
+ * direction: markdown-only constructs (fenced/indented code, tables, lists)
1777
+ * keep their meaning, so an HTML sample inside a code fence is displayed
1778
+ * rather than spliced. The dispatch this replaces counted tag-shaped LINES and
1779
+ * took the source branch above 30% of them, which got that case wrong — it
1780
+ * spliced hidden-element examples out of documentation code blocks.
1781
+ *
1782
+ * Character data is judged by its DECODED value, as a renderer sees it: the
1783
+ * ignored `<html>`/`<head>`/`<body>` tags of a full page leave their
1784
+ * surrounding newlines merged into one text node, and `&nbsp;` between two
1785
+ * elements is whitespace on the page.
1786
+ * @param {string} text
1787
+ * @returns {any}
1788
+ */
1789
+ function htmlSourceTree(text) {
1790
+ if (hasMarkdownCode(text)) return null;
1791
+ const tree = parseFragment(text);
1792
+ let sawElement = false;
1793
+ // Only ROOT children can hold character data outside an element; everything
1794
+ // deeper is by construction inside one.
1795
+ for (const node of tree.children) {
1796
+ if (node.type === "element") sawElement = true;
1797
+ // Comments and the doctype are markup, not character data.
1798
+ else if (node.type === "text" && node.value.trim() !== "") return null;
1726
1799
  }
1727
- return htmlLines / lines.length > 0.3;
1800
+ return sawElement ? tree : null;
1801
+ }
1802
+
1803
+ /**
1804
+ * True when `text` is HTML source rather than markdown that merely contains
1805
+ * tags — see `htmlSourceTree` for the definition and the fail-open rationale.
1806
+ * @param {string} text
1807
+ * @returns {boolean}
1808
+ */
1809
+ export function looksLikeHtmlSource(text) {
1810
+ return htmlSourceTree(text) !== null;
1728
1811
  }
1729
1812
 
1730
1813
  /**
@@ -1740,9 +1823,10 @@ export function sanitizeHtml(text) {
1740
1823
  /** @type {{ ranges: Array<{start: number, end: number, kind: "comment" | "hidden"}>, warned: ReturnType<typeof newWarned> }} */
1741
1824
  let scan;
1742
1825
  try {
1743
- scan = looksLikeHtmlSource(text)
1744
- ? scanHtmlFragment(text)
1745
- : scanMarkdown(text);
1826
+ // One parse decides the branch AND feeds it, so the source branch does not
1827
+ // tokenize the input twice.
1828
+ const sourceTree = htmlSourceTree(text);
1829
+ scan = sourceTree ? scanFragmentTree(text, sourceTree) : scanMarkdown(text);
1746
1830
  } catch {
1747
1831
  // The parse/visit blew up (stack overflow on pathological nesting, or any
1748
1832
  // other parser error). Fail CLOSED at this boundary so `sanitize`/
@@ -2233,7 +2317,7 @@ function multiUrlAttr(value) {
2233
2317
  * @returns {Array<{ url: string, isImage: boolean, context: "resource" | "form" | "refresh" }>}
2234
2318
  */
2235
2319
  function extractHtmlUrls(text) {
2236
- const tree = unified().use(rehypeParse, { fragment: true }).parse(text);
2320
+ const tree = parseFragment(text);
2237
2321
  /** @type {Array<{ url: string, isImage: boolean, context: "resource" | "form" | "refresh" }>} */
2238
2322
  const urls = [];
2239
2323
  visit(tree, "element", (/** @type {any} */ node) => {
package/types/html.d.mts CHANGED
@@ -51,6 +51,8 @@ export function scanHtmlFragment(html: string): {
51
51
  warned: ReturnType<typeof newWarned>;
52
52
  };
53
53
  /**
54
+ * True when `text` is HTML source rather than markdown that merely contains
55
+ * tags — see `htmlSourceTree` for the definition and the fail-open rationale.
54
56
  * @param {string} text
55
57
  * @returns {boolean}
56
58
  */