agent-sanitizer 2.47.9 → 2.47.11

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.47.9",
3
+ "version": "2.47.11",
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": {
@@ -63,6 +63,7 @@
63
63
  "globals": "17.6.0",
64
64
  "lint-staged": "^17.0.5",
65
65
  "prettier": "^3.0.0",
66
+ "rehype-parse": "9.0.1",
66
67
  "smol-toml": "^1.7.1",
67
68
  "typescript": "6.0.3",
68
69
  "typescript-eslint": "8.61.0",
@@ -214,12 +215,14 @@
214
215
  "dependencies": {
215
216
  "agent-control-plane-core": "0.3.0",
216
217
  "css-tree": "^3.2.1",
218
+ "hast-util-from-parse5": "8.0.3",
217
219
  "namespace-guard": "0.20.0",
218
- "rehype-parse": "9.0.1",
220
+ "parse5": "7.3.0",
219
221
  "remark-gfm": "4.0.1",
220
222
  "remark-parse": "11.0.0",
221
223
  "unified": "11.0.5",
222
- "unist-util-visit": "5.1.0"
224
+ "unist-util-visit": "5.1.0",
225
+ "vfile": "6.0.3"
223
226
  },
224
227
  "scripts": {
225
228
  "test": "node scripts/coverage.mjs",
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The parse5 tree adapter this package parses HTML fragments with, and the
3
+ * fragment parse itself.
4
+ *
5
+ * WHY IT EXISTS — parse5 ends a fragment parse by moving every child of the
6
+ * parsed `html` element into the fragment it returns, one at a time, always
7
+ * taking the first (`_adoptNodes`). The default tree adapter removes each one
8
+ * with `childNodes.splice(0, 1)`, which shifts every remaining sibling: for a
9
+ * document with N top-level nodes that is N^2/2 element moves. 256 KB of
10
+ * ordinary `<p>` prose is ~8600 of them, and the removal alone was a quarter of
11
+ * the whole HTML layer's cost. The cursor below turns each removal into an
12
+ * increment, so the drain is linear in N.
13
+ *
14
+ * It is a separate module, and not in the package's `exports` map, because it
15
+ * is an implementation detail of `./html` that its own test parses against.
16
+ */
17
+ import {
18
+ defaultTreeAdapter,
19
+ parseFragment as parse5ParseFragment,
20
+ } from "parse5";
21
+ import { fromParse5 } from "hast-util-from-parse5";
22
+ import { VFile } from "vfile";
23
+
24
+ /**
25
+ * A tree adapter for ONE parse, plus the settle step that ends it.
26
+ *
27
+ * The pending state is per-parse rather than module-level so that a parse which
28
+ * throws cannot leave a deferred removal for the next one to inherit.
29
+ * @returns {{ adapter: any, settle: () => void }}
30
+ */
31
+ export function createTreeAdapter() {
32
+ /**
33
+ * Where a parent's un-detached children start, for every parent with a
34
+ * removal still deferred.
35
+ * @type {Map<any, number>}
36
+ */
37
+ const drainedUpTo = new Map();
38
+
39
+ /**
40
+ * Put `parent.childNodes` in the state the default adapter's splices would
41
+ * have left it in.
42
+ *
43
+ * INVARIANT — every adapter method that reads or writes a parent's children
44
+ * BY POSITION calls this first, so a deferred removal is never observable to
45
+ * parse5, which reaches every child through this adapter. `setDocumentType`
46
+ * is the one method that scans a node's children without going through those:
47
+ * it scans the DOCUMENT's, and a fragment parse detaches nothing from the
48
+ * document.
49
+ * @param {any} parent
50
+ */
51
+ function flush(parent) {
52
+ const at = drainedUpTo.get(parent);
53
+ if (at === undefined) return;
54
+ drainedUpTo.delete(parent);
55
+ parent.childNodes.splice(0, at);
56
+ }
57
+
58
+ /**
59
+ * Flush every parent still holding a deferred removal.
60
+ *
61
+ * This is what makes the deferral invisible to the tree's READERS, which get
62
+ * the nodes directly and not through the adapter: `hast-util-from-parse5`
63
+ * here, and any consumer of the parse5 AST. Call it once the parse has
64
+ * returned, before anything reads the tree.
65
+ */
66
+ function settle() {
67
+ for (const parent of [...drainedUpTo.keys()]) flush(parent);
68
+ }
69
+
70
+ const adapter = {
71
+ ...defaultTreeAdapter,
72
+ /** @param {any} node */
73
+ detachNode(node) {
74
+ const parent = node.parentNode;
75
+ if (!parent) return;
76
+ const at = drainedUpTo.get(parent) ?? 0;
77
+ // The front of the un-drained region: record the removal and move on. Any
78
+ // other position is a mid-tree detach (the adoption agency algorithm,
79
+ // foster parenting), which is rare and takes the default adapter's splice.
80
+ if (parent.childNodes[at] === node) {
81
+ drainedUpTo.set(parent, at + 1);
82
+ node.parentNode = null;
83
+ return;
84
+ }
85
+ flush(parent);
86
+ defaultTreeAdapter.detachNode(node);
87
+ },
88
+ /** @param {any} node */
89
+ getFirstChild(node) {
90
+ return node.childNodes[drainedUpTo.get(node) ?? 0];
91
+ },
92
+ /** @param {any} node */
93
+ getChildNodes(node) {
94
+ flush(node);
95
+ return defaultTreeAdapter.getChildNodes(node);
96
+ },
97
+ /** @param {any} parent @param {any} child */
98
+ appendChild(parent, child) {
99
+ flush(parent);
100
+ defaultTreeAdapter.appendChild(parent, child);
101
+ },
102
+ /** @param {any} parent @param {any} child @param {any} reference */
103
+ insertBefore(parent, child, reference) {
104
+ flush(parent);
105
+ defaultTreeAdapter.insertBefore(parent, child, reference);
106
+ },
107
+ /** @param {any} parent @param {string} text */
108
+ insertText(parent, text) {
109
+ flush(parent);
110
+ defaultTreeAdapter.insertText(parent, text);
111
+ },
112
+ /** @param {any} parent @param {string} text @param {any} reference */
113
+ insertTextBefore(parent, text, reference) {
114
+ flush(parent);
115
+ defaultTreeAdapter.insertTextBefore(parent, text, reference);
116
+ },
117
+ };
118
+
119
+ return { adapter, settle };
120
+ }
121
+
122
+ /**
123
+ * Parse `html` as an HTML fragment and return the hast tree.
124
+ *
125
+ * This is `hast-util-from-html`'s fragment path with {@link createTreeAdapter}'s
126
+ * adapter in place of the default one — the option `rehype-parse` does not
127
+ * forward. The settings are that path's own: positions on, parse errors ignored
128
+ * (this package reports on the tree, never on the tokenizer's complaints), and
129
+ * scripting off, so `noscript` content parses as markup.
130
+ * @param {string} html
131
+ * @returns {any}
132
+ */
133
+ export function parseHtmlFragment(html) {
134
+ const { adapter, settle } = createTreeAdapter();
135
+ const fragment = parse5ParseFragment(html, {
136
+ sourceCodeLocationInfo: true,
137
+ onParseError: null,
138
+ scriptingEnabled: false,
139
+ treeAdapter: adapter,
140
+ });
141
+ settle();
142
+ return fromParse5(fragment, { file: new VFile(html) });
143
+ }
package/src/html.mjs CHANGED
@@ -49,7 +49,7 @@ import { ident as cssIdent } from "css-tree/utils";
49
49
  import { unified } from "unified";
50
50
  import remarkParse from "remark-parse";
51
51
  import remarkGfm from "remark-gfm";
52
- import rehypeParse from "rehype-parse";
52
+ import { parseHtmlFragment } from "./html-tree-adapter.mjs";
53
53
  import { SKIP, EXIT } from "unist-util-visit";
54
54
  import {
55
55
  HTML_TAG_PRESENT,
@@ -1420,11 +1420,6 @@ function hasDataSrc(el) {
1420
1420
  );
1421
1421
  }
1422
1422
 
1423
- // One shared fragment parser for every HTML parse in this module (mirroring
1424
- // `mdParser` below): all of them must agree on the tokenizer's verdict, so
1425
- // there is exactly one parser configuration to reason about.
1426
- const htmlParser = unified().use(rehypeParse, { fragment: true });
1427
-
1428
1423
  /**
1429
1424
  * Preorder depth-first walk of a unist tree, calling `visitor(node, index,
1430
1425
  * parent)` on every node whose `type` is `test` (or on every node when `test` is
@@ -1507,11 +1502,12 @@ function lastParseCached(parse) {
1507
1502
  }
1508
1503
 
1509
1504
  /**
1510
- * Parse `html` as an HTML fragment with the real tokenizer (parse5, via rehype).
1511
- * @param {string} html
1512
- * @returns {any}
1505
+ * The one HTML parse in this module: the real tokenizer (parse5, through
1506
+ * `./html-tree-adapter.mjs`), with the last (input, tree) pair remembered. Every
1507
+ * parse here must agree on the tokenizer's verdict, so there is exactly one
1508
+ * parser configuration to reason about.
1513
1509
  */
1514
- const parseFragment = lastParseCached((html) => htmlParser.parse(html));
1510
+ const parseFragment = lastParseCached(parseHtmlFragment);
1515
1511
 
1516
1512
  /**
1517
1513
  * @param {string} htmlValue
@@ -1722,7 +1718,7 @@ function hasWarned(warned) {
1722
1718
  /**
1723
1719
  * Scan raw HTML for hidden content to strip and preserved tags to report.
1724
1720
  * Returned ranges are offsets into `html`; comments and hidden elements span
1725
- * the whole element including its content (rehype positions cover open tag
1721
+ * the whole element including its content (hast positions cover open tag
1726
1722
  * through matching close, and parse5 extends an unclosed element to the end
1727
1723
  * of the fragment — fail-closed for truncated markup).
1728
1724
  * @param {string} html
@@ -1803,8 +1799,8 @@ const BOGUS_COMMENT_OPEN_RE = /<[!?]/g;
1803
1799
  // comment / declaration (`<!`, `<?`). Per the HTML tokenizer such a construct
1804
1800
  // keeps consuming the input stream until the next `>`, so it absorbs the
1805
1801
  // following inline-html node (an open tag swallows it as bogus attributes; a
1806
- // bogus end tag / `<!…` opens a bogus comment). parse5 (the flow/source branch,
1807
- // via rehype) models this; the per-tag balance walk below does not, so without
1802
+ // bogus end tag / `<!…` opens a bogus comment). parse5 (the flow/source branch)
1803
+ // models this; the per-tag balance walk below does not, so without
1808
1804
  // this a fragment parses differently as a flow block than as a paragraph —
1809
1805
  // breaking idempotency once a first pass demotes a block to phrasing (see
1810
1806
  // html-property "second pass changes nothing"). An open/end tag requires a
@@ -1830,7 +1826,7 @@ function foldAbsorb(absorbing, raw) {
1830
1826
 
1831
1827
  /**
1832
1828
  * Map of comment start-offset -> end-offset (exclusive) for EVERY comment the
1833
- * HTML tokenizer finds in `value`, from a SINGLE rehype parse. Validated against
1829
+ * HTML tokenizer finds in `value`, from a SINGLE parse5 parse. Validated against
1834
1830
  * the real tokenizer (parse5) rather than a hand-rolled bogus-comment state
1835
1831
  * machine, so a bogus comment (`<!bogus>`, `<?php?>`, `<![CDATA[…]]>`) is spliced
1836
1832
  * to exactly the span a browser hides and a `<Foo>` element, a `<!doctype>`, or
@@ -1964,7 +1960,7 @@ function hasHtmlLeaf(node) {
1964
1960
  * the container's end when unbalanced — fail-closed), comments become
1965
1961
  * single-node ranges, and preserved tags are counted. Inline html is tokenized
1966
1962
  * per TAG (an element's content sits in sibling text nodes), which is why this
1967
- * walk exists instead of handing the value to rehype.
1963
+ * walk exists instead of handing the value to parse5.
1968
1964
  *
1969
1965
  * The absorb state is folded from the RAW source between html nodes (not from
1970
1966
  * mdast node values), so markdown constructs that reshuffle the character
@@ -2076,7 +2072,7 @@ function scanMarkdown(text) {
2076
2072
  const ranges = [];
2077
2073
  const warned = newWarned();
2078
2074
 
2079
- // Flow html blocks carry complete markup, so rehype locates comments/hidden
2075
+ // Flow html blocks carry complete markup, so parse5 locates comments/hidden
2080
2076
  // elements precisely within them; block-local offsets are shifted to
2081
2077
  // document coordinates.
2082
2078
  walk(tree, "html", (/** @type {any} */ node, _index, parent) => {
@@ -2137,7 +2133,7 @@ function hasMarkdownCode(text) {
2137
2133
  * The parsed fragment tree for `text` when `text` is HTML *source*, else null.
2138
2134
  *
2139
2135
  * "HTML source" means the markup accounts for the WHOLE document: the real
2140
- * tokenizer (parse5, via rehype) places every element there is, and the only
2136
+ * tokenizer (parse5) places every element there is, and the only
2141
2137
  * character data it leaves OUTSIDE all of them is whitespace. That is exactly
2142
2138
  * the property the source branch needs — it hands the whole input to
2143
2139
  * `scanHtmlFragment` as one fragment, which is faithful only when there is no
@@ -2847,7 +2843,7 @@ function parseSrcset(value) {
2847
2843
 
2848
2844
  /**
2849
2845
  * Candidate URLs of a `srcset` (a "url descriptor" string parsed per the HTML
2850
- * grammar) or `ping` (a space-separated url list rehype delivers as an array)
2846
+ * grammar) or `ping` (a space-separated url list hast delivers as an array)
2851
2847
  * attribute. An absent attribute (neither string nor array) yields none.
2852
2848
  * @param {unknown} value
2853
2849
  * @returns {string[]}
@@ -2862,8 +2858,9 @@ function multiUrlAttr(value) {
2862
2858
  }
2863
2859
 
2864
2860
  /**
2865
- * URL-bearing attributes of every HTML element in `text`, parsed with rehype so
2866
- * quoting/casing/entities are handled correctly (no hand-rolled tag regex).
2861
+ * URL-bearing attributes of every HTML element in `text`, parsed with the real
2862
+ * HTML tokenizer so quoting/casing/entities are handled correctly (no
2863
+ * hand-rolled tag regex).
2867
2864
  * `context` selects the per-URL check the caller applies: resource URLs get the
2868
2865
  * exfil-shape test; form-submission and meta-refresh targets additionally flag
2869
2866
  * any absolute off-origin destination.
@@ -2906,7 +2903,7 @@ function extractHtmlUrls(text) {
2906
2903
  autoFetched: true,
2907
2904
  context: "form",
2908
2905
  });
2909
- // rehype delivers `http-equiv` as an array (comma-separated); join it back
2906
+ // hast delivers `http-equiv` as an array (comma-separated); join it back
2910
2907
  // so a `refresh` directive is matched regardless of how it was tokenized.
2911
2908
  const httpEquiv = Array.isArray(props.httpEquiv)
2912
2909
  ? props.httpEquiv.join(",").toLowerCase()
@@ -0,0 +1,23 @@
1
+ /**
2
+ * A tree adapter for ONE parse, plus the settle step that ends it.
3
+ *
4
+ * The pending state is per-parse rather than module-level so that a parse which
5
+ * throws cannot leave a deferred removal for the next one to inherit.
6
+ * @returns {{ adapter: any, settle: () => void }}
7
+ */
8
+ export function createTreeAdapter(): {
9
+ adapter: any;
10
+ settle: () => void;
11
+ };
12
+ /**
13
+ * Parse `html` as an HTML fragment and return the hast tree.
14
+ *
15
+ * This is `hast-util-from-html`'s fragment path with {@link createTreeAdapter}'s
16
+ * adapter in place of the default one — the option `rehype-parse` does not
17
+ * forward. The settings are that path's own: positions on, parse errors ignored
18
+ * (this package reports on the tree, never on the tokenizer's complaints), and
19
+ * scripting off, so `noscript` content parses as markup.
20
+ * @param {string} html
21
+ * @returns {any}
22
+ */
23
+ export function parseHtmlFragment(html: string): any;
package/types/html.d.mts CHANGED
@@ -53,7 +53,7 @@ export function spliceRanges(text: string, ranges: SpliceRange[]): {
53
53
  /**
54
54
  * Scan raw HTML for hidden content to strip and preserved tags to report.
55
55
  * Returned ranges are offsets into `html`; comments and hidden elements span
56
- * the whole element including its content (rehype positions cover open tag
56
+ * the whole element including its content (hast positions cover open tag
57
57
  * through matching close, and parse5 extends an unclosed element to the end
58
58
  * of the fragment — fail-closed for truncated markup).
59
59
  * @param {string} html