agent-sanitizer 2.10.0 → 2.10.1

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.
@@ -136,16 +136,28 @@ function findMdFiles(dir) {
136
136
  * under `dir`. Claude Code loads these as project instructions on entry to their
137
137
  * containing directory — a load path that bypasses the PostToolUse sanitizer — so
138
138
  * a payload planted in e.g. `packages/foo/CLAUDE.md` reaches the model uncleaned
139
- * unless it is scanned here. Skips node_modules; `**` skips dot directories by
140
- * default (`.git`, and `.claude`, which the caller scans separately).
139
+ * unless it is scanned here. Skips node_modules.
140
+ *
141
+ * `**` does not descend into dot directories, so NESTED `.claude/` trees need
142
+ * their own pattern: the caller scans only the project-root `.claude`, which
143
+ * would leave a directory-scoped skill at `packages/foo/.claude/skills/x/SKILL.md`
144
+ * — model context by the same load path — never scanned.
141
145
  * @param {string} dir
142
146
  * @returns {string[]}
143
147
  */
144
148
  function findInstructionFiles(dir) {
145
- return globSync(["**/CLAUDE.md", "**/CLAUDE.local.md", "**/AGENTS.md"], {
146
- cwd: dir,
147
- exclude: (name) => name === "node_modules",
148
- }).map((name) => join(dir, name));
149
+ return globSync(
150
+ [
151
+ "**/CLAUDE.md",
152
+ "**/CLAUDE.local.md",
153
+ "**/AGENTS.md",
154
+ "**/.claude/**/*.md",
155
+ ],
156
+ {
157
+ cwd: dir,
158
+ exclude: (name) => name === "node_modules",
159
+ },
160
+ ).map((name) => join(dir, name));
149
161
  }
150
162
 
151
163
  // Scanner
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.10.0",
3
+ "version": "2.10.1",
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": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$comment": "The credential-noun vocabulary: the words that make an identifier name a secret. Published so every consumer derives its own matcher from ONE list — a newly recognized noun reaches them all through a version bump instead of N hand edits. Read it from Python via agent_sanitizer.secrets (credential_name_segments / credential_field_name_patterns / non_secret_name_segments) or from JavaScript via the npm subpath export `agent-sanitizer/credential-names`. `parts` are the lowercase words of the noun; a consumer renders them for its own matcher (underscore-joined `API_KEY` and bare-joined `APIKEY` for an env-var NAME, `api[_-]?key` for a `field = value` regex). `uses` says which matcher may use the noun, because the two are not interchangeable: `env-name` matches a variable NAME and never inspects a value, so a broad noun there costs nothing, while `field-value` redacts whatever follows `noun = ` and a broad noun there mangles ordinary text — `key = <20 chars>` is a false-positive flood, so `key`, `pat`, `credential`, `credentials`, `secrets` and `passphrase` are env-name only. `nonSecretSuffixes` are the trailing words that make a credential-shaped name hold a NON-secret (a key's identifier, the public half of a keypair), which a consumer must not redact. Every part is restricted to a-z0-9 so it carries no regex metacharacter; the accessors enforce that and fail closed on a violation, an empty list, or an unknown `uses` value. It sits inside the Python package because a wheel can only ship data under its package directory, while npm's `files`/`exports` can name any path — so ONE physical file backs both ecosystems and there is no copy to drift.",
2
+ "$comment": "The credential-noun vocabulary: the words that make an identifier name a secret. Published so every consumer derives its own matcher from ONE list — a newly recognized noun reaches them all through a version bump instead of N hand edits. Read it from Python via agent_sanitizer.secrets (credential_name_segments / credential_field_name_patterns / non_secret_name_segments) or from JavaScript via the npm subpath export `agent-sanitizer/credential-names`. `parts` are the lowercase words of the noun; a consumer renders them for its own matcher (underscore-joined `API_KEY` and bare-joined `APIKEY` for an env-var NAME, `api[_-]?key` for a `field = value` regex). `uses` says which matcher may use the noun, because the two are not interchangeable: `env-name` matches a variable NAME and never inspects a value, so a broad noun there costs nothing, while `field-value` redacts whatever follows `noun = ` and a broad noun there mangles ordinary text — `key = <20 chars>` is a false-positive flood, so `key`, `pat`, `credential`, `credentials` and `secrets` are env-name only — `credential`/`credentials` are everyday variable names, and an attribute chain (`credentials = service_account.Credentials.from_service_account_info(info)`) escapes every value-shape skip, so a field-value rendering would redact ordinary source lines. `passphrase` is the exception among them: it is as shape-specific as `password`, and detect-secrets' own KeywordDetector DENYLIST omits it, so name-only would let `passphrase = <secret>` reach the model in cleartext. `nonSecretSuffixes` are the trailing words that make a credential-shaped name hold a NON-secret (a key's identifier, the public half of a keypair), which a consumer must not redact. Every part is restricted to a-z0-9 so it carries no regex metacharacter; the accessors enforce that and fail closed on a violation, an empty list, or an unknown `uses` value. It sits inside the Python package because a wheel can only ship data under its package directory, while npm's `files`/`exports` can name any path — so ONE physical file backs both ecosystems and there is no copy to drift.",
3
3
  "nouns": [
4
4
  { "parts": ["api", "key"], "uses": ["env-name", "field-value"] },
5
5
  { "parts": ["access", "key"], "uses": ["env-name", "field-value"] },
@@ -12,7 +12,7 @@
12
12
  { "parts": ["authorization"], "uses": ["env-name", "field-value"] },
13
13
  { "parts": ["password"], "uses": ["env-name", "field-value"] },
14
14
  { "parts": ["passwd"], "uses": ["env-name", "field-value"] },
15
- { "parts": ["passphrase"], "uses": ["env-name"] },
15
+ { "parts": ["passphrase"], "uses": ["env-name", "field-value"] },
16
16
  { "parts": ["bearer"], "uses": ["env-name", "field-value"] },
17
17
  { "parts": ["secret"], "uses": ["env-name", "field-value"] },
18
18
  { "parts": ["secrets"], "uses": ["env-name"] },
package/src/html.mjs CHANGED
@@ -1069,6 +1069,23 @@ const VOID_ELEMENTS = new Set([
1069
1069
  "wbr",
1070
1070
  ]);
1071
1071
 
1072
+ // Foreign-content roots. Inside SVG and MathML the HTML parser honours a
1073
+ // self-closing `/>` (it does not in HTML content), so such a tag opens and
1074
+ // closes in one node and the text after it is a sibling, not its child.
1075
+ const FOREIGN_ELEMENTS = new Set(["svg", "math"]);
1076
+
1077
+ /**
1078
+ * True when `value` is a foreign-content tag that closed itself. Splicing it as
1079
+ * a balance region instead would run to the container's end and delete every
1080
+ * visible word after a decorative hidden `<svg/>`.
1081
+ * @param {string} tagName
1082
+ * @param {string} value
1083
+ * @returns {boolean}
1084
+ */
1085
+ function isSelfClosedForeign(tagName, value) {
1086
+ return FOREIGN_ELEMENTS.has(tagName) && /\/\s*>\s*$/.test(value);
1087
+ }
1088
+
1072
1089
  // Elements whose content is RAW TEXT / RCDATA / script data: parse5 recognizes
1073
1090
  // NO markup inside them (a `<!…` is not a comment, a `<b>` is not a tag) until
1074
1091
  // the matching end tag. The per-tag balance walk must model this or it would
@@ -1523,7 +1540,10 @@ function scanInlineChildren(node, text, ranges, warned) {
1523
1540
  // A void element never emits a matching close, so a balance region
1524
1541
  // would extend to the container end and splice out following visible
1525
1542
  // text. Emit a single-node range instead (the source branch does too).
1526
- if (VOID_ELEMENTS.has(tagName))
1543
+ // A self-closed FOREIGN element behaves the same way: in SVG/MathML
1544
+ // content the HTML spec honours the `/>` flag, so the element closes
1545
+ // immediately and everything after it renders.
1546
+ if (VOID_ELEMENTS.has(tagName) || isSelfClosedForeign(tagName, value))
1527
1547
  ranges.push({ start: base, end, kind: "hidden" });
1528
1548
  else {
1529
1549
  state.tag = tagName;
package/src/invisible.mjs CHANGED
@@ -13,8 +13,18 @@ import { joiningType, isVirama } from "./joining-type.mjs";
13
13
  import { isStandardizedVariant } from "./standardized-variants.mjs";
14
14
  import { CF_CODEPOINTS } from "./cf-charset.mjs";
15
15
 
16
+ // Unicode's Variation_Selector property, whole: the FE00 run, the Mongolian
17
+ // free variation selectors, and the astral E0100 supplement. The Mongolian four
18
+ // are general category Mn, so \p{Cf} does not reach them and CF_CODEPOINTS
19
+ // carries only U+180E (the vowel SEPARATOR) — without them a run of U+180B..D
20
+ // renders as nothing, survives untouched, and counts as VISIBLE length, which
21
+ // also widens the preserved-joiner budget.
16
22
  export const VS = [
17
23
  ...Array.from({ length: 16 }, (_, i) => 0xfe00 + i),
24
+ 0x180b,
25
+ 0x180c,
26
+ 0x180d,
27
+ 0x180f,
18
28
  ...Array.from({ length: 240 }, (_, i) => 0xe0100 + i),
19
29
  ]
20
30
  .map((codePoint) => String.fromCodePoint(codePoint))
package/src/layer1.mjs CHANGED
@@ -112,20 +112,27 @@ const ANSI_RE = new RegExp(`(?:${OSC_BRANCH}|${CSI_BRANCH})`, "gu");
112
112
  export const LONE_SURROGATE_RE =
113
113
  /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
114
114
 
115
+ const MAX_ANSI_PASSES = 3;
116
+
115
117
  /**
116
118
  * Strip ANSI escape sequences to a fixed point. Removing one sequence can
117
119
  * reconstitute another around it (a lone ESC left of `ESC[32m[0m` gains the
118
120
  * trailing `[0m` once the inner sequence is removed, forming a brand-new valid
119
- * sequence the single pass would miss), so iterate until stable: every changed
120
- * pass consumes at least one ESC introducer, so the pass count is bounded by
121
- * the input's ESC count, and ANSI-free text exits after one pass.
121
+ * sequence the single pass would miss), so iterate but only a fixed few
122
+ * times. Bounding by the input's ESC count is quadratic on attacker-controlled
123
+ * text: `("\x1b[").repeat(n) + "m".repeat(n)` reconstitutes exactly ONE
124
+ * sequence per pass, so n full O(n) scans run, and 48 KB already costs seconds.
125
+ * The passes are not what makes Layer 1 safe — applyLayer1's residual
126
+ * CONTROL_INTRODUCER_RE sweep is, and it removes every ESC/C1 byte whatever
127
+ * survives here. Past the bound a reconstituted sequence therefore degrades to
128
+ * VISIBLE text rather than a hidden control, which is the fail-open direction.
122
129
  * @param {string} input
123
130
  * @returns {string}
124
131
  */
125
132
  export function stripAnsiFully(input) {
126
133
  let prev = input;
127
134
  let out = prev.replace(ANSI_RE, "");
128
- while (out !== prev) {
135
+ for (let pass = 1; pass < MAX_ANSI_PASSES && out !== prev; pass++) {
129
136
  prev = out;
130
137
  out = prev.replace(ANSI_RE, "");
131
138
  }
@@ -29,8 +29,12 @@ export function findMdFiles(dir: string): string[];
29
29
  * under `dir`. Claude Code loads these as project instructions on entry to their
30
30
  * containing directory — a load path that bypasses the PostToolUse sanitizer — so
31
31
  * a payload planted in e.g. `packages/foo/CLAUDE.md` reaches the model uncleaned
32
- * unless it is scanned here. Skips node_modules; `**` skips dot directories by
33
- * default (`.git`, and `.claude`, which the caller scans separately).
32
+ * unless it is scanned here. Skips node_modules.
33
+ *
34
+ * `**` does not descend into dot directories, so NESTED `.claude/` trees need
35
+ * their own pattern: the caller scans only the project-root `.claude`, which
36
+ * would leave a directory-scoped skill at `packages/foo/.claude/skills/x/SKILL.md`
37
+ * — model context by the same load path — never scanned.
34
38
  * @param {string} dir
35
39
  * @returns {string[]}
36
40
  */
@@ -2,9 +2,14 @@
2
2
  * Strip ANSI escape sequences to a fixed point. Removing one sequence can
3
3
  * reconstitute another around it (a lone ESC left of `ESC[32m[0m` gains the
4
4
  * trailing `[0m` once the inner sequence is removed, forming a brand-new valid
5
- * sequence the single pass would miss), so iterate until stable: every changed
6
- * pass consumes at least one ESC introducer, so the pass count is bounded by
7
- * the input's ESC count, and ANSI-free text exits after one pass.
5
+ * sequence the single pass would miss), so iterate but only a fixed few
6
+ * times. Bounding by the input's ESC count is quadratic on attacker-controlled
7
+ * text: `("\x1b[").repeat(n) + "m".repeat(n)` reconstitutes exactly ONE
8
+ * sequence per pass, so n full O(n) scans run, and 48 KB already costs seconds.
9
+ * The passes are not what makes Layer 1 safe — applyLayer1's residual
10
+ * CONTROL_INTRODUCER_RE sweep is, and it removes every ESC/C1 byte whatever
11
+ * survives here. Past the bound a reconstituted sequence therefore degrades to
12
+ * VISIBLE text rather than a hidden control, which is the fail-open direction.
8
13
  * @param {string} input
9
14
  * @returns {string}
10
15
  */