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.
- package/dist/cli/skill-validate-pretool.mjs +15 -2
- package/dist/cli/switchroom.js +672 -441
- package/dist/host-control/main.js +149 -3
- package/package.json +4 -2
- package/profiles/_base/start.sh.hbs +37 -12
- package/telegram-plugin/dist/gateway/gateway.js +311 -93
- package/telegram-plugin/format.ts +70 -15
- package/telegram-plugin/gateway/gateway.ts +22 -29
- package/telegram-plugin/gateway/ipc-server.ts +18 -15
- package/telegram-plugin/gateway/model-command.ts +34 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +12 -1
- package/telegram-plugin/operator-events.ts +40 -16
- package/telegram-plugin/secret-detect/db-uri.ts +90 -0
- package/telegram-plugin/secret-detect/index.ts +24 -1
- package/telegram-plugin/secret-detect/inert-values.ts +147 -0
- package/telegram-plugin/secret-detect/kv-scanner.ts +108 -0
- package/telegram-plugin/secret-detect/patterns.ts +24 -4
- package/telegram-plugin/tests/format-consistency.test.ts +93 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +40 -2
- package/telegram-plugin/tests/ipc-server-validate-operator.test.ts +20 -11
- package/telegram-plugin/tests/outbound-send-path.test.ts +1 -1
- package/telegram-plugin/tests/secret-detect-cross-engine.test.ts +263 -0
- package/telegram-plugin/tests/secret-detect-write-path.test.ts +403 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +2 -2
- package/vendor/hindsight-memory/scripts/lib/client.py +58 -0
- package/vendor/hindsight-memory/scripts/lib/secret_patterns.json +431 -0
- package/vendor/hindsight-memory/scripts/lib/secret_redact.py +563 -0
- package/vendor/hindsight-memory/scripts/lib/secret_redaction_vectors.json +397 -0
- 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._\-+=]+)/
|
|
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
|
|
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/
|
|
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
|
|
333
|
+
const idx = GATEWAY_SRC.indexOf('const resolutionTimeoutMs = resolveSessionModelResolutionTimeoutMs(')
|
|
299
334
|
expect(idx).toBeGreaterThan(0)
|
|
300
|
-
const win = GATEWAY_SRC.slice(idx, idx +
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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)
|
|
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
|
+
});
|