agent-sanitizer 2.66.1 → 2.66.2

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/THREAT-MODEL.md CHANGED
@@ -134,9 +134,13 @@ spliced, a `<script>` inside it reported, a URL inside it read by Layer 3. A
134
134
  declarative shadow root (`shadowrootmode="open"`/`"closed"`) is reported too,
135
135
  since one the parser fails to attach (a second root on one host, say) is an
136
136
  ordinary inert template; its content is laid out in its host's box, so a flex
137
- host blockifies it as it would a light-DOM child. Slot assignment is not
138
- modelled: a hidden `<slot>` splices only itself, not the light-DOM children
139
- the browser renders through it. A `template` inside SVG or MathML (or an HTML
137
+ host blockifies it as it would a light-DOM child. An inherited hide on the host
138
+ (`visibility:hidden`, say) is judged through the root the parser attaches (the
139
+ first one, on a host `attachShadow` accepts), whose content inherits from the
140
+ host and never from the template's own attributes; a slot given light-DOM
141
+ children there renders them, so its fallback content is spliced. Slot
142
+ assignment is otherwise not modelled: a hidden `<slot>` splices only itself,
143
+ not the light-DOM children the browser renders through it. A `template` inside SVG or MathML (or an HTML
140
144
  `template.x`) is not reported: it has no template content, so its children are
141
145
  ordinary children and are scanned as such.
142
146
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.66.1",
3
+ "version": "2.66.2",
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
@@ -1680,6 +1680,100 @@ function layoutContainer(parent) {
1680
1680
  }
1681
1681
  }
1682
1682
 
1683
+ // The elements `attachShadow` accepts as a host, besides a custom element.
1684
+ const SHADOW_HOST_TAGS = new Set([
1685
+ "article",
1686
+ "aside",
1687
+ "blockquote",
1688
+ "body",
1689
+ "div",
1690
+ "footer",
1691
+ "h1",
1692
+ "h2",
1693
+ "h3",
1694
+ "h4",
1695
+ "h5",
1696
+ "h6",
1697
+ "header",
1698
+ "main",
1699
+ "nav",
1700
+ "p",
1701
+ "section",
1702
+ "span",
1703
+ ]);
1704
+ // Hyphenated names HTML reserves, so no custom element takes them.
1705
+ const RESERVED_CUSTOM_ELEMENT_NAMES = new Set([
1706
+ "annotation-xml",
1707
+ "color-profile",
1708
+ "font-face",
1709
+ "font-face-src",
1710
+ "font-face-uri",
1711
+ "font-face-format",
1712
+ "font-face-name",
1713
+ "missing-glyph",
1714
+ ]);
1715
+
1716
+ // True for an ASCII valid custom element name (the tokenizer only opens a tag
1717
+ // on a letter, lowercased). The grammar also admits non-ASCII names; reading
1718
+ // one as no host leaves its shadow content inert, which only ever splices more
1719
+ // of a hidden host.
1720
+ /** @param {string} name @returns {boolean} */
1721
+ function isCustomElementName(name) {
1722
+ return (
1723
+ name.includes("-") &&
1724
+ !RESERVED_CUSTOM_ELEMENT_NAMES.has(name) &&
1725
+ [...name].every(
1726
+ (char) =>
1727
+ (char >= "a" && char <= "z") ||
1728
+ (char >= "0" && char <= "9") ||
1729
+ "-._".includes(char),
1730
+ )
1731
+ );
1732
+ }
1733
+
1734
+ // The declarative shadow root the parser attaches to `host`: its first template
1735
+ // child with a valid `shadowrootmode`, when `attachShadow` accepts the host.
1736
+ // Every other template stays in the tree as an ordinary, inert one.
1737
+ /** @param {any} host a hast element @returns {any} the attached template */
1738
+ function attachedShadowTemplate(host) {
1739
+ if (!SHADOW_HOST_TAGS.has(host.tagName) && !isCustomElementName(host.tagName))
1740
+ return undefined;
1741
+ return host.children.find(
1742
+ (/** @type {any} */ child) =>
1743
+ child.content !== undefined && !isInertTemplate(child),
1744
+ );
1745
+ }
1746
+
1747
+ // The slot names `host`'s light children are assigned to: an element's `slot`
1748
+ // attribute, or the default slot for a text node or an element without one.
1749
+ /** @param {any} host @param {any} template @returns {Set<string>} */
1750
+ function assignedSlotNames(host, template) {
1751
+ const names = new Set();
1752
+ for (const child of host.children) {
1753
+ if (child === template) continue;
1754
+ if (child.type === "text") names.add("");
1755
+ else if (child.type === "element")
1756
+ names.add(String(child.properties.slot ?? ""));
1757
+ }
1758
+ return names;
1759
+ }
1760
+
1761
+ // `node`'s children in render order, as runs no splice may span. A declarative
1762
+ // shadow root attached to `node` replaces its template with the content's
1763
+ // children, laid out in `node` and inheriting from it: the parser removes the
1764
+ // template, so its own attributes style nothing. That run carries the template.
1765
+ /** @param {any} node @returns {Array<[any[], any]>} */
1766
+ function renderedRuns(node) {
1767
+ const template = attachedShadowTemplate(node);
1768
+ if (!template) return [[node.children, undefined]];
1769
+ const at = node.children.indexOf(template);
1770
+ return [
1771
+ [node.children.slice(0, at), undefined],
1772
+ [template.content.children, template],
1773
+ [node.children.slice(at + 1), undefined],
1774
+ ];
1775
+ }
1776
+
1683
1777
  // True when `node` lays out as a non-replaced INLINE box. A flex/grid layout
