switchroom 0.14.30 → 0.14.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/agent-scheduler/index.js +80 -80
  2. package/dist/auth-broker/index.js +80 -80
  3. package/dist/cli/drive-write-pretool.mjs +10 -10
  4. package/dist/cli/notion-write-pretool.mjs +82 -82
  5. package/dist/cli/skill-validate-pretool.mjs +72 -72
  6. package/dist/cli/switchroom.js +453 -366
  7. package/dist/host-control/main.js +235 -157
  8. package/dist/vault/approvals/kernel-server.js +82 -82
  9. package/dist/vault/broker/server.js +83 -83
  10. package/package.json +1 -1
  11. package/telegram-plugin/dist/bridge/bridge.js +112 -112
  12. package/telegram-plugin/dist/gateway/gateway.js +357 -213
  13. package/telegram-plugin/dist/server.js +160 -160
  14. package/telegram-plugin/gateway/gateway.ts +83 -9
  15. package/telegram-plugin/hooks/hooks.json +9 -0
  16. package/telegram-plugin/hooks/sentinel-reply-guard-pretool.mjs +114 -0
  17. package/telegram-plugin/hooks/silent-end-scan.mjs +61 -5
  18. package/telegram-plugin/registry/turns-schema.test.ts +34 -0
  19. package/telegram-plugin/registry/turns-schema.ts +18 -0
  20. package/telegram-plugin/secret-detect/generic-entropy.ts +87 -0
  21. package/telegram-plugin/secret-detect/index.ts +42 -23
  22. package/telegram-plugin/secret-detect/patterns.ts +64 -2
  23. package/telegram-plugin/secret-detect/redact.ts +10 -1
  24. package/telegram-plugin/tests/secret-detect-generic-entropy.test.ts +94 -0
  25. package/telegram-plugin/tests/secret-detect-providers.test.ts +74 -0
  26. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +8 -4
  27. package/telegram-plugin/tests/sentinel-reply-guard-pretool.test.ts +109 -0
  28. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +118 -0
  29. package/telegram-plugin/tests/turn-flush-safety.test.ts +41 -0
  30. package/telegram-plugin/turn-flush-safety.ts +41 -0
@@ -427,6 +427,7 @@ import {
427
427
  recordTurnEnd,
428
428
  findLatestTurnIfInterrupted,
429
429
  findRecentTurnsForChat,
430
+ getTurnByKey,
430
431
  } from '../registry/turns-schema.js'
