switchroom 0.14.30 → 0.14.31

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.
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PreToolUse hook — drops a `reply` / `stream_reply` call whose entire
4
+ * payload is only the silent sentinel(s) NO_REPLY / HEARTBEAT_OK.
5
+ *
6
+ * Defense-in-depth for #2053. The silent-end Stop hook and the gateway
7
+ * flush gate already recognise prose+trailing-NO_REPLY as "intentionally
8
+ * silent", but if a nag-loop (or any other path) ever pushes a
9
+ * sentinel-only payload through the reply tool, it must NEVER reach the
10
+ * Telegram chat. This guard is the last line: it intercepts the tool
11
+ * call itself, before the gateway sees it.
12
+ *
13
+ * Match discipline — EXACT, not substring:
14
+ * - The trimmed payload must be ONLY one or more silent markers
15
+ * (each on its own line, optional trailing punctuation per marker).
16
+ * - A real reply that happens to mention "NO_REPLY" inside genuine
17
+ * prose (e.g. "reply with exactly NO_REPLY if nothing to add") is
18
+ * NOT dropped — it has non-marker content, so it is delivered.
19
+ *
20
+ * Claude Code PreToolUse protocol (v1):
21
+ * Input: JSON on stdin — { session_id, tool_name, tool_input, ... }
22
+ * Output: exit 0 + empty stdout → allow.
23
+ * exit 0 + JSON stdout { decision: "block", reason } → block.
24
+ *
25
+ * Fail-open on any parse/IO error — a malfunctioning guard must not wedge
26
+ * the reply path.
27
+ */
28
+
29
+ import { readFileSync } from 'node:fs'
30
+ import { argv } from 'node:process'
31
+ import { fileURLToPath } from 'node:url'
32
+
33
+ const REPLY_TOOLS = new Set([
34
+ 'mcp__switchroom-telegram__reply',
35
+ 'mcp__switchroom-telegram__stream_reply',
36
+ ])
37
+
38
+ // Mirrors turn-flush-safety.ts:isSilentFlushMarker and
39
+ // silent-end-scan.mjs:SILENT_MARKER_RE — a single bare marker with
40
+ // optional trailing punctuation.
41
+ const SILENT_MARKER_RE = /^(NO_REPLY|HEARTBEAT_OK)[\s.!?]*$/i
42
+
43
+ function readStdin() {
44
+ try {
45
+ return readFileSync(0, 'utf8')
46
+ } catch {
47
+ return ''
48
+ }
49
+ }
50
+
51
+ /**
52
+ * True when `text` is composed ENTIRELY of silent markers — every
53
+ * non-empty line is a bare NO_REPLY / HEARTBEAT_OK — with at least one
54
+ * such line. Exact-match per line, never a substring of prose.
55
+ *
56
+ * @param {string} text
57
+ * @returns {boolean}
58
+ */
59
+ export function isSentinelOnly(text) {
60
+ if (typeof text !== 'string') return false
61
+ const lines = text
62
+ .split('\n')
63
+ .map((l) => l.trim())
64
+ .filter((l) => l.length > 0)
65
+ if (lines.length === 0) return false
66
+ return lines.every((l) => SILENT_MARKER_RE.test(l))
67
+ }
68
+
69
+ function main() {
70
+ const raw = readStdin().trim()
71
+ if (!raw) process.exit(0)
72
+
73
+ let event
74
+ try {
75
+ event = JSON.parse(raw)
76
+ } catch {
77
+ process.exit(0)
78
+ }
79
+
80
+ const toolName = event?.tool_name
81
+ if (!REPLY_TOOLS.has(toolName)) process.exit(0)
82
+
83
+ const text = event?.tool_input?.text
84
+ if (typeof text !== 'string') process.exit(0)
85
+
86
+ if (isSentinelOnly(text)) {
87
+ process.stderr.write(
88
+ '[sentinel-reply-guard] dropped sentinel-only reply payload (#2053) — ' +
89
+ 'NO_REPLY/HEARTBEAT_OK must never reach chat\n',
90
+ )
91
+ process.stdout.write(
92
+ JSON.stringify({
93
+ decision: 'block',
94
+ reason:
95
+ 'This reply payload is only the silent sentinel (NO_REPLY / ' +
96
+ 'HEARTBEAT_OK). That sentinel signals "send nothing" — it must not ' +
97
+ 'be delivered to the user as a message. The turn is already ' +
98
+ 'treated as intentionally silent; do not call the reply tool with ' +
99
+ 'it. End your turn.',
100
+ }),
101
+ )
102
+ process.exit(0)
103
+ }
104
+
105
+ process.exit(0)
106
+ }
107
+
108
+ // Only run the stdin-reading entrypoint when invoked directly as the hook
109
+ // script. When imported (e.g. by the unit test exercising `isSentinelOnly`)
110
+ // the top-level `readFileSync(0)` would otherwise block on the importer's
111
+ // stdin and hang the process.
112
+ if (argv[1] && fileURLToPath(import.meta.url) === argv[1]) {
113
+ main()
114
+ }
@@ -43,6 +43,38 @@ const FINAL_ANSWER_MIN_CHARS = 200
43
43
  // variants like "NO_REPLY." / "no_reply").
