@coo-quack/sensitive-canary 0.6.0 → 0.8.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.
Files changed (37) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +830 -0
  3. package/README.md +269 -43
  4. package/dist/lib/bash-commands.js +405 -0
  5. package/dist/lib/command-tables.js +462 -0
  6. package/dist/lib/default-config.json +570 -0
  7. package/dist/lib/encoding.js +123 -0
  8. package/dist/lib/fail-closed.js +31 -0
  9. package/dist/lib/inspector.js +0 -0
  10. package/dist/lib/rules.js +399 -0
  11. package/dist/lib/shapes.js +161 -0
  12. package/dist/lib/shell.js +436 -0
  13. package/dist/lib/tool-inputs.js +217 -0
  14. package/dist/lib/transcript.js +115 -0
  15. package/dist/lib/validators.js +435 -0
  16. package/dist/pre-tool-use-hook.js +773 -0
  17. package/dist/user-prompt-submit-hook.js +105 -0
  18. package/hooks/hooks.json +1 -1
  19. package/package.json +25 -11
  20. package/src/lib/bash-commands.ts +455 -0
  21. package/src/lib/command-tables.ts +518 -0
  22. package/src/lib/default-config.json +570 -0
  23. package/src/lib/encoding.ts +135 -0
  24. package/src/lib/fail-closed.ts +36 -0
  25. package/src/lib/inspector.ts +0 -0
  26. package/src/lib/rules.ts +482 -267
  27. package/src/lib/shapes.ts +175 -0
  28. package/src/lib/shell.ts +512 -0
  29. package/src/lib/tool-inputs.ts +235 -0
  30. package/src/lib/transcript.ts +142 -0
  31. package/src/lib/validators.ts +435 -0
  32. package/src/pre-tool-use-hook.ts +774 -198
  33. package/src/user-prompt-submit-hook.ts +60 -18
  34. package/src/__tests__/pre-tool-use-hook.test.ts +0 -779
  35. package/src/__tests__/user-prompt-submit-hook.test.ts +0 -297
  36. package/src/lib/__tests__/inspector.test.ts +0 -281
  37. package/src/lib/__tests__/rules.test.ts +0 -448
