switchroom 0.19.35 → 0.19.37
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 +678 -444
- package/dist/host-control/main.js +149 -3
- package/package.json +4 -2
- package/profiles/_base/start.sh.hbs +37 -12
- package/telegram-plugin/bridge/bridge.ts +4 -4
- package/telegram-plugin/dist/bridge/bridge.js +4 -4
- package/telegram-plugin/dist/gateway/gateway.js +317 -95
- package/telegram-plugin/dist/server.js +4 -4
- package/telegram-plugin/format.ts +100 -29
- 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/render/unsupported-token-guard.ts +10 -6
- 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 +111 -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/mcp-instructions-budget.test.ts +8 -1
- package/telegram-plugin/tests/outbound-send-path.test.ts +1 -1
- package/telegram-plugin/tests/render/unsupported-token-guard.test.ts +36 -5
- 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
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inert VALUES — placeholders, variable references and already-masked
|
|
3
|
+
* text that occupy a credential-shaped slot without being a credential.
|
|
4
|
+
*
|
|
5
|
+
* Masking one of these buys nothing and costs real information:
|
|
6
|
+
*
|
|
7
|
+
* - `POSTGRES_PASSWORD: vault:pg/password` — the vault KEY NAME is
|
|
8
|
+
* precisely what an agent is supposed to remember. Redacting it
|
|
9
|
+
* deletes the pointer and keeps nothing.
|
|
10
|
+
* - `JWT_SECRET=<generate-with-openssl-rand>` — an instruction.
|
|
11
|
+
* - `const API_KEY = process.env.ANTHROPIC_API_KEY` — a reference.
|
|
12
|
+
*
|
|
13
|
+
* Before #3982's review this list existed only inside the new
|
|
14
|
+
* `memorable_password` rule, so letter case decided the outcome:
|
|
15
|
+
* `password: ${DB_PASSWORD}` survived while `PASSWORD: ${DB_PASSWORD}`
|
|
16
|
+
* (matched by the ALL-CAPS `env_key_value` rule) was destroyed. Every
|
|
17
|
+
* rule whose capture is a LABELLED slot now shares the list —
|
|
18
|
+
* `env_key_value`, `json_secret_field`, `cli_flag` and
|
|
19
|
+
* `memorable_password`.
|
|
20
|
+
*
|
|
21
|
+
* Mirrored in `vendor/hindsight-memory/scripts/lib/secret_redact.py`
|
|
22
|
+
* (`_INERT_VALUE_RES` / `_INERT_GATED_RULES`) and pinned across both
|
|
23
|
+
* engines by `secret_redaction_vectors.json`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export const INERT_VALUE_RE = [
|
|
27
|
+
// Our own marker (idempotence). `maskToken` only ever emits
|
|
28
|
+
// `[REDACTED]` or `[REDACTED:<rule_id>]`, so the closing bracket is
|
|
29
|
+
// part of the shape.
|
|
30
|
+
/^\[REDACTED(?::[A-Za-z0-9_]+)?\]$/i,
|
|
31
|
+
/^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$/, // $PASSWORD, ${DB_PASSWORD}
|
|
32
|
+
// `.` is spelled out: JS `.` and Python `.` exclude different line
|
|
33
|
+
// terminators, and the two engines must agree byte for byte.
|
|
34
|
+
/^\{\{[^\n\r\u2028\u2029]*\}\}$/, // {{ handlebars }}
|
|
35
|
+
/^<[^<>\n\r\u2028\u2029]*>$/, // <your-password-here>
|
|
36
|
+
/^%[A-Za-z_][A-Za-z0-9_]*%$/, // %PASSWORD% (Windows)
|
|
37
|
+
/^vault:[A-Za-z0-9_./-]*$/i, // switchroom vault reference
|
|
38
|
+
/^[*x•.]+$/i, // ***, xxxx, ••••
|
|
39
|
+
// Code reference — `process.env.FOO`, `import.meta.env.VITE_X`,
|
|
40
|
+
// `os.environ["FOO"]`. The member path is PART OF THE SHAPE: a bare
|
|
41
|
+
// prefix followed by anything else is not a reference.
|
|
42
|
+
/^(?:process\.env|import\.meta\.env|os\.environ)(?:\.[A-Za-z_$][A-Za-z0-9_$]*|\[["']?[A-Za-z_$][A-Za-z0-9_$]*["']?\])*$/,
|
|
43
|
+
// Well-known "fill this in" values. A live credential never begins
|
|
44
|
+
// with the word telling you to replace it.
|
|
45
|
+
/^(?:changeme|change-me|replaceme|replace-me|placeholder|todo|tbd|yourkey|your-key|yourpassword|your-password|yoursecret|your-secret|yourtoken|your-token)(?:[-_][A-Za-z0-9-]+)?$/i,
|
|
46
|
+
// A lowercase English phrase — a help string or a JSON doc value such
|
|
47
|
+
// as `{"token": "the bearer token to use"}`, never a credential.
|
|
48
|
+
/^[a-z]+(?: [a-z]+){2,}$/,
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* An unbroken run of `[A-Za-z0-9]` long enough to BE a credential.
|
|
53
|
+
*
|
|
54
|
+
* Placeholder text is words joined by separators (`your-password-here`,
|
|
55
|
+
* `generate-with-openssl-rand`, `pg/password`, `ANTHROPIC_API_KEY`) — the
|
|
56
|
+
* longest alphanumeric run across every value this list protects is 10
|
|
57
|
+
* (`production`). A credential is the opposite shape: one dense run.
|
|
58
|
+
*/
|
|
59
|
+
const CREDENTIAL_RUN_RE = /[A-Za-z0-9]{12,}/g
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Mixed-class floor — `ENV_KV_MIN_LEN`, the length below which the env
|
|
63
|
+
* scanner already declines to call something a secret. Must not exceed
|
|
64
|
+
* `CREDENTIAL_RUN_RE`'s own floor.
|
|
65
|
+
*/
|
|
66
|
+
const MIXED_CLASS_RUN_MIN = 12
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Single-class floor. A run of only letters or only digits is far more
|
|
70
|
+
* likely to be an English word than a credential (`authentication`,
|
|
71
|
+
* `configuration`), so it gets more rope — but not unlimited rope:
|
|
72
|
+
* `abcdefghijklmnop` and a 40-`x` filler are values `main` masked, and no
|
|
73
|
+
* word in the protected corpus reaches 16.
|
|
74
|
+
*/
|
|
75
|
+
const SINGLE_CLASS_RUN_MIN = 16
|
|
76
|
+
|
|
77
|
+
function runIsCredentialShaped(run: string): boolean {
|
|
78
|
+
// Classes present among {lower, upper, digit}. Hex, base62 and
|
|
79
|
+
// CamelCase-with-digits tokens are two or three; `yourtokenhere` and
|
|
80
|
+
// the segments of `YOUR_TOKEN_HERE` are one.
|
|
81
|
+
let classes = 0
|
|
82
|
+
if (/[a-z]/.test(run)) classes++
|
|
83
|
+
if (/[A-Z]/.test(run)) classes++
|
|
84
|
+
if (/[0-9]/.test(run)) classes++
|
|
85
|
+
return (
|
|
86
|
+
run.length >= (classes >= 2 ? MIXED_CLASS_RUN_MIN : SINGLE_CLASS_RUN_MIN)
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* True when `value` carries a credential-shaped run anywhere inside it.
|
|
92
|
+
*
|
|
93
|
+
* #3982 review, MAJOR 7. The inert SHAPES above are necessary but not
|
|
94
|
+
* sufficient: `<a8f3…40 hex…>` and `{{tok_…}}` are legal instances of the
|
|
95
|
+
* `<…>` / `{{…}}` shapes, and a wrapper left behind after someone filled
|
|
96
|
+
* the value in (`vault:pg/password<secret>`, `[REDACTED]<secret>`,
|
|
97
|
+
* `changeme-<secret>`) turned a false-positive fix into a bypass of
|
|
98
|
+
* already-shipped coverage. "Inert shape AND carries no credential" is
|
|
99
|
+
* the property actually wanted; end-anchoring alone is not enough,
|
|
100
|
+
* because `<secret>` is a well-formed instance of the shape.
|
|
101
|
+
*/
|
|
102
|
+
export function hasCredentialShapedRun(value: string): boolean {
|
|
103
|
+
for (const run of value.match(CREDENTIAL_RUN_RE) ?? []) {
|
|
104
|
+
if (runIsCredentialShaped(run)) return true
|
|
105
|
+
}
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** True when `value` is a placeholder / reference rather than a secret. */
|
|
110
|
+
export function isInertValue(value: string): boolean {
|
|
111
|
+
if (!INERT_VALUE_RE.some((re) => re.test(value))) return false
|
|
112
|
+
return !hasCredentialShapedRun(value)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Rule ids whose captured value is a labelled slot, and which therefore
|
|
117
|
+
* honour `isInertValue`. Keep in sync with `_INERT_GATED_RULES` in
|
|
118
|
+
* `secret_redact.py`.
|
|
119
|
+
*/
|
|
120
|
+
export const INERT_GATED_RULES = new Set([
|
|
121
|
+
'env_key_value',
|
|
122
|
+
'json_secret_field',
|
|
123
|
+
'cli_flag',
|
|
124
|
+
])
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The heuristic KEY=VALUE scanner honours the same list — it is the rule
|
|
128
|
+
* that ate `ANTHROPIC_API_KEY: vault:anthropic/api_key` and
|
|
129
|
+
* `const API_KEY = process.env.ANTHROPIC_API_KEY`. Kept as its own
|
|
130
|
+
* export because `scanKeyValue` applies it directly rather than through
|
|
131
|
+
* the `ALL_PATTERNS` loop.
|
|
132
|
+
*/
|
|
133
|
+
export const KV_ENTROPY_RULE_ID = 'kv_entropy'
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Trailing punctuation an English sentence puts AFTER a value. The value
|
|
137
|
+
* character classes are `[^\s"']`-shaped, so a sentence-final `.` or a
|
|
138
|
+
* list comma is swallowed INTO the value — and a `.` alone supplies a
|
|
139
|
+
* second character class, which is what made
|
|
140
|
+
* "The password is required." redact as a credential (#3982 review,
|
|
141
|
+
* BLOCKER 2).
|
|
142
|
+
*/
|
|
143
|
+
const TRAILING_PUNCT_RE = /[.,;:!?)\]}]+$/
|
|
144
|
+
|
|
145
|
+
export function stripTrailingPunctuation(value: string): string {
|
|
146
|
+
return value.replace(TRAILING_PUNCT_RE, '')
|
|
147
|
+
}
|
|
@@ -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',
|
|
@@ -245,6 +245,24 @@ describe('normalizePunctuation', () => {
|
|
|
245
245
|
expect(normalizePunctuation('<not—a—url>')).toBe('<not, a, url>')
|
|
246
246
|
})
|
|
247
247
|
|
|
248
|
+
test('does NOT rewrite a dash inside a `>` blockquote (verbatim quote)', () => {
|
|
249
|
+
// `>` blockquotes hold quoted text the author is reproducing verbatim;
|
|
250
|
+
// rewriting their em-dash to `, ` corrupts the quotation.
|
|
251
|
+
expect(normalizePunctuation('> she said — plainly — no')).toBe(
|
|
252
|
+
'> she said — plainly — no',
|
|
253
|
+
)
|
|
254
|
+
// Indented blockquote line is still a blockquote.
|
|
255
|
+
expect(normalizePunctuation(' > quoted — text')).toBe(' > quoted — text')
|
|
256
|
+
// The expandable-blockquote opener `**>` is a blockquote line too.
|
|
257
|
+
expect(normalizePunctuation('**> first — line\n> continues — here')).toBe(
|
|
258
|
+
'**> first — line\n> continues — here',
|
|
259
|
+
)
|
|
260
|
+
// Prose OUTSIDE the quote still normalizes; only the quoted line is spared.
|
|
261
|
+
expect(normalizePunctuation('intro — here\n> quote — verbatim\nafter — end')).toBe(
|
|
262
|
+
'intro, here\n> quote — verbatim\nafter, end',
|
|
263
|
+
)
|
|
264
|
+
})
|
|
265
|
+
|
|
248
266
|
test('idempotent', () => {
|
|
249
267
|
const once = normalizePunctuation('a — b\n• c\nd—e\n1–2')
|
|
250
268
|
expect(normalizePunctuation(once)).toBe(once)
|
|
@@ -334,4 +352,97 @@ describe('stripExcessBold', () => {
|
|
|
334
352
|
const once = stripExcessBold(input)
|
|
335
353
|
expect(stripExcessBold(once)).toBe(once)
|
|
336
354
|
})
|
|
355
|
+
|
|
356
|
+
// ── Heading exemption in the GLOBAL (>30%) rule ──────────────────────────
|
|
357
|
+
// A bold-dense digest tripped the global ratio and lost EVERY bold marker,
|
|
358
|
+
// including its section headings, with no signal. Short standalone
|
|
359
|
+
// pseudo-heading blocks must now survive the global strip.
|
|
360
|
+
|
|
361
|
+
const boldDenseBody =
|
|
362
|
+
'**alpha** **bravo** **charlie** **delta** **echo** **foxtrot** **golf** ' +
|
|
363
|
+
'**hotel** **india** **juliet** **kilo** **lima** plus a short plain tail here.'
|
|
364
|
+
|
|
365
|
+
test('global strip keeps a short standalone bold heading, strips the rest', () => {
|
|
366
|
+
const input = `**Section One**\n\n${boldDenseBody}`
|
|
367
|
+
const out = stripExcessBold(input)
|
|
368
|
+
// Heading survives.
|
|
369
|
+
expect(out).toContain('**Section One**')
|
|
370
|
+
// Non-heading inline bold is flattened.
|
|
371
|
+
expect(out).toContain('alpha')
|
|
372
|
+
expect(out).not.toContain('**alpha**')
|
|
373
|
+
expect(out).not.toContain('**golf**')
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
test('global strip preserves EVERY heading in a multi-section digest', () => {
|
|
377
|
+
const input =
|
|
378
|
+
`**Overview**\n\n${boldDenseBody}\n\n` +
|
|
379
|
+
`**Next steps:**\n\n**one** **two** **three** **four** **five** **six** ` +
|
|
380
|
+
'plus a plain closing clause long enough to matter here.'
|
|
381
|
+
const out = stripExcessBold(input)
|
|
382
|
+
expect(out).toContain('**Overview**')
|
|
383
|
+
expect(out).toContain('**Next steps:**')
|
|
384
|
+
expect(out).not.toContain('**one**')
|
|
385
|
+
expect(out).not.toContain('**six**')
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
test('regression: global strip with NO headings still fully strips', () => {
|
|
389
|
+
const input = `${boldDenseBody}`
|
|
390
|
+
const out = stripExcessBold(input)
|
|
391
|
+
expect(out).not.toContain('**')
|
|
392
|
+
expect(out).toContain('alpha')
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
test('regression: under-threshold message keeps all bold (incl. headings)', () => {
|
|
396
|
+
const input = `**Summary**\n\n${filler} The key fact is **42**.`
|
|
397
|
+
expect(stripExcessBold(input)).toBe(input)
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
test('48/49-char pseudo-heading boundary honoured under global strip', () => {
|
|
401
|
+
// Heading length is measured WITH the `**` markers. 44 inner chars → 48
|
|
402
|
+
// total (exempt); 45 inner chars → 49 total (stripped).
|
|
403
|
+
const heading48 = '**' + 'H'.repeat(44) + '**' // length 48
|
|
404
|
+
const heading49 = '**' + 'H'.repeat(45) + '**' // length 49
|
|
405
|
+
expect(heading48.length).toBe(48)
|
|
406
|
+
expect(heading49.length).toBe(49)
|
|
407
|
+
|
|
408
|
+
const out48 = stripExcessBold(`${heading48}\n\n${boldDenseBody}`)
|
|
409
|
+
expect(out48).toContain(heading48)
|
|
410
|
+
|
|
411
|
+
const out49 = stripExcessBold(`${heading49}\n\n${boldDenseBody}`)
|
|
412
|
+
expect(out49).not.toContain(heading49)
|
|
413
|
+
expect(out49).toContain('H'.repeat(45))
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
test('multi-line fully-bolded block is NOT mislabelled a heading (global)', () => {
|
|
417
|
+
// Two bolded lines in one block must be flattened, not exempted — the
|
|
418
|
+
// heading exemption is single-line only.
|
|
419
|
+
const input = `**First bold line here**\n**Second bold line here**\n\n${boldDenseBody}`
|
|
420
|
+
const out = stripExcessBold(input)
|
|
421
|
+
expect(out).not.toContain('**First bold line here**')
|
|
422
|
+
expect(out).toContain('First bold line here')
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
// ── Observability ────────────────────────────────────────────────────────
|
|
426
|
+
test('onStrip fires with rule=global + ratio when global rule strips', () => {
|
|
427
|
+
const calls: Array<{ rule: string; ratio: number }> = []
|
|
428
|
+
stripExcessBold(`**Section One**\n\n${boldDenseBody}`, (d) => calls.push(d))
|
|
429
|
+
expect(calls).toHaveLength(1)
|
|
430
|
+
expect(calls[0].rule).toBe('global')
|
|
431
|
+
expect(calls[0].ratio).toBeGreaterThan(0.3)
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
test('onStrip fires with rule=per-block when only a block is flattened', () => {
|
|
435
|
+
const calls: Array<{ rule: string; ratio: number }> = []
|
|
436
|
+
const input = `${filler}\n\n**This whole paragraph is bold.**\n**Every single line of it.**`
|
|
437
|
+
stripExcessBold(input, (d) => calls.push(d))
|
|
438
|
+
expect(calls).toHaveLength(1)
|
|
439
|
+
expect(calls[0].rule).toBe('per-block')
|
|
440
|
+
expect(calls[0].ratio).toBeLessThanOrEqual(0.3)
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
test('onStrip does NOT fire when nothing is stripped', () => {
|
|
444
|
+
const calls: Array<{ rule: string; ratio: number }> = []
|
|
445
|
+
stripExcessBold(`**Summary**\n\n${filler} The key fact is **42**.`, (d) => calls.push(d))
|
|
446
|
+
expect(calls).toHaveLength(0)
|
|
447
|
+
})
|
|
337
448
|
})
|
|
@@ -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)
|
|
@@ -162,8 +162,15 @@ describe("moved instruction content landed in tool descriptions", () => {
|
|
|
162
162
|
|
|
163
163
|
it("format modes landed in the reply description", () => {
|
|
164
164
|
expect(bridgeCode).toContain("FORMAT:");
|
|
165
|
-
|
|
165
|
+
// The description must describe the ONE real render path (rich GFM), and
|
|
166
|
+
// still name the legacy aliases so the enum values stay documented. It must
|
|
167
|
+
// NOT resurrect the deleted-engine claims (html→HTML conversion, MarkdownV2
|
|
168
|
+
// auto-escaping) — those are false at HEAD (single GFM path, no escaping).
|
|
169
|
+
expect(bridgeCode).toMatch(/rich GFM markdown/);
|
|
166
170
|
expect(bridgeCode).toContain("markdownv2");
|
|
171
|
+
expect(bridgeCode).not.toMatch(/default format is "html"/);
|
|
172
|
+
expect(bridgeCode).not.toMatch(/auto-converted to Telegram HTML/);
|
|
173
|
+
expect(bridgeCode).not.toMatch(/MarkdownV2 with auto-escaping/);
|
|
167
174
|
});
|
|
168
175
|
|
|
169
176
|
it("history-buffer rationale landed in get_recent_messages", () => {
|
|
@@ -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)
|
|
@@ -71,15 +71,46 @@ describe("guardUnsupportedTokens — deterministic send-time repair", () => {
|
|
|
71
71
|
expect(guardUnsupportedTokens("x^2^ metres")).toBe("x2 metres");
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
-
it("
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
it("repairs alphanumeric footnote markers, not just numeric", () => {
|
|
75
|
+
// POLISH-4: widened from digits-only so `[^note]`/`[^ref]` no longer ship
|
|
76
|
+
// as literal bracket noise. Each would survive UNTOUCHED on origin/main.
|
|
77
|
+
expect(guardUnsupportedTokens("see the note[^note] here")).toBe(
|
|
78
|
+
"see the note here",
|
|
79
|
+
);
|
|
80
|
+
expect(guardUnsupportedTokens("as shown[^ref] above")).toBe(
|
|
81
|
+
"as shown above",
|
|
82
|
+
);
|
|
83
|
+
expect(guardUnsupportedTokens("point[^fn1] made")).toBe("point made");
|
|
84
|
+
// A single-letter id is a footnote too.
|
|
85
|
+
expect(guardUnsupportedTokens("claim[^a] holds")).toBe("claim holds");
|
|
86
|
+
// Definition lines with an alphanumeric id are still left intact (`(?!:)`).
|
|
87
|
+
expect(guardUnsupportedTokens("[^note]: the definition")).toBe(
|
|
88
|
+
"[^note]: the definition",
|
|
78
89
|
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("leaves regex-literal negated char classes intact", () => {
|
|
93
|
+
// Real negated char classes contain punctuation / ranges / escapes, so the
|
|
94
|
+
// alphanumeric-only id requirement excludes them — these stay verbatim.
|
|
79
95
|
expect(guardUnsupportedTokens("use [^/] to match")).toBe(
|
|
80
96
|
"use [^/] to match",
|
|
81
97
|
);
|
|
82
|
-
|
|
98
|
+
expect(guardUnsupportedTokens("strip [^a-z] chars")).toBe(
|
|
99
|
+
"strip [^a-z] chars",
|
|
100
|
+
);
|
|
101
|
+
expect(guardUnsupportedTokens('match [^"] here')).toBe('match [^"] here');
|
|
102
|
+
// Accepted tradeoff (task POLISH-4): a PURE-alphanumeric negated class in
|
|
103
|
+
// BARE prose like `array[^index]` is now treated as a footnote and stripped.
|
|
104
|
+
// This is rare — regex/index literals in real prose live in code spans,
|
|
105
|
+
// which splitProtectedSegments masks (see code-span test below).
|
|
106
|
+
expect(guardUnsupportedTokens("array[^index] lookup")).toBe("array lookup");
|
|
107
|
+
// …but inside a code span the same literal is untouched.
|
|
108
|
+
expect(guardUnsupportedTokens("`array[^index]` lookup")).toBe(
|
|
109
|
+
"`array[^index]` lookup",
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("repairs a real numeric footnote marker", () => {
|
|
83
114
|
expect(guardUnsupportedTokens("see the note[^1] here")).toBe(
|
|
84
115
|
"see the note here",
|
|
85
116
|
);
|