@doccloak/core 0.8.0 → 0.9.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/CHANGELOG.md +108 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +6 -3
- package/dist/model-loader.d.ts +45 -2
- package/dist/model-loader.js +99 -5
- package/dist/providers/bardsai.d.ts +12 -0
- package/dist/providers/bardsai.js +35 -3
- package/dist/providers/gliner.d.ts +12 -0
- package/dist/providers/gliner.js +36 -4
- package/dist/regex/rules.data.js +186 -0
- package/dist/regex/validators.d.ts +7 -0
- package/dist/regex/validators.js +95 -4
- package/dist/restore-tokens.d.ts +67 -0
- package/dist/restore-tokens.js +120 -0
- package/dist/session.d.ts +89 -14
- package/dist/session.js +270 -37
- package/dist/surrogates.d.ts +82 -0
- package/dist/surrogates.js +1510 -0
- package/dist/types.d.ts +1 -1
- package/dist/types.js +4 -0
- package/package.json +7 -1
- package/rules/gb.json +17 -0
- package/rules/schema.json +2 -0
- package/rules/universal.json +169 -0
package/dist/regex/rules.data.js
CHANGED
|
@@ -275,6 +275,175 @@ export const RULES_DATA = [
|
|
|
275
275
|
"75008 Paris"
|
|
276
276
|
],
|
|
277
277
|
"falsePositiveNotes": "May match non-address number+word sequences"
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
"id": "regex:universal:aws_access_key",
|
|
281
|
+
"entityType": "API_KEY",
|
|
282
|
+
"pattern": "\\b(?:AKIA|ASIA|ABIA|ACCA|AGPA|AIDA|AIPA|ANPA|ANVA|AROA)[0-9A-Z]{16}\\b",
|
|
283
|
+
"flags": "g",
|
|
284
|
+
"confidence": 0.95,
|
|
285
|
+
"domains": [
|
|
286
|
+
"technical"
|
|
287
|
+
],
|
|
288
|
+
"description": "AWS access key ID (AKIA/ASIA + 16 uppercase alphanumeric)",
|
|
289
|
+
"examples": [
|
|
290
|
+
"AKIAIOSFODNN7EXAMPLE",
|
|
291
|
+
"ASIAJEXAMPLEKEY12345"
|
|
292
|
+
]
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
"id": "regex:universal:github_token",
|
|
296
|
+
"entityType": "API_KEY",
|
|
297
|
+
"pattern": "\\b(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9]{22}_[A-Za-z0-9]{59})\\b",
|
|
298
|
+
"flags": "g",
|
|
299
|
+
"confidence": 0.95,
|
|
300
|
+
"domains": [
|
|
301
|
+
"technical"
|
|
302
|
+
],
|
|
303
|
+
"description": "GitHub token (classic ghp_/gho_/ghu_/ghs_/ghr_ or fine-grained github_pat_)",
|
|
304
|
+
"examples": [
|
|
305
|
+
"ghp_16C7e42F292c6912E7710c838347Ae178B4a",
|
|
306
|
+
"github_pat_11ABCDEFG0123456789abc_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456"
|
|
307
|
+
]
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
"id": "regex:universal:slack_token",
|
|
311
|
+
"entityType": "API_KEY",
|
|
312
|
+
"pattern": "\\bxox[abprs]-[A-Za-z0-9-]{10,64}\\b",
|
|
313
|
+
"flags": "g",
|
|
314
|
+
"confidence": 0.95,
|
|
315
|
+
"domains": [
|
|
316
|
+
"technical"
|
|
317
|
+
],
|
|
318
|
+
"description": "Slack token (xoxb- bot, xoxp- user, xoxa-/xoxr-/xoxs- variants)",
|
|
319
|
+
"examples": [
|
|
320
|
+
"xoxb-NotARealBotToken-DocCloakTestFixture",
|
|
321
|
+
"xoxp-FakeUserToken-DocCloakTestFixture00"
|
|
322
|
+
]
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
"id": "regex:universal:openai_api_key",
|
|
326
|
+
"entityType": "API_KEY",
|
|
327
|
+
"pattern": "\\bsk-(?:(?:proj|svcacct|admin)-[A-Za-z0-9_-]{40,}|[A-Za-z0-9]{40,})\\b",
|
|
328
|
+
"flags": "g",
|
|
329
|
+
"confidence": 0.92,
|
|
330
|
+
"domains": [
|
|
331
|
+
"technical"
|
|
332
|
+
],
|
|
333
|
+
"description": "OpenAI API key (legacy sk- + 40+ alphanumeric, or sk-proj-/sk-svcacct-/sk-admin- project keys)",
|
|
334
|
+
"examples": [
|
|
335
|
+
"sk-proj-AbCdEfGhIjKlMnOpQrStUvWxYz0123456789AbCdEfGh",
|
|
336
|
+
"sk-AbCdEfGhIjKlMnOpQrStUvWxYz0123456789AbCdEfGhIjKl"
|
|
337
|
+
],
|
|
338
|
+
"falsePositiveNotes": "Anthropic sk-ant- keys never reach the alphanumeric-only branch (hyphen after ant stops it); the dedicated anthropic rule wins overlap resolution via its longer prefix"
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
"id": "regex:universal:anthropic_api_key",
|
|
342
|
+
"entityType": "API_KEY",
|
|
343
|
+
"pattern": "\\bsk-ant-[A-Za-z0-9_-]{24,}\\b",
|
|
344
|
+
"flags": "g",
|
|
345
|
+
"confidence": 0.95,
|
|
346
|
+
"domains": [
|
|
347
|
+
"technical"
|
|
348
|
+
],
|
|
349
|
+
"description": "Anthropic API key (sk-ant- prefix)",
|
|
350
|
+
"examples": [
|
|
351
|
+
"sk-ant-api03-AbCdEfGhIjKlMnOpQrStUvWxYz0123456789-AbCdEfGh"
|
|
352
|
+
]
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
"id": "regex:universal:google_api_key",
|
|
356
|
+
"entityType": "API_KEY",
|
|
357
|
+
"pattern": "\\bAIza[0-9A-Za-z_-]{35}\\b",
|
|
358
|
+
"flags": "g",
|
|
359
|
+
"confidence": 0.95,
|
|
360
|
+
"domains": [
|
|
361
|
+
"technical"
|
|
362
|
+
],
|
|
363
|
+
"description": "Google API key (AIza + 35 characters)",
|
|
364
|
+
"examples": [
|
|
365
|
+
"AIzaSyD-9tSrke72PouQMnMX-a7eZSW0jkFMBWY"
|
|
366
|
+
]
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
"id": "regex:universal:private_key_block",
|
|
370
|
+
"entityType": "SECRET",
|
|
371
|
+
"pattern": "-----BEGIN (?:[A-Z]+ )*PRIVATE KEY(?: BLOCK)?-----[\\s\\S]*?-----END (?:[A-Z]+ )*PRIVATE KEY(?: BLOCK)?-----",
|
|
372
|
+
"flags": "g",
|
|
373
|
+
"confidence": 0.99,
|
|
374
|
+
"domains": [
|
|
375
|
+
"technical"
|
|
376
|
+
],
|
|
377
|
+
"description": "PEM private key block (RSA/EC/DSA/OPENSSH/ENCRYPTED/PGP), including SSH private keys",
|
|
378
|
+
"examples": [
|
|
379
|
+
"-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA0Z3VS5JJcds3xfn\n-----END RSA PRIVATE KEY-----",
|
|
380
|
+
"-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmU\n-----END OPENSSH PRIVATE KEY-----"
|
|
381
|
+
]
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
"id": "regex:universal:jwt",
|
|
385
|
+
"entityType": "SECRET",
|
|
386
|
+
"pattern": "\\beyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\b",
|
|
387
|
+
"flags": "g",
|
|
388
|
+
"confidence": 0.95,
|
|
389
|
+
"domains": [
|
|
390
|
+
"technical"
|
|
391
|
+
],
|
|
392
|
+
"description": "JSON Web Token (three base64url segments, header starts with eyJ = base64 of '{\")",
|
|
393
|
+
"examples": [
|
|
394
|
+
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"
|
|
395
|
+
]
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
"id": "regex:universal:connection_string_creds",
|
|
399
|
+
"entityType": "SECRET",
|
|
400
|
+
"pattern": "\\b[a-zA-Z][a-zA-Z0-9+.-]{1,30}:\\/\\/[^\\s:@\\/]{1,64}:[^\\s@]{1,128}@[^\\s\"']{1,256}",
|
|
401
|
+
"flags": "g",
|
|
402
|
+
"confidence": 0.9,
|
|
403
|
+
"domains": [
|
|
404
|
+
"technical"
|
|
405
|
+
],
|
|
406
|
+
"description": "Connection string with inline credentials (scheme://user:password@host)",
|
|
407
|
+
"examples": [
|
|
408
|
+
"postgres://admin:S3cr3tPass@db.example.com:5432/mydb",
|
|
409
|
+
"mongodb+srv://appuser:hunter2pass@cluster0.abc.mongodb.net/prod"
|
|
410
|
+
],
|
|
411
|
+
"falsePositiveNotes": "Plain URLs without a user:password pair never match (the colon between user and password is required before the @)"
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
"id": "regex:universal:secret_assignment",
|
|
415
|
+
"entityType": "SECRET",
|
|
416
|
+
"pattern": "\\b[a-zA-Z0-9_-]{0,32}?(?:api[_-]?key|apikey|secret|token|passwd|password|credential|auth|access[_-]?key|client[_-]?secret|private[_-]?key|signing[_-]?key)[a-zA-Z0-9_-]{0,32}[\"']?\\s*[:=]>?\\s*[\"']?[A-Za-z0-9+\\/_=-]{16,}",
|
|
417
|
+
"flags": "gi",
|
|
418
|
+
"confidence": 0.75,
|
|
419
|
+
"domains": [
|
|
420
|
+
"technical"
|
|
421
|
+
],
|
|
422
|
+
"description": "Secret-ish keyword assignment (api_key = ..., password: ..., aws_secret_access_key: ...) with a 16+ char value; the value is Shannon-entropy gated by the secretAssignment validator",
|
|
423
|
+
"examples": [
|
|
424
|
+
"api_key = \"9fQz3kX7Lm2Pw8Rt5Vy1Jh6Nd4Bg0Cs\"",
|
|
425
|
+
"aws_secret_access_key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
|
426
|
+
"session_token=a3f8c2d914b7e605d2c8a1f4b9e7d305"
|
|
427
|
+
],
|
|
428
|
+
"falsePositiveNotes": "The keyword and separator are part of the match (lookbehind is banned for new rules), so the config key name is redacted along with the value. Low-entropy placeholder values (your_api_key_here, changeme) are rejected by the validator.",
|
|
429
|
+
"validate": "secretAssignment"
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
"id": "regex:universal:high_entropy_token",
|
|
433
|
+
"entityType": "SECRET",
|
|
434
|
+
"pattern": "\\b[A-Za-z0-9][A-Za-z0-9+\\/]{39,}={0,2}",
|
|
435
|
+
"flags": "g",
|
|
436
|
+
"confidence": 0.6,
|
|
437
|
+
"domains": [
|
|
438
|
+
"technical"
|
|
439
|
+
],
|
|
440
|
+
"description": "Bare high-entropy base64/alphanumeric token (40+ chars); the highEntropyToken validator requires mixed character classes and Shannon entropy >= 4.5 bits/char",
|
|
441
|
+
"examples": [
|
|
442
|
+
"Zq7PmX2vR9kLtY4wNb8QcJ3fHd6GsA1uEi5oT0xVrKp",
|
|
443
|
+
"kM3nP8qR2sT7uV1wX5yZ9aB4cD6eF0gH1jK2lM3nP4qR5sT6"
|
|
444
|
+
],
|
|
445
|
+
"falsePositiveNotes": "Low confidence by design. Git SHAs and UUID fragments are single-case hex (rejected by the class-mix check); base64 data blobs exceed the 256 char cap; identifiers and prose fall below the entropy threshold.",
|
|
446
|
+
"validate": "highEntropyToken"
|
|
278
447
|
}
|
|
279
448
|
]
|
|
280
449
|
},
|
|
@@ -469,6 +638,23 @@ export const RULES_DATA = [
|
|
|
469
638
|
"Tesco Ltd",
|
|
470
639
|
"Deloitte LLP"
|
|
471
640
|
]
|
|
641
|
+
},
|
|
642
|
+
{
|
|
643
|
+
"id": "regex:gb:driving_licence",
|
|
644
|
+
"entityType": "SSN",
|
|
645
|
+
"pattern": "\\b[A-Z9]{5}\\s?\\d{6}\\s?[A-Z9]{2}\\d[A-Z]{2}\\b",
|
|
646
|
+
"flags": "g",
|
|
647
|
+
"confidence": 0.8,
|
|
648
|
+
"domains": [
|
|
649
|
+
"identity"
|
|
650
|
+
],
|
|
651
|
+
"description": "UK driving licence number (16 chars: surname block, encoded date of birth, initials, check chars)",
|
|
652
|
+
"examples": [
|
|
653
|
+
"MORGA657054SM9IJ",
|
|
654
|
+
"SMITH 751125 JM9AB"
|
|
655
|
+
],
|
|
656
|
+
"falsePositiveNotes": "Best-effort: other 16-char uppercase codes can match the shape; the validator checks the embedded date-of-birth digit block",
|
|
657
|
+
"validate": "ukDrivingLicence"
|
|
472
658
|
}
|
|
473
659
|
]
|
|
474
660
|
},
|
|
@@ -10,6 +10,13 @@
|
|
|
10
10
|
* luhn_check -> luhn, nir_check -> nir, ipv4_check -> ipOctets.
|
|
11
11
|
*/
|
|
12
12
|
export type RuleValidator = (match: string) => boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Shannon entropy of a string in bits per character. Exported for tests
|
|
15
|
+
* and threshold tuning; the secrets-tier validators below gate the
|
|
16
|
+
* generic candidate rules on it (detect-secrets / Prompt Armour
|
|
17
|
+
* precedent). Named patterns (AKIA..., ghp_...) need no entropy gate.
|
|
18
|
+
*/
|
|
19
|
+
export declare function shannonEntropy(value: string): number;
|
|
13
20
|
/**
|
|
14
21
|
* The registry. Keys are the names allowed in the `validate` field of
|
|
15
22
|
* rules/*.json; loader.ts throws at module init on any unknown name.
|
package/dist/regex/validators.js
CHANGED
|
@@ -56,15 +56,20 @@ function ipOctets(ip) {
|
|
|
56
56
|
return n >= 0 && n <= 255;
|
|
57
57
|
});
|
|
58
58
|
}
|
|
59
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* UK National Insurance Number structural rules (HMRC prefix spec):
|
|
61
|
+
* first letter is never D, F, I, Q, U, V; second letter is never
|
|
62
|
+
* D, F, I, O, Q, U, V; the prefixes BG, GB, NK, KN, TN, NT, ZZ are
|
|
63
|
+
* never allocated. T100 fixed the second-letter class, which was
|
|
64
|
+
* missing V (the Python twin in DocCloak.Cli validators.py still has
|
|
65
|
+
* the pre-fix class until the packs are re-vendored there).
|
|
66
|
+
*/
|
|
60
67
|
function nino(match) {
|
|
61
68
|
const clean = match.replace(/\s/g, '').toUpperCase();
|
|
62
|
-
// NINO cannot start with D, F, I, Q, U, V; second letter cannot be D, F, I, O, Q, U, V
|
|
63
69
|
if (/^[DFIQUV]/.test(clean))
|
|
64
70
|
return false;
|
|
65
|
-
if (/^.[
|
|
71
|
+
if (/^.[DFIOQUV]/.test(clean))
|
|
66
72
|
return false;
|
|
67
|
-
// Cannot be BG, GB, NK, KN, TN, NT, ZZ prefix
|
|
68
73
|
if (/^(?:BG|GB|NK|KN|TN|NT|ZZ)/.test(clean))
|
|
69
74
|
return false;
|
|
70
75
|
return true;
|
|
@@ -85,6 +90,29 @@ function nhs(match) {
|
|
|
85
90
|
return false; // invalid
|
|
86
91
|
return checkDigit === parseInt(digits[9], 10);
|
|
87
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* UK driving licence driver number (T100, best-effort): after stripping
|
|
95
|
+
* spaces the 16-char code carries a 6-digit encoded date of birth at
|
|
96
|
+
* positions 6-11 (decade digit, month 01-12 or 51-62 for women, day
|
|
97
|
+
* 01-31, year-in-decade digit). There is no public checksum for the
|
|
98
|
+
* final check characters, so only the date block is validated.
|
|
99
|
+
*/
|
|
100
|
+
function ukDrivingLicence(match) {
|
|
101
|
+
const clean = match.replace(/\s/g, '').toUpperCase();
|
|
102
|
+
if (clean.length !== 16)
|
|
103
|
+
return false;
|
|
104
|
+
const dob = clean.slice(5, 11);
|
|
105
|
+
if (!/^\d{6}$/.test(dob))
|
|
106
|
+
return false;
|
|
107
|
+
const rawMonth = parseInt(dob.slice(1, 3), 10);
|
|
108
|
+
const month = rawMonth > 50 ? rawMonth - 50 : rawMonth;
|
|
109
|
+
if (month < 1 || month > 12)
|
|
110
|
+
return false;
|
|
111
|
+
const day = parseInt(dob.slice(3, 5), 10);
|
|
112
|
+
if (day < 1 || day > 31)
|
|
113
|
+
return false;
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
88
116
|
/** Polish PESEL weighted checksum. */
|
|
89
117
|
function pesel(match) {
|
|
90
118
|
const digits = match.replace(/\D/g, '');
|
|
@@ -456,6 +484,66 @@ function pps(match) {
|
|
|
456
484
|
const expected = remainder === 0 ? 'W' : String.fromCharCode(64 + remainder);
|
|
457
485
|
return checkChar === expected;
|
|
458
486
|
}
|
|
487
|
+
/**
|
|
488
|
+
* Shannon entropy of a string in bits per character. Exported for tests
|
|
489
|
+
* and threshold tuning; the secrets-tier validators below gate the
|
|
490
|
+
* generic candidate rules on it (detect-secrets / Prompt Armour
|
|
491
|
+
* precedent). Named patterns (AKIA..., ghp_...) need no entropy gate.
|
|
492
|
+
*/
|
|
493
|
+
export function shannonEntropy(value) {
|
|
494
|
+
if (value.length === 0)
|
|
495
|
+
return 0;
|
|
496
|
+
const counts = new Map();
|
|
497
|
+
for (const ch of value)
|
|
498
|
+
counts.set(ch, (counts.get(ch) ?? 0) + 1);
|
|
499
|
+
let entropy = 0;
|
|
500
|
+
for (const count of counts.values()) {
|
|
501
|
+
const p = count / value.length;
|
|
502
|
+
entropy -= p * Math.log2(p);
|
|
503
|
+
}
|
|
504
|
+
return entropy;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Generic "keyword = value" secret assignment. The rule includes the
|
|
508
|
+
* keyword and separator in the match (lookbehind is banned for new
|
|
509
|
+
* rules), so extract the value: everything after the first ':' or '=',
|
|
510
|
+
* with quotes/whitespace and base64 '=' padding stripped. Gate it on
|
|
511
|
+
* Shannon entropy: pure-hex values pass at >= 3.0 bits/char (random hex
|
|
512
|
+
* tops out at 4.0), anything else needs >= 4.0. Pure-digit values are
|
|
513
|
+
* rejected outright (IDs and phone numbers, not credentials).
|
|
514
|
+
* Thresholds tuned in tests/regex/secrets.test.ts.
|
|
515
|
+
*/
|
|
516
|
+
function secretAssignment(match) {
|
|
517
|
+
const sep = match.search(/[:=]/);
|
|
518
|
+
if (sep === -1)
|
|
519
|
+
return false;
|
|
520
|
+
const value = match.slice(sep + 1).replace(/^[>\s"']+/, '').replace(/=+$/, '');
|
|
521
|
+
if (value.length < 16)
|
|
522
|
+
return false;
|
|
523
|
+
if (/^\d+$/.test(value))
|
|
524
|
+
return false;
|
|
525
|
+
if (/^[0-9a-f]+$/i.test(value))
|
|
526
|
+
return shannonEntropy(value) >= 3.0;
|
|
527
|
+
return shannonEntropy(value) >= 4.0;
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Bare high-entropy token gate for the generic base64/alphanumeric
|
|
531
|
+
* candidate rule. Requires the shape of machine-generated key material:
|
|
532
|
+
* 40-256 chars after stripping '=' padding (longer runs are data blobs
|
|
533
|
+
* like base64 images, not credentials), a mix of lowercase, uppercase,
|
|
534
|
+
* and digits (rejects git SHAs and UUIDs, which are single-case hex),
|
|
535
|
+
* and Shannon entropy >= 4.5 bits/char (rejects identifiers and prose;
|
|
536
|
+
* random 40+ char base64 sits near 4.8). Thresholds tuned in
|
|
537
|
+
* tests/regex/secrets.test.ts.
|
|
538
|
+
*/
|
|
539
|
+
function highEntropyToken(match) {
|
|
540
|
+
const value = match.replace(/=+$/, '');
|
|
541
|
+
if (value.length < 40 || value.length > 256)
|
|
542
|
+
return false;
|
|
543
|
+
if (!/[a-z]/.test(value) || !/[A-Z]/.test(value) || !/\d/.test(value))
|
|
544
|
+
return false;
|
|
545
|
+
return shannonEntropy(value) >= 4.5;
|
|
546
|
+
}
|
|
459
547
|
/**
|
|
460
548
|
* The registry. Keys are the names allowed in the `validate` field of
|
|
461
549
|
* rules/*.json; loader.ts throws at module init on any unknown name.
|
|
@@ -471,6 +559,7 @@ export const VALIDATORS = {
|
|
|
471
559
|
dni,
|
|
472
560
|
fodselsnummer,
|
|
473
561
|
hetu,
|
|
562
|
+
highEntropyToken,
|
|
474
563
|
ibanMod97,
|
|
475
564
|
ipOctets,
|
|
476
565
|
luhn,
|
|
@@ -487,6 +576,8 @@ export const VALIDATORS = {
|
|
|
487
576
|
pps,
|
|
488
577
|
regon,
|
|
489
578
|
samordningsnummer,
|
|
579
|
+
secretAssignment,
|
|
490
580
|
steuerId,
|
|
491
581
|
svnr,
|
|
582
|
+
ukDrivingLicence,
|
|
492
583
|
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tolerant restore-token matching (T098), extracted to a side-effect-free
|
|
3
|
+
* leaf module so the extension content bundle can import these VALUES
|
|
4
|
+
* without pulling in the session/surrogate machinery (see the T036 alias
|
|
5
|
+
* note in DocCloak.Extension/vite.content.config.ts).
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Bare typed token as an LLM writes it after dropping the brackets:
|
|
9
|
+
* STRICTLY uppercase segments ending in an underscore-digit run, on
|
|
10
|
+
* non-wordish boundaries (underscore excluded on both sides so an
|
|
11
|
+
* embedded "SOME_PERSON_1" identifier never yields a partial match).
|
|
12
|
+
* Case is deliberately NOT tolerated here: without brackets, only the
|
|
13
|
+
* exact uppercase shape is distinctive enough to touch ("person 1" in
|
|
14
|
+
* lowercase prose must stay prose). An immediately preceding '[' or '<'
|
|
15
|
+
* is also excluded: a dangling "[PERSON_1" (a token split mid-stream,
|
|
16
|
+
* possibly a truncated "[PERSON_11]") must never be partially restored -
|
|
17
|
+
* the complete bracketed form is the bracket scan's job.
|
|
18
|
+
* Global: callers reset lastIndex.
|
|
19
|
+
*/
|
|
20
|
+
export declare const BARE_TYPED_TOKEN_RE: RegExp;
|
|
21
|
+
/**
|
|
22
|
+
* Canonical comparison form of a placeholder candidate or map key:
|
|
23
|
+
* markdown emphasis stripped, edge junk (brackets, punctuation, space)
|
|
24
|
+
* stripped, lowercased, separator runs collapsed to single underscores.
|
|
25
|
+
* "[PERSON_1]", "[person 1]", "**[PERSON-1]**" and "PERSON_1" all yield
|
|
26
|
+
* "person_1". Returns null when nothing substantive remains.
|
|
27
|
+
*/
|
|
28
|
+
export declare function canonicalPlaceholderForm(candidate: string): string | null;
|
|
29
|
+
/**
|
|
30
|
+
* The type-family prefix of a canonical typed token ("person_1" ->
|
|
31
|
+
* "person", "credit_card_2" -> "credit_card"), or null for canonical
|
|
32
|
+
* forms that are not typed-token shaped (renamed labels like "ceo").
|
|
33
|
+
*/
|
|
34
|
+
export declare function canonicalTypePrefix(canonical: string): string | null;
|
|
35
|
+
export interface TolerantTokenIndex {
|
|
36
|
+
/**
|
|
37
|
+
* Canonical form -> the ONE exact map key it identifies. Forms claimed
|
|
38
|
+
* by two different keys are ambiguous and absent by construction, so a
|
|
39
|
+
* lookup hit is always safe to restore.
|
|
40
|
+
*/
|
|
41
|
+
byCanonical: ReadonlyMap<string, string>;
|
|
42
|
+
/**
|
|
43
|
+
* Canonical type prefixes of the typed keys in the map ("person",
|
|
44
|
+
* "redacted"). A candidate of a KNOWN family that resolves to no key is
|
|
45
|
+
* a DocCloak token the model mangled beyond recovery - callers may
|
|
46
|
+
* surface it as could-not-restore. Unknown families (site prose like
|
|
47
|
+
* "[citation_1]") are never DocCloak's to flag.
|
|
48
|
+
*/
|
|
49
|
+
typePrefixes: ReadonlySet<string>;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Build the tolerant lookup for a map's keys. Only token-shaped keys
|
|
53
|
+
* participate: literal surrogate keys are natural-language text whose
|
|
54
|
+
* case/spacing variants collide with genuine prose, so they keep the
|
|
55
|
+
* exact-literal path only. Canonical collisions between two different
|
|
56
|
+
* keys are dropped entirely (never guess between [PERSON_1] and a
|
|
57
|
+
* renamed "[Person 1]"); exact matching still restores both.
|
|
58
|
+
*/
|
|
59
|
+
export declare function buildTolerantTokenIndex(keys: Iterable<string>): TolerantTokenIndex;
|
|
60
|
+
/**
|
|
61
|
+
* Resolve one mangled candidate to its exact map key, or null when the
|
|
62
|
+
* candidate identifies no key or more than one (ambiguity never
|
|
63
|
+
* restores). Candidates come from bracketed/legacy token scans or from
|
|
64
|
+
* BARE_TYPED_TOKEN_RE; arbitrary prose canonicalizes to forms no key
|
|
65
|
+
* owns and falls through to null.
|
|
66
|
+
*/
|
|
67
|
+
export declare function resolveMangledToken(candidate: string, index: TolerantTokenIndex): string | null;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tolerant restore-token matching (T098), extracted to a side-effect-free
|
|
3
|
+
* leaf module so the extension content bundle can import these VALUES
|
|
4
|
+
* without pulling in the session/surrogate machinery (see the T036 alias
|
|
5
|
+
* note in DocCloak.Extension/vite.content.config.ts).
|
|
6
|
+
*/
|
|
7
|
+
// ── Tolerant restore matching (T098) ───────────────────────
|
|
8
|
+
//
|
|
9
|
+
// LLM replies mangle placeholders: case changes ("[person_1]"), separator
|
|
10
|
+
// swaps ("[PERSON 1]", "[PERSON-1]"), markdown wrapping ("**[PERSON_1]**",
|
|
11
|
+
// "[`PERSON_1`]", "`PERSON_1`"), dropped brackets ("PERSON_1") and stray
|
|
12
|
+
// edge punctuation ("[PERSON_1.]"). The tolerant matcher reduces mapping
|
|
13
|
+
// keys and reply candidates to one canonical form and restores only on an
|
|
14
|
+
// UNAMBIGUOUS canonical hit: a canonical form claimed by two different map
|
|
15
|
+
// keys is never matched, and a candidate that resolves to nothing is left
|
|
16
|
+
// exactly as the model wrote it. A wrong restore silently corrupts the
|
|
17
|
+
// user's document; a missed restore is visible and recoverable.
|
|
18
|
+
/** A key shaped like one full token ([PERSON_1], <<REDACTED_1>>). */
|
|
19
|
+
const TOKEN_SHAPED_KEY_RE = /^(?:\[[^[\]\n]+\]|<<[^<>\n]+>>)$/;
|
|
20
|
+
/** Markdown emphasis characters LLMs wrap tokens in (bold/code/strike). */
|
|
21
|
+
const MARKDOWN_EMPHASIS_RE = /[*`~]/g;
|
|
22
|
+
/**
|
|
23
|
+
* Non-alphanumeric runs at either edge of a candidate: bracket/angle
|
|
24
|
+
* delimiters, stray punctuation and whitespace. Stripping edges (never
|
|
25
|
+
* the middle) keeps "[PERSON_1.]" and "PERSON_1" comparable while inner
|
|
26
|
+
* punctuation still distinguishes genuinely different labels.
|
|
27
|
+
*/
|
|
28
|
+
const EDGE_JUNK_RE = /^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu;
|
|
29
|
+
/** Separator runs unified to one '_': whitespace, underscores, hyphens. */
|
|
30
|
+
const SEPARATOR_RUN_RE = /[\s_-]+/g;
|
|
31
|
+
/**
|
|
32
|
+
* Bare typed token as an LLM writes it after dropping the brackets:
|
|
33
|
+
* STRICTLY uppercase segments ending in an underscore-digit run, on
|
|
34
|
+
* non-wordish boundaries (underscore excluded on both sides so an
|
|
35
|
+
* embedded "SOME_PERSON_1" identifier never yields a partial match).
|
|
36
|
+
* Case is deliberately NOT tolerated here: without brackets, only the
|
|
37
|
+
* exact uppercase shape is distinctive enough to touch ("person 1" in
|
|
38
|
+
* lowercase prose must stay prose). An immediately preceding '[' or '<'
|
|
39
|
+
* is also excluded: a dangling "[PERSON_1" (a token split mid-stream,
|
|
40
|
+
* possibly a truncated "[PERSON_11]") must never be partially restored -
|
|
41
|
+
* the complete bracketed form is the bracket scan's job.
|
|
42
|
+
* Global: callers reset lastIndex.
|
|
43
|
+
*/
|
|
44
|
+
export const BARE_TYPED_TOKEN_RE = /(?<![\p{L}\p{N}_[<])[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*_\d+(?![\p{L}\p{N}_])/gu;
|
|
45
|
+
/**
|
|
46
|
+
* Canonical comparison form of a placeholder candidate or map key:
|
|
47
|
+
* markdown emphasis stripped, edge junk (brackets, punctuation, space)
|
|
48
|
+
* stripped, lowercased, separator runs collapsed to single underscores.
|
|
49
|
+
* "[PERSON_1]", "[person 1]", "**[PERSON-1]**" and "PERSON_1" all yield
|
|
50
|
+
* "person_1". Returns null when nothing substantive remains.
|
|
51
|
+
*/
|
|
52
|
+
export function canonicalPlaceholderForm(candidate) {
|
|
53
|
+
const stripped = candidate
|
|
54
|
+
.replace(MARKDOWN_EMPHASIS_RE, '')
|
|
55
|
+
.replace(EDGE_JUNK_RE, '')
|
|
56
|
+
.toLowerCase();
|
|
57
|
+
if (stripped.length === 0)
|
|
58
|
+
return null;
|
|
59
|
+
const parts = stripped.split(SEPARATOR_RUN_RE).filter((p) => p.length > 0);
|
|
60
|
+
if (parts.length === 0)
|
|
61
|
+
return null;
|
|
62
|
+
return parts.join('_');
|
|
63
|
+
}
|
|
64
|
+
/** Canonical typed-token shape: type prefix + '_' + counter digits. */
|
|
65
|
+
const CANONICAL_TYPED_RE = /^([a-z][a-z0-9_]*)_\d+$/;
|
|
66
|
+
/**
|
|
67
|
+
* The type-family prefix of a canonical typed token ("person_1" ->
|
|
68
|
+
* "person", "credit_card_2" -> "credit_card"), or null for canonical
|
|
69
|
+
* forms that are not typed-token shaped (renamed labels like "ceo").
|
|
70
|
+
*/
|
|
71
|
+
export function canonicalTypePrefix(canonical) {
|
|
72
|
+
const match = CANONICAL_TYPED_RE.exec(canonical);
|
|
73
|
+
return match ? match[1] : null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Build the tolerant lookup for a map's keys. Only token-shaped keys
|
|
77
|
+
* participate: literal surrogate keys are natural-language text whose
|
|
78
|
+
* case/spacing variants collide with genuine prose, so they keep the
|
|
79
|
+
* exact-literal path only. Canonical collisions between two different
|
|
80
|
+
* keys are dropped entirely (never guess between [PERSON_1] and a
|
|
81
|
+
* renamed "[Person 1]"); exact matching still restores both.
|
|
82
|
+
*/
|
|
83
|
+
export function buildTolerantTokenIndex(keys) {
|
|
84
|
+
const byCanonical = new Map();
|
|
85
|
+
const ambiguous = new Set();
|
|
86
|
+
const typePrefixes = new Set();
|
|
87
|
+
for (const key of keys) {
|
|
88
|
+
if (!TOKEN_SHAPED_KEY_RE.test(key))
|
|
89
|
+
continue;
|
|
90
|
+
const canonical = canonicalPlaceholderForm(key);
|
|
91
|
+
if (canonical === null)
|
|
92
|
+
continue;
|
|
93
|
+
const prefix = canonicalTypePrefix(canonical);
|
|
94
|
+
if (prefix !== null)
|
|
95
|
+
typePrefixes.add(prefix);
|
|
96
|
+
if (ambiguous.has(canonical))
|
|
97
|
+
continue;
|
|
98
|
+
const existing = byCanonical.get(canonical);
|
|
99
|
+
if (existing !== undefined && existing !== key) {
|
|
100
|
+
byCanonical.delete(canonical);
|
|
101
|
+
ambiguous.add(canonical);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
byCanonical.set(canonical, key);
|
|
105
|
+
}
|
|
106
|
+
return { byCanonical, typePrefixes };
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Resolve one mangled candidate to its exact map key, or null when the
|
|
110
|
+
* candidate identifies no key or more than one (ambiguity never
|
|
111
|
+
* restores). Candidates come from bracketed/legacy token scans or from
|
|
112
|
+
* BARE_TYPED_TOKEN_RE; arbitrary prose canonicalizes to forms no key
|
|
113
|
+
* owns and falls through to null.
|
|
114
|
+
*/
|
|
115
|
+
export function resolveMangledToken(candidate, index) {
|
|
116
|
+
const canonical = canonicalPlaceholderForm(candidate);
|
|
117
|
+
if (canonical === null)
|
|
118
|
+
return null;
|
|
119
|
+
return index.byCanonical.get(canonical) ?? null;
|
|
120
|
+
}
|