@@ -0,0 +1,435 @@
1
+ // The checksum algorithms and range checks a rule may name in its `validate`
2
+ // field.
3
+ //
4
+ // These are code where the rules are data: a rule says `"validate": "luhn"` and
5
+ // the registry at the foot of this file resolves the name. Each answers one
6
+ // question about one value — does this pass the checksum, is this address
7
+ // reserved — and none of them knows what a rule is.
8
+
9
+ // Luhn algorithm checksum validation. Returns true if the number (digits only) passes.
10
+ // Card numbers every payment gateway publishes as test data. They pass Luhn by
11
+ // design, and a developer pasting one into a prompt or a fixture is not leaking
12
+ // anything — but the block reads the same as a real one, and that is the kind of
13
+ // block that gets the tool turned off.
14
+ const TEST_CARD_NUMBERS = new Set([
15
+ "4242424242424242",
16
+ "4111111111111111",
17
+ "4012888888881881",
18
+ "4000056655665556",
19
+ "5555555555554444",
20
+ "5105105105105100",
21
+ "5200828282828210",
22
+ "378282246310005",
23
+ "371449635398431",
24
+ "6011111111111117",
25
+ "6011000990139424",
26
+ "3056930009020004",
27
+ "3566002020360505",
28
+ ]);
29
+
30
+ // A card number that is not published test data and passes the checksum. This
31
+ // is what the `luhn` validator resolves to: `luhn` alone answers a narrower
32
+ // question, and having one function answer both meant it returned false for
33
+ // numbers that do pass the checksum.
34
+ export function isRealCardNumber(str: string): boolean {
35
+ if (TEST_CARD_NUMBERS.has(str.replace(/\D/g, ""))) return false;
36
+ return luhn(str);
37
+ }
38
+
39
+ // AWS writes every key in its documentation with `EXAMPLE` where the random
40
+ // part would end — `AKIAIOSFODNN7EXAMPLE`, `ASIAIOSFODNN7EXAMPLE`,
41
+ // `AKIAI44QH8DHBEXAMPLE`. Those appear in setup guides, in READMEs that copy
42
+ // them, and in this project's own documentation, and a block on one reads
43
+ // exactly like a block on a live key.
44
+ //
45
+ // The suffix is the test rather than a list of bodies. The bodies differ
46
+ // between guides, so a list would exempt the three anyone thought to write down
47
+ // and block the fourth; the convention is what AWS keeps to. What it costs is a
48
+ // real key whose last seven characters spell the word, one in thirty-six to the
49
+ // seventh, and the rule's own character class keeps that to uppercase keys.
50
+ export function isRealAwsKey(str: string): boolean {
51
+ return !/EXAMPLE$/.test(str);
52
+ }
53
+
54
+ export function luhn(str: string): boolean {
55
+ const digits = str.replace(/\D/g, "");
56
+ if (digits.length === 0) return false;
57
+ let sum = 0;
58
+ let double = false;
59
+ for (let i = digits.length - 1; i >= 0; i--) {
60
+ let d = parseInt(digits[i] ?? "", 10);
61
+ if (double) {
62
+ d *= 2;
63
+ if (d > 9) d -= 9;
64
+ }
65
+ sum += d;
66
+ double = !double;
67
+ }
68
+ return sum % 10 === 0;
69
+ }
70
+
71
+ // ── National ID checksum validators ──────────────────────────────────────────
72
+
73
+ // Japanese Individual Number (My Number): 12 digits, weighted checksum over the
74
+ // first 11 digits with weights 6,5,4,3,2,7,6,5,4,3,2. The 12th digit is
75
+ // 11 - (sum mod 11); when the remainder is 0 or 1, the check digit is 0.
76
+ // Spec: 地方公共団体情報システム機構 (J-LIS).
77
+ export function validateMyNumber(input: string): boolean {
78
+ // Twelve of the same digit satisfies the weighted sum by arithmetic, not by
79
+ // being anyone's number. Padding, zeroed records and hex dumps are full of
80
+ // them.
81
+ if (/^(\d)\1*$/.test(input.replace(/[-\s]/g, ""))) return false;
82
+ const digits = input.replace(/\D/g, "");
83
+ if (digits.length !== 12) return false;
84
+ const weights = [6, 5, 4, 3, 2, 7, 6, 5, 4, 3, 2];
85
+ let sum = 0;
86
+ for (let i = 0; i < 11; i++) {
87
+ sum += parseInt(digits[i] ?? "", 10) * (weights[i] ?? 0);
88
+ }
89
+ const remainder = sum % 11;
90
+ const checkDigit = remainder <= 1 ? 0 : 11 - remainder;
91
+ return checkDigit === parseInt(digits[11] ?? "", 10);
92
+ }
93
+
94
+ // French NIR (Numéro de sécurité sociale / INSEE): 15 digits, 2-digit check key
95
+ // computed as 97 - (N mod 97) over the leading 13 digits. Corsica departements
96
+ // use 2A/2B, substituted to 19/18 before the mod. The 13-digit value can exceed
97
+ // Number.MAX_SAFE_INTEGER, so BigInt is used. Spec: INSEE / décret n°82-103.
98
+ export function validateFrenchNIR(input: string): boolean {
99
+ const cleaned = input.replace(/\s/g, "");
100
+ let nir13: string;
101
+ let keyStr: string;
102
+
103
+ const standard = cleaned.match(/^([12]\d{12})(\d{2})$/);
104
+ const corseA = cleaned.match(/^([12]\d{4}2A\d{6})(\d{2})$/i);
105
+ const corseB = cleaned.match(/^([12]\d{4}2B\d{6})(\d{2})$/i);
106
+
107
+ if (standard) {
108
+ nir13 = standard[1] ?? "";
109
+ keyStr = standard[2] ?? "";
110
+ } else if (corseA) {
111
+ nir13 = (corseA[1] ?? "").replace(/2A/i, "19");
112
+ keyStr = corseA[2] ?? "";
113
+ } else if (corseB) {
114
+ nir13 = (corseB[1] ?? "").replace(/2B/i, "18");
115
+ keyStr = corseB[2] ?? "";
116
+ } else {
117
+ return false;
118
+ }
119
+
120
+ const num = BigInt(nir13);
121
+ const computedKey = 97 - Number(num % 97n);
122
+ return computedKey === parseInt(keyStr, 10);
123
+ }
124
+
125
+ // Italian Codice Fiscale: 16 alphanumeric chars. The last char is a control
126
+ // character computed by summing odd/even position values (different maps) mod 26.
127
+ // Spec: Agenzia delle Entrate, DM 12 giugno 2007.
128
+ const CF_ODD_VALUES: Record<string, number> = {
129
+ "0": 1,
130
+ "1": 0,
131
+ "2": 5,
132
+ "3": 7,
133
+ "4": 9,
134
+ "5": 13,
135
+ "6": 15,
136
+ "7": 17,
137
+ "8": 19,
138
+ "9": 21,
139
+ A: 1,
140
+ B: 0,
141
+ C: 5,
142
+ D: 7,
143
+ E: 9,
144
+ F: 13,
145
+ G: 15,
146
+ H: 17,
147
+ I: 19,
148
+ J: 21,
149
+ K: 2,
150
+ L: 4,
151
+ M: 18,
152
+ N: 20,
153
+ O: 11,
154
+ P: 3,
155
+ Q: 6,
156
+ R: 8,
157
+ S: 12,
158
+ T: 14,
159
+ U: 16,
160
+ V: 10,
161
+ W: 22,
162
+ X: 25,
163
+ Y: 24,
164
+ Z: 23,
165
+ };
166
+
167
+ export function validateCodiceFiscale(input: string): boolean {
168
+ const cf = input.toUpperCase().replace(/\s/g, "");
169
+ // Omocodia: when two people would share the first fifteen characters, the
170
+ // Agenzia delle Entrate substitutes letters for digits from the right,
171
+ // 0=L 1=M 2=N 3=P 4=Q 5=R 6=S 7=T 8=U 9=V, over the seven numeric
172
+ // positions. Requiring digits there rejected every substituted code — all
173
+ // of them issued to real people. The check character below needs no change:
174
+ // it is defined over the substituted fifteen, and the odd/even tables
175
+ // already carry letters.
176
+ if (
177
+ !/^[A-Z]{6}[0-9LMNPQRSTUV]{2}[A-Z][0-9LMNPQRSTUV]{2}[A-Z][0-9LMNPQRSTUV]{3}[A-Z]$/.test(
178
+ cf,
179
+ )
180
+ )
181
+ return false;
182
+
183
+ let sum = 0;
184
+ for (let i = 0; i < 15; i++) {
185
+ const ch = cf[i] ?? "";
186
+ if (i % 2 === 0) {
187
+ sum += CF_ODD_VALUES[ch] ?? -1;
188
+ } else if (/[0-9]/.test(ch)) {
189
+ sum += parseInt(ch, 10);
190
+ } else {
191
+ sum += ch.charCodeAt(0) - 65;
192
+ }
193
+ }
194
+ const expected = String.fromCharCode(65 + (sum % 26));
195
+ return expected === cf[15];
196
+ }
197
+
198
+ // German Steuer-Identifikationsnummer (IdNr.): 11 digits, first digit non-zero.
199
+ // The procedure is ISO/IEC 7064 MOD 11,10, though the tax administration's own
200
+ // specification states it as code rather than by that name.
201
+ //
202
+ // Deliberately not enforced: the digit-composition rule. Since 2016 it reads
203
+ // "exactly one digit occurs twice or three times in positions 1-10", replacing
204
+ // an older "exactly twice". Adding the older form as a tightening would reject
205
+ // valid current numbers.
206
+ // Spec: ELSTER, Prüfung der Steuer- und Steueridentifikationsnummer, §2.2.
207
+ export function validateGermanIdNr(input: string): boolean {
208
+ const cleaned = input.replace(/\s/g, "");
209
+ if (!/^[1-9]\d{10}$/.test(cleaned)) return false;
210
+
211
+ let produkt = 10;
212
+ for (let i = 0; i < 10; i++) {
213
+ let summe = (parseInt(cleaned[i] ?? "", 10) + produkt) % 10;
214
+ if (summe === 0) summe = 10;
215
+ produkt = (summe * 2) % 11;
216
+ }
217
+ let check = 11 - produkt;
218
+ if (check === 10) check = 0;
219
+ return check === parseInt(cleaned[10] ?? "", 10);
220
+ }
221
+
222
+ // Spanish DNI (8 digits + letter) and NIE (X/Y/Z + 7 digits + letter). The
223
+ // control letter is selected from TRWAGMYFPDXBNJZSQVHLCKE by the number mod 23.
224
+ // NIE leading letters map X→0, Y→1, Z→2 before the mod.
225
+ // The X/Y/Z mapping and the mod-23 alphabet are what every implementation uses,
226
+ // but they were not confirmed against a Spanish government source here — the
227
+ // Interior page that documents them was unreachable. The governing decree is
228
+ // Real Decreto 255/2025, which repealed RD 1553/2005 on 2025-04-02, and it
229
+ // specifies neither digit count nor separator; the Agencia Tributaria describes
230
+ // the number as "ocho dígitos ... más una letra de control", with no separator.
231
+ // Hyphens are stripped above so both the official and the common form are read.
232
+ const NIF_LETTERS = "TRWAGMYFPDXBNJZSQVHLCKE";
233
+
234
+ export function validateSpanishNIF(input: string): boolean {
235
+ const cleaned = input.toUpperCase().replace(/[\s-]/g, "");
236
+
237
+ const dni = cleaned.match(/^(\d{8})([A-Z])$/);
238
+ if (dni) {
239
+ return NIF_LETTERS[parseInt(dni[1] ?? "", 10) % 23] === dni[2];
240
+ }
241
+
242
+ const nie = cleaned.match(/^([XYZ])(\d{7})([A-Z])$/);
243
+ if (nie) {
244
+ const prefix = nie[1] === "X" ? "0" : nie[1] === "Y" ? "1" : "2";
245
+ const num = parseInt(prefix + (nie[2] ?? ""), 10);
246
+ return NIF_LETTERS[num % 23] === nie[3];
247
+ }
248
+
249
+ return false;
250
+ }
251
+
252
+ // Korean Resident Registration Number (RRN, 주민등록번호): 13 digits.
253
+ // Checksum is (11 - (weighted sum mod 11)) mod 10 with weights
254
+ // 2,3,4,5,6,7,8,9,2,3,4,5 over the first 12 digits.
255
+ // Numbers newly issued or changed on or after 2020-10-05 randomize digits 8-13,
256
+ // and the check digit is the 13th — so it is inside the randomized block and the
257
+ // weighted sum above holds only by chance, roughly one time in ten. Treat a pass
258
+ // as evidence, never as a requirement. 주민등록법 시행규칙 제2조 (행정안전부령
259
+ // 제204호) now reads "생년월일ㆍ성별 등을 표시할 수 있는 13자리의 숫자", with the
260
+ // 지역 (region) term of the older text removed. No rule ever specified the check
261
+ // digit, so no rule announces its end either.
262
+ // Spec: 주민등록법 시행규칙 제2조; 주민등록 사무편람 (Ministry of the Interior and Safety).
263
+ export function validateKoreanRRN(input: string): boolean {
264
+ const s = input.replace(/[-\s]/g, "");
265
+ if (!/^\d{13}$/.test(s)) return false;
266
+ const weights = [2, 3, 4, 5, 6, 7, 8, 9, 2, 3, 4, 5];
267
+ let sum = 0;
268
+ for (let i = 0; i < 12; i++) {
269
+ sum += parseInt(s[i] ?? "", 10) * (weights[i] ?? 0);
270
+ }
271
+ const check = (11 - (sum % 11)) % 10;
272
+ return check === parseInt(s[12] ?? "", 10);
273
+ }
274
+
275
+ // Korean Business Registration Number (사업자등록번호): 10 digits. Uses the
276
+ // NTS (Hometax) standard algorithm: weights 1,3,7,1,3,7,1,3,5 over digits 1-9,
277
+ // plus floor(digit9 × 5 / 10), and the check digit is (10 - (sum mod 10)) mod 10.
278
+ export function validateKoreanBRN(input: string): boolean {
279
+ const s = input.replace(/[-\s]/g, "");
280
+ if (!/^\d{10}$/.test(s)) return false;
281
+ const weights = [1, 3, 7, 1, 3, 7, 1, 3, 5];
282
+ let sum = 0;
283
+ for (let i = 0; i < 9; i++) {
284
+ sum += parseInt(s[i] ?? "", 10) * (weights[i] ?? 0);
285
+ }
286
+ sum += Math.floor((parseInt(s[8] ?? "", 10) * 5) / 10);
287
+ return (10 - (sum % 10)) % 10 === parseInt(s[9] ?? "", 10);
288
+ }
289
+
290
+ // Chinese Resident Identity Card (居民身份证): 18 chars (17 digits + check).
291
+ // ISO 7064 MOD 11-2 per GB 11643-1999. Weights
292
+ // 7,9,10,5,8,4,2,1,6,3,7,9,10,5,8,4,2; remainder maps to "10X98765432".
293
+ export function validateChineseID(input: string): boolean {
294
+ const s = input.toUpperCase().replace(/[-\s]/g, "");
295
+ if (!/^\d{17}[\dX]$/.test(s)) return false;
296
+ const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
297
+ const code = "10X98765432";
298
+ let sum = 0;
299
+ for (let i = 0; i < 17; i++) {
300
+ sum += parseInt(s[i] ?? "", 10) * (weights[i] ?? 0);
301
+ }
302
+ return code[sum % 11] === s[17];
303
+ }
304
+
305
+ // IPv4 reserved / non-public ranges. Returns true for addresses that should
306
+ // NOT be flagged as PII (loopback, private, link-local, TEST-NET, multicast,
307
+ // reserved, CGN, benchmarking). Used by pii-ipv4-public to keep only public IPs.
308
+ export function isReservedIpv4(ip: string): boolean {
309
+ const octets = ip.split(".");
310
+ // Require exactly 4 octets of 1–3 digits each, so partial parses
311
+ // (e.g. "1a" → 1 via parseInt) are treated as malformed, not public.
312
+ if (octets.length !== 4 || octets.some((o) => !/^\d{1,3}$/.test(o))) {
313
+ return true;
314
+ }
315
+ const parts = octets.map((o) => parseInt(o, 10));
316
+ if (parts.some((p) => p > 255)) {
317
+ return true;
318
+ }
319
+ const a = parts[0] ?? 0;
320
+ const b = parts[1] ?? 0;
321
+ const c = parts[2] ?? 0;
322
+ if (a === 0 || a === 10) return true;
323
+ if (a === 100 && b >= 64 && b <= 127) return true; // CGN 100.64.0.0/10
324
+ if (a === 127) return true; // loopback
325
+ if (a === 169 && b === 254) return true; // link-local
326
+ if (a === 172 && b >= 16 && b <= 31) return true; // private
327
+ if (a === 192 && b === 0 && c === 0) return true; // IETF protocol assignments
328
+ if (a === 192 && b === 0 && c === 2) return true; // TEST-NET-1
329
+ if (a === 192 && b === 88 && c === 99) return true; // 6to4 relay anycast (deprecated)
330
+ if (a === 192 && b === 168) return true; // private
331
+ if (a === 198 && (b === 18 || b === 19)) return true; // benchmark
332
+ if (a === 198 && b === 51 && c === 100) return true; // TEST-NET-2
333
+ if (a === 203 && b === 0 && c === 113) return true; // TEST-NET-3
334
+ if (a >= 224) return true; // multicast + reserved
335
+ return false;
336
+ }
337
+
338
+ // IPv6 reserved / non-public ranges. Returns true for addresses that should
339
+ // NOT be flagged as PII (loopback, unspecified, link-local, unique-local,
340
+ // multicast, documentation). Properly handles both compressed (::) and
341
+ // fully-expanded (0:0:0:0:0:0:0:1) forms. Used by pii-ipv6.
342
+ // Each group must be 1–4 hex digits; anything else is malformed.
343
+ const isHexGroup = (g: string): boolean => /^[0-9a-f]{1,4}$/.test(g);
344
+ export function isReservedIpv6(ip: string): boolean {
345
+ const lower = ip.toLowerCase();
346
+
347
+ // Multiple :: is invalid — treat as reserved.
348
+ const halves = lower.split("::");
349
+ if (halves.length > 2) return true;
350
+
351
+ // Split and expand :: notation into zero groups.
352
+ let groups: number[];
353
+ if (halves.length === 1) {
354
+ const raw = lower.split(":");
355
+ if (raw.some((g) => !isHexGroup(g))) return true;
356
+ groups = raw.map((g) => Number.parseInt(g, 16));
357
+ } else {
358
+ const leftRaw = halves[0] ? halves[0].split(":") : [];
359
+ const rightRaw = halves[1] ? halves[1].split(":") : [];
360
+ if (
361
+ leftRaw.some((g) => !isHexGroup(g)) ||
362
+ rightRaw.some((g) => !isHexGroup(g))
363
+ ) {
364
+ return true;
365
+ }
366
+ // Too many groups to fit in 128 bits — malformed. A "::" that compresses
367
+ // zero groups (left + right === 8) is also invalid per RFC 4291 §2.2.
368
+ if (leftRaw.length + rightRaw.length >= 8) return true;
369
+ const left = leftRaw.map((g) => Number.parseInt(g, 16));
370
+ const right = rightRaw.map((g) => Number.parseInt(g, 16));
371
+ const zeros = Array(8 - left.length - right.length).fill(0);
372
+ groups = [...left, ...zeros, ...right];
373
+ }
374
+
375
+ if (groups.length !== 8) return true; // malformed — treat as reserved
376
+
377
+ // Unspecified (::)
378
+ if (groups.every((g) => g === 0)) return true;
379
+ // Loopback (::1)
380
+ if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1) return true;
381
+ // Link-local fe80::/10
382
+ if ((groups[0] ?? 0) >= 0xfe80 && (groups[0] ?? 0) <= 0xfebf) return true;
383
+ // Unique-local fc00::/7
384
+ if (((groups[0] ?? 0) & 0xfe00) === 0xfc00) return true;
385
+ // Multicast ff00::/8
386
+ if (((groups[0] ?? 0) & 0xff00) === 0xff00) return true;
387
+ // Documentation 2001:db8::/32
388
+ if ((groups[0] ?? 0) === 0x2001 && (groups[1] ?? 0) === 0x0db8) return true;
389
+
390
+ return false;
391
+ }
392
+
393
+ // ── Validator registry ───────────────────────────────────────────────────────
394
+ // Validators are code (checksum algorithms), not data. They live here and are
395
+ // referenced by name from the JSON config. User-defined rules can use any of
396
+ // these validators or omit `validate` entirely.
397
+
398
+ // A Japanese telephone number: ten digits, or eleven for a mobile. The pattern
399
+ // alone also matched `01-02-2024`, which is a date, and `0000 0000 0000`, which
400
+ // is an identifier. Freephone prefixes are excluded — 0120 and 0800 belong to a
401
+ // business and are printed to be dialled.
402
+ export function validateJapanesePhone(input: string): boolean {
403
+ const digits = input.replace(/\D/g, "");
404
+ if (!/^0\d{8,10}$/.test(digits)) return false;
405
+ if (digits.length !== 10 && digits.length !== 11) return false;
406
+ if (/^0(?:120|800)/.test(digits)) return false;
407
+ return true;
408
+ }
409
+
410
+ const VALIDATORS: Readonly<Record<string, (str: string) => boolean>> = {
411
+ luhn: isRealCardNumber,
412
+ "aws-key": isRealAwsKey,
413
+ "mynumber-jp": validateMyNumber,
414
+ "phone-jp": validateJapanesePhone,
415
+ "nir-fr": validateFrenchNIR,
416
+ "codice-fiscale-it": validateCodiceFiscale,
417
+ "steuer-id-de": validateGermanIdNr,
418
+ "dni-nie-es": validateSpanishNIF,
419
+ "rrn-kr": validateKoreanRRN,
420
+ "brn-kr": validateKoreanBRN,
421
+ "resident-id-cn": validateChineseID,
422
+ "public-ipv4": (ip: string) => !isReservedIpv4(ip),
423
+ "public-ipv6": (ip: string) => !isReservedIpv6(ip),
424
+ };
425
+
426
+ // The names a config file may put in `validate`. Exported so the documents can
427
+ // be held to the same list: `phone-jp` was added to the registry and named in
428
+ // neither document, so a user writing a rule could not know it existed.
429
+ export const VALIDATOR_NAMES: readonly string[] = Object.keys(VALIDATORS);
430
+
431
+ export function getValidator(
432
+ name: string,
433
+ ): ((str: string) => boolean) | undefined {
434
+ return VALIDATORS[name];
435
+ }