switchroom 0.19.35 → 0.19.36

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 (29) hide show
  1. package/dist/cli/skill-validate-pretool.mjs +15 -2
  2. package/dist/cli/switchroom.js +672 -441
  3. package/dist/host-control/main.js +149 -3
  4. package/package.json +4 -2
  5. package/profiles/_base/start.sh.hbs +37 -12
  6. package/telegram-plugin/dist/gateway/gateway.js +311 -93
  7. package/telegram-plugin/format.ts +70 -15
  8. package/telegram-plugin/gateway/gateway.ts +22 -29
  9. package/telegram-plugin/gateway/ipc-server.ts +18 -15
  10. package/telegram-plugin/gateway/model-command.ts +34 -0
  11. package/telegram-plugin/gateway/outbound-send-path.ts +12 -1
  12. package/telegram-plugin/operator-events.ts +40 -16
  13. package/telegram-plugin/secret-detect/db-uri.ts +90 -0
  14. package/telegram-plugin/secret-detect/index.ts +24 -1
  15. package/telegram-plugin/secret-detect/inert-values.ts +147 -0
  16. package/telegram-plugin/secret-detect/kv-scanner.ts +108 -0
  17. package/telegram-plugin/secret-detect/patterns.ts +24 -4
  18. package/telegram-plugin/tests/format-consistency.test.ts +93 -0
  19. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +40 -2
  20. package/telegram-plugin/tests/ipc-server-validate-operator.test.ts +20 -11
  21. package/telegram-plugin/tests/outbound-send-path.test.ts +1 -1
  22. package/telegram-plugin/tests/secret-detect-cross-engine.test.ts +263 -0
  23. package/telegram-plugin/tests/secret-detect-write-path.test.ts +403 -0
  24. package/telegram-plugin/tests/turn-flush-safety.test.ts +2 -2
  25. package/vendor/hindsight-memory/scripts/lib/client.py +58 -0
  26. package/vendor/hindsight-memory/scripts/lib/secret_patterns.json +431 -0
  27. package/vendor/hindsight-memory/scripts/lib/secret_redact.py +563 -0
  28. package/vendor/hindsight-memory/scripts/lib/secret_redaction_vectors.json +397 -0
  29. package/vendor/hindsight-memory/scripts/tests/test_secret_redact.py +522 -0
@@ -13,6 +13,7 @@
13
13
  * `key=value` match), so the rewriter can preserve the `key=` prefix.
14
14
  */
15
15
  import { shannonEntropy } from './entropy.js'
16
+ import { isInertValue, stripTrailingPunctuation } from './inert-values.js'
16
17
 