44
44
  const SILENT_MARKER_RE = /^(NO_REPLY|HEARTBEAT_OK)[\s.!?]*$/i
45
45
 
46
+ /**
47
+ * True when `text`'s final non-empty line is a bare silent marker
48
+ * (NO_REPLY / HEARTBEAT_OK + optional trailing punctuation), regardless
49
+ * of what precedes it. Closes #2053: a turn that emits prose then a
50
+ * trailing bare `NO_REPLY` line is the model explicitly signalling
51
+ * "intentionally silent". The anchored `SILENT_MARKER_RE` only matches
52
+ * when the ENTIRE trimmed output is the bare marker, so prose+NO_REPLY
53
+ * slipped through → the hook blocked → nag loop → sentinel leak.
54
+ *
55
+ * Approximately mirrors `turn-flush-safety.ts:endsWithSilentMarker` (TS
56
+ * gateway side). NOT byte-identical: this .mjs uses `SILENT_MARKER_RE`
57
+ * directly (no length cap, unlimited trailing punctuation), whereas the
58
+ * TS side delegates to `isSilentFlushMarker` (length-capped, single
59
+ * trailing punct). This side is intentionally the more permissive of the
60
+ * two; the divergence is benign in direction — both suppress the common
61
+ * `prose\nNO_REPLY` shape, and the extra leniency here only ever
62
+ * suppresses MORE (never leaks, never wrongly silences a user-awaited
63
+ * reply, which is gated separately).
64
+ *
65
+ * @param {string} text
66
+ * @returns {boolean}
67
+ */
68
+ export function endsWithSilentMarker(text) {
69
+ if (typeof text !== 'string') return false
70
+ const lines = text
71
+ .split('\n')
72
+ .map((l) => l.trim())
73
+ .filter((l) => l.length > 0)
74
+ if (lines.length === 0) return false
75
+ return SILENT_MARKER_RE.test(lines[lines.length - 1])
76
+ }
77
+
46
78
  /**
47
79
  * Predicate ported from `telegram-plugin/final-answer-detect.ts:78-83`.
48
80
  * Kept in this .mjs so the hook is fully self-contained (no TS import).
@@ -69,13 +101,15 @@ export function isFinalAnswerReply({ text, disableNotification, done }) {
69
101
  * @returns {{ chatId: string | null, threadId: number | null }}
70
102
  */
71
103
  function parseChannelEnvelope(content) {
72
- if (typeof content !== 'string') return { chatId: null, threadId: null }
104
+ if (typeof content !== 'string') return { chatId: null, threadId: null, source: null }
73
105
  const chatMatch = content.match(/chat_id="([^"]+)"/)
74
106
  const threadMatch = content.match(/message_thread_id="([^"]+)"/)
107
+ const sourceMatch = content.match(/<channel[^>]*\bsource="([^"]+)"/)
75
108
  const threadRaw = threadMatch ? Number(threadMatch[1]) : NaN
76
109
  return {
77
110
  chatId: chatMatch ? chatMatch[1] : null,
78
111
  threadId: Number.isFinite(threadRaw) && threadRaw !== 0 ? threadRaw : null,
112
+ source: sourceMatch ? sourceMatch[1] : null,
79
113
  }
80
114
  }
81
115
 
