@swfte/nexus-sdk 0.1.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/src/redact.cjs ADDED
@@ -0,0 +1,734 @@
1
+ 'use strict';
2
+ /**
3
+ * In-process scrubbing, before anything is queued.
4
+ *
5
+ * A port of the Python SDK's `nexus/redact.py`, kept deliberately close to it: the two SDKs write
6
+ * into one ledger, and `tier: 'full'` has to mean the same thing in both or a customer who read one
7
+ * README and deployed the other gets a surprise made of their users' data.
8
+ *
9
+ * Obfuscation happens at the source, never at the backend — the one thing from Datadog's design
10
+ * worth copying without modification. Once a secret has left the process it has left the process;
11
+ * "we redact on ingest" is a promise about somebody else's infrastructure that a security review
12
+ * cannot verify.
13
+ *
14
+ * This is a deliberately smaller redactor than the wrapper's `nexus_devtools/redact.py`, and the
15
+ * difference is a design decision rather than an omission. That one runs on a developer's laptop
16
+ * against arbitrary shell output and can afford entropy heuristics and a wide pattern set. This one
17
+ * runs on the request path of a production service, where the cost is paid per call by somebody
18
+ * else's end users — so it is a bounded set of anchored patterns plus key-name matching, compiled
19
+ * once, with no entropy scan over unbounded text.
20
+ *
21
+ * The compensating control is that the **default tier is `metadata_only`**: at T0 no free text is
22
+ * transmitted at all, so redaction is a second line of defence rather than the only one.
23
+ *
24
+ * **Redaction failing must fail closed on the field, not on the event** (case 5.8). Dropping the
25
+ * whole event because one string could not be scrubbed also drops the governance record, which is
26
+ * exactly the record you most want when something odd is happening.
27
+ *
28
+ * ── What is different from the Python, and why ────────────────────────────────────────────────
29
+ *
30
+ * Three things could not be transliterated, and each is a correctness issue rather than a style
31
+ * one. They are called out where they occur, but in summary:
32
+ *
33
+ * 1. **Unicode classes.** Python's `re.UNICODE` makes `\w` and `[^\W\d_]` mean "any script".
34
+ * JavaScript's `\w` is ASCII-only even with the `u` flag, so `[^\W\d_]` would have silently
35
+ * become "ASCII letter" and `jane@example.中国` would have stopped being an email address — the
36
+ * exact defect the Python file's comment says it fixed. Written with `\p{L}` instead.
37
+ * 2. **The IBAN checksum.** Python builds one arbitrarily large integer and takes `% 97`.
38
+ * JavaScript numbers lose integer precision past 2^53, so a 34-character IBAN would produce a
39
+ * rounded value and a checksum that passes or fails at random. Computed incrementally instead,
40
+ * which is exact.
41
+ * 3. **Regex state.** A `g`-flagged regex in JavaScript carries a mutable `lastIndex`, so calling
42
+ * `.test()` on a shared one gives a different answer every other call. Every anchored predicate
43
+ * here uses a non-global regex, and every scanner goes through `String.replace`, which resets it.
44
+ */
45
+
46
+ const MASK = '[REDACTED]';
47
+
48
+ // ═══════════════════════════════════════════════════════════════════════════════════════════════
49
+ // credentials
50
+ // ═══════════════════════════════════════════════════════════════════════════════════════════════
51
+
52
+ /**
53
+ * Opening markers of secrets whose pattern has **no bounded length**, so a match can require
54
+ * arbitrarily many characters to complete. Everything else this module hunts is short and either
55
+ * matches inside the scan window or starts past the emitted prefix and is truncated away. See
56
+ * {@link scanWindow}.
57
+ */
58
+ const UNTERMINATED_MARKERS = ['-----BEGIN'];
59
+
60
+ /**
61
+ * A `name =` / `name:` opener, used to answer the question the marker list cannot: *did the emitted
62
+ * prefix stop in the middle of somebody's secret?*
63
+ *
64
+ * A 9 kB `password = "…"` runs past the scan window, never meets its closing quote, matches no
65
+ * pattern, and would ship its first 280 characters. This is a post-condition on the output: the
66
+ * last assignment visible in the emitted text is looked up in {@link isSensitiveKey} and, if the
67
+ * mask does not follow it, the value ran past what we could scan. It needs no list of secret
68
+ * shapes, so a vendor format nobody has invented yet is covered on the day it appears.
69
+ */
70
+ const ASSIGN_OPENER_RE = /([A-Za-z_][A-Za-z0-9_\-]{0,63})\s*[:=]/g;
71
+
72
+ /**
73
+ * Anchored, high-precision patterns. Order matters only in that longer forms come first.
74
+ *
75
+ * The set was chosen from the LLM-vendor list, which is the wrong axis: this SDK watches agents
76
+ * *calling tools*, and the credential an agent hands a tool is a payments key or a package-registry
77
+ * token far more often than it is a model key. Each of the second group was measured leaking
78
+ * through the Python `redact()` before being added there.
79
+ *
80
+ * Deliberately NOT here: bare 32-hex. It is the shape of an Azure key and also the shape of every
81
+ * MD5 in every log line this SDK will ever see, and a rule that masks content hashes deletes the
82
+ * record it exists to protect. `sk_test_` IS included alongside `sk_live_`: a test key is still a
83
+ * credential, and telling them apart is the customer's job, not ours.
84
+ */
85
+ const PATTERNS = [
86
+ /\bsk-ant-[A-Za-z0-9_-]{16,}/gi, // Anthropic
87
+ /\bsk-proj-[A-Za-z0-9_-]{16,}/gi, // OpenAI project keys
88
+ /\bsk-[A-Za-z0-9]{20,}/gi, // OpenAI classic
89
+ /\bAIza[0-9A-Za-z_-]{30,}/gi, // Google API
90
+ /\bAKIA[0-9A-Z]{16}\b/gi, // AWS access key id
91
+ /\bASIA[0-9A-Z]{16}\b/gi, // AWS temporary key id
92
+ /\bgh[pousr]_[A-Za-z0-9]{20,}/gi, // GitHub
93
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}/gi, // Slack
94
+ /\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/gi, // JWT
95
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/gi,
96
+ /\bhf_[A-Za-z0-9]{20,}/gi, // HuggingFace
97
+ /\b[sr]k_(?:live|test)_[A-Za-z0-9]{20,}/gi, // Stripe secret/restricted
98
+ /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/gi, // SendGrid
99
+ /\bnpm_[A-Za-z0-9]{30,}/gi, // npm automation token
100
+ /\bpypi-[A-Za-z0-9_-]{32,}/gi, // PyPI API token
101
+ /\bglpat-[A-Za-z0-9_-]{20}/gi, // GitLab PAT
102
+ /\bdop_v1_[a-f0-9]{64}/gi, // DigitalOcean
103
+ ];
104
+
105
+ /**
106
+ * The words that make a *name* sensitive, written once.
107
+ *
108
+ * Two rules consume this alternation — the `key=value` rule that runs over prose, and the key-name
109
+ * rule that runs over mapping keys — and they are deliberately two separately compiled patterns
110
+ * rather than one built out of the other. Sharing the vocabulary keeps them from drifting; building
111
+ * one from the other makes one of them a no-op, which is a defect the Python file carries a long
112
+ * comment about having shipped.
113
+ *
114
+ * These are safe as substrings — no ordinary English or field-name word contains them — so they
115
+ * live here rather than in the segment list below. The test for membership is not "is this secret"
116
+ * but "can this word appear inside an innocent name": `cvv` cannot, `pin` can (`shipping`), and
117
+ * that is the whole basis for the split.
118
+ */
119
+ const SENSITIVE_WORDS =
120
+ 'api[_\\-]?key|secret|token|password|passwd|passphrase|credential|' +
121
+ 'authorization|session[_\\-]?id|private[_\\-]?key|access[_\\-]?key|cookie|' +
122
+ 'mnemonic|totp|cvv|cvc|mfa[_\\-]?code|recovery[_\\-]?code|seed[_\\-]?phrase';
123
+
124
+ /**
125
+ * Words sensitive only as a **whole segment** of a name.
126
+ *
127
+ * `auth` as a substring also condemns `author` and `authored_by` — which are not secrets, and are
128
+ * precisely the fields a governance ledger exists to carry. A rule that masks the author of a
129
+ * deploy has stopped protecting anything and started deleting the record. Likewise `pin` inside
130
+ * `shipping`, `otp` inside `dotproduct`, `dob` inside `dobson`.
131
+ *
132
+ * Deliberately absent, having been considered and rejected upstream: `pass` (`pass_rate`,
133
+ * `first_pass` are metrics, and `password`/`passwd`/`passphrase` already cover the credential),
134
+ * `salt`, `nonce` and `sig` (public by construction — masking a signature deletes the governance
135
+ * record without protecting anything), and `refresh` (`refresh_token` is already caught by `token`;
136
+ * `refresh` alone is an interval).
137
+ */
138
+ const SEGMENT_WORDS = 'auth|pwd|pin|otp|ssn|dob|jwt|bearer';
139
+
140
+ const SEGMENT_IN_NAME = '(?:[A-Za-z0-9]+[_\\-])*(?:' + SEGMENT_WORDS + ')(?:[_\\-][A-Za-z0-9]+)*';
141
+
142
+ /**
143
+ * `KEY=value` / `"key": "value"` / `--token value` / `Authorization: Bearer …`
144
+ *
145
+ * Prose, so `auth` stays in: `auth = hunter2hunter2` in a log line is a credential whatever the
146
+ * surrounding sentence is, and the value group's four-character floor keeps it off ordinary text.
147
+ * Both vocabularies are spelled with the scope each one requires, in BOTH rules — splicing the
148
+ * segment words in as bare substrings here makes `pin` mask `shipping: 12345` and `otp` mask
149
+ * `dotproduct = 0.9871`.
150
+ *
151
+ * Three capture groups, and the count is load-bearing: the replacement keeps 1 and 2 and masks 3.
152
+ */
153
+ const SENSITIVE_KEY = new RegExp(
154
+ '\\b(' + SEGMENT_IN_NAME + '|[A-Za-z0-9_\\-]*(?:' + SENSITIVE_WORDS + ')[A-Za-z0-9_\\-]*)' +
155
+ '(\\s*[:=]\\s*|\\s+)' +
156
+ '("[^"]{4,}"|\'[^\']{4,}\'|[^\\s,;\'"})]{4,})',
157
+ 'gi');
158
+
159
+ /**
160
+ * A mapping key whose *name* alone condemns its value. Anchored end to end over a character class
161
+ * that includes `.`, so `x-api-key`, `AWS_SECRET_ACCESS_KEY` and `db_password` match while
162
+ * `headers` does not — that one is recursed into instead.
163
+ *
164
+ * Non-global on purpose: it is used with `.test()`, and a `g` flag would make it stateful.
165
+ */
166
+ const SENSITIVE_NAME = new RegExp(
167
+ '^[A-Za-z0-9_\\-.]*(?:' + SENSITIVE_WORDS + ')[A-Za-z0-9_\\-.]*$', 'i');
168
+
169
+ /**
170
+ * The segment-scoped rule: the word must occupy whole `_`/`-`/`.`-delimited segments of the name.
171
+ * `auth`, `auth_token` and `req.auth` match; `authored_by` does not.
172
+ */
173
+ const SENSITIVE_SEGMENT = new RegExp(
174
+ '^(?:[A-Za-z0-9]+[_\\-.])*(?:' + SEGMENT_WORDS + ')(?:[_\\-.][A-Za-z0-9]+)*$', 'i');
175
+
176
+ const BEARER = /\b(bearer|basic|token)\s+([A-Za-z0-9._\-+/=]{8,})/gi;
177
+
178
+ /** userinfo in a URL: `https://user:pass@host/…` */
179
+ const URL_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)([^/@\s:]+):([^/@\s]+)@/gi;
180
+
181
+ // ═══════════════════════════════════════════════════════════════════════════════════════════════
182
+ // personal data — the *second* line of defence, never the first
183
+ //
184
+ // The first line is the tier: at `metadata_only` no free text reaches the wire at all, so none of
185
+ // the patterns below has to be right for the default configuration to be safe. They earn their
186
+ // place at T1/T2, where a customer has consciously opted into content and still should not ship a
187
+ // card number by accident.
188
+ //
189
+ // The set is small and anchored, for the reason in the module header: this runs on somebody else's
190
+ // request path. It is also, by construction, incomplete — `John Doe` is not recognisable by any
191
+ // regular expression, and a field that names a patient will always leak at T2. That is an argument
192
+ // for the tier gate being the primary control, not for a longer pattern list here.
193
+ // ═══════════════════════════════════════════════════════════════════════════════════════════════
194
+
195
+ /**
196
+ * Unicode dashes. A card number pasted out of a word processor, a PDF or a CRM arrives with U+2011
197
+ * (non-breaking hyphen) or U+2013 (en dash) where the author typed a hyphen, and to the reader it
198
+ * is identical. Written as a character class rather than by normalising the input, because
199
+ * normalising would change the very text we are about to emit — NFKC is not length-preserving, so
200
+ * every match offset would then be an offset into a string the caller never gave us.
201
+ */
202
+ const DASHES = '\\-\u2010\u2011\u2012\u2013\u2014\u2015\u2212\uff0d';
203
+
204
+ /**
205
+ * Email.
206
+ *
207
+ * **This is the rule the unicode difference bites hardest.** Python spells the domain and TLD
208
+ * `[^\W\d_]` under `re.UNICODE`, which means "a letter in any script" and is what lets
209
+ * `jane@example.中国` — an internationalised domain, ordinary in half the world — be recognised.
210
+ * JavaScript's `\w` stays ASCII even under the `u` flag, so the literal transliteration would have
211
+ * quietly narrowed the rule back to ASCII-only and reintroduced the exact defect Python's comment
212
+ * records fixing. `\p{L}` is the honest equivalent.
213
+ *
214
+ * The boundaries are lookarounds rather than `\b` for the same reason: `\b` is ASCII-defined in
215
+ * JavaScript, so it would fire in the middle of a non-ASCII TLD.
216
+ */
217
+ const EMAIL = /(?<![A-Za-z0-9._%+\-])[A-Za-z0-9._%+\-]+@\p{L}(?:[\p{L}\p{N}_.\-]*[\p{L}\p{N}_])?\.\p{L}{2,24}(?!\p{L})/gu;
218
+
219
+ /**
220
+ * US SSN and the national-ID shapes that share its form. Applied before the phone rules, which
221
+ * overlap it; the more specific rule must win.
222
+ *
223
+ * The separator is any of space / dot / dash, but **the same one twice** — the backreference is
224
+ * what keeps this off `123 45-6789`-shaped coincidences and, more importantly, off arbitrary digit
225
+ * soup. A bare `123456789` is deliberately NOT matched: nine digits with no separator is also every
226
+ * order id, every epoch-ish counter and every autoincrement primary key in the corpus, and a rule
227
+ * that masks all of them stops being a redactor and starts being a censor.
228
+ */
229
+ const NATIONAL_ID = new RegExp(
230
+ '(?<![\\d' + DASHES + '])\\d{3}([ .' + DASHES + '])\\d{2}\\1\\d{4}(?![\\d' + DASHES + '])', 'g');
231
+
232
+ /**
233
+ * IBAN. Gated on the ISO 7064 mod-97 checksum for the same reason {@link CARD} is gated on Luhn:
234
+ * the shape alone (two letters, two digits, alphanumerics) matches product SKUs and git refs.
235
+ */
236
+ const IBAN = /\b[A-Za-z]{2}\d{2}[A-Za-z0-9]{11,30}\b/g;
237
+
238
+ /**
239
+ * E.164 and the written NANP forms. The lookarounds keep it off version strings and durations.
240
+ *
241
+ * An extension has to be *consumed*, not merely tolerated: the trailing `(?![\w\-])` is what keeps
242
+ * this rule off version strings, so it cannot simply be relaxed. Without the extension branch,
243
+ * `415.555.2671x22` was not a phone number — and a direct line with an extension is more
244
+ * identifying than one without, not less.
245
+ */
246
+ const PHONE = new RegExp(
247
+ '(?<![\\w.\\-])(?:\\+\\d{1,3}[ .\\-]?)?(?:\\(\\d{3}\\)[ .\\-]?|\\d{3}[ .\\-])\\d{3}[ .\\-]\\d{4}' +
248
+ '(?:[ ]?(?:x|ext\\.?|extension)[ ]?\\d{1,6})?(?![\\w\\-])' +
249
+ '|(?<![\\w.\\-])\\+\\d{9,15}(?![\\w\\-])', 'g');
250
+
251
+ /**
252
+ * Payment-card candidates. Shape alone over-matches every long integer in a log line, so a
253
+ * candidate is masked only once it passes Luhn — precision bought for a few microseconds.
254
+ */
255
+ const CARD = new RegExp(
256
+ '(?<![\\d' + DASHES + '])(?:\\d[ .' + DASHES + ']?){12,18}\\d(?![\\d' + DASHES + '])', 'g');
257
+
258
+ function luhn(digits) {
259
+ let total = 0;
260
+ let alt = false;
261
+ for (let i = digits.length - 1; i >= 0; i -= 1) {
262
+ let d = digits.charCodeAt(i) - 48;
263
+ if (alt) {
264
+ d *= 2;
265
+ if (d > 9) d -= 9;
266
+ }
267
+ total += d;
268
+ alt = !alt;
269
+ }
270
+ return total % 10 === 0;
271
+ }
272
+
273
+ function maskCard(m) {
274
+ const digits = m.replace(/\D/g, '');
275
+ return digits.length >= 13 && digits.length <= 19 && luhn(digits) ? MASK : m;
276
+ }
277
+
278
+ /**
279
+ * ISO 7064 mod-97: rotate the first four characters to the end, letters as A=10..Z=35, `% 97 == 1`.
280
+ *
281
+ * Same role as {@link luhn} — it turns a shape that over-matches into a rule precise enough to run
282
+ * on somebody else's request path. `GB29NWBK60161331926819` passes; `DE49PRODUCTSKU12345` does not,
283
+ * and neither does a git ref that happens to start with two letters and two digits.
284
+ *
285
+ * **Computed incrementally rather than as one integer, and that is not a micro-optimisation.**
286
+ * Python can take `% 97` of an arbitrarily large int exactly. A 34-character IBAN expands to a
287
+ * ~40-digit number, which in JavaScript is far past `Number.MAX_SAFE_INTEGER` — the literal port
288
+ * would round it and then decide the checksum from whatever the rounding produced, passing and
289
+ * failing effectively at random. Reducing digit by digit is exact and needs no BigInt.
290
+ */
291
+ function ibanOk(s) {
292
+ const up = String(s).toUpperCase();
293
+ const rotated = up.slice(4) + up.slice(0, 4);
294
+ let remainder = 0;
295
+ for (let i = 0; i < rotated.length; i += 1) {
296
+ const code = rotated.charCodeAt(i);
297
+ let part;
298
+ if (code >= 65 && code <= 90) part = String(code - 55); // A..Z -> 10..35
299
+ else if (code >= 48 && code <= 57) part = String(code - 48); // 0..9
300
+ else return false;
301
+ for (let j = 0; j < part.length; j += 1) {
302
+ remainder = (remainder * 10 + (part.charCodeAt(j) - 48)) % 97;
303
+ }
304
+ }
305
+ return remainder === 1;
306
+ }
307
+
308
+ function maskIban(m) {
309
+ return ibanOk(m) ? MASK : m;
310
+ }
311
+
312
+ /**
313
+ * `[pattern, replacement]`. IBAN runs before {@link CARD}: an IBAN's digit run can be long enough
314
+ * for the card rule to consider it, and the more specific rule must win — the same ordering
315
+ * argument as {@link NATIONAL_ID} before {@link PHONE}.
316
+ */
317
+ const PII_RULES = [
318
+ [EMAIL, () => MASK],
319
+ [NATIONAL_ID, () => MASK],
320
+ [IBAN, maskIban],
321
+ [CARD, maskCard],
322
+ [PHONE, () => MASK],
323
+ ];
324
+
325
+ /**
326
+ * Return `{ text, redacted }`. Never throws — see case 5.8.
327
+ *
328
+ * On failure the whole string becomes the mask rather than propagating: the caller keeps its event,
329
+ * this one field becomes unreadable. That is the correct direction, because the alternative is
330
+ * dropping the governance record at exactly the moment something odd is happening.
331
+ */
332
+ function redact(text) {
333
+ if (text === null || text === undefined || text === '') {
334
+ return { text: text === 0 ? '0' : (text || ''), redacted: false };
335
+ }
336
+ try {
337
+ let out = String(text);
338
+ const before = out;
339
+ for (const pat of PATTERNS) out = out.replace(pat, MASK);
340
+ out = out.replace(URL_USERINFO, (_m, scheme, user) => scheme + user + ':' + MASK + '@');
341
+ out = out.replace(BEARER, (_m, kind) => kind + ' ' + MASK);
342
+ out = out.replace(SENSITIVE_KEY, (_m, name, sep) => name + sep + MASK);
343
+ for (const [pat, repl] of PII_RULES) out = out.replace(pat, repl);
344
+ return { text: out, redacted: out !== before };
345
+ } catch (_err) {
346
+ return { text: MASK, redacted: true };
347
+ }
348
+ }
349
+
350
+ // ═══════════════════════════════════════════════════════════════════════════════════════════════
351
+ // URLs
352
+ // ═══════════════════════════════════════════════════════════════════════════════════════════════
353
+
354
+ /**
355
+ * A path segment that carries a secret rather than naming a resource. Two shapes:
356
+ *
357
+ * - twelve or more characters containing **both** a letter and a digit — `TOKEN123ABCDEF`,
358
+ * `ORD-2026-0012`, a base62 invite code. Requiring both is what keeps `messages`, `completions`
359
+ * and `pancreatic-carcinoma` out of it; requiring twelve is what keeps `v1` and `2026-08-27` out.
360
+ * - thirty-two or more characters of anything path-safe — the length alone is the tell.
361
+ *
362
+ * Numeric-only segments are deliberately kept. `/users/12345` is a resource id and it is what makes
363
+ * a path groupable.
364
+ */
365
+ const PATH_TOKEN =
366
+ /^(?=[A-Za-z0-9._~-]{12,}$)(?=[^A-Za-z]*[A-Za-z])(?=[^0-9]*[0-9])[A-Za-z0-9._~-]+$|^[A-Za-z0-9._~-]{32,}$/;
367
+
368
+ /**
369
+ * The path with token-shaped segments masked, then run through {@link redact}.
370
+ *
371
+ * Both steps are needed and neither subsumes the other. `redact` catches a JWT or an API key sitting
372
+ * in a path, because those have recognisable shapes; it cannot catch `/reset/TOKEN123ABCDEF`,
373
+ * because a random opaque string has no shape to recognise. The segment rule catches exactly that,
374
+ * by position and entropy-ish shape. Single-use credentials live in paths far more often than in
375
+ * queries — `/reset/<token>`, `/invite/<token>`, `/verify/<token>` are the standard shape of every
376
+ * password-reset email ever sent.
377
+ */
378
+ function safePath(path) {
379
+ return redact(String(path).split('/')
380
+ .map((seg) => (PATH_TOKEN.test(seg) ? MASK : seg))
381
+ .join('/')).text;
382
+ }
383
+
384
+ /**
385
+ * scheme + host + path. Query, fragment and userinfo are dropped.
386
+ *
387
+ * A URL stops being metadata the moment it has a query string: `?q=…` is free text of exactly the
388
+ * class the tier gate just refused to store, and magic links carry single-use tokens that no
389
+ * pattern above can recognise.
390
+ */
391
+ function safeUrl(u) {
392
+ try {
393
+ const parsed = new URL(String(u));
394
+ const host = parsed.port ? parsed.hostname + ':' + parsed.port : parsed.hostname;
395
+ return parsed.protocol + '//' + host + safePath(parsed.pathname);
396
+ } catch (_err) {
397
+ return MASK;
398
+ }
399
+ }
400
+
401
+ // ═══════════════════════════════════════════════════════════════════════════════════════════════
402
+ // key names
403
+ // ═══════════════════════════════════════════════════════════════════════════════════════════════
404
+
405
+ /**
406
+ * Key names whose numeric value describes the *operation* rather than the subject of it. These are
407
+ * the numbers telemetry exists to carry, and they are what makes the tier gate affordable — without
408
+ * them `metadata_only` would emit no measurements at all and nobody would leave it on.
409
+ *
410
+ * An allowlist, not a denylist, and that direction is the whole point. A denylist of sensitive
411
+ * numeric names has to anticipate `ssn`, `mrn`, `iban`, `lat`, `salary` and whatever the next
412
+ * customer's schema calls them; it fails open on the first one nobody thought of. This fails
413
+ * closed: an unrecognised numeric key is masked, and the cost of being wrong is one masked metric
414
+ * that somebody notices and adds here.
415
+ */
416
+ const STRUCTURAL_NUMBER_KEYS = new Set([
417
+ 'attempt', 'attempts', 'batch', 'capacity', 'code', 'concurrency', 'count', 'depth',
418
+ 'dropped', 'duration', 'elapsed', 'errors', 'exit_code', 'failures', 'hits', 'index',
419
+ 'iteration', 'length', 'limit', 'lines', 'misses', 'offset', 'page', 'pending', 'port',
420
+ 'position', 'priority', 'queued', 'retries', 'rows', 'size', 'status', 'status_code',
421
+ 'step', 'timeout', 'tokens', 'total', 'version', 'weight',
422
+ ]);
423
+
424
+ /**
425
+ * Suffixes that make a numeric key structural whatever its stem: `upload_bytes`,
426
+ * `render_duration_ms`, `retry_count`. Matching on the suffix rather than the whole name is what
427
+ * keeps the allowlist from needing an entry per metric a customer invents.
428
+ */
429
+ const STRUCTURAL_NUMBER_SUFFIXES = [
430
+ '_bytes', '_count', '_duration', '_elapsed', '_index', '_len', '_length', '_limit', '_ms',
431
+ '_ns', '_offset', '_pct', '_percent', '_rate', '_ratio', '_retries', '_rows', '_s',
432
+ '_seconds', '_size', '_status', '_total', '_us',
433
+ ];
434
+
435
+ const endsWithStructuralSuffix = (k) => STRUCTURAL_NUMBER_SUFFIXES.some((s) => k.endsWith(s));
436
+
437
+ /**
438
+ * Whether a numeric value under this key is a measurement rather than a fact about a person.
439
+ *
440
+ * Deliberately conservative: it decides on the *name*, which is the only evidence available, and a
441
+ * name is weak evidence. `lat`/`lon`, `ssn`, `card` and `balance` are absent and therefore masked
442
+ * below `full`; `duration_ms` and `rows` are present and survive.
443
+ */
444
+ function isStructuralNumberKey(key) {
445
+ const k = String(key).trim().toLowerCase();
446
+ return STRUCTURAL_NUMBER_KEYS.has(k) || endsWithStructuralSuffix(k);
447
+ }
448
+
449
+ /**
450
+ * The same question for the elements of a *list* under this key — and a stricter answer.
451
+ *
452
+ * A list element has no name of its own, so it can only inherit its parent key's verdict. But the
453
+ * two halves of the allowlist do not survive that inheritance equally, and treating them as if they
454
+ * did is how `{ rows: [123456789, 4111111111111111] }` would ship at `metadata_only`:
455
+ *
456
+ * - The **suffix** half names a *unit* — `_ms`, `_bytes`, `_pct`. A unit means the same thing
457
+ * whether the key holds one measurement or a hundred, so `durations_ms: [12, 15, 402]` is exactly
458
+ * as structural as `duration_ms: 12`. These inherit.
459
+ * - The **bare-name** half names a *quantity* — `count`, `rows`, `batch`, `depth`. Under a scalar
460
+ * that is the tally; under a list it is the things being tallied, which is the opposite of
461
+ * structural. These do not inherit.
462
+ */
463
+ function isStructuralNumberKeyForSequence(key) {
464
+ return endsWithStructuralSuffix(String(key).trim().toLowerCase());
465
+ }
466
+
467
+ /**
468
+ * Codepoint ranges for the scripts whose letterforms are confusable with ASCII: Greek and Cyrillic.
469
+ * Named as *classes* rather than as a list of homoglyph characters, because a list of characters is
470
+ * a list and the attacker picks the character.
471
+ */
472
+ const CONFUSABLE_RANGES = [[0x0370, 0x03ff], [0x0400, 0x052f], [0x1f00, 0x1fff]];
473
+
474
+ /**
475
+ * Whether `name` mixes ASCII letters with Cyrillic or Greek ones.
476
+ *
477
+ * `аpi_key` with a Cyrillic `а` renders identically to `api_key` and matches neither pattern, so the
478
+ * value would ship. NFKC does not help: Cyrillic `а` and Latin `a` are different letters in
479
+ * different scripts, not compatibility variants of one letter, and Unicode is right about that.
480
+ *
481
+ * So this asks a different question — not "is this character a homoglyph" but "is this name written
482
+ * in one script". A field name that mixes ASCII with Cyrillic is mojibake or an attack; it is not a
483
+ * name a developer typed on purpose. Fails closed on the mix.
484
+ *
485
+ * Only these two scripts are named, and only against ASCII. A name written wholly in Cyrillic,
486
+ * Greek, Japanese or Arabic is somebody's ordinary field name and is left entirely alone — the rule
487
+ * is about the *mixture*, which is the thing that has no innocent reading.
488
+ */
489
+ function hasConfusableScript(name) {
490
+ let hasAscii = false;
491
+ let hasConfusable = false;
492
+ for (const ch of String(name)) {
493
+ // `\p{L}` rather than a case-comparison trick: the question is "is this a letter in any
494
+ // script", and only a unicode property escape answers it for Greek and Cyrillic alike.
495
+ if (!/\p{L}/u.test(ch)) continue;
496
+ const o = ch.codePointAt(0);
497
+ if (o < 128) hasAscii = true;
498
+ else if (CONFUSABLE_RANGES.some(([lo, hi]) => o >= lo && o <= hi)) hasConfusable = true;
499
+ if (hasAscii && hasConfusable) return true;
500
+ }
501
+ return false;
502
+ }
503
+
504
+ /**
505
+ * Does this mapping key's *name* alone condemn its value?
506
+ *
507
+ * Fails closed: an unrepresentable key is treated as sensitive. Masking a harmless value costs a
508
+ * field; not masking a harmful one costs a credential.
509
+ *
510
+ * **The name is normalised before it is matched, and that is a bug fix rather than a courtesy.**
511
+ * Both patterns are anchored over a character class that excludes whitespace, so `"API_KEY "` — one
512
+ * trailing space — fails the match outright and ships the value. Keys arrive with stray whitespace
513
+ * constantly: parsed headers, CSV columns, YAML round-trips, hand-written JSON. NFKC additionally
514
+ * folds the compatibility forms (fullwidth `api_key` is the same key wearing different
515
+ * codepoints).
516
+ */
517
+ function isSensitiveKey(key) {
518
+ try {
519
+ const name = String(key).normalize('NFKC').trim();
520
+ if (hasConfusableScript(name)) return true;
521
+ return SENSITIVE_NAME.test(name) || SENSITIVE_SEGMENT.test(name);
522
+ } catch (_err) {
523
+ return true;
524
+ }
525
+ }
526
+
527
+ // ═══════════════════════════════════════════════════════════════════════════════════════════════
528
+ // the bounded scan
529
+ // ═══════════════════════════════════════════════════════════════════════════════════════════════
530
+
531
+ /**
532
+ * How much text is scanned beyond the emitted limit.
533
+ *
534
+ * Redacting a whole input and then keeping 256 characters makes every pattern walk a megabyte to
535
+ * produce a tweet. Scanning a window slightly wider than the limit keeps the cost constant while
536
+ * leaving room for a secret that straddles the cut — truncating first, exactly at the limit, would
537
+ * slice a key in half and ship the surviving prefix.
538
+ */
539
+ const SCAN_OVERSCAN = 4096;
540
+
541
+ /**
542
+ * Ceiling on how much text is scanned when the caller asked for no truncation at all. Some bound
543
+ * has to exist or the absence of a limit is an unbounded regex walk on the caller's request thread;
544
+ * 64 KiB is far above any free-text field a sane caller emits and far below the point where the
545
+ * walk is felt.
546
+ */
547
+ const FULL_SCAN_CEILING = 65536;
548
+
549
+ /**
550
+ * Redact with a bounded scan. Returns `{ text, redacted, unfinished }`.
551
+ *
552
+ * The single implementation of two rules, so that no caller can hold a different opinion about
553
+ * either one:
554
+ *
555
+ * - **Scan a bounded window, not the whole input.**
556
+ * - **Never emit the prefix of something you could not finish scanning.** The overscan covers a
557
+ * secret straddling the cut. It cannot cover one *longer than the window*, because a PEM needs
558
+ * its `-----END` terminator before the pattern fires — so the first `limit` characters, which are
559
+ * the start of the key, would ship untouched. `unfinished` reports that, and every caller turns
560
+ * it into a mask.
561
+ *
562
+ * `unfinished` is a post-condition on the *output* rather than a guess about the input, so it
563
+ * generalises to the next unterminated pattern instead of enumerating this one. Two questions are
564
+ * asked of the emitted text: does an unbounded-length opener survive in it, and does the last
565
+ * assignment in it name something sensitive with no mask after it — meaning the value is still
566
+ * running when the output stops. Both are linear in the emitted prefix, which is already bounded.
567
+ */
568
+ function scanWindow(text, limit) {
569
+ const s = String(text);
570
+ const window = limit === null || limit === undefined ? FULL_SCAN_CEILING : limit + SCAN_OVERSCAN;
571
+ const truncated = s.length > window;
572
+ const r = redact(s.slice(0, window));
573
+ let scrubbed = r.text;
574
+ if (limit !== null && limit !== undefined) scrubbed = scrubbed.slice(0, limit);
575
+
576
+ let unfinished = UNTERMINATED_MARKERS.some((m) => scrubbed.includes(m));
577
+ if (!unfinished && truncated) {
578
+ let last = null;
579
+ // `matchAll` rather than `exec` in a loop: the shared pattern is `g`-flagged, and `exec`
580
+ // would mutate its `lastIndex` across calls — the JavaScript hazard with no Python analogue.
581
+ for (const m of scrubbed.matchAll(ASSIGN_OPENER_RE)) last = m;
582
+ if (last !== null && isSensitiveKey(last[1])) {
583
+ unfinished = !scrubbed.slice(last.index + last[1].length).includes(MASK);
584
+ }
585
+ }
586
+ return { text: scrubbed, redacted: r.redacted, unfinished };
587
+ }
588
+
589
+ // ═══════════════════════════════════════════════════════════════════════════════════════════════
590
+ // the tier ladder's leaves
591
+ // ═══════════════════════════════════════════════════════════════════════════════════════════════
592
+
593
+ /**
594
+ * The three tiers, defined here rather than in `core.cjs`.
595
+ *
596
+ * They used to live in the core, which meant this module could not name them without a circular
597
+ * require. Since the redactor is the thing that *acts* on a tier, it is the honest owner of the
598
+ * vocabulary, and the core imports it from here.
599
+ */
600
+ const TIER_METADATA_ONLY = 'metadata_only';
601
+ const TIER_HASHED = 'hashed';
602
+ const TIER_FULL = 'full';
603
+
604
+ /**
605
+ * The single gate every free-text field passes through when a caller wants one string back.
606
+ *
607
+ * It lives in one function rather than at the call sites because that is precisely how these fields
608
+ * leaked in the wrapper: one shell command was redacted on the `tool_action` path and shipped raw
609
+ * on three others. A gate here cannot be forgotten by the next call site.
610
+ *
611
+ * **Three tiers, three branches.** At T0 this returns `null`, and the envelope builder drops keys
612
+ * whose value is `null`, so the field is *absent* rather than blanked — a reader can tell "no
613
+ * content was captured" from "the content was empty". Returning `null` also means a new tier, or a
614
+ * typo, degrades to silence rather than to egress, which is the only failure direction worth having.
615
+ */
616
+ function wireText(text, tier, limit) {
617
+ if (text === null || text === undefined) return null;
618
+ const s = String(text);
619
+ if (!s) return s;
620
+ if (tier !== TIER_FULL && tier !== TIER_HASHED) return null;
621
+ const r = scanWindow(s, limit === undefined ? 512 : limit);
622
+ return r.unfinished ? MASK : r.text;
623
+ }
624
+
625
+ /**
626
+ * Structured redaction — the common miss.
627
+ *
628
+ * Tool arguments and structured outputs are objects, not prompt strings, and a redactor that only
629
+ * walks free text passes an API key straight through when it arrives as
630
+ * `{ headers: { Authorization: 'Bearer …' } }`. Bounded in width and depth so a pathological
631
+ * payload cannot turn scrubbing into the slow part of a request.
632
+ *
633
+ * Three rules worth stating, each of which was a defect somewhere before it was a rule:
634
+ *
635
+ * - A sensitive **name** masks the whole value whatever its type. `{ api_key: 12345678 }` and
636
+ * `{ auth: { token: '…' } }` are both condemned by the name; descending into them to scrub the
637
+ * leaves would emit the shape of a credential, and a credential's shape is a credential. This
638
+ * over-approximates — a field named `token_count` masks a harmless integer — and the trade is
639
+ * deliberate: a false positive costs one number, a false negative costs a secret.
640
+ * - **A number's type says nothing about its sensitivity.** `{ ssn: 123456789, card:
641
+ * 4111111111111111 }` would otherwise pass every tier unchanged while the same values as strings
642
+ * were correctly dropped — a type-confusion hole, not a policy decision. So numbers go through
643
+ * the structural-name allowlist below `full`.
644
+ * - **Depth is bounded.** Without it the walk is capped only by the stack.
645
+ *
646
+ * At T0 string values are dropped rather than blanked, for the reason in {@link wireText}.
647
+ * Booleans and `null` survive every tier — a flag is not content.
648
+ */
649
+ function scrubMapping(data, tier, opts) {
650
+ const o = opts || {};
651
+ const maxKeys = o.maxKeys === undefined ? 32 : o.maxKeys;
652
+ const limit = o.limit === undefined ? 256 : o.limit;
653
+ const maxDepth = o.maxDepth === undefined ? 6 : o.maxDepth;
654
+
655
+ if (!data || typeof data !== 'object' || Array.isArray(data) || maxDepth <= 0) return {};
656
+ const out = {};
657
+ for (const k of Object.keys(data).slice(0, maxKeys)) {
658
+ const key = String(k).slice(0, 64);
659
+ const v = data[k];
660
+ if (isSensitiveKey(key)) { out[key] = MASK; continue; }
661
+
662
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
663
+ out[key] = scrubMapping(v, tier, { maxKeys, limit, maxDepth: maxDepth - 1 });
664
+ } else if (Array.isArray(v)) {
665
+ // The key's verdict travels with its elements, but only the half of it that survives the
666
+ // trip — see `isStructuralNumberKeyForSequence`. Without any inheritance a list under
667
+ // `durations_ms` would have every measurement masked below `full`, and the allowlist exists
668
+ // precisely so `metadata_only` still carries numbers somebody will look at.
669
+ out[key] = v.slice(0, 16).map((i) => scrubItem(i, tier, maxKeys, limit, maxDepth - 1,
670
+ isStructuralNumberKeyForSequence(key)));
671
+ } else if (v === null || v === undefined || typeof v === 'boolean') {
672
+ out[key] = v === undefined ? null : v;
673
+ } else if (typeof v === 'number' || typeof v === 'bigint') {
674
+ out[key] = (tier === TIER_FULL || isStructuralNumberKey(key)) ? v : MASK;
675
+ } else {
676
+ const scrubbed = wireText(String(v), tier, limit);
677
+ if (scrubbed !== null) out[key] = scrubbed;
678
+ }
679
+ }
680
+ return out;
681
+ }
682
+
683
+ /**
684
+ * One element of a list.
685
+ *
686
+ * The container types recurse into each other, and the depth budget is what stops it. Fixing a
687
+ * nesting hole at the level it was found — one more type check for the two-deep case — only moves
688
+ * it to three deep: `{ batch: [[{ api_key: '…' }]] }` is not exotic, it is a batch insert, a
689
+ * JSON-RPC batch, a paged tool result.
690
+ *
691
+ * A list element has no key of its own to judge, so it inherits its parent key's verdict for
692
+ * numbers. Without that, `{ rows: [123456789, 4111111111111111] }` comes through `metadata_only` —
693
+ * the tier that promises no content — completely unchanged.
694
+ */
695
+ function scrubItem(item, tier, maxKeys, limit, maxDepth, numbersOk) {
696
+ if (maxDepth <= 0) return MASK;
697
+ if (item && typeof item === 'object' && !Array.isArray(item)) {
698
+ return scrubMapping(item, tier, { maxKeys, limit, maxDepth });
699
+ }
700
+ if (Array.isArray(item)) {
701
+ return item.slice(0, 16).map((i) => scrubItem(i, tier, maxKeys, limit, maxDepth - 1, numbersOk));
702
+ }
703
+ if (item === null || item === undefined || typeof item === 'boolean') {
704
+ return item === undefined ? null : item;
705
+ }
706
+ if (typeof item === 'number' || typeof item === 'bigint') {
707
+ return (tier === TIER_FULL || numbersOk) ? item : MASK;
708
+ }
709
+ return wireText(String(item), tier, limit);
710
+ }
711
+
712
+ module.exports = {
713
+ MASK,
714
+ TIER_METADATA_ONLY,
715
+ TIER_HASHED,
716
+ TIER_FULL,
717
+ redact,
718
+ scanWindow,
719
+ wireText,
720
+ scrubMapping,
721
+ safeUrl,
722
+ safePath,
723
+ isSensitiveKey,
724
+ isStructuralNumberKey,
725
+ isStructuralNumberKeyForSequence,
726
+ hasConfusableScript,
727
+ luhn,
728
+ ibanOk,
729
+ // Exported for tests that need to assert on the vocabulary rather than on one example.
730
+ SENSITIVE_WORDS,
731
+ SEGMENT_WORDS,
732
+ STRUCTURAL_NUMBER_KEYS,
733
+ STRUCTURAL_NUMBER_SUFFIXES,
734
+ };