17
18
  export interface RawHit {
18
19
  rule_id: string
@@ -31,6 +32,107 @@ const KV_RE = /\b([A-Za-z_][A-Za-z0-9_-]*(?:password|passwd|token|secret|key|api
31
32
 
32
33
  export const KV_ENTROPY_THRESHOLD = 4.0
33
34
 
35
+ // ─── Human-memorable passwords ────────────────────────────────────────
36
+ //
37
+ // Gap closed (2026-07 hindsight write-path audit): a password a human can
38
+ // remember — family names + a year, a two-word phrase with a digit — has
39
+ // Shannon entropy well under KV_ENTROPY_THRESHOLD, so `scanKeyValue` above
40
+ // skips it and it was stored in agent memory verbatim.
41
+ //
42
+ // Lowering KV_ENTROPY_THRESHOLD is NOT the fix: that threshold guards a
43
+ // wide LHS set (`key`, `token`, `secret`, `api_key`, and any identifier
44
+ // ending in them), where dropping the entropy floor would mask ordinary
45
+ // prose and config chatter wholesale.
46
+ //
47
+ // Instead this is a SEPARATE, much narrower rule:
48
+ //
49
+ // * the LHS must be the password family specifically — `password`,
50
+ // `passwd`, `passphrase`, `pwd` (optionally prefixed, e.g.
51
+ // `db_password`). Not `key`/`token`/`secret`.
52
+ // * the connector is `:`/`=` or the literal word `is` (so "the wifi
53
+ // password is <value>" is covered, which is how a human actually
54
+ // writes one down).
55
+ // * the value must LOOK like a chosen credential rather than a word:
56
+ // 8..64 bytes, no whitespace, and at least two distinct character
57
+ // classes of {lower, upper, digit, symbol}.
58
+ //
59
+ // The two-class gate is what keeps ordinary prose out — but ONLY once
60
+ // trailing punctuation has been stripped off the candidate value.
61
+ //
62
+ // #3982 review, BLOCKER 2: the value class is `[^\s"']`, so a sentence
63
+ // swallows its own terminator into the match. "The password is
64
+ // required." captured `required.` — lowercase letters PLUS a `.`, which
65
+ // is two character classes — and redacted as a credential. Same for
66
+ // `incorrect.`, `unchanged.`, and for a list comma in
67
+ // "password: required, minimum twelve characters". Sentence-final is the
68
+ // single most common position for those words, so the original comment
69
+ // here ("single-class lowercase and never match") was not just wrong, it
70
+ // was wrong about the common case: "Ken confirmed the password is
71
+ // unchanged." stored as "…the password is [REDACTED:memorable_password]",
72
+ // which INVERTS the meaning of the sentence it corrupts.
73
+ //
74
+ // `looksLikeMemorablePassword` therefore runs its length / distinct-char
75
+ // / character-class gates against the punctuation-stripped core, while
76
+ // the MASK still covers the whole captured value (a trailing `!` is more
77
+ // likely the last byte of the password than the end of a sentence).
78
+ //
79
+ // The deliberate trade that remains: a single-class credential such as an
80
+ // all-lowercase passphrase still slips through. That is the conservative
81
+ // side of the line — over-redacting prose corrupts stored conversation and
82
+ // is unrecoverable, whereas this rule's misses are the pre-existing
83
+ // behaviour, not a regression.
84
+ const MEMORABLE_PW_RE =
85
+ /\b([A-Za-z0-9_-]*(?:password|passwd|passphrase|pwd))\b\s*(?:[:=]\s*|\s+is\s+)(["']?)([^\s"']{8,64})\2/gi
86
+
87
+ export const MEMORABLE_PW_RULE_ID = 'memorable_password'
88
+
89
+ /** Minimum distinct character classes for a value to look chosen-by-a-human. */
90
+ export const MEMORABLE_PW_MIN_CLASSES = 2
91
+
92
+ function charClassCount(value: string): number {
93
+ let n = 0
94
+ if (/[a-z]/.test(value)) n++
95
+ if (/[A-Z]/.test(value)) n++
96
+ if (/[0-9]/.test(value)) n++
97
+ if (/[^A-Za-z0-9]/.test(value)) n++
98
+ return n
99
+ }
100
+
101
+ /** True when `value` looks like a human-chosen password, not prose. */
102
+ export function looksLikeMemorablePassword(value: string): boolean {
103
+ if (isInertValue(value)) return false
104
+ // Gate on the value WITHOUT the sentence punctuation it swallowed.
105
+ const core = stripTrailingPunctuation(value)
106
+ if (core.length < 8 || core.length > 64) return false
107
+ if (isInertValue(core)) return false
108
+ // A single repeated character is a mask, not a password.
109
+ if (new Set(core).size < 4) return false
110
+ return charClassCount(core) >= MEMORABLE_PW_MIN_CLASSES
111
+ }
112
+
113
+ export function scanMemorablePasswords(text: string): RawHit[] {
114
+ const hits: RawHit[] = []
115
+ MEMORABLE_PW_RE.lastIndex = 0
116
+ let m: RegExpExecArray | null
117
+ while ((m = MEMORABLE_PW_RE.exec(text)) !== null) {
118
+ const keyName = m[1]!
119
+ const value = m[3]
120
+ if (!value || !looksLikeMemorablePassword(value)) continue
121
+ const valueOffsetInMatch = m[0].indexOf(value, keyName.length)
122
+ if (valueOffsetInMatch < 0) continue
123
+ const start = m.index + valueOffsetInMatch
124
+ hits.push({
125
+ rule_id: MEMORABLE_PW_RULE_ID,
126
+ start,
127
+ end: start + value.length,
128
+ matched_text: value,
129
+ key_name: keyName,
130
+ confidence: 'ambiguous',
131
+ })
132
+ }
133
+ return hits
134
+ }
135
+
34
136
  export function scanKeyValue(text: string): RawHit[] {
35
137
  const hits: RawHit[] = []
36
138
  KV_RE.lastIndex = 0
@@ -38,6 +140,12 @@ export function scanKeyValue(text: string): RawHit[] {
38
140
  while ((m = KV_RE.exec(text)) !== null) {
39
141
  const [, keyName, value] = m
40
142
  if (!value) continue
143
+ // Placeholders / references are not credentials — see inert-values.ts.
144
+ // `ANTHROPIC_API_KEY: vault:anthropic/api_key` and
145
+ // `const API_KEY = process.env.ANTHROPIC_API_KEY` both land HERE, not
146
+ // on the ALL_CAPS `env_key_value` pattern, and both were being
147
+ // destroyed (#3982 review, MAJOR 5).
148
+ if (isInertValue(value)) continue
41
149
  // Shannon entropy gate — only flag values that actually look random.
42
150
  const h = shannonEntropy(value)
43
151
  if (h < KV_ENTROPY_THRESHOLD) continue
@@ -94,20 +94,40 @@ export const STRUCTURED_PATTERNS: PatternDef[] = [
94
94
  captureIndex: 2,
95
95
  slugHint: 'cli_flag',
96
96
  },
97
- // Authorization: Bearer token (form 1 — explicit Authorization header)
97
+ // Authorization: Bearer token (form 1 — explicit Authorization header).
98
+ //
99
+ // CASE-INSENSITIVE since #3982's review: HTTP/2 and HTTP/3 lowercase
100
+ // every header name on the wire, so `authorization: bearer <token>` is
101
+ // what an agent actually pastes out of a curl trace or a proxy log —
102
+ // and it sailed through both engines unmasked. RFC 9110 makes the
103
+ // header name case-insensitive and the auth SCHEME token
104
+ // case-insensitive too, so matching case-sensitively was simply wrong.
98
105
  {
99
106
  rule_id: 'bearer_auth_header',
100
- regex: /Authorization\s*[:=]\s*Bearer\s+([A-Za-z0-9._\-+=]+)/g,
107
+ regex: /Authorization\s*[:=]\s*Bearer\s+([A-Za-z0-9._\-+=]+)/gi,
101
108
  captureIndex: 1,
102
109
  slugHint: 'bearer_token',
103
110
  },
104
- // Bare "Bearer XYZ" (length-gated to cut false positives on the word "Bearer")
111
+ // Bare "Bearer XYZ" (length-gated to cut false positives on the word
112
+ // "Bearer"). The 18-char floor is what keeps the now case-insensitive
113
+ // match off prose like "the bearer token to use".
105
114
  {
106
115
  rule_id: 'bearer_loose',
107
- regex: /\bBearer\s+([A-Za-z0-9._\-+=]{18,})\b/g,
116
+ regex: /\bBearer\s+([A-Za-z0-9._\-+=]{18,})\b/gi,
108
117
  captureIndex: 1,
109
118
  slugHint: 'bearer_token',
110
119
  },
120
+ // Authorization: Basic <base64(user:password)>. Base64 is an encoding,
121
+ // not a cipher — a Basic header is a plaintext credential with extra
122
+ // steps, and it reached agent memory unmasked (#3982 review, MAJOR 6).
123
+ // Anchored on the header + scheme, so a bare base64 blob elsewhere in
124
+ // the text is untouched (that stays a documented gap).
125
+ {
126
+ rule_id: 'basic_auth_header',
127
+ regex: /Authorization\s*[:=]\s*Basic\s+([A-Za-z0-9+/=]{8,})/gi,
128
+ captureIndex: 1,
129
+ slugHint: 'basic_auth',
130
+ },
111
131
  // PEM private key block — single greedy capture, non-overlapping.
112
132
  {
113
133
  rule_id: 'pem_private_key',
@@ -334,4 +334,97 @@ describe('stripExcessBold', () => {
334
334
  const once = stripExcessBold(input)
335
335
  expect(stripExcessBold(once)).toBe(once)
336
336
  })
337
+
338
+ // ── Heading exemption in the GLOBAL (>30%) rule ──────────────────────────
339
+ // A bold-dense digest tripped the global ratio and lost EVERY bold marker,
340
+ // including its section headings, with no signal. Short standalone
341
+ // pseudo-heading blocks must now survive the global strip.
342
+
343
+ const boldDenseBody =
344
+ '**alpha** **bravo** **charlie** **delta** **echo** **foxtrot** **golf** ' +
345
+ '**hotel** **india** **juliet** **kilo** **lima** plus a short plain tail here.'
346
+
347
+ test('global strip keeps a short standalone bold heading, strips the rest', () => {
348
+ const input = `**Section One**\n\n${boldDenseBody}`
349
+ const out = stripExcessBold(input)
350
+ // Heading survives.
351
+ expect(out).toContain('**Section One**')
352
+ // Non-heading inline bold is flattened.
353
+ expect(out).toContain('alpha')
354
+ expect(out).not.toContain('**alpha**')
355
+ expect(out).not.toContain('**golf**')
356
+ })
357
+
358
+ test('global strip preserves EVERY heading in a multi-section digest', () => {
359
+ const input =
360
+ `**Overview**\n\n${boldDenseBody}\n\n` +
361
+ `**Next steps:**\n\n**one** **two** **three** **four** **five** **six** ` +
362
+ 'plus a plain closing clause long enough to matter here.'
363
+ const out = stripExcessBold(input)
364
+ expect(out).toContain('**Overview**')
365
+ expect(out).toContain('**Next steps:**')
366
+ expect(out).not.toContain('**one**')
367
+ expect(out).not.toContain('**six**')
368
+ })
369
+
370
+ test('regression: global strip with NO headings still fully strips', () => {
371
+ const input = `${boldDenseBody}`
372
+ const out = stripExcessBold(input)
373
+ expect(out).not.toContain('**')
374
+ expect(out).toContain('alpha')
375
+ })
376
+
377
+ test('regression: under-threshold message keeps all bold (incl. headings)', () => {
378
+ const input = `**Summary**\n\n${filler} The key fact is **42**.`
379
+ expect(stripExcessBold(input)).toBe(input)
380
+ })
381
+
382
+ test('48/49-char pseudo-heading boundary honoured under global strip', () => {
383
+ // Heading length is measured WITH the `**` markers. 44 inner chars → 48
384
+ // total (exempt); 45 inner chars → 49 total (stripped).
385
+ const heading48 = '**' + 'H'.repeat(44) + '**' // length 48
386
+ const heading49 = '**' + 'H'.repeat(45) + '**' // length 49
387
+ expect(heading48.length).toBe(48)
388
+ expect(heading49.length).toBe(49)
389
+
390
+ const out48 = stripExcessBold(`${heading48}\n\n${boldDenseBody}`)
391
+ expect(out48).toContain(heading48)
392
+
393
+ const out49 = stripExcessBold(`${heading49}\n\n${boldDenseBody}`)
394
+ expect(out49).not.toContain(heading49)
395
+ expect(out49).toContain('H'.repeat(45))
396
+ })
397
+
398
+ test('multi-line fully-bolded block is NOT mislabelled a heading (global)', () => {
399
+ // Two bolded lines in one block must be flattened, not exempted — the
400
+ // heading exemption is single-line only.
401
+ const input = `**First bold line here**\n**Second bold line here**\n\n${boldDenseBody}`
402
+ const out = stripExcessBold(input)
403
+ expect(out).not.toContain('**First bold line here**')
404
+ expect(out).toContain('First bold line here')
405
+ })
406
+
407
+ // ── Observability ────────────────────────────────────────────────────────
408
+ test('onStrip fires with rule=global + ratio when global rule strips', () => {
409
+ const calls: Array<{ rule: string; ratio: number }> = []
410
+ stripExcessBold(`**Section One**\n\n${boldDenseBody}`, (d) => calls.push(d))
411
+ expect(calls).toHaveLength(1)
412
+ expect(calls[0].rule).toBe('global')
413
+ expect(calls[0].ratio).toBeGreaterThan(0.3)
414
+ })
415
+
416
+ test('onStrip fires with rule=per-block when only a block is flattened', () => {
417
+ const calls: Array<{ rule: string; ratio: number }> = []
418
+ const input = `${filler}\n\n**This whole paragraph is bold.**\n**Every single line of it.**`
419
+ stripExcessBold(input, (d) => calls.push(d))
420
+ expect(calls).toHaveLength(1)
421
+ expect(calls[0].rule).toBe('per-block')
422
+ expect(calls[0].ratio).toBeLessThanOrEqual(0.3)
423
+ })
424
+
425
+ test('onStrip does NOT fire when nothing is stripped', () => {
426
+ const calls: Array<{ rule: string; ratio: number }> = []
427
+ stripExcessBold(`**Summary**\n\n${filler} The key fact is **42**.`, (d) => calls.push(d))
428
+ expect(calls).toHaveLength(0)
429
+ })
337
430
  })
@@ -21,6 +21,8 @@ import {
21
21
  classifyModelSwitchConfirmation,
22
22
  formatModelRelaunchDiagLog,
23
23
  resolveModelSwitchBootNotice,
24
+ resolveSessionModelResolutionTimeoutMs,
25
+ waitForSessionModelResolution,
24
26
  } from '../gateway/model-command.js'
25
27
 
26
28
  const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -147,6 +149,39 @@ describe('gateway: the live callback dispatcher routes every switch tap to the h
147
149
  })
148
150
 
149
151
  describe('gateway boot: session-model re-hydration + confirmation + alert relay', () => {
152
+ it('does not classify stale previous-boot state before the resolution barrier', async () => {
153
+ const staleLaunched = 'claude-opus-4-8'
154
+ const configured = 'claude-opus-4-8'
155
+ const reason = 'user: /model fable (session-only relaunch, menu)'
156
+ const resolved = await waitForSessionModelResolution({
157
+ barrierExists: () => false,
158
+ timeoutMs: 0,
159
+ })
160
+ const confirmation = resolved
161
+ ? classifyModelSwitchConfirmation({ reason, launched: staleLaunched, configured })
162
+ : null
163
+ expect(resolved).toBe(false)
164
+ expect(confirmation).toBeNull()
165
+ })
166
+
167
+ it('waits asynchronously until the resolution barrier appears', async () => {
168
+ let checks = 0
169
+ const resolved = await waitForSessionModelResolution({
170
+ barrierExists: () => ++checks >= 2,
171
+ timeoutMs: 1_000,
172
+ sleep: async () => {},
173
+ })
174
+ expect(resolved).toBe(true)
175
+ expect(checks).toBe(2)
176
+ })
177
+
178
+ it('uses a safe default for absent or invalid barrier timeout overrides', () => {
179
+ expect(resolveSessionModelResolutionTimeoutMs(undefined)).toBe(180_000)
180
+ expect(resolveSessionModelResolutionTimeoutMs('not-a-number')).toBe(180_000)
181
+ expect(resolveSessionModelResolutionTimeoutMs('-1')).toBe(180_000)
182
+ expect(resolveSessionModelResolutionTimeoutMs('2500')).toBe(2_500)
183
+ })
184
+
150
185
  it('re-hydrates the override from .active-session-model (launched !== configured)', () => {
151
186
  const idx = GATEWAY_SRC.indexOf("join(smAgentDir, '.active-session-model')")
152
187
  expect(idx).toBeGreaterThan(0)
@@ -295,9 +330,12 @@ describe('gateway boot: session-model re-hydration + confirmation + alert relay'
295
330
  })
296
331
 
297
332
  it('wires the pipeline into the boot rehydration (gateway calls classify → diag log → notice)', () => {
298
- const idx = GATEWAY_SRC.indexOf('const isApplyBoot = launched.length > 0')
333
+ const idx = GATEWAY_SRC.indexOf('const resolutionTimeoutMs = resolveSessionModelResolutionTimeoutMs(')
299
334
  expect(idx).toBeGreaterThan(0)
300
- const win = GATEWAY_SRC.slice(idx, idx + 4200)
335
+ const win = GATEWAY_SRC.slice(idx, idx + 7000)
336
+ expect(win).toContain('waitForSessionModelResolution({')
337
+ expect(win).toContain('if (!resolved) {')
338
+ expect(win).toContain('gw /model relaunch UNRESOLVED')
301
339
  expect(win).toContain('classifyModelSwitchConfirmation({')
302
340
  expect(win).toContain('formatModelRelaunchDiagLog')
303
341
  expect(win).toContain('if (confirmation != null)')
@@ -13,18 +13,13 @@
13
13
 
14
14
  import { describe, it, expect } from 'vitest'
15
15
  import { validateClientMessage } from '../gateway/ipc-server.js'
16
+ import { OPERATOR_EVENT_KINDS } from '../operator-events.js'
16
17
 
17
- const VALID_KINDS = [
18
- 'credentials-expired',
19
- 'credentials-invalid',
20
- 'credit-exhausted',
21
- 'quota-exhausted',
22
- 'rate-limited',
23
- 'agent-crashed',
24
- 'agent-restarted-unexpectedly',
25
- 'unknown-4xx',
26
- 'unknown-5xx',
27
- ]
18
+ // Drift-proof: iterate the CANONICAL taxonomy the validator now derives its
19
+ // allowlist from, not a hand-copied literal. A kind added to
20
+ // OPERATOR_EVENT_KINDS is automatically covered here; a validator that fails
21
+ // to accept any canonical kind fails this test.
22
+ const VALID_KINDS = OPERATOR_EVENT_KINDS
28
23
 
29
24
  function base() {
30
25
  return {
@@ -43,6 +38,20 @@ describe('validateClientMessage — operator_event', () => {
43
38
  }
44
39
  })
45
40
 
41
+ // Regression for the 2026-07-30 silent-out-of-credits incident: these three
42
+ // kinds were added to the OperatorEventKind union but NOT to the gateway IPC
43
+ // validator's hand-maintained allowlist. The bridge forwarded a
44
+ // `provider-credit-exhausted` operator_event; the validator rejected it as an
45
+ // "invalid IPC message shape" and dropped it, so an OpenRouter/LiteLLM 402
46
+ // credit wall produced NO loud Telegram card. Named explicitly (not just via
47
+ // the canonical-list loop above) so the failure message points straight at
48
+ // the incident if the allowlist ever regresses to a hand-maintained literal.
49
+ it('accepts the operator-actionable kinds forwarded by the bridge over IPC', () => {
50
+ for (const kind of ['provider-credit-exhausted', 'mcp-dependency-blocked', 'proxy-misconfig']) {
51
+ expect(validateClientMessage({ ...base(), kind }), kind).toBe(true)
52
+ }
53
+ })
54
+
46
55
  it('rejects unknown kinds', () => {
47
56
  expect(validateClientMessage({ ...base(), kind: 'something-else' })).toBe(false)
48
57
  expect(validateClientMessage({ ...base(), kind: '' })).toBe(false)
@@ -379,7 +379,7 @@ describe('outbound-send-path — temporal pass wiring (#3501)', () => {
379
379
  it('temporal runs AFTER punctuation/bold and BEFORE scrubVoice (structural pin)', () => {
380
380
  const src = readFileSync(new URL('../gateway/outbound-send-path.ts', import.meta.url), 'utf8')
381
381
  const start = src.indexOf('export function normalizeOutboundBody(')
382
- const boldIdx = src.indexOf('stripExcessBold(normalizePunctuation(text))', start)
382
+ const boldIdx = src.indexOf('stripExcessBold(normalizePunctuation(text),', start)
383
383
  const temporalIdx = src.indexOf('normalizeTemporal(text, tz, nowMs)', start)
384
384
  const scrubIdx = src.indexOf('scrubVoice(text)', start)
385
385
  expect(boldIdx).toBeGreaterThan(start)
@@ -0,0 +1,263 @@
1
+ /**
2
+ * DIFFERENTIAL test — run BOTH redaction engines over the same corpus and
3
+ * compare their output byte for byte.
4
+ *
5
+ * Why this file exists (#3982 adversarial review, BLOCKER 1 + MAJOR 4):
6
+ *
7
+ * The write-path work shipped two anti-fork mechanisms and neither could
8
+ * see the fork that actually existed.
9
+ *
10
+ * 1. `check-secret-pattern-parity` byte-compares the generated table
11
+ * against `patterns.ts`. It proves the pattern SOURCES are equal —
12
+ * and the sources WERE equal. Identical source is not identical
13
+ * behaviour: Python `re` treats `\b` / `\w` / `\d` and `re.I`
14
+ * case-folding as UNICODE-aware for `str` patterns, JavaScript
15
+ * `RegExp` without the `u` flag treats them as ASCII-only. Every
16
+ * generated rule is `\b`-anchored, so a synthetic `sk-ant-`-shaped
17
+ * key written next to Japanese, Chinese, Russian or accented-Latin
18
+ * text was masked by TypeScript and stored VERBATIM by Python.
19
+ * CJK has no word spacing, so "token abutting a non-ASCII letter"
20
+ * is the ordinary case, not an exotic one.
21
+ * 2. `secret_redaction_vectors.json` pins BEHAVIOUR — but every vector
22
+ * it shipped with exercised only the two hand-written imperative
23
+ * rules. Zero vectors touched any of the ~60 GENERATED patterns, so
24
+ * the whole generated table had no behavioural coverage at all.
25
+ *
26
+ * Fixed vectors are still the primary contract (they pin what the output
27
+ * should BE, which a differential test cannot). This file adds the thing
28
+ * neither mechanism had: a machine-generated corpus, wide enough to cover
29
+ * combinations nobody thought to write down, run through both engines
30
+ * with the outputs compared. A future semantic divergence — a new
31
+ * shorthand class, an `re.I` folding difference, a `\s` definition
32
+ * mismatch — fails HERE without anyone having predicted it.
33
+ *
34
+ * The Python half runs in a real `python3` subprocess importing the
35
+ * shipped `lib/secret_redact.py`. That is deliberate: an in-process
36
+ * re-implementation would be a third engine, and a third engine can fork
37
+ * too.
38
+ *
39
+ * Every credential-shaped literal is synthetic, assembled at runtime, and
40
+ * never echoed into an assertion message — divergences are reported by
41
+ * family + neighbour codepoint only.
42
+ */
43
+ import { describe, it, expect } from "vitest";
44
+ import { execFileSync } from "node:child_process";
45
+ import { dirname, join } from "node:path";
46
+ import { fileURLToPath } from "node:url";
47
+
48
+ import { redact } from "../secret-detect/redact.js";
49
+
50
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
51
+ const PY_SCRIPTS_DIR = join(repoRoot, "vendor", "hindsight-memory", "scripts");
52
+
53
+ /**
54
+ * Read a JSON array of strings on stdin, write the redacted array on
55
+ * stdout. Nothing is printed to stdout except the JSON, so a stray
56
+ * warning cannot be mistaken for output.
57
+ */
58
+ const PY_DRIVER = [
59
+ "import json,sys",
60
+ "sys.path.insert(0, sys.argv[1])",
61
+ "from lib.secret_redact import redact",
62
+ "data = json.load(sys.stdin)",
63
+ "sys.stdout.write(json.dumps([redact(s) for s in data]))",
64
+ ].join("\n");
65
+
66
+ function pythonRedactAll(inputs: string[]): string[] {
67
+ const out = execFileSync("python3", ["-c", PY_DRIVER, PY_SCRIPTS_DIR], {
68
+ input: JSON.stringify(inputs),
69
+ encoding: "utf-8",
70
+ maxBuffer: 32 * 1024 * 1024,
71
+ });
72
+ return JSON.parse(out) as string[];
73
+ }
74
+
75
+ // ─── Corpus ───────────────────────────────────────────────────────────
76
+
77
+ /** One synthetic credential per family the generated table claims to cover. */
78
+ const FILL = "AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJ";
79
+ const FAMILIES: Record<string, string> = {
80
+ anthropic_api_key: "sk-" + "ant-" + "api03-" + FILL,
81
+ openai_api_key: "sk-" + FILL + "KKKK",
82
+ github_pat: "ghp_" + FILL.slice(0, 36),
83
+ aws_access_key: "AKIA" + FILL.slice(0, 16),
84
+ google_api_key: "AIza" + FILL.slice(0, 35),
85
+ slack_token: "xoxb-" + "111111111111" + "-" + FILL.slice(0, 24),
86
+ jwt: "eyJ" + "AAAABBBBCCCC" + "." + "DDDDEEEEFFFF" + "." + "GGGGHHHHIIII",
87
+ telegram_bot_token: "1234567" + ":" + FILL.slice(0, 30),
88
+ laravel_sanctum_token: "17|" + FILL,
89
+ env_key_value: "API_TOKEN=" + "zQ7x" + "Vb2n" + "Kd9w" + "Rt4y",
90
+ bearer_header: "Authorization: Bearer " + FILL.slice(0, 24),
91
+ basic_header: "Authorization: Basic " + "YWxpY2U6" + "c3VwZXJzZWNyZXQ=",
92
+ db_uri: "postgres://appuser:" + "LmN0pQrS7t" + "@db.internal:5432/prod",
93
+ memorable_password: "wifi password: " + "Fluffy" + "Barnaby" + "1998",
94
+ };
95
+
96
+ /**
97
+ * Neighbour characters placed immediately before and after the
98
+ * credential. The non-ASCII entries are the whole point: they are where
99
+ * `\b` means different things in the two engines. The whitespace entries
100
+ * cover the OPPOSITE hazard — JS `\s` is Unicode-aware without `/u`, so
101
+ * a naive `re.ASCII` port would fork on the SEPARATOR instead.
102
+ */
103
+ const NEIGHBOURS = [
104
+ "",
105
+ " ",
106
+ "\n",
107
+ "\t",
108
+ ".",
109
+ ",",
110
+ ")",
111
+ '"',
112
+ "'",
113
+ "-",
114
+ "_",
115
+ "/",
116
+ "é", // é — accented Latin
117
+ "к", // к — Cyrillic
118
+ "中", // 中 — CJK (no word spacing: the ordinary case)
119
+ "あ", // あ — Japanese kana
120
+ "ก", // ก — Thai
121
+ "ω", // ω — Greek
122
+ " ", // NBSP — JS \s matches, ASCII \s does not
123
+ " ", // ideographic space — same
124
+ "", // BOM — same
125
+ "K", // K (Kelvin sign) — re.I folds this to 'k' in Unicode mode
126
+ "ſ", // ſ (long s) — re.I folds this to 's' in Unicode mode
127
+ ];
128
+
129
+ /** Prose that must survive both engines untouched. */
130
+ const NEGATIVES = [
131
+ "The password is required.",
132
+ "The password is incorrect.",
133
+ "Ken confirmed the password is unchanged.",
134
+ "password: required, minimum twelve characters, mixed case",
135
+ "POSTGRES_PASSWORD: vault:pg/password",
136
+ "postgres_password: vault:pg/password",
137
+ "PASSWORD: ${DB_PASSWORD}",
138
+ "JWT_SECRET=<generate-with-openssl-rand>",
139
+ "ANTHROPIC_API_KEY: vault:anthropic/api_key",
140
+ "const API_KEY = process.env.ANTHROPIC_API_KEY",
141
+ "--token <value> the API token",
142
+ '{"token": "the bearer token to use"}',
143
+ "I told him the answer was probably Gandalf the Grey.",
144
+ "他のパスワードは安全です。",
145
+ ];
146
+
147
+ interface Case {
148
+ text: string;
149
+ family: string;
150
+ pre: string;
151
+ post: string;
152
+ /**
153
+ * True when the neighbours leave the rule's `\b` anchors intact, i.e.
154
+ * when a hit is EXPECTED. An empty neighbour glues the credential to
155
+ * the surrounding `note`/`end` filler and a `_` is itself a word
156
+ * character, so `noteAKIA…` is legitimately not a token in EITHER
157
+ * engine — those cases still have to AGREE, but they must not be
158
+ * asserted to mask.
159
+ */
160
+ anchored: boolean;
161
+ }
162
+
163
+ const isWordChar = (s: string) => /^[A-Za-z0-9_]$/.test(s);
164
+
165
+ function buildCorpus(): Case[] {
166
+ const cases: Case[] = [];
167
+ for (const [family, value] of Object.entries(FAMILIES)) {
168
+ for (const pre of NEIGHBOURS) {
169
+ for (const post of NEIGHBOURS) {
170
+ // `note` / `end` supply the effective neighbour when the slot is
171
+ // empty.
172
+ const anchored =
173
+ !isWordChar(pre === "" ? "e" : pre) &&
174
+ !isWordChar(post === "" ? "e" : post);
175
+ cases.push({
176
+ text: `note${pre}${value}${post}end`,
177
+ family,
178
+ pre,
179
+ post,
180
+ anchored,
181
+ });
182
+ }
183
+ }
184
+ }
185
+ for (const text of NEGATIVES) {
186
+ cases.push({ text, family: "negative", pre: "", post: "", anchored: false });
187
+ }
188
+ return cases;
189
+ }
190
+
191
+ /** `U+0041` style, so a failure message never carries secret bytes. */
192
+ function cp(s: string): string {
193
+ if (s === "") return "none";
194
+ return `U+${s.codePointAt(0)!.toString(16).toUpperCase().padStart(4, "0")}`;
195
+ }
196
+
197
+ // ─── Tests ────────────────────────────────────────────────────────────
198
+
199
+ describe("TS and Python redactors agree (differential)", () => {
200
+ const corpus = buildCorpus();
201
+ const tsOut = corpus.map((c) => redact(c.text));
202
+ const pyOut = pythonRedactAll(corpus.map((c) => c.text));
203
+
204
+ it("covers every credential family across a wide neighbour matrix", () => {
205
+ // A shrinking corpus would silently weaken the guarantee.
206
+ expect(corpus.length).toBeGreaterThanOrEqual(1000);
207
+ expect(Object.keys(FAMILIES).length).toBeGreaterThanOrEqual(14);
208
+ });
209
+
210
+ it("produces byte-identical output for every case", () => {
211
+ const divergences = corpus
212
+ .map((c, i) => ({ c, i }))
213
+ .filter(({ i }) => tsOut[i] !== pyOut[i])
214
+ .map(({ c, i }) => ({
215
+ family: c.family,
216
+ pre: cp(c.pre),
217
+ post: cp(c.post),
218
+ // Booleans only — never the text.
219
+ tsMasked: tsOut[i]!.includes("[REDACTED"),
220
+ pyMasked: pyOut[i]!.includes("[REDACTED"),
221
+ }));
222
+ expect(divergences).toEqual([]);
223
+ });
224
+
225
+ it("never lets one engine store a credential the other masked", () => {
226
+ // The asymmetric half of the fork, stated as its own outcome: this is
227
+ // the assertion that would have gone red on the shipped code.
228
+ const leaks = corpus
229
+ .map((c, i) => ({ c, i }))
230
+ .filter(
231
+ ({ c, i }) =>
232
+ c.family !== "negative" &&
233
+ !pyOut[i]!.includes("[REDACTED") &&
234
+ tsOut[i]!.includes("[REDACTED"),
235
+ )
236
+ .map(({ c }) => ({ family: c.family, pre: cp(c.pre), post: cp(c.post) }));
237
+ expect(leaks).toEqual([]);
238
+ });
239
+
240
+ it("masks the credential in both engines for every anchored case", () => {
241
+ const anchored = corpus
242
+ .map((c, i) => ({ c, i }))
243
+ .filter(({ c }) => c.anchored);
244
+ // Sanity: the anchored subset must still be the bulk of the matrix,
245
+ // otherwise the filter above could silently hollow this out.
246
+ expect(anchored.length).toBeGreaterThanOrEqual(600);
247
+ const missed = anchored
248
+ .filter(
249
+ ({ i }) =>
250
+ !(tsOut[i]!.includes("[REDACTED") && pyOut[i]!.includes("[REDACTED")),
251
+ )
252
+ .map(({ c }) => ({ family: c.family, pre: cp(c.pre), post: cp(c.post) }));
253
+ expect(missed).toEqual([]);
254
+ });
255
+
256
+ it("leaves prose untouched in both engines", () => {
257
+ for (const [i, c] of corpus.entries()) {
258
+ if (c.family !== "negative") continue;
259
+ expect(tsOut[i]).toBe(c.text);
260
+ expect(pyOut[i]).toBe(c.text);
261
+ }
262
+ });
263
+ });