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.
- package/dist/agent-scheduler/index.js +80 -80
- package/dist/auth-broker/index.js +80 -80
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +82 -82
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +453 -366
- package/dist/host-control/main.js +235 -157
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- package/package.json +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +357 -213
- package/telegram-plugin/dist/server.js +160 -160
- package/telegram-plugin/gateway/gateway.ts +83 -9
- package/telegram-plugin/hooks/hooks.json +9 -0
- package/telegram-plugin/hooks/sentinel-reply-guard-pretool.mjs +114 -0
- package/telegram-plugin/hooks/silent-end-scan.mjs +61 -5
- package/telegram-plugin/registry/turns-schema.test.ts +34 -0
- package/telegram-plugin/registry/turns-schema.ts +18 -0
- package/telegram-plugin/secret-detect/generic-entropy.ts +87 -0
- package/telegram-plugin/secret-detect/index.ts +42 -23
- package/telegram-plugin/secret-detect/patterns.ts +64 -2
- package/telegram-plugin/secret-detect/redact.ts +10 -1
- package/telegram-plugin/tests/secret-detect-generic-entropy.test.ts +94 -0
- package/telegram-plugin/tests/secret-detect-providers.test.ts +74 -0
- package/telegram-plugin/tests/secret-detect-secretlint.test.ts +8 -4
- package/telegram-plugin/tests/sentinel-reply-guard-pretool.test.ts +109 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +118 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +41 -0
- 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
|
-
|
|
18348
|
-
//
|
|
18349
|
-
//
|
|
18350
|
-
//
|
|
18351
|
-
|
|
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).
|
|
18482
|
-
//
|
|
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
|
-
|
|
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,
|
|
@@ -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).
|
|
169
|
-
//
|
|
170
|
-
|
|
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
|
|
175
|
-
*
|
|
176
|
-
* Authorization Bearer match
|
|
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
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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.
|
|
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
|
-
|
|
240
|
+
const consider = (d: Detection): void => {
|
|
223
241
|
const key = `${d.start}:${d.end}`
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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).
|