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.
- package/bin/sanitize-cli.mjs +9 -5
- package/package.json +1 -1
- package/src/html.mjs +141 -2
package/bin/sanitize-cli.mjs
CHANGED
|
@@ -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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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.
|
|
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 [
|
|
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
|
-
|
|
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
|
|