agent-sanitizer 2.5.0 → 2.7.0

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/README.md CHANGED
@@ -219,21 +219,38 @@ await rehydrateRedacted("Edit", toolInput, {
219
219
  }); // { updatedInput, context } | { deny } | null — a deny never exposes a secret
220
220
  ```
221
221
 
222
- The credential-noun vocabulary the words that make an identifier name a
223
- secret is published as data so a consumer with its own matcher derives it
224
- rather than forking it. Each noun's `uses` marks where it is valid: `env-name`
225
- inspects a variable NAME only, `field-value` also redacts what follows
226
- `noun = ` (too broad for `key` and `pat`, which stay name-only).
222
+ Ask whether a variable NAME holds a credential don't render the noun list into
223
+ a pattern of your own. Sharing the words but not the rule re-derives the same
224
+ bugs: matching only when the noun ENDS the name misses `DEPLOY_TOKEN_ORG`, a
225
+ case-sensitive match misses npm's lower-case `npm_config__authToken` channel, and
226
+ one alternation of the nouns backtracks polynomially on a long name.
227
+
228
+ `scope` is the choice that is genuinely yours: `trailing` for a redactor, which
229
+ must not mangle text a human reads; `any-segment` for an env scrub, where an
230
+ unstripped credential leaks silently but an over-stripped one breaks loudly.
227
231
 
228
232
  ```js
229
- import { createRequire } from "node:module";
230
- createRequire(import.meta.url)("agent-sanitizer/credential-names").nouns;
231
- // [{ parts: ["api", "key"], uses: ["env-name", "field-value"] }, …]
233
+ import { credentialNameMatcher } from "agent-sanitizer/credential-names-matcher";
234
+ const holds = credentialNameMatcher({ scope: "any-segment" }); // build once
235
+ holds("TEMPLATE_SYNC_TOKEN_ORG"); // true
236
+ holds("AWS_ACCESS_KEY_ID"); // false — an identifier, not a secret
232
237
  ```
233
238
 
234
239
  ```python