1684
1778
  // container (see {@link layoutContainer}) blockifies it.
1685
1779
  /** @param {any} node @param {any} [parent] @returns {boolean} */
@@ -1780,7 +1874,8 @@ function keepsHide(kind, cascade, inlineBox, color) {
1780
1874
  return keeps;
1781
1875
  }
1782
1876
 
1783
- /** @typedef {{ kinds: Set<InheritedHide>, color: ColorState }} RegionState */
1877
+ // `slots`: the assigned slot names of the shadow tree a node sits in, if any.
1878
+ /** @typedef {{ kinds: Set<InheritedHide>, color: ColorState, slots: Set<string> | undefined }} RegionState */
1784
1879
  // `neutral`: nothing to see either way; `mixed`: hidden and visible content.
1785
1880
  /** @typedef {"hidden" | "neutral" | "visible" | "mixed"} RegionVerdict */
1786
1881
 
@@ -1795,11 +1890,17 @@ function regionVerdicts(root, kinds) {
1795
1890
  const stack = [];
1796
1891
  /** @param {any} node @param {RegionState} state */
1797
1892
  const pushChildren = (node, state) => {
1798
- for (const child of node.children) stack.push([child, node, state]);
1893
+ for (const [children, template] of renderedRuns(node)) {
1894
+ const runState = template
1895
+ ? { ...state, slots: assignedSlotNames(node, template) }
1896
+ : state;
1897
+ for (const child of children) stack.push([child, node, runState]);
1898
+ }
1799
1899
  };
1800
1900
  pushChildren(root, {
1801
1901
  kinds,
1802
1902
  color: colorState(elementCascade(root), null),
1903
+ slots: undefined,
1803
1904
  });
1804
1905
  const order = [root];
1805
1906
  while (stack.length > 0) {
@@ -1821,7 +1922,11 @@ function regionVerdicts(root, kinds) {
1821
1922
  // Only an element's own context-free hides are ever added on the way
1822
1923
  // down, so a region never hides more than its elements would alone.
1823
1924
  const own = elementHides(node, parent);
1824
- if (own.has("box")) {
1925
+ // A slot given light children renders them, never its fallback content.
1926
+ const unrenderedFallback =
1927
+ node.tagName === "slot" &&
1928
+ state.slots?.has(String(node.properties.name ?? ""));
1929
+ if (own.has("box") || unrenderedFallback) {
1825
1930
  verdicts.set(node, "hidden");
1826
1931
  continue;
1827
1932
  }
@@ -1834,7 +1939,7 @@ function regionVerdicts(root, kinds) {
1834
1939
  if (REPLACED_ELEMENTS.has(node.tagName))
1835
1940
  verdicts.set(node, inForce.has("visibility") ? "hidden" : "visible");
1836
1941
  else if (inForce.size === 0) verdicts.set(node, "visible");
1837
- else pushChildren(node, { kinds: inForce, color });
1942
+ else pushChildren(node, { kinds: inForce, color, slots: state.slots });
1838
1943
  }
1839
1944
  }
1840
1945
  for (let i = order.length - 1; i >= 0; i--) {
@@ -1842,7 +1947,7 @@ function regionVerdicts(root, kinds) {
1842
1947
  if (verdicts.has(node)) continue;
1843
1948
  let hidden = false;
1844
1949
  let shown = false;
1845
- for (const child of node.children) {
1950
+ for (const child of renderedRuns(node).flatMap(([children]) => children)) {
1846
1951
  const verdict = verdicts.get(child);
1847
1952
  if (verdict === "hidden") hidden = true;
1848
1953
  else if (verdict === "visible" || verdict === "mixed") shown = true;
@@ -1888,38 +1993,43 @@ function inheritedRegionSplice(root, kinds) {
1888
1993
  trailingComments = [];
1889
1994
  trailingElements = [];
1890
1995
  };
1891
- for (const child of node.children) {
1892
- const position = child.position;
1893
- if (
1894
- !position ||
1895
- position.start.offset < lastEnd ||
1896
- position.end.offset > node.position.end.offset
1897
- )
1898
- return null;
1899
- lastEnd = position.end.offset;
1900
- const verdict = verdicts.get(child);
1901
- if (verdict === "hidden") {
1902
- if (runStart === -1) {
1903
- closeRun(); // what precedes the run stays outside it
1904
- runStart = position.start.offset;
1996
+ for (const [children, template] of renderedRuns(node)) {
1997
+ closeRun();
1998
+ // The template the parser removed is left standing, not spliced.
1999
+ if (template) mixed.push(template);
2000
+ for (const child of children) {
2001
+ const position = child.position;
2002
+ if (
2003
+ !position ||
2004
+ position.start.offset < lastEnd ||
2005
+ position.end.offset > node.position.end.offset
2006
+ )
2007
+ return null;
2008
+ lastEnd = position.end.offset;
2009
+ const verdict = verdicts.get(child);
2010
+ if (verdict === "hidden") {
2011
+ if (runStart === -1) {
2012
+ closeRun(); // what precedes the run stays outside it
2013
+ runStart = position.start.offset;
2014
+ }
2015
+ runEnd = position.end.offset;
2016
+ trailingComments = [];
2017
+ trailingElements = [];
2018
+ } else if (verdict === "mixed") {
2019
+ closeRun();
2020
+ stack.push(child);
2021
+ } else if (verdict === "visible") {
2022
+ closeRun();
2023
+ rescan.push([child, node]);
2024
+ } else if (child.type === "comment") {
2025
+ trailingComments.push({
2026
+ start: position.start.offset,
2027
+ end: position.end.offset,
2028
+ kind: "comment",
2029
+ });
2030
+ } else if (child.type === "element") {
2031
+ trailingElements.push([child, node]);
1905
2032
  }
1906
- runEnd = position.end.offset;
1907
- trailingComments = [];
1908
- trailingElements = [];
1909
- } else if (verdict === "mixed") {
1910
- closeRun();
1911
- stack.push(child);
1912
- } else if (verdict === "visible") {
1913
- closeRun();
1914
- rescan.push([child, node]);
1915
- } else if (child.type === "comment") {
1916
- trailingComments.push({
1917
- start: position.start.offset,
1918
- end: position.end.offset,
1919
- kind: "comment",
1920
- });
1921
- } else if (child.type === "element") {
1922
- trailingElements.push([child, node]);
1923
2033
  }
1924
2034
  }
1925
2035
  closeRun();
package/types/html.d.mts CHANGED
@@ -195,6 +195,7 @@ export type ColorState = {
195
195
  export type RegionState = {
196
196
  kinds: Set<InheritedHide>;
197
197
  color: ColorState;
198
+ slots: Set<string> | undefined;
198
199
  };
199
200
  export type RegionVerdict = "hidden" | "neutral" | "visible" | "mixed";
200
201
  export type CollectedUrl = {