agent-sanitizer 2.41.2 → 2.41.4

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.41.2",
3
+ "version": "2.41.4",
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
@@ -1585,19 +1585,12 @@ export const COMMENT_PLACEHOLDER = "[HTML comment removed";
1585
1585
  export const UNPARSEABLE_PLACEHOLDER = "[HTML unparseable — withheld]";
1586
1586
 
1587
1587
  /**
1588
- * Replace each range of `text` with its kind's keyed placeholder, preserving
1589
- * every byte outside the ranges verbatim. Overlapping/nested ranges are merged
1590
- * (defense-in-depth — the scanners emit disjoint ranges).
1591
- *
1592
- * Returns the spliced text plus `pairs`, one per emitted placeholder in output
1593
- * order, each pairing the placeholder with the ORIGINAL bytes it replaced and
1594
- * its start offset in the RETURNED text (UTF-16 code-unit string indices, the
1595
- * same space as `ranges`) — everything a rehydrator needs to undo the splice.
1596
- * @param {string} text
1588
+ * `ranges` sorted by start with every overlap unioned one entry per
1589
+ * placeholder {@link spliceRanges} emits, in the same order as its `pairs`.
1597
1590
  * @param {SpliceRange[]} ranges
1598
- * @returns {{ text: string, pairs: SplicePair[] }}
1591
+ * @returns {SpliceRange[]}
1599
1592
  */
