switchroom 0.14.29 → 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.
- package/dist/cli/switchroom.js +98 -11
- package/dist/host-control/main.js +87 -9
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +144 -24
- package/telegram-plugin/gateway/gateway.ts +27 -0
- 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/pending-work-progress.ts +10 -3
- 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/pending-work-progress.test.ts +22 -4
- 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
- package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +2 -1
- package/telegram-plugin/uat/scenarios/jtbd-pending-progress-html-dm.test.ts +2 -1
|
@@ -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
|
|
@@ -35,7 +35,11 @@
|
|
|
35
35
|
* turn_end with pending+anchor → activate the timer for the key
|
|
36
36
|
* tick (every 5s, edit every → editMessageText against the anchor
|
|
37
37
|
* EDIT_INTERVAL_MS) appending/refreshing the suffix
|
|
38
|
-
* " — still working (Nm)
|
|
38
|
+
* " — still working (Nm) · message me
|
|
39
|
+
* anytime, I'll keep you posted"
|
|
40
|
+
* (the reachability clause signals the
|
|
41
|
+
* agent is still listening while a
|
|
42
|
+
* background worker runs — issue PR3)
|
|
39
43
|
* inbound user message → clear (user re-engaged or moved on)
|
|
40
44
|
* subagent_handback inject → clear (model about to re-engage)
|
|
41
45
|
* MAX_LIFETIME_MS budget cap → clear (give up; 30 min default)
|
|
@@ -70,10 +74,13 @@ export const TELEGRAM_MSG_CAP = 4000
|
|
|
70
74
|
/**
|
|
71
75
|
* Regex matching the suffix we append. Used to strip a prior suffix
|
|
72
76
|
* before appending the next one. The (\d+) covers "1m" / "12m" / etc.
|
|
77
|
+
* The reachability clause is optional so anchors carrying a pre-v0.14.30
|
|
78
|
+
* suffix (no clause) are still stripped during a rolling upgrade.
|
|
73
79
|
* Kept anchored to end-of-string so it only matches OUR suffix, not
|
|
74
80
|
* something the model happened to write.
|
|
75
81
|
*/
|
|
76
|
-
const SUFFIX_RE =
|
|
82
|
+
const SUFFIX_RE =
|
|
83
|
+
/\n\n— still working \(\d+m\)( · message me anytime, I'll keep you posted)?$/
|
|
77
84
|
|
|
78
85
|
export interface PendingProgressEditCtx {
|
|
79
86
|
chatId: string
|
|
@@ -380,7 +387,7 @@ function tick(now: number): void {
|
|
|
380
387
|
// user-visible counter reads honestly (we only edit at intervals
|
|
381
388
|
// ≥ EDIT_INTERVAL_MS = 60s).
|
|
382
389
|
const minutes = Math.max(1, Math.round(elapsed / 60_000))
|
|
383
|
-
const suffix = `\n\n— still working (${minutes}m)`
|
|
390
|
+
const suffix = `\n\n— still working (${minutes}m) · message me anytime, I'll keep you posted`
|
|
384
391
|
const newText = s.anchorOriginalText + suffix
|
|
385
392
|
|
|
386
393
|
if (newText.length > TELEGRAM_MSG_CAP) {
|
|
@@ -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).
|
|
@@ -118,6 +118,68 @@ export const STRUCTURED_PATTERNS: PatternDef[] = [
|
|
|
118
118
|
]
|
|
119
119
|
|
|
120
120
|
/**
|
|
121
|
-
*
|
|
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
|
-
|
|
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.
|
|
@@ -139,7 +139,7 @@ describe('pending-work-progress', () => {
|
|
|
139
139
|
expect(cap.edits).toHaveLength(1)
|
|
140
140
|
expect(cap.edits[0].messageId).toBe(100)
|
|
141
141
|
expect(cap.edits[0].newText).toBe(
|
|
142
|
-
|
|
142
|
+
"Background sleep running; awaiting completion.\n\n— still working (1m) · message me anytime, I'll keep you posted",
|
|
143
143
|
)
|
|
144
144
|
|
|
145
145
|
// Tick at 3 intervals total — second edit, "3m".
|
|
@@ -148,7 +148,7 @@ describe('pending-work-progress', () => {
|
|
|
148
148
|
await flush()
|
|
149
149
|
expect(cap.edits).toHaveLength(2)
|
|
150
150
|
expect(cap.edits[1].newText).toBe(
|
|
151
|
-
|
|
151
|
+
"Background sleep running; awaiting completion.\n\n— still working (3m) · message me anytime, I'll keep you posted",
|
|
152
152
|
)
|
|
153
153
|
})
|
|
154
154
|
|
|
@@ -168,7 +168,25 @@ describe('pending-work-progress', () => {
|
|
|
168
168
|
await flush()
|
|
169
169
|
// The new edit should be based on 'worker dispatched' alone.
|
|
170
170
|
expect(cap.edits[0].newText).toBe(
|
|
171
|
-
|
|
171
|
+
"worker dispatched\n\n— still working (1m) · message me anytime, I'll keep you posted",
|
|
172
|
+
)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('strips a prior NEW-shape suffix (with reachability clause) too', async () => {
|
|
176
|
+
const cap = setup()
|
|
177
|
+
startTurn(KEY)
|
|
178
|
+
noteAsyncDispatch(KEY)
|
|
179
|
+
noteOutbound(KEY, {
|
|
180
|
+
messageId: 100,
|
|
181
|
+
text:
|
|
182
|
+
"worker dispatched\n\n— still working (12m) · message me anytime, I'll keep you posted",
|
|
183
|
+
})
|
|
184
|
+
noteTurnEnd(KEY)
|
|
185
|
+
cap.now = EDIT_INTERVAL_MS
|
|
186
|
+
__tickForTests(cap.now)
|
|
187
|
+
await flush()
|
|
188
|
+
expect(cap.edits[0].newText).toBe(
|
|
189
|
+
"worker dispatched\n\n— still working (1m) · message me anytime, I'll keep you posted",
|
|
172
190
|
)
|
|
173
191
|
})
|
|
174
192
|
|
|
@@ -338,7 +356,7 @@ describe('pending-work-progress', () => {
|
|
|
338
356
|
expect(cap.edits).toHaveLength(1)
|
|
339
357
|
expect(cap.edits[0].parseMode).toBe('HTML')
|
|
340
358
|
expect(cap.edits[0].newText).toBe(
|
|
341
|
-
|
|
359
|
+
"<b>Worker back.</b> Both blockers fixed.\n\n— still working (1m) · message me anytime, I'll keep you posted",
|
|
342
360
|
)
|
|
343
361
|
})
|
|
344
362
|
|