agent-sanitizer 2.31.1 → 2.31.3

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 */
@@ -21,6 +21,15 @@
21
21
  * *literals* (`\033`, `\x1b`, `\e`) — a *raw* ESC byte in authored content
22
22
  * is anomalous.
23
23
  *
24
+ * SCOPE IS DECLARED, NOT INFERRED. Which tools this layer touches is a
25
+ * partition — {@link AUTHORED_FIELDS} (covered, with the field list) and
26
+ * {@link EXEMPT_TOOLS}/{@link EXEMPT_TOOL_PATTERNS} (looked at, with the reason
27
+ * nothing is sanitized) — resolved through the single
28
+ * {@link authoredScopeDecision} helper. Notably `mcp__*` server tools are
29
+ * exempt, so a PR body written via `gh pr create` IS stripped while the same
30
+ * body sent through a GitHub MCP tool is NOT; that asymmetry is a stated
31
+ * position with a rationale, not an oversight.
32
+ *
24
33
  * Distinct from sanitize-output.mjs, which scrubs tool *responses* flowing
25
34
  * toward the model (data the model reads). This scrubs what the model emits
26
35
  * (data the model writes out). In pretooluse-sanitize.mjs it runs *after*
@@ -55,14 +64,76 @@ const { STRIP, LONG_RUN_RE, SCATTERED_THRESHOLD, stripInvisible } =
55
64
  // A "key[].sub" entry addresses `sub` on every element of the array at `key`
56
65
  // (MultiEdit batches its writes as edits[].new_string), so the nested authored
57
66
  // content is sanitized too — not just the top-level fields.
67
+ //
68
+ // Null-prototype: `tool` comes from the payload, and on a plain object literal
69
+ // `FIELDS["constructor"]` answers a truthy inherited value that the field loop
70
+ // below would then try to iterate.
58
71
  /** @type {Record<string, string[]>} */
59
- const FIELDS = {
60
- Write: ["content"],
61
- Edit: ["new_string"],
62
- MultiEdit: ["edits[].new_string"],
63
- NotebookEdit: ["new_source"],
64
- Bash: ["command"],
65
- };
72
+ export const AUTHORED_FIELDS = Object.freeze(
73
+ Object.assign(Object.create(null), {
74
+ Write: ["content"],
75
+ Edit: ["new_string"],
76
+ MultiEdit: ["edits[].new_string"],
77
+ NotebookEdit: ["new_source"],
78
+ Bash: ["command"],
79
+ }),
80
+ );
81
+
82
+ // The other half of the partition: tools this layer has LOOKED AT and decided
83
+ // carry no model-authored free text, each with the reason. Together with
84
+ // AUTHORED_FIELDS this is a declared scope rather than a fallthrough — an
85
+ // omission becomes a reviewable line instead of the absence of one, and
86
+ // test/claude-hooks-authored-scope.test.mjs fails when a tool the package
87
+ // elsewhere claims to know lands in neither side.
88
+ /** @type {Record<string, string>} */
89
+ export const EXEMPT_TOOLS = Object.freeze(
90
+ Object.assign(Object.create(null), {
91
+ Read: "inputs are a path plus offsets — nothing the model authored is persisted or displayed",
92
+ Grep: "inputs are a search pattern and a path; rewriting a pattern would change what the search matches",
93
+ Glob: "inputs are a glob pattern and a path; rewriting a pattern would change what it matches",
94
+ LS: "input is a path — the confusable layer's domain, not authored free text",
95
+ }),
96
+ );
97
+
98
+ // Prefix-shaped exemptions, for tool families no fixed list can enumerate.
99
+ /** @type {ReadonlyArray<{ pattern: RegExp, reason: string }>} */
100
+ export const EXEMPT_TOOL_PATTERNS = Object.freeze([
101
+ Object.freeze({
102
+ pattern: /^mcp__/u,
103
+ reason:
104
+ "MCP tool inputs follow a server-declared schema this package cannot see, " +
105
+ "so there is no field it can name as authored free text. A blanket walk over " +
106
+ "every string in the input would buy recall at a real precision cost — it " +
107
+ "would rewrite opaque IDs, base64 blobs and protocol fields the server parses " +
108
+ "— so the gap is DECLARED rather than closed. A deployment that wants a " +
109
+ "specific server's body field covered adds it to AUTHORED_FIELDS by its full " +
110
+ 'tool name (e.g. mcp__github__create_issue: ["body"]).',
111
+ }),
112
+ ]);
113
+
114
+ /**
115
+ * The single place an unlisted tool's fate is decided: covered by a field list,
116
+ * exempt with a stated reason, or undeclared — nobody has classified it.
117
+ *
118
+ * `undeclared` is NOT a runtime alarm. Every arm returns the same
119
+ * pass-through behaviour, because a stderr line on each of the many tools no
120
+ * one has had a reason to classify (Task, TodoWrite, WebFetch, …) is alert
121
+ * fatigue, and the doctrine here is precision over recall. The signal is the
122
+ * partition test, which reads this function.
123
+ * @param {string} tool
124
+ * @returns {{ kind: "covered", fields: string[] } | { kind: "exempt", reason: string } | { kind: "undeclared" }}
125
+ */
126
+ export function authoredScopeDecision(tool) {
127
+ const fields = AUTHORED_FIELDS[tool];
128
+ if (fields) return { kind: "covered", fields };
129
+ const exempt = EXEMPT_TOOLS[tool];
130
+ if (exempt) return { kind: "exempt", reason: exempt };
131
+ const matched = EXEMPT_TOOL_PATTERNS.find((entry) =>
132
+ entry.pattern.test(tool),
133
+ );
134
+ if (matched) return { kind: "exempt", reason: matched.reason };
135
+ return { kind: "undeclared" };
136
+ }
66
137
 