1600
- export function spliceRanges(text, ranges) {
1593
+ function mergeRanges(ranges) {
1601
1594
  const sorted = [...ranges].sort(
1602
1595
  (left, right) => left.start - right.start || left.end - right.end,
1603
1596
  );
@@ -1616,6 +1609,24 @@ export function spliceRanges(text, ranges) {
1616
1609
  merged.push({ ...range });
1617
1610
  }
1618
1611
  }
1612
+ return merged;
1613
+ }
1614
+
1615
+ /**
1616
+ * Replace each range of `text` with its kind's keyed placeholder, preserving
1617
+ * every byte outside the ranges verbatim. Overlapping/nested ranges are merged
1618
+ * (defense-in-depth — the scanners emit disjoint ranges).
1619
+ *
1620
+ * Returns the spliced text plus `pairs`, one per emitted placeholder in output
1621
+ * order, each pairing the placeholder with the ORIGINAL bytes it replaced and
1622
+ * its start offset in the RETURNED text (UTF-16 code-unit string indices, the
1623
+ * same space as `ranges`) — everything a rehydrator needs to undo the splice.
1624
+ * @param {string} text
1625
+ * @param {SpliceRange[]} ranges
1626
+ * @returns {{ text: string, pairs: SplicePair[] }}
1627
+ */
1628
+ export function spliceRanges(text, ranges) {
1629
+ const merged = mergeRanges(ranges);
1619
1630
  let out = "";
1620
1631
  let cursor = 0;
1621
1632
  /** @type {SplicePair[]} */
@@ -2122,6 +2133,50 @@ export function looksLikeHtmlSource(text) {
2122
2133
  return htmlSourceTree(text) !== null;
2123
2134
  }
2124
2135
 
2136
+ // How many scan/splice rounds one `sanitizeHtml` call may spend before it
2137
+ // withholds the document instead. Every input measured settles in at most 2;
2138
+ // the headroom is for a shape the measurement did not reach, and the ceiling is
2139
+ // what keeps a crafted reveal-chain from buying one whole reparse per node.
2140
+ const MAX_SPLICE_ROUNDS = 8;
2141
+
2142
+ /**
2143
+ * `ranges`, which index a text spliced at `merged` (with the resulting
2144
+ * `pairs`), translated back into coordinates of the source that was spliced.
2145
+ *
2146
+ * Every offset outside a placeholder maps by the length the splices before it
2147
+ * removed. No offset ever falls INSIDE one: a placeholder holds neither `<`
2148
+ * nor `>`, and a scanner range always opens on a `<` and closes after a `>` or
2149
+ * at the end of the text — so a placeholder edge is the closest an offset gets,
2150
+ * and the same shift is exact there.
2151
+ * @param {SpliceRange[]} ranges
2152
+ * @param {SpliceRange[]} merged the spliced spans, sorted, one per pair
2153
+ * @param {SplicePair[]} pairs
2154
+ * @returns {SpliceRange[]}
2155
+ */
2156
+ function toSourceRanges(ranges, merged, pairs) {
2157
+ // Placeholder ends ascend across `pairs`, so the splices before an offset are
2158
+ // found by bisection. Walking `pairs` per offset instead costs the product of
2159
+ // the two lists, which on a document of 10k hidden elements is 10^8 steps.
2160
+ const ends = pairs.map((pair) => pair.start + pair.placeholder.length);
2161
+ const shifts = merged.map((range, index) => range.end - ends[index]);
2162
+ /** @param {number} offset @returns {number} */
2163
+ const toSource = (offset) => {
2164
+ let low = 0;
2165
+ let high = ends.length;
2166
+ while (low < high) {
2167
+ const mid = Math.floor((low + high) / 2);
2168
+ if (ends[mid] <= offset) low = mid + 1;
2169
+ else high = mid;
2170
+ }
2171
+ return low === 0 ? offset : offset + shifts[low - 1];
2172
+ };
2173
+ return ranges.map((range) => ({
2174
+ start: toSource(range.start),
2175
+ end: toSource(range.end),
2176
+ kind: range.kind,
2177
+ }));
2178
+ }
2179
+
2125
2180
  /**
2126
2181
  * Layer 2 over web-ingress text: splice out HTML comments and hidden elements
2127
2182
  * (keyed placeholders mark the cuts; all other bytes are preserved verbatim)
@@ -2139,41 +2194,90 @@ export function looksLikeHtmlSource(text) {
2139
2194
  * located, so nothing is recoverable per-splice (the caller's pre-splice
2140
2195
  * `reveal` is the only copy).
2141
2196
  *
2142
- * Idempotent over its own output: a keyed placeholder contains no `<`, so a
2143
- * re-run neither gates on it (HTML_TAG_PRESENT needs a tag) nor reads it as
2144
- * markup placeholders already in the text pass through byte-identical.
2197
+ * Idempotent over its own output, and by CONSTRUCTION rather than by argument:
2198
+ * the scan is re-run over the spliced text until it finds nothing more, so the
2199
+ * returned text is a fixed point. One pass is not enough on its own, because
2200
+ * removing an element changes how parse5 reparents the bytes around it, which
2201
+ * can flip {@link htmlSourceTree}'s markdown/source verdict for the next run.
2202
+ * A document that has not settled within {@link MAX_SPLICE_ROUNDS} rounds is
2203
+ * withheld whole, on the same fail-closed path a parser blow-up takes.
2145
2204
  * @param {string} text
2146
2205
  * @returns {{ text: string, removed: { comments: number, hidden: number }, warned: { tags: Record<string, number>, dataSrc: number }, splices: SplicePair[], unparseable?: true } | null}
2147
2206
  */
2148
2207
  export function sanitizeHtml(text) {
2149
2208
  if (!HTML_TAG_PRESENT.test(text)) return null;
2150
- /** @type {{ ranges: SpliceRange[], warned: ReturnType<typeof newWarned> }} */
2151
- let scan;
2152
- try {
2153
- // One parse decides the branch AND feeds it, so the source branch does not
2154
- // tokenize the input twice.
2155
- const sourceTree = htmlSourceTree(text);
2156
- scan = sourceTree ? scanFragmentTree(text, sourceTree) : scanMarkdown(text);
2157
- } catch {
2158
- // The parse/visit blew up (stack overflow on pathological nesting, or any
2159
- // other parser error). Fail CLOSED at this boundary so `sanitize`/
2160
- // `sanitizeText` keep their never-throw contract: withhold the whole input
2161
- // behind a placeholder and report it as hidden content removed.
2162
- return {
2163
- text: UNPARSEABLE_PLACEHOLDER,
2164
- removed: { comments: 0, hidden: 1 },
2165
- warned: newWarned(),
2166
- splices: [],
2167
- unparseable: true,
2168
- };
2209
+ /** @type {SpliceRange[]} Every span found so far, in SOURCE coordinates. */
2210
+ let ranges = [];
2211
+ let spliced = { text, pairs: /** @type {SplicePair[]} */ ([]) };
2212
+ const removed = { comments: 0, hidden: 0 };
2213
+ /** @type {ReturnType<typeof newWarned>} */
2214
+ let warned;
2215
+ // Each round splices at least one more span of `text`, so the covered length
2216
+ // grows strictly and the loop terminates. A placeholder holds no `<`, so no
2217
+ // round can find a span inside one and re-cover ground already covered.
2218
+ for (let round = 0; ; round++) {
2219
+ // Termination alone is not a cost bound: each round is a whole reparse, so
2220
+ // an input that reveals one more node per round would pay one per node.
2221
+ // This refuses past the ceiling instead, the way MAX_DEPTH in output.mjs
2222
+ // refuses past a nesting depth.
2223
+ /* c8 ignore start -- no input is known to reach round MAX_SPLICE_ROUNDS:
2224
+ 370k adversarial draws over the shapes that DO chain (a stray `<td>` the
2225
+ source branch drops, a bogus comment, the adoption-agency reparent that
2226
+ flips the branch) settle in at most 2. Defense in depth on the
2227
+ web-ingress boundary, not a path with a fixture. */
2228
+ if (round === MAX_SPLICE_ROUNDS)
2229
+ return {
2230
+ text: UNPARSEABLE_PLACEHOLDER,
2231
+ removed: { comments: 0, hidden: 1 },
2232
+ warned: newWarned(),
2233
+ splices: [],
2234
+ unparseable: true,
2235
+ };
2236
+ /* c8 ignore stop */
2237
+ /** @type {{ ranges: SpliceRange[], warned: ReturnType<typeof newWarned> }} */
2238
+ let scan;
2239
+ try {
2240
+ // One parse decides the branch AND feeds it, so the source branch does not
2241
+ // tokenize the input twice.
2242
+ const sourceTree = htmlSourceTree(spliced.text);
2243
+ scan = sourceTree
2244
+ ? scanFragmentTree(spliced.text, sourceTree)
2245
+ : scanMarkdown(spliced.text);
2246
+ } catch {
2247
+ // The parse/visit blew up (stack overflow on pathological nesting, or any
2248
+ // other parser error). Fail CLOSED at this boundary so `sanitize`/
2249
+ // `sanitizeText` keep their never-throw contract: withhold the whole input
2250
+ // behind a placeholder and report it as hidden content removed.
2251
+ return {
2252
+ text: UNPARSEABLE_PLACEHOLDER,
2253
+ removed: { comments: 0, hidden: 1 },
2254
+ warned: newWarned(),
2255
+ splices: [],
2256
+ unparseable: true,
2257
+ };
2258
+ }
2259
+ // The last scan saw exactly the text being returned, so its tag counts are
2260
+ // the ones that describe that text.
2261
+ warned = scan.warned;
2262
+ if (scan.ranges.length === 0) break;
2263
+ // Re-splice from the SOURCE every round: pairs then carry original bytes
2264
+ // and offsets into the returned text, never a nested earlier placeholder.
2265
+ const grown = mergeRanges([
2266
+ ...ranges,
2267
+ ...toSourceRanges(scan.ranges, ranges, spliced.pairs),
2268
+ ]);
2269
+ const next = spliceRanges(text, grown);
2270
+ /* c8 ignore next 4 -- unreachable: every range holds a `<` and no
2271
+ placeholder does, so each round covers source bytes the last one did
2272
+ not and the text must change. Kept because the alternative to this
2273
+ stop is a hook that spins on untrusted input. */
2274
+ if (next.text === spliced.text) break;
2275
+ for (const range of scan.ranges)
2276
+ removed[range.kind === "comment" ? "comments" : "hidden"]++;
2277
+ ranges = grown;
2278
+ spliced = next;
2169
2279
  }
2170
- const { ranges, warned } = scan;
2171
2280
  if (ranges.length === 0 && !hasWarned(warned)) return null;
2172
- const removed = { comments: 0, hidden: 0 };
2173
- for (const range of ranges)
2174
- removed[range.kind === "comment" ? "comments" : "hidden"]++;
2175
- const spliced =
2176
- ranges.length > 0 ? spliceRanges(text, ranges) : { text, pairs: [] };
2177
2281
  return {
2178
2282
  text: spliced.text,
2179
2283
  removed,
package/types/html.d.mts CHANGED
@@ -87,9 +87,13 @@ export function looksLikeHtmlSource(text: string): boolean;
87
87
  * located, so nothing is recoverable per-splice (the caller's pre-splice
88
88
  * `reveal` is the only copy).
89
89
  *
90
- * Idempotent over its own output: a keyed placeholder contains no `<`, so a
91
- * re-run neither gates on it (HTML_TAG_PRESENT needs a tag) nor reads it as
92
- * markup placeholders already in the text pass through byte-identical.
90
+ * Idempotent over its own output, and by CONSTRUCTION rather than by argument:
91
+ * the scan is re-run over the spliced text until it finds nothing more, so the
92
+ * returned text is a fixed point. One pass is not enough on its own, because
93
+ * removing an element changes how parse5 reparents the bytes around it, which
94
+ * can flip {@link htmlSourceTree}'s markdown/source verdict for the next run.
95
+ * A document that has not settled within {@link MAX_SPLICE_ROUNDS} rounds is
96
+ * withheld whole, on the same fail-closed path a parser blow-up takes.
93
97
  * @param {string} text
94
98
  * @returns {{ text: string, removed: { comments: number, hidden: number }, warned: { tags: Record<string, number>, dataSrc: number }, splices: SplicePair[], unparseable?: true } | null}
95
99
  */