431
432
  import {
432
433
  buildResumeInterruptedInbound,
@@ -1117,6 +1118,41 @@ try {
1117
1118
  turnsDb = null
1118
1119
  }
1119
1120
 
1121
+ /**
1122
+ * Resolve the chat/thread a background sub-agent was dispatched from, so
1123
+ * its live worker card + handback route back to the originating
1124
+ * conversation (group / forum topic) instead of the operator DM.
1125
+ *
1126
+ * Walks jsonl_agent_id → `subagents.parent_turn_key` →
1127
+ * `turns.chat_id`/`thread_id`. Returns null on any miss so the caller
1128
+ * keeps its existing `allowFrom[0]` DM fallback — best-effort, never
1129
+ * throws out of the worker-card hot path. This restores the chat context
1130
+ * the pinned-card fleet used to carry before it was removed in #1122
1131
+ * (progressDriver is permanently null, so the old fleet lookup always
1132
+ * yielded the DM for a Task dispatched from a group/topic).
1133
+ */
1134
+ function resolveSubagentOriginChat(
1135
+ agentId: string,
1136
+ ): { chatId: string; threadId?: number } | null {
1137
+ if (turnsDb == null) return null
1138
+ try {
1139
+ const sub = getSubagentByJsonlId(turnsDb, agentId)
1140
+ if (sub?.parent_turn_key == null) return null
1141
+ const turn = getTurnByKey(turnsDb, sub.parent_turn_key)
1142
+ if (turn == null || turn.chat_id.length === 0) return null
1143
+ const threadNum =
1144
+ turn.thread_id != null && turn.thread_id.length > 0
1145
+ ? Number(turn.thread_id)
1146
+ : NaN
1147
+ return {
1148
+ chatId: turn.chat_id,
1149
+ threadId: Number.isFinite(threadNum) ? threadNum : undefined,
1150
+ }
1151
+ } catch {
1152
+ return null
1153
+ }
1154
+ }
1155
+
1120
1156
  // ─── Periodic history reaper (#1073) ──────────────────────────────────────
1121
1157
  // The init-time prune in history.ts only touched the `messages` table.
1122
1158
  // `subagents` and `turns` in registry.db grew unbounded — every Agent()
@@ -10515,6 +10551,33 @@ async function handleInbound(
10515
10551
  return
10516
10552
  }
10517
10553
 
10554
+ // Pre-send composer clear (the marko wedge). The inbound is about to be
10555
+ // delivered as an MCP `notifications/claude/channel` notification, which
10556
+ // the unmodified CLI appends into its composer and auto-submits ONLY when
10557
+ // the composer is empty + idle. The #1556 gate above guarantees idle, but
10558
+ // NOT empty: stale typed-ahead / ghost text stranded in the composer
10559
+ // (observed live on agent `marko`: "Yes, go ahead on both") makes the
10560
+ // appended inbound fail to submit and silently swallows every subsequent
10561
+ // queued inbound until a hard restart. Wipe the composer first so the
10562
+ // notification lands at a clean line. Soft-fail by contract — a clear
10563
+ // failure (no tmux session under legacy_pty, socket missing, timeout)
10564
+ // must NEVER block delivery; log and proceed.
10565
+ if (selfAgent) {
10566
+ try {
10567
+ const { clearAgentComposer } = await import('../../src/agents/tmux.js')
10568
+ const cleared = clearAgentComposer({ agentName: selfAgent })
10569
+ if ('error' in cleared) {
10570
+ process.stderr.write(
10571
+ `telegram gateway: pre-send composer-clear soft-failed agent=${selfAgent}: ${cleared.error} — delivering anyway\n`,
10572
+ )
10573
+ }
10574
+ } catch (err) {
10575
+ process.stderr.write(
10576
+ `telegram gateway: pre-send composer-clear threw agent=${selfAgent}: ${(err as Error).message} — delivering anyway\n`,
10577
+ )
10578
+ }
10579
+ }
10580
+
10518
10581
  const delivered = ipcServer.sendToAgent(selfAgent, inboundMsg)
10519
10582
  if (delivered) markClaudeBusyForInbound(inboundMsg)
10520
10583
  if (!delivered) {
@@ -18344,11 +18407,15 @@ void (async () => {
18344
18407
  handbackEnvValue: process.env.SWITCHROOM_SUBAGENT_HANDBACK,
18345
18408
  outcome,
18346
18409
  isBackground,
18347
- fleetChatId,
18348
- // Owner-chat fallback: if the progress-driver fleet
18349
- // entry was already cleaned up, route to the owner
18350
- // chat. Every switchroom fleet agent is DM-shaped, so
18351
- // allowFrom[0] is the conversation that dispatched.
18410
+ // Route the handback (the worker's result → a synthesized
18411
+ // turn) back to the conversation the Task was dispatched
18412
+ // from, so the result lands where the user asked — not the
18413
+ // agent's DM. Falls back to fleetChatId/ownerChatId.
18414
+ fleetChatId: resolveSubagentOriginChat(agentId)?.chatId || fleetChatId,
18415
+ // Owner-chat fallback: if the parent-turn chat can't be
18416
+ // resolved, route to the owner chat. Every switchroom fleet
18417
+ // agent is DM-shaped, so allowFrom[0] is the conversation
18418
+ // that dispatched.
18352
18419
  ownerChatId: loadAccess().allowFrom[0] ?? '',
18353
18420
  taskDescription: description,
18354
18421
  resultText,
@@ -18478,12 +18545,16 @@ void (async () => {
18478
18545
  // message owns the progress beat. Push a running cue and
18479
18546
  // return BEFORE the legacy bucket relay so the same activity
18480
18547
  // isn't double-surfaced (in-message edit + injected
18481
- // "still working" inbound turn). Chat = owner DM, since the
18482
- // pinned-card fleet is gone and every agent is DM-shaped.
18548
+ // "still working" inbound turn). Route to the conversation
18549
+ // the Task was dispatched from (group / forum topic) via the
18550
+ // parent turn; fall back to the owner DM when that can't be
18551
+ // resolved (the pinned-card fleet that used to carry the chat
18552
+ // is gone — see resolveSubagentOriginChat).
18483
18553
  if (workerFeedEnabled) {
18554
+ const origin = resolveSubagentOriginChat(agentId)
18484
18555
  void workerActivityFeed.update(
18485
18556
  agentId,
18486
- fleetChatId || (loadAccess().allowFrom[0] ?? ''),
18557
+ origin?.chatId || fleetChatId || (loadAccess().allowFrom[0] ?? ''),
18487
18558
  {
18488
18559
  description: dispatch.feedDescription,
18489
18560
  lastTool,
@@ -18492,6 +18563,7 @@ void (async () => {
18492
18563
  elapsedMs,
18493
18564
  state: 'running',
18494
18565
  },
18566
+ origin?.threadId,
18495
18567
  )
18496
18568
  return
18497
18569
  }
@@ -18499,7 +18571,9 @@ void (async () => {
18499
18571
  const decision = decideSubagentProgress({
18500
18572
  disableEnvValue: process.env.SWITCHROOM_DISABLE_SUBAGENT_PROGRESS,
18501
18573
  isBackground,
18502
- fleetChatId,
18574
+ // Prefer the conversation the Task was dispatched from over
18575
+ // the owner DM (see resolveSubagentOriginChat).
18576
+ fleetChatId: resolveSubagentOriginChat(agentId)?.chatId || fleetChatId,
18503
18577
  ownerChatId: loadAccess().allowFrom[0] ?? '',
18504
18578
  subagentJsonlId: agentId,
18505
18579
  taskDescription: description,
@@ -10,6 +10,15 @@
10
10
  }
11
11
  ]
12
12
  },
13
+ {
14
+ "hooks": [
15
+ {
16
+ "type": "command",
17
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/sentinel-reply-guard-pretool.mjs\"",
18
+ "timeout": 5
19
+ }
20
+ ]
21
+ },
13
22
  {
14
23
  "matcher": "^(Agent|Task)$",
15
24
  "hooks": [
@@ -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
@@ -20,6 +20,7 @@ import {
20
20
  recordTurnStart,
21
21
  recordTurnEnd,
22
22
  findRecentTurnsForChat,
23
+ getTurnByKey,
23
24
  } from './turns-schema.js'
24
25
 
25
26
  // ---------------------------------------------------------------------------
@@ -99,3 +100,36 @@ describe('findRecentTurnsForChat', () => {
99
100
  db.close()
100
101
  })
101
102
  })
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // getTurnByKey — recover the dispatch chat/thread for a sub-agent's parent
106
+ // turn (subagents.parent_turn_key -> turns.turn_key). Without this the
107
+ // worker card / handback fall back to the operator DM (#worker-card-routing).
108
+ // ---------------------------------------------------------------------------
109
+
110
+ describe('getTurnByKey', () => {
111
+ it('returns null when the turn key does not exist', () => {
112
+ const db = openTurnsDbInMemory()
113
+ expect(getTurnByKey(db, 'nope')).toBeNull()
114
+ db.close()
115
+ })
116
+
117
+ it('recovers chat_id + thread_id for a group/topic turn', () => {
118
+ const db = openTurnsDbInMemory()
119
+ recordTurnStart(db, { turnKey: 'g:11', chatId: '-1001234567890', threadId: '42' })
120
+ const turn = getTurnByKey(db, 'g:11')
121
+ expect(turn?.turn_key).toBe('g:11')
122
+ expect(turn?.chat_id).toBe('-1001234567890')
123
+ expect(turn?.thread_id).toBe('42')
124
+ db.close()
125
+ })
126
+
127
+ it('recovers chat_id with null thread_id for a plain group/DM turn', () => {
128
+ const db = openTurnsDbInMemory()
129
+ recordTurnStart(db, { turnKey: 'dm:7', chatId: '12345' })
130
+ const turn = getTurnByKey(db, 'dm:7')
131
+ expect(turn?.chat_id).toBe('12345')
132
+ expect(turn?.thread_id).toBeNull()
133
+ db.close()
134
+ })
135
+ })
@@ -348,6 +348,24 @@ export function findOrphanedTurns(db: SqliteDatabase, chatId: string): Turn[] {
348
348
  return rows.map(mapRow)
349
349
  }
350
350
 
351
+ /**
352
+ * Fetch a single turn by its primary key, or null if absent.
353
+ *
354
+ * Used to recover the chat/thread a background sub-agent was dispatched
355
+ * from: `subagents.parent_turn_key` is an FK-by-convention to
356
+ * `turns.turn_key`, so this resolves the originating conversation
357
+ * (chat_id + thread_id) for a worker card / handback. Without it the
358
+ * worker feed falls back to the operator DM (the pinned-card fleet that
359
+ * used to carry the chat was removed in #1122), so a Task dispatched from
360
+ * a group/topic posted its progress to the agent's DM instead.
361
+ */
362
+ export function getTurnByKey(db: SqliteDatabase, turnKey: string): Turn | null {
363
+ const row = db
364
+ .prepare(`SELECT * FROM turns WHERE turn_key = ?`)
365
+ .get(turnKey) as RawTurnRow | undefined
366
+ return row ? mapRow(row) : null
367
+ }
368
+
351
369
  export interface OrphanClassifyOpts {
352
370
  /**
353
371
  * `turnKey` from the on-disk `turn-active.json` marker — the single
@@ -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).