agent-sanitizer 2.31.1 → 2.31.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.
@@ -14,7 +14,7 @@
14
14
  * Protocol — a request is a JSON object with an `op` (default `"sanitize"` so a
15
15
  * bare `{ text, html }` keeps working). Per op:
16
16
  *
17
- * sanitize { text, html? } -> { cleaned, found, warnings, notes }
17
+ * sanitize { text, html? } -> { cleaned, found, warnings, notes, splices? }
18
18
  * sanitizeText { text, html?, exfilScan? } -> { cleaned, warnings, notes, modified, sgrNote }
19
19
  * classifyPrompt { text } -> { action, reason? }
20
20
  * scanInstructionFiles { globs, cwd? } -> { findings: [{ file, findings }] }
@@ -114,10 +114,14 @@ export const OPS = {
114
114
  /** @param {Record<string, unknown>} req */
115
115
  async sanitize(req) {
116
116
  const text = requireString(req, "text");
117
- const { cleaned, found, warnings, notes } = await sanitize(text, {
118
- html: Boolean(req.html),
119
- });
120
- return { cleaned, found, warnings, notes };
117
+ // Forwarded whole rather than re-listed field by field. A hand-picked
118
+ // projection is a second copy of the return shape that nothing keeps in
119
+ // sync: `splices` was part of `sanitize()`'s result and silently never
120
+ // reached the wire, so every non-JS caller was blind to Layer 2's spliced
121
+ // ranges. `test/cli-response-contract.test.mjs` pins this set against the
122
+ // Python client's field list, so growing the result stays a two-file edit
123
+ // that CI notices instead of a silent drop.
124
+ return await sanitize(text, { html: Boolean(req.html) });
121
125
  },
122
126
 
123
127
  /** @param {Record<string, unknown>} req */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.31.1",
3
+ "version": "2.31.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
@@ -882,6 +882,131 @@ function hasImageLayer(nodeOf) {
882
882
  return paintsImageLayer(nodeOf("background"));
883
883
  }
884
884
 
885
+ // The declarations that add to an axis's BORDER box beyond its content-box
886
+ // length, per axis. Shorthands are listed alongside the longhands they can set,
887
+ // because a shorthand this checker cannot resolve must fail OPEN rather than be
888
+ // ignored — ignoring it is what let `height:0; padding-bottom:56.25%` read as
889
+ // invisible.
890
+ // Logical spellings are listed beside their physical twins: `padding-block-end`
891
+ // IS the aspect-ratio idiom in a logical stylesheet, so omitting it would leave
892
+ // the exact false positive this checker exists to close.
893
+ const BLOCK_AXIS_EXTENT_PROPS = [
894
+ "padding",
895
+ "padding-top",
896
+ "padding-bottom",
897
+ "padding-block",
898
+ "padding-block-start",
899
+ "padding-block-end",
900
+ "border",
901
+ "border-width",
902
+ "border-top",
903
+ "border-bottom",
904
+ "border-top-width",
905
+ "border-bottom-width",
906
+ "border-block",
907
+ "border-block-width",
908
+ "border-block-start",
909
+ "border-block-end",
910
+ "border-block-start-width",
911
+ "border-block-end-width",
912
+ ];
913
+ const INLINE_AXIS_EXTENT_PROPS = [
914
+ "padding",
915
+ "padding-left",
916
+ "padding-right",
917
+ "padding-inline",
918
+ "padding-inline-start",
919
+ "padding-inline-end",
920
+ "border",
921
+ "border-width",
922
+ "border-left",
923
+ "border-right",
924
+ "border-left-width",
925
+ "border-right-width",
926
+ "border-inline",
927
+ "border-inline-width",
928
+ "border-inline-start",
929
+ "border-inline-end",
930
+ "border-inline-start-width",
931
+ "border-inline-end-width",
932
+ ];
933
+
934
+ // The shorthands that can set a width alongside a style and a color. An omitted
935
+ // width computes to `medium`, so these need an explicit numeric width before
936
+ // the declaration can be called zero-extent.
937
+ const BORDER_SHORTHANDS = new Set([
938
+ "border",
939
+ "border-top",
940
+ "border-bottom",
941
+ "border-left",
942
+ "border-right",
943
+ "border-block",
944
+ "border-inline",
945
+ "border-block-start",
946
+ "border-block-end",
947
+ "border-inline-start",
948
+ "border-inline-end",
949
+ ]);
950
+
951
+ // `border-width`'s keyword values. They are LENGTHS, so a border shorthand that
952
+ // names one (or names none at all, defaulting to `medium`) has real extent.
953
+ const BORDER_WIDTH_KEYWORDS = new Set(["thin", "medium", "thick"]);
954
+
955
+ /**
956
+ * True when a declared axis-additive property provably contributes NO extent.
957
+ *
958
+ * Deliberately conservative: every numeric token must be near zero, no
959
+ * border-width keyword may appear, and a `border*` shorthand must carry an
960
+ * explicit numeric width (an omitted width computes to `medium`, i.e. 3px). A
961
+ * `calc()`, a `var()`, or any unit this cannot resolve leaves a non-numeric
962
+ * token behind and returns false — the fail-open the module's own policy
963
+ * requires, since an unresolvable value may well paint a visible box.
964
+ * @param {string} prop @param {any} node @returns {boolean}
965
+ */
966
+ function contributesNoExtent(prop, node) {
967
+ const tokens = valueTokens(node);
968
+ if (tokens.length === 0) return false;
969
+ const isBorderShorthand = BORDER_SHORTHANDS.has(prop);
970
+ let sawNumeric = false;
971
+ for (const token of tokens) {
972
+ if (
973
+ token.type === "Number" ||
974
+ token.type === "Dimension" ||
975
+ token.type === "Percentage"
976
+ ) {
977
+ if (Math.abs(parseFloat(token.value)) >= NEAR_ZERO_EPSILON) return false;
978
+ sawNumeric = true;
979
+ continue;
980
+ }
981
+ // A hex color is a `Hash` node, never a length — accepting it keeps
982
+ // `border:0 solid #ccc` resolvable without weakening the fail-open below.
983
+ if (token.type === "Hash") continue;
984
+ // A style/color identifier (`solid`, `red`) adds no length, but a
985
+ // width keyword does — and anything else (a function node, `var()`) is
986
+ // unresolvable and must fail open.
987
+ if (token.type !== "Identifier") return false;
988
+ if (BORDER_WIDTH_KEYWORDS.has(String(token.name).toLowerCase()))
989
+ return false;
990
+ }
991
+ return isBorderShorthand ? sawNumeric : true;
992
+ }
993
+
994
+ /**
995
+ * True when every declaration that could add to `axisProps`' axis is either
996
+ * absent or provably zero, so a near-zero content-box length really does mean
997
+ * the rendered border box is empty.
998
+ * @param {(key: string) => any} nodeOf @param {string[]} axisProps
999
+ * @returns {boolean}
1000
+ */
1001
+ function axisExtentProvablyZero(nodeOf, axisProps) {
1002
+ for (const prop of axisProps) {
1003
+ const node = nodeOf(prop);
1004
+ if (!node) continue; // undeclared: contributes its initial value, 0
1005
+ if (!contributesNoExtent(prop, node)) return false;
1006
+ }
1007
+ return true;
1008
+ }
1009
+
885
1010
  /**
886
1011
  * @param {(key: string) => any} nodeOf value node for a property, or null
887
1012
  * @param {(key: string) => string} textOf decoded/lowercased text for a property
@@ -889,10 +1014,24 @@ function hasImageLayer(nodeOf) {
889
1014
  */
890
1015
  function isOverflowHidden(nodeOf, textOf) {
891
1016
  if (textOf("overflow") !== "hidden") return false;
892
- for (const dim of ["height", "width", "max-height", "max-width"])
1017
+ for (const [dim, axisProps] of /** @type {[string, string[]][]} */ ([
1018
+ ["height", BLOCK_AXIS_EXTENT_PROPS],
1019
+ ["width", INLINE_AXIS_EXTENT_PROPS],
1020
+ ["max-height", BLOCK_AXIS_EXTENT_PROPS],
1021
+ ["max-width", INLINE_AXIS_EXTENT_PROPS],
1022
+ ]))
893
1023
  // Near-zero (epsilon band), not exact 0, so `height:0.0001px` still counts —
894
1024
  // matching the standalone size checks a browser renders as invisible.
895
- if (isNearZeroLength(nodeOf(dim))) return true;
1025
+ // The content box being empty is necessary but NOT sufficient: the universal
1026
+ // aspect-ratio wrapper (`height:0; padding-bottom:56.25%; overflow:hidden` —
1027
+ // Bootstrap's `.ratio`, and every hand-pasted padding-bottom hack) renders
1028
+ // at 56.25% of its container with everything inside it on screen. Reading
1029
+ // the content-box length alone spliced that visible content out.
1030
+ if (
1031
+ isNearZeroLength(nodeOf(dim)) &&
1032
+ axisExtentProvablyZero(nodeOf, axisProps)
1033
+ )
1034
+ return true;
896
1035
  return false;
897
1036
  }
898
1037