67
138
  // Payload-capable: a long contiguous run, or enough scattered invisibles to
68
139
  // carry a message. Mirrors sanitize-user-prompt so the model→world and
@@ -123,8 +194,10 @@ export function authoredContext(changed) {
123
194
  * @returns {{ updatedInput: any, changed: string[] } | null}
124
195
  */
125
196
  export function sanitizeAuthoredContent(tool, toolInput) {
126
- const keys = FIELDS[tool];
127
- if (!keys || toolInput === null || toolInput === undefined) return null;
197
+ const scope = authoredScopeDecision(tool);
198
+ if (scope.kind !== "covered" || toolInput === null || toolInput === undefined)
199
+ return null;
200
+ const keys = scope.fields;
128
201
 
129
202
  const changed = [];
130
203
  // Null-prototype copy: toolInput is untrusted parsed JSON where a `__proto__`
@@ -210,12 +210,20 @@ export const PermissionDecision = Object.freeze({
210
210
  export const FAIL_OPEN_ENV = "AGENT_SANITIZER_FAIL_OPEN";
211
211
 
212
212
  /**
213
- * Values that turn the default posture back to fail-closed. Matched exactly,
214
- * so the launcher's shell `case` can state the same two literals — a
215
- * case-insensitive match here would need `tr`, which the launcher cannot reach
216
- * (it runs its no-node arm on shell builtins alone) and the two would drift.
213
+ * Values that turn the default posture back to fail-closed. Matched exactly:
214
+ * a case-insensitive match would need `tr`, which the launcher cannot reach
215
+ * (it runs its no-node arm on shell builtins alone).
216
+ *
217
+ * THE SINGLE SOURCE OF TRUTH for the closed set. The shell shims cannot import
218
+ * it, so `plugin/scripts/lib/fail-open.sh` is GENERATED from it by
219
+ * `scripts/gen-fail-open-lib.mjs` and committed; the round trip is asserted in
220
+ * plugin/test/fail-open-parity.test.mjs. Everything else that spells the set
221
+ * out by hand is an implementation that must appear in the parity table in
222
+ * tests/test_safe_launch.py.
217
223
  */
218
- const FAIL_CLOSED_VALUES = new Set(["0", "false"]);
224
+ export const FAIL_CLOSED_VALUES = Object.freeze(["0", "false"]);
225
+
226
+ const FAIL_CLOSED_SET = new Set(FAIL_CLOSED_VALUES);
219
227
 
220
228
  /**
221
229
  * Whether hook failures pass the guarded action through. True unless the caller
@@ -231,7 +239,7 @@ const FAIL_CLOSED_VALUES = new Set(["0", "false"]);
231
239
  * @returns {boolean}
232
240
  */
233
241
  export function failOpenEnabled(env = process.env) {
234
- return !FAIL_CLOSED_VALUES.has(env[FAIL_OPEN_ENV] ?? "");
242
+ return !FAIL_CLOSED_SET.has(env[FAIL_OPEN_ENV] ?? "");
235
243
  }
236
244
 
237
245
  /**
@@ -98,7 +98,7 @@ export function layer2KeysIn(value, depth = 0) {
98
98
  // Tools whose inputs the rehydration layer itself resolves (or, for
99
99
  // MultiEdit/NotebookEdit, refuses with guidance). Both advisories stay silent
100
100
  // on these: their placeholder handling is a verdict, not a note.
101
- const REHYDRATED_TOOLS = new Set([
101
+ export const REHYDRATED_TOOLS = new Set([
102
102
  "Edit",
103
103
  "Write",
104
104
  "MultiEdit",
@@ -686,7 +686,7 @@ export const REDACTION_HINT = "[REDACTED";
686
686
  // mention "[REDACTED" benignly far too often — grepping for it, discussing
687
687
  // it — for an ask to hold precision there. That is an accepted gap, named in
688
688
  // THREAT-MODEL.md's carve-out paragraph, not a completeness claim.
689
- const WRITE_SHAPED_TOOLS = new Set([
689
+ export const WRITE_SHAPED_TOOLS = new Set([
690
690
  "Write",
691
691
  "Edit",
692
692
  "MultiEdit",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.31.1",
3
+ "version": "2.31.3",
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": {
@@ -216,6 +216,7 @@
216
216
  "typecheck": "tsc --noEmit && tsc -p tsconfig.hooks.json --noEmit",
217
217
  "build:types": "tsc -p tsconfig.build.json && tsc -p tsconfig.build-hooks.json",
218
218
  "gen:joining-type": "node scripts/gen-joining-type.mjs",
219
+ "gen:fail-open-lib": "node scripts/gen-fail-open-lib.mjs",
219
220
  "lint": "eslint .",
220
221
  "test:mutation": "node scripts/mutate.mjs",
221
222
  "format": "prettier --write .",
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
 
@@ -1,3 +1,24 @@
1
+ /**
2
+ * The single place an unlisted tool's fate is decided: covered by a field list,
3
+ * exempt with a stated reason, or undeclared — nobody has classified it.
4
+ *
5
+ * `undeclared` is NOT a runtime alarm. Every arm returns the same
6
+ * pass-through behaviour, because a stderr line on each of the many tools no
7
+ * one has had a reason to classify (Task, TodoWrite, WebFetch, …) is alert
8
+ * fatigue, and the doctrine here is precision over recall. The signal is the
9
+ * partition test, which reads this function.
10
+ * @param {string} tool
11
+ * @returns {{ kind: "covered", fields: string[] } | { kind: "exempt", reason: string } | { kind: "undeclared" }}
12
+ */
13
+ export function authoredScopeDecision(tool: string): {
14
+ kind: "covered";
15
+ fields: string[];
16
+ } | {
17
+ kind: "exempt";
18
+ reason: string;
19
+ } | {
20
+ kind: "undeclared";
21
+ };
1
22
  /** @param {string[]} changed */
2
23
  export function authoredContext(changed: string[]): string;
3
24
  /**
@@ -13,3 +34,12 @@ export function sanitizeAuthoredContent(tool: string, toolInput: any): {
13
34
  updatedInput: any;
14
35
  changed: string[];
15
36
  } | null;
37
+ /** @type {Record<string, string[]>} */
38
+ export const AUTHORED_FIELDS: Record<string, string[]>;
39
+ /** @type {Record<string, string>} */
40
+ export const EXEMPT_TOOLS: Record<string, string>;
41
+ /** @type {ReadonlyArray<{ pattern: RegExp, reason: string }>} */
42
+ export const EXEMPT_TOOL_PATTERNS: ReadonlyArray<{
43
+ pattern: RegExp;
44
+ reason: string;
45
+ }>;
@@ -430,6 +430,19 @@ export const PermissionDecision: Readonly<{
430
430
  * by construction, not by remembering to set an env var.
431
431
  */
432
432
  export const FAIL_OPEN_ENV: "AGENT_SANITIZER_FAIL_OPEN";
433
+ /**
434
+ * Values that turn the default posture back to fail-closed. Matched exactly:
435
+ * a case-insensitive match would need `tr`, which the launcher cannot reach
436
+ * (it runs its no-node arm on shell builtins alone).
437
+ *
438
+ * THE SINGLE SOURCE OF TRUTH for the closed set. The shell shims cannot import
439
+ * it, so `plugin/scripts/lib/fail-open.sh` is GENERATED from it by
440
+ * `scripts/gen-fail-open-lib.mjs` and committed; the round trip is asserted in
441
+ * plugin/test/fail-open-parity.test.mjs. Everything else that spells the set
442
+ * out by hand is an implementation that must appear in the parity table in
443
+ * tests/test_safe_launch.py.
444
+ */
445
+ export const FAIL_CLOSED_VALUES: readonly string[];
433
446
  /**
434
447
  * Hard cap on hook stdin. A well-formed Claude Code hook payload is at most a
435
448
  * few MB (tool input plus the harness-truncated tool output); 64 MiB leaves
@@ -117,6 +117,7 @@ export const LAYER2_PLACEHOLDER_RE: RegExp;
117
117
  * points at the sidecar instead of a span file.
118
118
  */
119
119
  export const UNPARSEABLE_MARKER: "[HTML unparseable \u2014 withheld]";
120
+ export const REHYDRATED_TOOLS: Set<string>;
120
121
  /**
121
122
  * One found token: the exact placeholder text and the dotted field path of the
122
123
  * FIRST input field carrying it (empty for a bare string input).
@@ -210,6 +210,7 @@ export const PRE_TOOL_USE_MESSAGES: Readonly<{
210
210
  remedy: string;
211
211
  }>;
212
212
  export const REDACTION_HINT: "[REDACTED";
213
+ export const WRITE_SHAPED_TOOLS: Set<string>;
213
214
  /**
214
215
  * A host-supplied deny gate: given the PreToolUse input, the reason this call
215
216
  * must be blocked, or null to let the pipeline continue. Hosts use these for