235
- from agent_sanitizer.secrets import credential_name_segments
236
- credential_name_segments() # ("API_KEY", "APIKEY", "ACCESS_KEY", …)
240
+ from agent_sanitizer.secrets import credential_name_matcher
241
+ holds = credential_name_matcher(scope="any-segment")
242
+ ```
243
+
244
+ The vocabulary stays published as data for a consumer that needs the words rather
245
+ than the predicate (a generated config, an alternation for a different matcher).
246
+ Each noun's `uses` marks where it is valid: `env-name` inspects a variable NAME
247
+ only, `field-value` also redacts what follows `noun = ` (too broad for `key` and
248
+ `pat`, which stay name-only).
249
+
250
+ ```js
251
+ import { createRequire } from "node:module";
252
+ createRequire(import.meta.url)("agent-sanitizer/credential-names").nouns;
253
+ // [{ parts: ["api", "key"], uses: ["env-name", "field-value"] }, …]
237
254
  ```
238
255
 
239
256
  ## Limits
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.5.0",
3
+ "version": "2.7.0",
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": {
@@ -113,6 +113,10 @@
113
113
  "types": "./types/output.d.mts",
114
114
  "default": "./src/output.mjs"
115
115
  },
116
+ "./credential-names-matcher": {
117
+ "types": "./types/credential-names.d.mts",
118
+ "default": "./src/credential-names.mjs"
119
+ },
116
120
  "./view-map": {
117
121
  "types": "./types/view-map.d.mts",
118
122
  "default": "./src/view-map.mjs"
@@ -0,0 +1,226 @@
1
+ /**
2
+ * The credential-noun vocabulary, and the NAME matcher built from it.
3
+ *
4
+ * `agent-sanitizer/credential-names` publishes the vocabulary as data so a
5
+ * consumer derives its matcher from one list instead of forking one. That shares
6
+ * the words but not the RULE, and the rule is where the mistakes are: whether a
7
+ * noun must be the name's trailing segment or may sit anywhere in it, whether the
8
+ * comparison folds case, whether a multi-word noun is compared as one run, and
9
+ * whether the walk stays linear in the name's length. A consumer that renders the
10
+ * vocabulary into one alternation regex gets a pattern with polynomial
11
+ * backtracking on a long name; one that anchors on the trailing segment alone
12
+ * matches nothing at all for `DEPLOY_TOKEN_ORG` or `OAUTH_TOKEN_FALLBACK_4`.
13
+ *
14
+ * So the mechanics live here and the POLICY stays the caller's, selected by
15
+ * `scope`. The two scopes are not interchangeable and neither is a better
16
+ * default:
17
+ *
18
+ * * `"trailing"` — the noun is the name's last underscore-delimited run. What a
19
+ * REDACTOR wants: it decides what to cut out of text a human will read, where
20
+ * over-matching mangles legitimate output.
21
+ * * `"any-segment"` — the noun is any whole run of the name's segments. What an
22
+ * env-var SCRUB wants: it decides what a subprocess may inherit, where the two
23
+ * error directions are not symmetric — an unstripped credential leaks
24
+ * silently, an over-stripped variable breaks the command loudly.
25
+ *
26
+ * Matching is set membership over the name's underscore-delimited runs, never an
27
+ * alternation of the nouns: 28 prefix-sharing renderings (`API_KEY`, `APIKEY`,
28
+ * `ACCESS_KEY`, …) compile to a pattern a redos analyzer measures as polynomial,
29
+ * and a matcher a hostile variable NAME can stall has a denial-of-service in
30
+ * front of it. The run length is bounded by the longest noun, so the walk is
31
+ * linear in the name's segment count rather than quadratic.
32
+ *
33
+ * The vocabulary itself is `python/agent_sanitizer/secrets/data/credential-names.json`
34
+ * — the same file the Python renderings read and the same file the
35
+ * `agent-sanitizer/credential-names` subpath exports, so the ecosystems cannot
36
+ * drift apart on the words either.
37
+ */
38
+ import { readFileSync } from "node:fs";
39
+ import { dirname, join } from "node:path";
40
+ import { fileURLToPath } from "node:url";
41
+
42
+ const DATA_FILE = join(
43
+ dirname(fileURLToPath(import.meta.url)),
44
+ "..",
45
+ "python",
46
+ "agent_sanitizer",
47
+ "secrets",
48
+ "data",
49
+ "credential-names.json",
50
+ );
51
+
52
+ const FILE_LABEL = "credential-names.json";
53
+ const ENV_NAME_USE = "env-name";
54
+ const FIELD_VALUE_USE = "field-value";
55
+ const KNOWN_USES = new Set([ENV_NAME_USE, FIELD_VALUE_USE]);
56
+
57
+ // a-z0-9 only is what lets a part interpolate into a consumer's pattern
58
+ // unescaped. Anchored with ^…$ over a single character class, so a part carrying
59
+ // a newline is rejected here rather than accepted and then rejected by the Python
60
+ // validator applying the same rule to the same file.
61
+ const PART_RE = /^[a-z0-9]+$/;
62
+
63
+ /** @param {string[]} values @returns {string[]} `values` without duplicates, first-occurrence order kept. */
64
+ function dedupe(values) {
65
+ return [...new Set(values)];
66
+ }
67
+
68
+ /** The whole-name forms of `parts`: underscore-joined and bare-joined, upper-cased.
69
+ *
70
+ * Both are emitted because both spellings occur in the wild (`API_KEY` and
71
+ * `APIKEY`) and a matcher comparing underscore-delimited runs sees them as
72
+ * different tokens. A single-part noun collapses to one form.
73
+ * @param {string[]} parts @returns {string[]} */
74
+ function segmentForms(parts) {
75
+ return dedupe([parts.join("_").toUpperCase(), parts.join("").toUpperCase()]);
76
+ }
77
+
78
+ /** `value` as a validated array of noun parts, or throw naming `field`.
79
+ * @param {unknown} value @param {string} field @returns {string[]} */
80
+ function parts(value, field) {
81
+ if (!Array.isArray(value) || value.length === 0)
82
+ throw new Error(`${FILE_LABEL}: ${field} is empty or missing`);
83
+ const bad = value.filter(
84
+ (part) => typeof part !== "string" || !PART_RE.test(part),
85
+ );
86
+ if (bad.length)
87
+ throw new Error(
88
+ `${FILE_LABEL}: bad part(s) ${JSON.stringify(bad)} in ${field}`,
89
+ );
90
+ return value;
91
+ }
92
+
93
+ /** `value` as a validated non-empty subset of the known uses, or throw.
94
+ *
95
+ * An unknown use is a refusal, not a skip: silently ignoring it would drop the
96
+ * noun from every rendering, which is how a credential noun becomes inert.
97
+ * @param {unknown} value @param {string} field @returns {Set<string>} */
98
+ function uses(value, field) {
99
+ if (!Array.isArray(value) || value.length === 0)
100
+ throw new Error(`${FILE_LABEL}: ${field} is empty or missing`);
101
+ const unknown = value.filter((use) => !KNOWN_USES.has(use)).sort();
102
+ if (unknown.length)
103
+ throw new Error(
104
+ `${FILE_LABEL}: unknown use(s) ${JSON.stringify(unknown)} in ${field}`,
105
+ );
106
+ return new Set(value);
107
+ }
108
+
109
+ /** @typedef {{ segments: string[], fieldNamePatterns: string[], nonSecretSegments: string[] }} CredentialNames */
110
+
111
+ /** Validate `spec` and return its renderings — the JavaScript twin of
112
+ * `agent_sanitizer.secrets.parse_credential_names`, over the same file.
113
+ *
114
+ * A malformed spec throws rather than degrading. An empty list would render an
115
+ * alternation that matches nothing (every credential forwarded verbatim) and a
116
+ * part carrying a regex metacharacter one that matches everything (all output
117
+ * blanked), so neither may reach a consumer's matcher.
118
+ * @param {Record<string, unknown>} spec @returns {CredentialNames} */
119
+ export function parseCredentialNames(spec) {
120
+ const nouns = spec?.nouns;
121
+ if (!Array.isArray(nouns) || nouns.length === 0)
122
+ throw new Error(`${FILE_LABEL}: nouns is empty or missing`);
123
+ /** @type {string[]} */
124
+ const segments = [];
125
+ /** @type {string[]} */
126
+ const fieldNamePatterns = [];
127
+ nouns.forEach((noun, index) => {
128
+ if (typeof noun !== "object" || noun === null || Array.isArray(noun))
129
+ throw new Error(`${FILE_LABEL}: nouns[${index}] is not an object`);
130
+ const nounParts = parts(noun.parts, `nouns[${index}].parts`);
131
+ const nounUses = uses(noun.uses, `nouns[${index}].uses`);
132
+ if (nounUses.has(ENV_NAME_USE)) segments.push(...segmentForms(nounParts));
133
+ if (nounUses.has(FIELD_VALUE_USE))
134
+ fieldNamePatterns.push(nounParts.join("[_-]?"));
135
+ });
136
+
137
+ const suffixes = spec?.nonSecretSuffixes;
138
+ if (!Array.isArray(suffixes) || suffixes.length === 0)
139
+ throw new Error(`${FILE_LABEL}: nonSecretSuffixes is empty or missing`);
140
+ const nonSecretSegments = suffixes.flatMap((suffix, index) =>
141
+ segmentForms(parts(suffix, `nonSecretSuffixes[${index}]`)),
142
+ );
143
+
144
+ // A vocabulary that renders nothing for one matcher would hand that consumer an
145
+ // empty set, which matches nothing and forwards every credential.
146
+ if (!segments.length)
147
+ throw new Error(`${FILE_LABEL}: no noun is marked ${ENV_NAME_USE}`);
148
+ if (!fieldNamePatterns.length)
149
+ throw new Error(`${FILE_LABEL}: no noun is marked ${FIELD_VALUE_USE}`);
150
+ return {
151
+ segments: dedupe(segments),
152
+ fieldNamePatterns: dedupe(fieldNamePatterns),
153
+ nonSecretSegments: dedupe(nonSecretSegments),
154
+ };
155
+ }
156
+
157
+ /** @type {CredentialNames | undefined} */
158
+ let _packaged;
159
+ /** The validated renderings of the packaged vocabulary, memoized.
160
+ *
161
+ * Read lazily, never at module load: a static importer that crashed at LOAD would
162
+ * abort before its own fail-closed catch installs, and a guardrail that fails to
163
+ * load is a guardrail that fails OPEN. Deferring to first use routes a missing or
164
+ * corrupt data file into the caller's catch instead.
165
+ * @returns {CredentialNames} */
166
+ export function credentialNames() {
167
+ return (_packaged ??= parseCredentialNames(
168
+ JSON.parse(readFileSync(DATA_FILE, "utf8")),
169
+ ));
170
+ }
171
+
172
+ /** @typedef {"trailing" | "any-segment"} CredentialNameScope */
173
+
174
+ /** A predicate: does this env-var NAME hold a credential?
175
+ *
176
+ * `scope` selects the rule — `"trailing"` for a redactor (the noun must be the
177
+ * name's last run), `"any-segment"` for an env scrub (the noun may be any run).
178
+ * See this module's header for why that choice belongs to the caller.
179
+ *
180
+ * `declineNonSecret` applies the vocabulary's `nonSecretSuffixes`: a name ending
181
+ * in one holds an identifier or a public key (`AWS_ACCESS_KEY_ID`), not a secret.
182
+ * Leave it on for a redactor, where redacting an identifier out of output is a
183
+ * visible defect; turn it off for a scrub whose failure to strip is the worse
184
+ * error. It is applied to the name's trailing run under both scopes, because a
185
+ * non-secret marker only means anything at the end of a name.
186
+ *
187
+ * The returned predicate closes over the parsed vocabulary, so build it once and
188
+ * reuse it — the parse and validation are not repeated per name.
189
+ *
190
+ * @param {{ scope?: CredentialNameScope, declineNonSecret?: boolean, spec?: Record<string, unknown> }} [options]
191
+ * @returns {(name: string) => boolean} */
192
+ export function credentialNameMatcher(options = {}) {
193
+ const { scope = "trailing", declineNonSecret = true, spec } = options;
194
+ if (scope !== "trailing" && scope !== "any-segment")
195
+ throw new Error(
196
+ `credentialNameMatcher: unknown scope ${JSON.stringify(scope)}`,
197
+ );
198
+ const vocabulary = spec ? parseCredentialNames(spec) : credentialNames();
199
+ const nouns = new Set(vocabulary.segments);
200
+ const nonSecret = new Set(vocabulary.nonSecretSegments);
201
+ // The longest noun's run length. A run longer than this can match no noun, so
202
+ // bounding the walk here is what keeps it linear in the name's segment count —
203
+ // without it a 2000-underscore name costs quadratic time.
204
+ const maxRun = Math.max(
205
+ ...vocabulary.segments.map((noun) => noun.split("_").length),
206
+ );
207
+ /** @param {string[]} words @returns {string[]} */
208
+ const trailingRuns = (words) =>
209
+ Array.from({ length: Math.min(maxRun, words.length) }, (_, i) =>
210
+ words.slice(words.length - (i + 1)).join("_"),
211
+ );
212
+ return (name) => {
213
+ const words = name.toUpperCase().split("_");
214
+ if (
215
+ declineNonSecret &&
216
+ trailingRuns(words).some((run) => nonSecret.has(run))
217
+ )
218
+ return false;
219
+ if (scope === "trailing")
220
+ return trailingRuns(words).some((run) => nouns.has(run));
221
+ for (let start = 0; start < words.length; start++)
222
+ for (let span = 1; span <= maxRun && start + span <= words.length; span++)
223
+ if (nouns.has(words.slice(start, start + span).join("_"))) return true;
224
+ return false;
225
+ };
226
+ }
@@ -0,0 +1,48 @@
1
+ /** @typedef {{ segments: string[], fieldNamePatterns: string[], nonSecretSegments: string[] }} CredentialNames */
2
+ /** Validate `spec` and return its renderings — the JavaScript twin of
3
+ * `agent_sanitizer.secrets.parse_credential_names`, over the same file.
4
+ *
5
+ * A malformed spec throws rather than degrading. An empty list would render an
6
+ * alternation that matches nothing (every credential forwarded verbatim) and a
7
+ * part carrying a regex metacharacter one that matches everything (all output
8
+ * blanked), so neither may reach a consumer's matcher.
9
+ * @param {Record<string, unknown>} spec @returns {CredentialNames} */
10
+ export function parseCredentialNames(spec: Record<string, unknown>): CredentialNames;
11
+ /** The validated renderings of the packaged vocabulary, memoized.
12
+ *
13
+ * Read lazily, never at module load: a static importer that crashed at LOAD would
14
+ * abort before its own fail-closed catch installs, and a guardrail that fails to
15
+ * load is a guardrail that fails OPEN. Deferring to first use routes a missing or
16
+ * corrupt data file into the caller's catch instead.
17
+ * @returns {CredentialNames} */
18
+ export function credentialNames(): CredentialNames;
19
+ /** @typedef {"trailing" | "any-segment"} CredentialNameScope */
20
+ /** A predicate: does this env-var NAME hold a credential?
21
+ *
22
+ * `scope` selects the rule — `"trailing"` for a redactor (the noun must be the
23
+ * name's last run), `"any-segment"` for an env scrub (the noun may be any run).
24
+ * See this module's header for why that choice belongs to the caller.
25
+ *
26
+ * `declineNonSecret` applies the vocabulary's `nonSecretSuffixes`: a name ending
27
+ * in one holds an identifier or a public key (`AWS_ACCESS_KEY_ID`), not a secret.
28
+ * Leave it on for a redactor, where redacting an identifier out of output is a
29
+ * visible defect; turn it off for a scrub whose failure to strip is the worse
30
+ * error. It is applied to the name's trailing run under both scopes, because a
31
+ * non-secret marker only means anything at the end of a name.
32
+ *
33
+ * The returned predicate closes over the parsed vocabulary, so build it once and
34
+ * reuse it — the parse and validation are not repeated per name.
35
+ *
36
+ * @param {{ scope?: CredentialNameScope, declineNonSecret?: boolean, spec?: Record<string, unknown> }} [options]
37
+ * @returns {(name: string) => boolean} */
38
+ export function credentialNameMatcher(options?: {
39
+ scope?: CredentialNameScope;
40
+ declineNonSecret?: boolean;
41
+ spec?: Record<string, unknown>;
42
+ }): (name: string) => boolean;
43
+ export type CredentialNames = {
44
+ segments: string[];
45
+ fieldNamePatterns: string[];
46
+ nonSecretSegments: string[];
47
+ };
48
+ export type CredentialNameScope = "trailing" | "any-segment";