@@ -128,7 +162,7 @@ export function scanTurnForFinalReply(jsonl) {
128
162
 
129
163
  // 1. Walk backward to most-recent queue-operation/enqueue.
130
164
  let startIdx = -1
131
- let envelope = { chatId: null, threadId: null }
165
+ let envelope = { chatId: null, threadId: null, source: null }
132
166
  for (let i = lines.length - 1; i >= 0; i--) {
133
167
  const line = lines[i]
134
168
  if (!line || line[0] !== '{') continue
@@ -159,15 +193,27 @@ export function scanTurnForFinalReply(jsonl) {
159
193
  const content = obj?.message?.content
160
194
  if (!Array.isArray(content)) continue
161
195
  for (const c of content) {
196
+ // Plain assistant text carve-out (#2053): a turn that ends with a
197
+ // trailing bare NO_REPLY / HEARTBEAT_OK line — emitted as plain
198
+ // transcript text, NOT through the reply tool — is the model
199
+ // explicitly signalling "intentionally silent". The anchored
200
+ // SILENT_MARKER_RE below only fires when the ENTIRE reply-tool
201
+ // text is the bare marker, so a plain-text prose+NO_REPLY turn
202
+ // matched nothing here → block → nag → sentinel leak. Treat a
203
+ // trailing-marker text block as a valid silent end.
204
+ if (c?.type === 'text' && endsWithSilentMarker(String(c.text ?? ''))) {
205
+ return { decided: 'allow', reason: 'silent-marker-text' }
206
+ }
162
207
  if (c?.type !== 'tool_use') continue
163
208
  if (!REPLY_TOOLS.has(c.name)) continue
164
209
  const input = c.input ?? {}
165
210
  const text = String(input.text ?? '')
166
211
  // Silent-marker carve-out: the operator explicitly signaled
167
212
  // "intentionally silent" (cron HEARTBEAT_OK, model-driven
168
- // NO_REPLY). Don't block same posture as the gateway's
169
- // silent-marker suppression at gateway.ts:6692.
170
- if (SILENT_MARKER_RE.test(text.trim())) {
213
+ // NO_REPLY). Accept both the whole-text bare marker and the
214
+ // prose+trailing-marker shape (#2053). Same posture as the
215
+ // gateway's silent-marker suppression at gateway.ts:6692.
216
+ if (SILENT_MARKER_RE.test(text.trim()) || endsWithSilentMarker(text)) {
171
217
  return { decided: 'allow', reason: 'silent-marker' }
172
218
  }
173
219
  if (isFinalAnswerReply({
@@ -180,6 +226,16 @@ export function scanTurnForFinalReply(jsonl) {
180
226
  }
181
227
  }
182
228
 
229
+ // Cron-fired turns (#2053): a scheduled turn that produced no
230
+ // qualifying reply is NOT a delivery failure the user is waiting on —
231
+ // nagging it only pushes the model to escape the loop by shoving a
232
+ // NO_REPLY sentinel through the reply tool, which leaks to chat. A
233
+ // cron turn that genuinely needs to speak will have called reply
234
+ // (caught above); otherwise let it end silently.
235
+ if (envelope.source === 'cron') {
236
+ return { decided: 'allow', reason: 'cron-source' }
237
+ }
238
+
183
239
  const block = { decided: 'block', reason: 'no-final-reply' }
184
240
  if (envelope.chatId) {
185
241
  block.chatId = envelope.chatId
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Generic bare-high-entropy detector — the long-tail fallback.
3
+ *
4
+ * The provider/anchored patterns only catch tokens with a known prefix
5
+ * (sk-, ghp_, shpat_, …) or a KEY=value context. A STANDALONE high-entropy
6
+ * token pasted in prose — a raw Sanctum/base62 token with no prefix —
7
+ * matches none of them and used to slip through (the 2026-06-01 Sanctum
8
+ * incident). This scanner closes that gap.
9
+ *
10
+ * Emitted at **`ambiguous`** confidence, and `redact()` deliberately
11
+ * EXCLUDES this rule (see redact.ts): a generic guess must never silently
12
+ * mask — it would corrupt agent replies and stored messages (dense
13
+ * identifiers look high-entropy too). Its sole job is to drive the inbound
14
+ * gate's "👀 looks like a high-entropy string — stash to vault or ignore?"
15
+ * ASK prompt, where the operator confirms.
16
+ *
17
+ * Precision (the hard part — distinguishing a random token from a long
18
+ * technical identifier), via three cheap, composable filters:
19
+ * 1. CHARSET `[A-Za-z0-9]` only — NO `_` `-` `/` `+` `=` `.` `:`. This
20
+ * breaks snake_case / kebab-case / npm paths / slugs / version strings
21
+ * into sub-28 runs, so identifiers like `get_user_profile_by_org`,
22
+ * `flex-row-gap-4`, `@babel/plugin-transform-modules-commonjs` never
23
+ * form a candidate. (Cost: base64url tokens with `-`/`_` aren't caught
24
+ * here — they usually appear in Bearer/JWT/KV contexts other rules
25
+ * handle.)
26
+ * 2. ≥18 DISTINCT chars — excludes hex hashes/SHAs (≤16), digit runs
27
+ * (≤10) by construction; and since 18 distinct is unreachable with
28
+ * digits alone, a passing token necessarily contains letters.
29
+ * 3. Contains ≥1 DIGIT — kills CamelCase-without-digits identifiers
30
+ * (`AbstractSingletonProxyFactoryBeanGenerator`, `TheQuickBrownFox…`),
31
+ * which are the residual no-separator FP shape. Real base62 tokens
32
+ * almost always contain a digit (>99% at 28+ chars).
33
+ */
34
+ import type { RawHit } from './kv-scanner.js'
35
+
36
+ const CANDIDATE_RE = /[A-Za-z0-9]{28,}/g
37
+
38
+ // Unreachable with digits alone (10) → excludes hex (≤16) and digit runs;
39
+ // real base62 tokens have 24–62 distinct.
40
+ export const GENERIC_MIN_DISTINCT = 18
41
+
42
+ // A real message has at most a handful of credentials; bound the work on
43
+ // pathological/junk input (the O(n²) overlap-dedup downstream is the cost).
44
+ const MAX_GENERIC_HITS = 20
45
+
46
+ /** True once `tok` has at least `n` distinct chars (early-exit). ASCII-only
47
+ * by construction — CANDIDATE_RE admits no code point ≥ 128. */
48
+ function hasDistinctChars(tok: string, n: number): boolean {
49
+ const seen = new Uint8Array(128)
50
+ let distinct = 0
51
+ for (let i = 0; i < tok.length; i++) {
52
+ const c = tok.charCodeAt(i)
53
+ if (seen[c] === 0) {
54
+ seen[c] = 1
55
+ if (++distinct >= n) return true
56
+ }
57
+ }
58
+ return false
59
+ }
60
+
61
+ function hasDigit(tok: string): boolean {
62
+ for (let i = 0; i < tok.length; i++) {
63
+ const c = tok.charCodeAt(i)
64
+ if (c >= 48 && c <= 57) return true
65
+ }
66
+ return false
67
+ }
68
+
69
+ export function scanGenericSecrets(text: string): RawHit[] {
70
+ const hits: RawHit[] = []
71
+ CANDIDATE_RE.lastIndex = 0
72
+ let m: RegExpExecArray | null
73
+ while ((m = CANDIDATE_RE.exec(text)) !== null) {
74
+ if (hits.length >= MAX_GENERIC_HITS) break
75
+ const tok = m[0]
76
+ if (!hasDigit(tok)) continue
77
+ if (!hasDistinctChars(tok, GENERIC_MIN_DISTINCT)) continue
78
+ hits.push({
79
+ rule_id: 'generic_high_entropy',
80
+ start: m.index,
81
+ end: m.index + tok.length,
82
+ matched_text: tok,
83
+ confidence: 'ambiguous',
84
+ })
85
+ }
86
+ return hits
87
+ }
@@ -25,6 +25,7 @@
25
25
  */
26
26
  import { ALL_PATTERNS } from './patterns.js'
27
27
  import { scanKeyValue, type RawHit } from './kv-scanner.js'
28
+ import { scanGenericSecrets } from './generic-entropy.js'
28
29
  import { shannonEntropy } from './entropy.js'
29
30
  import { chunk } from './chunker.js'
30
31
  import { isSuppressed } from './suppressor.js'
@@ -118,6 +119,14 @@ export function detectSecrets(text: string): Detection[] {
118
119
  for (const h of kvHits) {
119
120
  raw.push({ ...h, start: h.start + win.offset, end: h.end + win.offset })
120
121
  }
122
+ // Generic bare-high-entropy fallback (ambiguous). Catches standalone
123
+ // tokens no prefix/KV rule matched. dropOverlaps/dedupeRaw below prefer
124
+ // a high-confidence pattern hit over a generic one on the same range,
125
+ // so a recognized token isn't double-flagged.
126
+ const genHits = scanGenericSecrets(win.text)
127
+ for (const h of genHits) {
128
+ raw.push({ ...h, start: h.start + win.offset, end: h.end + win.offset })
129
+ }
121
130
  }
122
131
 
123
132
  // Dedupe by range + rule. If two rules hit the same range, prefer the
@@ -171,24 +180,28 @@ function dedupeRaw(raw: RawHit[]): RawHit[] {
171
180
  }
172
181
 
173
182
  /**
174
- * Drop hits fully contained inside another hit. Keeps the outer (typically
175
- * broader / higher-signal) hit — e.g. a JWT match wholly inside an
176
- * Authorization Bearer match keeps the Bearer.
183
+ * Drop an AMBIGUOUS hit that is fully contained inside another (larger)
184
+ * hit — e.g. a `generic_high_entropy` sub-span sitting inside a recognized
185
+ * high token, or inside an Authorization Bearer match. Narrow by design:
186
+ * it never drops a high-confidence hit and never touches high-vs-high
187
+ * overlaps, so it can't suppress a real detection — it only removes the
188
+ * redundant low-precision sub-spans the generic fallback can emit.
177
189
  */
178
190
  function dropOverlaps(hits: RawHit[]): RawHit[] {
179
- const sorted = [...hits].sort((a, b) => (a.end - a.start) - (b.end - b.start))
180
- const out: RawHit[] = []
181
- for (const h of sorted) {
182
- const contained = out.some(
183
- (existing) =>
184
- existing !== h &&
185
- existing.start <= h.start &&
186
- existing.end >= h.end &&
187
- !(existing.start === h.start && existing.end === h.end),
188
- )
189
- if (!contained) out.push(h)
190
- }
191
- // Re-sort by start offset for deterministic downstream handling.
191
+ const out = hits.filter(
192
+ (h) =>
193
+ !(
194
+ h.confidence === 'ambiguous' &&
195
+ hits.some(
196
+ (o) =>
197
+ o !== h &&
198
+ o.start <= h.start &&
199
+ o.end >= h.end &&
200
+ !(o.start === h.start && o.end === h.end),
201
+ )
202
+ ),
203
+ )
204
+ // Sort by start offset for deterministic downstream handling.
192
205
  out.sort((a, b) => a.start - b.start || a.end - b.end)
193
206
  return out
194
207
  }
@@ -217,16 +230,22 @@ export async function detectSecretsAsync(text: string): Promise<Detection[]> {
217
230
  import('./secretlint-source.js').then((m) => m.detectViaSecretlint(text)),
218
231
  ])
219
232
 
220
- // Merge with range-based dedupe. Vendored first wins on exact ties.
233
+ // Merge with range-based dedupe. On an exact-range tie, prefer the
234
+ // higher-confidence detection (else vendored-first). This matters since
235
+ // the vendored generic high-entropy fallback emits `ambiguous` — without
236
+ // the confidence tie-break it would shadow a Secretlint `high` provider
237
+ // hit on the same span and silently downgrade it (mirrors the sync
238
+ // dedupeRaw's high-over-ambiguous rule).
221
239
  const seen = new Map<string, Detection>()
222
- for (const d of vendored) {
240
+ const consider = (d: Detection): void => {
223
241
  const key = `${d.start}:${d.end}`
224
- if (!seen.has(key)) seen.set(key, d)
225
- }
226
- for (const d of viaSecretlint) {
227
- const key = `${d.start}:${d.end}`
228
- if (!seen.has(key)) seen.set(key, d)
242
+ const existing = seen.get(key)
243
+ if (!existing || (existing.confidence === 'ambiguous' && d.confidence === 'high')) {
244
+ seen.set(key, d)
245
+ }
229
246
  }
247
+ for (const d of vendored) consider(d)
248
+ for (const d of viaSecretlint) consider(d)
230
249
 
231
250
  // Re-derive slugs against the merged set (Secretlint and vendored each
232
251
  // had independent `existing` sets; we coalesce here).
@@ -118,6 +118,68 @@ export const STRUCTURED_PATTERNS: PatternDef[] = [
118
118
  ]
119
119
 
120
120
  /**
121
- * Concatenated registry anchored first, then structured.
121
+ * High-precision PREFIXED provider patterns (gitleaks/secret-scanner style).
122
+ *
123
+ * Every rule here has a distinctive literal prefix + length, so false
124
+ * positives on ordinary chat/code are near-zero — which is load-bearing,
125
+ * because the inbound gate DELETES a message on a high-confidence hit.
126
+ * We deliberately keep ONLY prefix-anchored rules here; a generic
127
+ * "any high-entropy string" detector (the bare-token gap that let the
128
+ * Sanctum `<id>|<token>` slip) is intentionally NOT here — that's a
129
+ * separate, ambiguous-routed change so it can't auto-delete on a guess.
130
+ *
131
+ * Baked as TS (not loaded from the vendored gitleaks.toml at runtime):
132
+ * the bundler inlines secret-detect into dist/server.js + gateway.js and
133
+ * does NOT ship the .toml alongside, so a runtime `loadGitleaksPatterns()`
134
+ * would silently resolve to nothing in the agent image. TS entries flow
135
+ * through ALL_PATTERNS into the shared detectSecrets engine, so they
136
+ * protect the inbound gate, the outbound mask, AND the issues pipeline.
137
+ *
138
+ * Provider prefixes already in ANCHORED_PATTERNS (sk-ant-, sk-, ghp_,
139
+ * github_pat_, AKIA, AIza, xox*, gsk_, pplx-, npm_, telegram id:token, jwt)
140
+ * are intentionally omitted to avoid duplicate hits.
141
+ */
142
+ export const PROVIDER_PATTERNS: PatternDef[] = [
143
+ { rule_id: 'slack_webhook', regex: /(https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9_/]+)/g, captureIndex: 1, slugHint: 'slack_webhook' },
144
+ { rule_id: 'stripe_live_secret', regex: /\b(sk_live_[A-Za-z0-9]{24,})\b/g, captureIndex: 1, slugHint: 'stripe_key' },
145
+ { rule_id: 'stripe_restricted', regex: /\b(rk_live_[A-Za-z0-9]{24,})\b/g, captureIndex: 1, slugHint: 'stripe_key' },
146
+ { rule_id: 'sendgrid_api_key', regex: /\b(SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43})\b/g, captureIndex: 1, slugHint: 'sendgrid_key' },
147
+ { rule_id: 'gitlab_pat', regex: /\b(glpat-[A-Za-z0-9_-]{20})\b/g, captureIndex: 1, slugHint: 'gitlab_pat' },
148
+ { rule_id: 'huggingface_token', regex: /\b(hf_[A-Za-z0-9]{34,})\b/g, captureIndex: 1, slugHint: 'huggingface_token' },
149
+ // (openai sk-proj-/sk-svcacct- are already covered by the anchored
150
+ // `openai_api_key` sk- rule — no separate entry needed.)
151
+ { rule_id: 'twilio_api_key', regex: /\b(SK[0-9a-f]{32})\b/g, captureIndex: 1, slugHint: 'twilio_api_key' },
152
+ { rule_id: 'mailgun_key', regex: /\b(key-[0-9a-f]{32})\b/g, captureIndex: 1, slugHint: 'mailgun_key' },
153
+ // (mailchimp keys are `<32-hex>-us<N>` with NO distinctive prefix — that
154
+ // collides with md5 hashes / ETags followed by `-usN` and would auto-delete
155
+ // benign messages. Deferred to the planned generic high-entropy detector,
156
+ // which asks instead of auto-deleting. Review #2054.)
157
+ { rule_id: 'digitalocean_pat', regex: /\b(dop_v1_[a-f0-9]{64})\b/g, captureIndex: 1, slugHint: 'digitalocean_token' },
158
+ { rule_id: 'digitalocean_oauth', regex: /\b(doo_v1_[a-f0-9]{64})\b/g, captureIndex: 1, slugHint: 'digitalocean_token' },
159
+ { rule_id: 'digitalocean_refresh', regex: /\b(dor_v1_[a-f0-9]{64})\b/g, captureIndex: 1, slugHint: 'digitalocean_token' },
160
+ { rule_id: 'doppler_token', regex: /\b(dp\.(?:pt|st|ct|sa|scim|audit)\.[A-Za-z0-9]{40,44})\b/g, captureIndex: 1, slugHint: 'doppler_token' },
161
+ { rule_id: 'linear_api_key', regex: /\b(lin_api_[A-Za-z0-9]{40})\b/g, captureIndex: 1, slugHint: 'linear_api_key' },
162
+ { rule_id: 'shopify_access_token', regex: /\b(shpat_[a-fA-F0-9]{32})\b/g, captureIndex: 1, slugHint: 'shopify_token' },
163
+ { rule_id: 'shopify_shared_secret', regex: /\b(shpss_[a-fA-F0-9]{32})\b/g, captureIndex: 1, slugHint: 'shopify_token' },
164
+ { rule_id: 'shopify_private_app', regex: /\b(shppa_[a-fA-F0-9]{32})\b/g, captureIndex: 1, slugHint: 'shopify_token' },
165
+ { rule_id: 'square_access_token', regex: /\b(sq0atp-[A-Za-z0-9_-]{22})\b/g, captureIndex: 1, slugHint: 'square_token' },
166
+ { rule_id: 'square_oauth_secret', regex: /\b(sq0csp-[A-Za-z0-9_-]{43})\b/g, captureIndex: 1, slugHint: 'square_token' },
167
+ { rule_id: 'newrelic_key', regex: /\b(NRAK-[A-Z0-9]{27})\b/g, captureIndex: 1, slugHint: 'newrelic_key' },
168
+ { rule_id: 'notion_token', regex: /\b(ntn_[A-Za-z0-9]{46})\b/g, captureIndex: 1, slugHint: 'notion_token' },
169
+ { rule_id: 'planetscale_password', regex: /\b(pscale_pw_[A-Za-z0-9_.-]{43})\b/g, captureIndex: 1, slugHint: 'planetscale_token' },
170
+ { rule_id: 'planetscale_token', regex: /\b(pscale_tkn_[A-Za-z0-9_.-]{43})\b/g, captureIndex: 1, slugHint: 'planetscale_token' },
171
+ { rule_id: 'supabase_service_key', regex: /\b(sbp_[a-f0-9]{40})\b/g, captureIndex: 1, slugHint: 'supabase_key' },
172
+ { rule_id: 'atlassian_token', regex: /\b(ATATT[A-Za-z0-9_\-=]{20,})\b/g, captureIndex: 1, slugHint: 'atlassian_token' },
173
+ { rule_id: 'dropbox_token', regex: /\b(sl\.[A-Za-z0-9_-]{130,})/g, captureIndex: 1, slugHint: 'dropbox_token' },
174
+ { rule_id: 'databricks_token', regex: /\b(dapi[a-f0-9]{32})\b/g, captureIndex: 1, slugHint: 'databricks_token' },
175
+ { rule_id: 'grafana_service_account', regex: /\b(glsa_[A-Za-z0-9]{32}_[a-fA-F0-9]{8})\b/g, captureIndex: 1, slugHint: 'grafana_token' },
176
+ { rule_id: 'pypi_token', regex: /\b(pypi-AgEIcHlwaS[A-Za-z0-9_-]{50,})/g, captureIndex: 1, slugHint: 'pypi_token' },
177
+ { rule_id: 'aws_temp_access_key', regex: /\b(ASIA[0-9A-Z]{16})\b/g, captureIndex: 1, slugHint: 'aws_access_key' },
178
+ { rule_id: 'gcp_oauth_token', regex: /\b(ya29\.[A-Za-z0-9_-]{30,})/g, captureIndex: 1, slugHint: 'gcp_oauth_token' },
179
+ ]
180
+
181
+ /**
182
+ * Concatenated registry — anchored + provider prefixes first (high
183
+ * precision), then structured.
122
184
  */
123
- export const ALL_PATTERNS: PatternDef[] = [...ANCHORED_PATTERNS, ...STRUCTURED_PATTERNS]
185
+ export const ALL_PATTERNS: PatternDef[] = [...ANCHORED_PATTERNS, ...PROVIDER_PATTERNS, ...STRUCTURED_PATTERNS]
@@ -50,7 +50,16 @@ export function redact(text: string): string {
50
50
  const urlScrubbed = redactUrls(text)
51
51
 
52
52
  // Step 2 — token shape detection over the URL-scrubbed text.
53
- const hits: Detection[] = detectSecrets(urlScrubbed)
53
+ // EXCLUDE the generic high-entropy fallback: it is a low-precision
54
+ // "looks like a secret" signal (it flags dense technical identifiers —
55
+ // CamelCase class names, snake_case symbols, npm paths, slugs — as well
56
+ // as real tokens). It exists to drive the inbound "stash to vault or
57
+ // ignore?" ASK prompt, NOT to silently mask. Letting it into redact()
58
+ // would corrupt agent replies (the outbound mask) and stored messages.
59
+ // Only prefix/structured (high) + the contextual kv_entropy hits mask.
60
+ const hits: Detection[] = detectSecrets(urlScrubbed).filter(
61
+ (h) => h.rule_id !== 'generic_high_entropy',
62
+ )
54
63
  if (hits.length === 0) return urlScrubbed
55
64
 
56
65
  // Apply replacements right-to-left so byte offsets stay valid.
@@ -0,0 +1,94 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { detectSecrets } from '../secret-detect/index.js'
3
+ import { scanGenericSecrets, GENERIC_MIN_DISTINCT } from '../secret-detect/generic-entropy.js'
4
+ import { redact } from '../secret-detect/redact.js'
5
+
6
+ /**
7
+ * Generic bare-high-entropy fallback (#1) — the long-tail detector for
8
+ * standalone tokens that no prefix/KV rule matches (the Sanctum class).
9
+ * Emitted at `ambiguous` confidence: the inbound gate ASKS ("stash to
10
+ * vault or ignore?") rather than auto-deleting, so recall can be generous.
11
+ *
12
+ * Fixtures built by concatenation (no contiguous secret-shaped literals).
13
+ */
14
+
15
+ // 32 varied base62 chars → high entropy (~5 bits/char).
16
+ const HIGH_ENTROPY = 'q7Wm2Zx9' + 'Lk4Rp1Vn' + '8Bs3Yt6H' + 'd5Gj0Fc7'
17
+ // 32 chars but only 3 distinct → low entropy (< 4), must NOT flag.
18
+ const LOW_ENTROPY = 'abc'.repeat(11) // 33 chars, entropy ~1.6
19
+
20
+ describe('generic high-entropy detector', () => {
21
+ it('flags a standalone high-entropy token as ambiguous', () => {
22
+ const hits = detectSecrets(`the value is ${HIGH_ENTROPY} ok`)
23
+ const hit = hits.find((d) => d.rule_id === 'generic_high_entropy')
24
+ expect(hit).toBeDefined()
25
+ expect(hit!.matched_text).toBe(HIGH_ENTROPY)
26
+ expect(hit!.confidence).toBe('ambiguous') // asks, never auto-deletes
27
+ })
28
+
29
+ it('redact() does NOT mask a generic-flagged token (the #2059 outbound-corruption regression)', () => {
30
+ // HIGH_ENTROPY flags as generic_high_entropy (ambiguous). redact() — the
31
+ // chokepoint for the outbound reply mask + history + issues — must leave
32
+ // it intact; masking it would corrupt agent replies. This is the exact
33
+ // BLOCK that shipped to review; pin it.
34
+ const text = `use ${HIGH_ENTROPY} for the deploy`
35
+ expect(redact(text)).toBe(text)
36
+ })
37
+
38
+ it('respects the distinct-char floor (repetitive long strings do not flag)', () => {
39
+ expect(scanGenericSecrets(LOW_ENTROPY).length).toBe(0) // 3 distinct < 18
40
+ expect(GENERIC_MIN_DISTINCT).toBe(18)
41
+ })
42
+
43
+ it('caps hits on pathological input (bounds the O(n²) overlap-dedup)', () => {
44
+ // 100 distinct high-entropy tokens; the scanner must not return all 100.
45
+ const blob = Array.from({ length: 100 }, (_, i) =>
46
+ ('q7Wm2Zx9Lk4Rp1Vn8Bs3Yt6H' + 'd5Gj0Fc7') + String(i).padStart(3, '0'),
47
+ ).join(' ')
48
+ expect(scanGenericSecrets(blob).length).toBeLessThanOrEqual(20)
49
+ })
50
+
51
+ it('respects the length floor (short tokens do not flag)', () => {
52
+ const short = 'q7Wm2Zx9Lk4Rp1Vn' // 16 chars
53
+ expect(scanGenericSecrets(short).length).toBe(0)
54
+ })
55
+
56
+ it('does NOT downgrade a recognized high-confidence token', () => {
57
+ // A ghp_ token is matched by the anchored pattern (high). The generic
58
+ // pass must not swallow/downgrade it to ambiguous.
59
+ const ghp = 'ghp_' + 'A1b2C3d4E5'.repeat(3) // ghp_ + 30
60
+ const hits = detectSecrets(`token ${ghp} here`)
61
+ const ghpHit = hits.find((d) => d.matched_text === ghp || d.rule_id === 'github_pat_classic')
62
+ expect(ghpHit).toBeDefined()
63
+ expect(ghpHit!.confidence).toBe('high')
64
+ })
65
+
66
+ describe('false-positive guards — benign high-entropy shapes do NOT flag', () => {
67
+ const BENIGN: Array<[string, string]> = [
68
+ ['a UUID', '550e8400-e29b-41d4-a716-446655440000'],
69
+ ['a git SHA (40 hex)', 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0'],
70
+ ['a sha256 (64 hex)', 'e3b0c44298fc1c149afbf4c8996fb924' + '27ae41e4649b934ca495991b7852b855'],
71
+ ['an md5 (32 hex)', 'd41d8cd98f00b204' + 'e9800998ecf8427e'],
72
+ ['a long digit run', '123456789012345678901234567890'],
73
+ ['plain prose', 'the quick brown fox jumps over the lazy dog repeatedly today'],
74
+ ['a file path', '/usr/local/lib/python3.11/site-packages/somepackage/internal/module.py'],
75
+ // Dense technical identifiers — the FP shapes the reviewer flagged.
76
+ // CamelCase-no-digit → killed by the digit requirement; separator
77
+ // styles (snake/kebab/npm/slug) → broken into sub-28 runs by the
78
+ // charset (no `_ - / .`).
79
+ ['a CamelCase class name', 'AbstractSingletonProxyFactoryBeanGenerator'],
80
+ ['a snake_case symbol', 'get_user_profile_by_organization_identifier'],
81
+ ['a kebab-case slug', 'how-to-configure-kubernetes-ingress-with-cert-manager'],
82
+ ['an npm package path', '@babel/plugin-transform-modules-commonjs'],
83
+ ['a CSS class string (has a digit)', 'flex-row-justify-between-items-center-gap-4'],
84
+ ['a long CamelCase phrase', 'TheQuickBrownFoxJumpsOverTheLazyDogToday'],
85
+ ['a 32-char base62 with NO digit', 'AbcdefGhijkLmnopQrstuVwxyzABCDEFG'],
86
+ ]
87
+ for (const [label, text] of BENIGN) {
88
+ it(`${label} does not flag generic_high_entropy`, () => {
89
+ const hits = detectSecrets(text).filter((d) => d.rule_id === 'generic_high_entropy')
90
+ expect(hits, `unexpected: ${JSON.stringify(hits.map((h) => h.matched_text))}`).toHaveLength(0)
91
+ })
92
+ }
93
+ })
94
+ })