switchroom 0.18.12 → 0.18.13

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 (49) hide show
  1. package/dist/agent-scheduler/index.js +8 -0
  2. package/dist/auth-broker/index.js +63 -65
  3. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  4. package/dist/cli/notion-write-pretool.mjs +9 -1
  5. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  6. package/dist/cli/switchroom.js +952 -3126
  7. package/dist/host-control/main.js +216 -2862
  8. package/dist/vault/approvals/kernel-server.js +67 -0
  9. package/dist/vault/broker/server.js +98 -44
  10. package/package.json +1 -1
  11. package/telegram-plugin/dist/bridge/bridge.js +49 -3
  12. package/telegram-plugin/dist/gateway/gateway.js +656 -2326
  13. package/telegram-plugin/dist/server.js +65 -3
  14. package/telegram-plugin/format.ts +19 -0
  15. package/telegram-plugin/gateway/approval-hold.ts +21 -2
  16. package/telegram-plugin/gateway/callback-query-handlers.ts +12 -0
  17. package/telegram-plugin/gateway/gateway.ts +221 -73
  18. package/telegram-plugin/history.ts +51 -0
  19. package/telegram-plugin/inline-keyboard-callbacks.ts +94 -0
  20. package/telegram-plugin/model-unavailable.ts +41 -11
  21. package/telegram-plugin/outbound-field-redact.ts +69 -0
  22. package/telegram-plugin/render/render.ts +32 -14
  23. package/telegram-plugin/scoped-approval.ts +11 -2
  24. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  25. package/telegram-plugin/secret-detect/index.ts +12 -56
  26. package/telegram-plugin/send-gate-degraded.test.ts +131 -0
  27. package/telegram-plugin/send-gate.test.ts +25 -6
  28. package/telegram-plugin/send-gate.ts +82 -8
  29. package/telegram-plugin/session-tail.ts +82 -7
  30. package/telegram-plugin/subagent-watcher.ts +71 -16
  31. package/telegram-plugin/tests/approval-hold-outcome.test.ts +36 -5
  32. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  33. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  34. package/telegram-plugin/tests/history.test.ts +115 -0
  35. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  36. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +164 -0
  37. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  38. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  39. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  40. package/telegram-plugin/tests/render/render.test.ts +88 -0
  41. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  42. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  43. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  44. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  45. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  46. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  47. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  48. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  49. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -30,6 +30,7 @@
30
30
 
31
31
  import {
32
32
  validateInlineKeyboard,
33
+ TELEGRAM_BUTTON_LIMITS,
33
34
  type AnyButton,
34
35
  type ButtonValidationError,
35
36
  } from './telegram-button-constraints.js'
@@ -114,6 +115,99 @@ export function wrapAgentCallbacks(keyboard: AnyButton[][]): AnyButton[][] {
114
115
  )
115
116
  }
116
117
 
118
+ /**
119
+ * Redact agent-authored free-text on an inline keyboard BEFORE it is sent to
120
+ * Telegram (#3148 fast-follow secret-scrub coverage). `wrapAgentCallbacks`
121
+ * rewrites only `callback_data`; the visible `text` label, the `ack_text`
122
+ * toast, and any `copy_text.text` clipboard payload pass through VERBATIM. An
123
+ * agent that puts a secret in any of those transmits it unmasked, and it
124
+ * resurfaces on tap — the label is echoed back (`button_text`), re-rendered in
125
+ * the "✅ You chose: <label>" annotation (#789), and `ack_text` is shown as the
126
+ * toast. `switch_inline_query` / `switch_inline_query_current_chat` /
127
+ * `switch_inline_query_chosen_chat.query` are also agent-authored free text
128
+ * that Telegram pastes into a chat's input box on tap (the current chat for
129
+ * the `_current_chat` variant, a user-picked chat for `_chosen_chat` — both
130
+ * user-visible), so they carry the same leak class. Route every one of these free-text fields
131
+ * through the SAME outbound redactor the reply `text` body uses, at the
132
+ * outbound boundary, so every downstream resurface reads already-masked bytes.
133
+ *
134
+ * `callback_data` is the routing key and is NEVER touched — redacting it would
135
+ * break tap round-tripping. Button structure/order is preserved; the redactor
136
+ * (`redact()`) only replaces detected secret byte-ranges with a non-empty
137
+ * marker, so it never empties a label (the non-empty-text invariant holds).
138
+ * Returns a fresh keyboard; does not mutate the input. `redactFn` is injected
139
+ * so this stays pure + unit-testable and carries no gateway import cycle.
140
+ *
141
+ * Only agent-authored keyboards flow through here (the `reply` tool path);
142
+ * framework-internal keyboards (approval cards, vault wizard, model menus) are
143
+ * built separately and are NOT redacted by this function.
144
+ */
145
+ export function redactAgentKeyboard(
146
+ keyboard: AnyButton[][],
147
+ redactFn: (s: string) => string,
148
+ ): AnyButton[][] {
149
+ // The keyboard is validated for length BEFORE redaction, but the redaction
150
+ // marker (`[REDACTED:...]`) can be longer than the secret it replaces, so a
151
+ // field that was within a Telegram cap can exceed it after masking. An
152
+ // over-limit button field makes sendMessage 400 → the WHOLE reply is dropped
153
+ // (worse than the leak we just closed), so clamp each masked free-text field
154
+ // to its cap. Truncating a marker is harmless — it's already non-secret.
155
+ const clamp = (s: string, max: number): string =>
156
+ s.length > max ? s.slice(0, max) : s
157
+ return keyboard.map((row) =>
158
+ row.map((btn) => {
159
+ const out: AnyButton = { ...btn }
160
+ if (typeof out.text === 'string') {
161
+ out.text = clamp(redactFn(out.text), TELEGRAM_BUTTON_LIMITS.TEXT_MAX)
162
+ }
163
+ // ack_text is a switchroom-side toast (answerCallbackQuery), not a
164
+ // sendMessage field, so an over-length value can't drop the reply — no
165
+ // clamp needed, but redact it all the same.
166
+ if (typeof out.ack_text === 'string') out.ack_text = redactFn(out.ack_text)
167
+ // switch_inline_query* paste agent free text into a chat input box on tap
168
+ // — same leak class as the label. URL-class fields (url/web_app/login_url)
169
+ // are deliberately left exact: a [REDACTED] marker would corrupt a URL.
170
+ const siq = (out as { switch_inline_query?: unknown }).switch_inline_query
171
+ if (typeof siq === 'string') {
172
+ (out as { switch_inline_query?: string }).switch_inline_query = clamp(
173
+ redactFn(siq), TELEGRAM_BUTTON_LIMITS.SWITCH_INLINE_QUERY_MAX)
174
+ }
175
+ const siqc = (out as { switch_inline_query_current_chat?: unknown })
176
+ .switch_inline_query_current_chat
177
+ if (typeof siqc === 'string') {
178
+ (out as { switch_inline_query_current_chat?: string })
179
+ .switch_inline_query_current_chat = clamp(
180
+ redactFn(siqc), TELEGRAM_BUTTON_LIMITS.SWITCH_INLINE_QUERY_MAX)
181
+ }
182
+ // switch_inline_query_chosen_chat.query is the third variant: agent free
183
+ // text pasted into a user-picked chat's input box on tap — same leak class.
184
+ const cc = (out as { switch_inline_query_chosen_chat?: unknown })
185
+ .switch_inline_query_chosen_chat
186
+ if (cc != null && typeof cc === 'object' &&
187
+ typeof (cc as { query?: unknown }).query === 'string') {
188
+ (out as { switch_inline_query_chosen_chat?: Record<string, unknown> })
189
+ .switch_inline_query_chosen_chat = {
190
+ ...(cc as Record<string, unknown>),
191
+ query: clamp(
192
+ redactFn((cc as { query: string }).query),
193
+ TELEGRAM_BUTTON_LIMITS.SWITCH_INLINE_QUERY_MAX),
194
+ }
195
+ }
196
+ const ct = out.copy_text
197
+ if (ct != null && typeof ct === 'object' &&
198
+ typeof (ct as { text?: unknown }).text === 'string') {
199
+ out.copy_text = {
200
+ ...(ct as Record<string, unknown>),
201
+ text: clamp(
202
+ redactFn((ct as { text: string }).text),
203
+ TELEGRAM_BUTTON_LIMITS.COPY_TEXT_MAX),
204
+ }
205
+ }
206
+ return out
207
+ }),
208
+ )
209
+ }
210
+
117
211
  /**
118
212
  * Extract per-button {@link AgentButtonMeta} from a raw (pre-wrap)
119
213
  * keyboard. Returns a map keyed by the raw (unprefixed) callback_data
@@ -41,6 +41,44 @@ export interface ModelUnavailableDetection {
41
41
  raw: string
42
42
  }
43
43
 
44
+ // ─── Transient-burst signals (canonical, single source of truth) ─────────────
45
+
46
+ /**
47
+ * Explicit markers of a TRANSIENT per-account burst / server-side throttle —
48
+ * a short-term RPM/burst 429 that Claude Code retries internally with backoff,
49
+ * NOT the 5h/7d subscription usage-limit wall. Anthropic emits these with a
50
+ * `rate_limit_error` whose wording explicitly NEGATES the account-quota reading
51
+ * ("not your usage limit" / "would exceed your account's rate limit … try again
52
+ * later"). Keyed on the explicit negation so a genuine wall that merely contains
53
+ * the word "limit" is never down-classified.
54
+ *
55
+ * Exported so `session-tail.ts` classifies a 429 by wording against THIS list
56
+ * rather than hand-rolling its own copy (keeps the two in sync — issue #2922).
57
+ */
58
+ export const transientUpstreamSignals = [
59
+ 'not your usage limit',
60
+ 'not your account',
61
+ "not your account's",
62
+ 'temporarily limiting requests',
63
+ 'temporarily rate',
64
+ 'server is temporarily',
65
+ 'would exceed your account’s rate limit',
66
+ "would exceed your account's rate limit",
67
+ ]
68
+
69
+ /**
70
+ * True when `text` carries an EXPLICIT transient-burst marker (see
71
+ * `transientUpstreamSignals`). Never throws on weird input. Used to keep the
72
+ * calm rate-limit path and the model-unavailable detector reading the same
73
+ * canonical signal list.
74
+ */
75
+ export function isTransientUpstreamSignal(text: string): boolean {
76
+ if (typeof text !== 'string' || text.length === 0) return false
77
+ const sample = text.length > 16_384 ? text.slice(0, 16_384) : text
78
+ const lower = sample.toLowerCase()
79
+ return transientUpstreamSignals.some(s => lower.includes(s))
80
+ }
81
+
44
82
  // ─── Detection ───────────────────────────────────────────────────────────────
45
83
 
46
84
  /**
@@ -77,17 +115,9 @@ export function detectModelUnavailable(
77
115
  // failover that self-cancels and leaves the turn dead. These are upstream
78
116
  // throttles Claude Code retries internally with backoff — classify them as
79
117
  // `overload` (the calm rate-limit path) BEFORE the quota substrings run, so
80
- // the negation is honoured and no failover is announced.
81
- const transientUpstreamSignals = [
82
- 'not your usage limit',
83
- 'not your account',
84
- "not your account's",
85
- 'temporarily limiting requests',
86
- 'temporarily rate',
87
- 'server is temporarily',
88
- 'would exceed your account’s rate limit',
89
- "would exceed your account's rate limit",
90
- ]
118
+ // the negation is honoured and no failover is announced. The signal list is
119
+ // module-level (`transientUpstreamSignals`) so session-tail.ts classifies a
120
+ // 429 against the SAME canonical wording.
91
121
  if (transientUpstreamSignals.some(s => lower.includes(s))) {
92
122
  const resetAt = parseResetTime(sample)
93
123
  return resetAt !== undefined
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Structured-payload outbound redaction (#2044 coverage extension).
3
+ *
4
+ * The original #2044 outbound scrub masks agent-authored FREE TEXT on the
5
+ * reply / edit_message / progress_update paths (see gateway.ts
6
+ * `redactOutboundText`). But several other outbound surfaces carry
7
+ * agent-authored text to Telegram inside STRUCTURED payloads and had no
8
+ * redaction at all:
9
+ *
10
+ * - ask_user → the question text + every option/button label
11
+ * - send_checklist / update_checklist → the title + every task's text
12
+ *
13
+ * An agent that echoes a token or a DATABASE_URL it just read into an
14
+ * ask_user question, a button label, or a checklist task would transmit it
15
+ * unmasked — defeating the same scrub invariant the reply `text` path
16
+ * honors.
17
+ *
18
+ * These helpers apply the SAME redactor to each such field. The redactor is
19
+ * INJECTED (the gateway passes its `redactOutboundText`; tests pass the real
20
+ * `redact`) so the masking is deterministic and unit-testable at the OUTCOME
21
+ * level: the object returned here is exactly what the downstream send call
22
+ * receives, so a test can assert the secret is gone from the sent payload —
23
+ * not merely that a redactor was invoked.
24
+ */
25
+
26
+ /** A pure text redactor: maps input text to a masked copy. */
27
+ export type FieldRedactor = (text: string) => string
28
+
29
+ /**
30
+ * Redact the two agent-authored outbound fields of an ask_user prompt: the
31
+ * question and each option label. Returns fresh values (the input arrays are
32
+ * not mutated) so the caller can assign them onto the validated args.
33
+ */
34
+ export function redactAskUserFields(
35
+ question: string,
36
+ options: readonly string[],
37
+ redact: FieldRedactor,
38
+ ): { question: string; options: string[] } {
39
+ return {
40
+ question: redact(question),
41
+ options: options.map((opt) => redact(opt)),
42
+ }
43
+ }
44
+
45
+ /** Minimal shape of a checklist task — only `text` is redacted; every other
46
+ * field (id, done, …) is carried through untouched. */
47
+ export interface ChecklistTaskLike {
48
+ text?: string
49
+ [key: string]: unknown
50
+ }
51
+
52
+ /**
53
+ * Redact the agent-authored outbound fields of a checklist: the title and
54
+ * each task's `text`. `title`/`tasks` may be undefined (update_checklist
55
+ * allows partial patches); undefined passes through untouched. A task whose
56
+ * `text` is undefined (an id-only patch) is left as-is.
57
+ */
58
+ export function redactChecklistFields<T extends ChecklistTaskLike>(
59
+ title: string | undefined,
60
+ tasks: readonly T[] | undefined,
61
+ redact: FieldRedactor,
62
+ ): { title: string | undefined; tasks: T[] | undefined } {
63
+ return {
64
+ title: title != null ? redact(title) : title,
65
+ tasks: tasks?.map((task) =>
66
+ task.text != null ? { ...task, text: redact(task.text) } : task,
67
+ ),
68
+ }
69
+ }
@@ -30,7 +30,7 @@
30
30
  // equivalent entities rather than accidentally re-triggering formatting or
31
31
  // breaking out of a code span.
32
32
 
33
- import { escapeMarkdown, codeSpanSafe, RICH_MESSAGE_MAX_CHARS } from "../format.js";
33
+ import { escapeMarkdown, codeSpanSafe, escapeLinkHref, RICH_MESSAGE_MAX_CHARS } from "../format.js";
34
34
  import type {
35
35
  Block,
36
36
  BlockquoteNode,
@@ -47,26 +47,44 @@ import type {
47
47
  // Inline rendering
48
48
  // ---------------------------------------------------------------------------
49
49
 
50
- function renderInline(node: Inline): string {
50
+ /** Rendering context threaded through the inline walk. `inTableCell` is set
51
+ * while rendering the inline content of a GFM table cell, where a literal `|`
52
+ * — even inside an inline-code span — would be read as a column separator and
53
+ * tear the row (F4). Plain-text `|` is already neutralised by `escapeMarkdown`
54
+ * (`|` is one of its specials); the only gap is the code span, whose content
55
+ * is otherwise verbatim, so we backslash-escape `|` there in the table
56
+ * context. GFM strips the `\` and keeps the pipe literal inside the span. */
57
+ interface InlineCtx {
58
+ inTableCell?: boolean;
59
+ }
60
+
61
+ function renderInline(node: Inline, ctx: InlineCtx = {}): string {
51
62
  switch (node.type) {
52
63
  case "plain":
53
64
  return escapeMarkdown(node.text);
54
65
  case "bold":
55
- return `**${renderInlineChildren(node.children)}**`;
66
+ return `**${renderInlineChildren(node.children, ctx)}**`;
56
67
  case "italic":
57
- return `*${renderInlineChildren(node.children)}*`;
68
+ return `*${renderInlineChildren(node.children, ctx)}*`;
58
69
  case "underline":
59
- return `__${renderInlineChildren(node.children)}__`;
70
+ return `__${renderInlineChildren(node.children, ctx)}__`;
60
71
  case "strike":
61
- return `~~${renderInlineChildren(node.children)}~~`;
72
+ return `~~${renderInlineChildren(node.children, ctx)}~~`;
62
73
  case "spoiler":
63
- return `||${renderInlineChildren(node.children)}||`;
74
+ return `||${renderInlineChildren(node.children, ctx)}||`;
64
75
  case "highlight":
65
- return `==${renderInlineChildren(node.children)}==`;
66
- case "code":
67
- return `\`${codeSpanSafe(node.text)}\``;
76
+ return `==${renderInlineChildren(node.children, ctx)}==`;
77
+ case "code": {
78
+ const safe = codeSpanSafe(node.text);
79
+ // In a table cell, an unescaped `|` inside the span closes the cell early
80
+ // and corrupts the row; `\|` survives (GFM keeps the pipe literal in the
81
+ // span and drops the backslash). Elsewhere the span content is verbatim.
82
+ return `\`${ctx.inTableCell ? safe.replace(/\|/g, "\\|") : safe}\``;
83
+ }
68
84
  case "link":
69
- return `[${renderInlineChildren(node.children)}](${node.href})`;
85
+ // Escape the href so a literal `)` in the URL can't terminate the
86
+ // destination early and break the link (F3).
87
+ return `[${renderInlineChildren(node.children, ctx)}](${escapeLinkHref(node.href)})`;
70
88
  default: {
71
89
  // Exhaustiveness guard — the IR union is closed; a new variant must be
72
90
  // handled above rather than silently dropped.
@@ -76,8 +94,8 @@ function renderInline(node: Inline): string {
76
94
  }
77
95
  }
78
96
 
79
- function renderInlineChildren(children: Inline[]): string {
80
- return children.map(renderInline).join("");
97
+ function renderInlineChildren(children: Inline[], ctx: InlineCtx = {}): string {
98
+ return children.map((child) => renderInline(child, ctx)).join("");
81
99
  }
82
100
 
83
101
  // ---------------------------------------------------------------------------
@@ -154,7 +172,7 @@ function renderList(node: ListNode): string {
154
172
  /** Render a single cell's inline content for a table (no line breaks — GFM
155
173
  * table cells can't contain them; pipes are escaped defensively). */
156
174
  function renderTableCell(cells: TableRow["cells"][number]): string {
157
- return renderInlineChildren(cells.children).replace(/\n+/g, " ");
175
+ return renderInlineChildren(cells.children, { inTableCell: true }).replace(/\n+/g, " ");
158
176
  }
159
177
 
160
178
  function alignSeparator(align: TableNode["align"][number]): string {
@@ -307,9 +307,18 @@ export function isDestructiveBashCommand(command: string): boolean {
307
307
  if (/\b(chmod|chown|chgrp)\b[^|;&]*(\s-(-recursive|[a-z]*r[a-z]*)\b)/.test(c)) return true;
308
308
  // redirection clobbering devices or system dirs
309
309
  if (/>\s*\/(dev|etc|boot|sys|proc)\b/.test(c)) return true;
310
- // destructive git
310
+ // destructive git — discards or rewrites history / working-tree state.
311
+ // checkout: `-f`/`--force` (force-overwrite), `git checkout .` / `./`
312
+ // / `./<path>` (path-restore of the whole tree or a subtree) and
313
+ // `checkout … -- <path>` all discard uncommitted work. A plain
314
+ // `git checkout <branch>` (branch switch, reversible) is deliberately
315
+ // NOT flagged. NOTE: a bare single-path discard without `--`
316
+ // (`git checkout src/x.ts`) is syntactically ambiguous with a branch
317
+ // name and is a known uncaught form — not full coverage.
318
+ // stash: `drop`/`clear`/`pop` remove stash state irreversibly
319
+ // (`stash`/`list`/`show`/`apply` keep it and stay unflagged).
311
320
  if (/\bgit\b/.test(c) &&
312
- /(push\b[^|;&]*(--force|-f\b|--force-with-lease)|push\s+[^\s]*\s+\+|reset\s+--hard|clean\s+-[a-z]*[fd]|filter-branch|reflog\s+expire|update-ref\s+-d|branch\s+-d{1,2}\b|checkout\s+--\s|restore\b)/.test(c)) return true;
321
+ /(push\b[^|;&]*(--force|-f\b|--force-with-lease)|push\s+[^\s]*\s+\+|reset\s+--hard|clean\s+-[a-z]*[fd]|filter-branch|reflog\s+expire|update-ref\s+-d|branch\s+-d{1,2}\b|checkout\b[^|;&]*(\s-f\b|\s--force\b|\s--(\s|$)|\s\.(\s|$|\/))|stash\s+(drop|clear|pop)\b|restore\b)/.test(c)) return true;
313
322
  // power / process control
314
323
  if (/(^|\s|;|&&|\|\||\()(shutdown|reboot|halt|poweroff|kill|killall|pkill)\b/.test(c)) return true;
315
324
  if (/(^|\s)init\s+0\b/.test(c)) return true;
@@ -1,13 +1,21 @@
1
1
  /**
2
2
  * Sliding-window chunker for ReDoS-bounded detection.
3
3
  *
4
- * Inputs larger than 32 KB are split into 16 KB windows with 1 KB overlap.
4
+ * Inputs larger than 32 KB are split into 16 KB windows with 8 KB overlap.
5
5
  * Each window is scanned independently; the caller is responsible for
6
6
  * dedupe-by-byte-offset when merging per-window hits back together.
7
7
  *
8
8
  * The overlap exists so a secret that straddles a window boundary is still
9
- * matched by at least one scan (provided the secret is 1 KB, which covers
10
- * every known token format plus typical PEM private keys).
9
+ * fully contained in at least one scan. The guarantee is exact: a secret is
10
+ * only missed if its length EXCEEDS the overlap (if length ≤ OVERLAP, a
11
+ * boundary-straddling secret is wholly inside the next window, which starts
12
+ * OVERLAP bytes before the boundary). The old 1 KB overlap was smaller than
13
+ * a real PEM private key — a 4096-bit RSA key in PEM armor is ~3.2 KB, so a
14
+ * boundary-straddling RSA key in a >32 KB payload slipped through unmasked
15
+ * (2026-07 secret-scrub review, tp-support F2). At 8 KB the overlap clears
16
+ * a 4096-bit RSA PEM (~3.2 KB) with >2x margin and also covers larger EC /
17
+ * certificate blobs. Cost: windows advance by WINDOW_SIZE−OVERLAP = 8 KB,
18
+ * so a big payload is scanned in ~2x as many windows — bounded and cheap.
11
19
  */
12
20
 
13
21
  export interface Window {
@@ -19,7 +27,13 @@ export interface Window {
19
27
 
20
28
  export const CHUNK_THRESHOLD = 32 * 1024
21
29
  export const WINDOW_SIZE = 16 * 1024
22
- export const OVERLAP = 1024
30
+ /**
31
+ * 8 KB. MUST be ≥ the largest secret we need to catch across a window
32
+ * boundary; a 4096-bit RSA private key in PEM armor is ~3.2 KB, so this
33
+ * clears it with >2x headroom. See the module header for the exact
34
+ * "missed only if secret length > OVERLAP" guarantee.
35
+ */
36
+ export const OVERLAP = 8 * 1024
23
37
 
24
38
  export function chunk(text: string): Window[] {
25
39
  if (text.length <= CHUNK_THRESHOLD) {
@@ -11,17 +11,23 @@
11
11
  * PEM blocks, CLI flags)
12
12
  * 3. KEY=VALUE heuristic with Shannon-entropy gate (≥ 4.0)
13
13
  *
14
- * Big inputs (>32 KB) are chunked into 16 KB windows with 1 KB overlap
15
- * (chunker.ts) for ReDoS bounding; we dedupe by byte-offset after.
14
+ * Big inputs (>32 KB) are chunked into 16 KB windows with 8 KB overlap
15
+ * (chunker.ts) for ReDoS bounding; we dedupe by byte-offset after. The
16
+ * overlap must exceed the largest single secret (an 8192-bit RSA PEM is
17
+ * ~6.5 KB) so a boundary-straddling key is never split across windows.
16
18
  *
17
19
  * Nearby test/mock/example/fixture/dummy markers (within 40 chars) demote
18
20
  * a hit to `suppressed: true`. The caller decides what that means (our
19
21
  * convention: suppressed high-confidence → ambiguous, user is asked).
20
22
  *
21
- * Secretlint is integrated as an async supplementary source via
22
- * `detectSecretsAsync`. The sync `detectSecrets` keeps the fast vendored-
23
- * pattern path for callers on the hot path (Telegram message ingest).
24
- * Gitleaks TOML is loaded via `gitleaks-loader.ts`.
23
+ * Detection is vendored-patterns-only and synchronous: every live caller
24
+ * (inbound gate, outbound scrub, `redact.ts`, `pipeline.ts`) runs
25
+ * `detectSecrets`. There is deliberately NO Secretlint/async "safety net"
26
+ * layered underneath an earlier `detectSecretsAsync` + Secretlint wrapper
27
+ * was never wired into any live path (test-only), so it was a false safety
28
+ * net and has been removed (2026-07 secret-scrub review, tp-support F1).
29
+ * Arming a new async scanner belongs in its own validated change, not the
30
+ * scrub-coverage PR. Gitleaks TOML is loaded via `gitleaks-loader.ts`.
25
31
  */
26
32
  import { ALL_PATTERNS } from './patterns.js'
27
33
  import { scanKeyValue, type RawHit } from './kv-scanner.js'
@@ -209,53 +215,3 @@ function dropOverlaps(hits: RawHit[]): RawHit[] {
209
215
  export { maskToken } from './mask.js'
210
216
  export { redactUrls } from './url-redact.js'
211
217
  export { deriveSlug } from './slug.js'
212
- export { detectViaSecretlint } from './secretlint-source.js'
213
-
214
- /**
215
- * Async detection pipeline — runs `detectSecrets` (fast vendored engine)
216
- * and Secretlint in parallel, then merges the results by deduping on
217
- * `[start, end)` byte ranges. If Secretlint and a vendored pattern both
218
- * match the same span, the first one wins (vendored, since it's listed
219
- * first in the merge array below).
220
- *
221
- * Slug collisions are re-resolved on the merged list so the overall
222
- * output has unique `suggested_slug` values.
223
- */
224
- export async function detectSecretsAsync(text: string): Promise<Detection[]> {
225
- if (!text || text.length === 0) return []
226
- const [vendored, viaSecretlint] = await Promise.all([
227
- Promise.resolve(detectSecrets(text)),
228
- // Lazy-import keeps the sync `detectSecrets` path free of Secretlint
229
- // initialization cost; paid once on first async call.
230
- import('./secretlint-source.js').then((m) => m.detectViaSecretlint(text)),
231
- ])
232
-
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).
239
- const seen = new Map<string, Detection>()
240
- const consider = (d: Detection): void => {
241
- const key = `${d.start}:${d.end}`
242
- const existing = seen.get(key)
243
- if (!existing || (existing.confidence === 'ambiguous' && d.confidence === 'high')) {
244
- seen.set(key, d)
245
- }
246
- }
247
- for (const d of vendored) consider(d)
248
- for (const d of viaSecretlint) consider(d)
249
-
250
- // Re-derive slugs against the merged set (Secretlint and vendored each
251
- // had independent `existing` sets; we coalesce here).
252
- const existing = new Set<string>()
253
- const out: Detection[] = Array.from(seen.values())
254
- .sort((a, b) => a.start - b.start)
255
- .map((d) => {
256
- const slug = deriveSlug({ key_name: d.key_name, rule_id: d.rule_id }, existing)
257
- existing.add(slug)
258
- return { ...d, suggested_slug: slug }
259
- })
260
- return out
261
- }
@@ -405,6 +405,137 @@ describe('send-gate PR2: M2 critical wait re-evaluates the window each iteration
405
405
  })
406
406
  })
407
407
 
408
+ describe('send-gate F4: a CRITICAL edit honours the fail-fast ceiling (no unbounded block)', () => {
409
+ it('fails fast with a structured FLOOD_WAIT_ACTIVE instead of blocking unbounded on the edit driver', async () => {
410
+ const clock = new FakeClock()
411
+ const { calls, fn } = recorder(clock)
412
+ const gate = createSendGate({
413
+ enabled: true,
414
+ clock,
415
+ criticalFailFastMs: 60_000,
416
+ // 10-minute ban >> the 60s ceiling. On the pre-fix edit path this window
417
+ // routed a `critical` card edit through the driver's UNBOUNDED admit and
418
+ // blocked for the whole ban; the fix must fail fast like the non-edit path.
419
+ initialWindows: [{ scopeKey: 'global', untilTs: 600_000 }],
420
+ })
421
+
422
+ let caught: unknown
423
+ try {
424
+ // messageId + editPayload → edit path; priorityClass:'critical' is the case
425
+ // handleEdit previously ignored (only 'cosmetic' was special-cased).
426
+ await gate.gate(fn('critical-edit'), {
427
+ messageId: 42,
428
+ editPayload: 'v1',
429
+ priorityClass: 'critical',
430
+ })
431
+ } catch (err) {
432
+ caught = err
433
+ }
434
+
435
+ // Fail fast (structured flood-wait), NOT an unbounded block that never
436
+ // settles — the multi-hour reply wedge the send-gate exists to eliminate.
437
+ expect(isFloodWaitActiveError(caught)).toBe(true)
438
+ const e = caught as { untilTs: number; retryAfterSec: number; error_code: number }
439
+ expect(e.error_code).toBe(429)
440
+ expect(e.untilTs).toBe(600_000)
441
+ expect(e.retryAfterSec).toBe(600)
442
+ expect(calls).toHaveLength(0) // never probed the API
443
+ expect(gate.stats().global.failedFast).toBe(1)
444
+ expect(gate.stats().global.sent).toBe(0)
445
+ })
446
+
447
+ it('waits out a SHORT window (<= ceiling) then sends the critical edit — mirrors the non-edit path', async () => {
448
+ const clock = new FakeClock()
449
+ const { calls, fn } = recorder(clock)
450
+ const gate = createSendGate({
451
+ enabled: true,
452
+ clock,
453
+ criticalFailFastMs: 60_000,
454
+ // 30s window, under the 60s ceiling → the critical edit waits it out and
455
+ // then sends (not shed, not failed fast).
456
+ initialWindows: [{ scopeKey: 'global', untilTs: 30_000 }],
457
+ })
458
+
459
+ const p = gate.gate(fn('critical-edit'), {
460
+ messageId: 7,
461
+ editPayload: 'v1',
462
+ priorityClass: 'critical',
463
+ })
464
+ await clock.advance(31_000)
465
+ const res = await p
466
+
467
+ expect(res).toBe('critical-edit')
468
+ expect(calls).toHaveLength(1)
469
+ expect(calls[0]!.at).toBeGreaterThanOrEqual(30_000)
470
+ expect(gate.stats().global.failedFast).toBe(0)
471
+ expect(gate.stats().global.sent).toBe(1)
472
+ })
473
+ })
474
+
475
+ describe('send-gate F2: a CRITICAL edit coalescing onto a non-critical driver still fail-fasts', () => {
476
+ it('a critical edit that coalesces onto a running useful driver rejects FLOOD_WAIT_ACTIVE (does NOT ride the unbounded admit)', async () => {
477
+ const clock = new FakeClock()
478
+ const { calls, fn } = recorder(clock)
479
+ const gate = createSendGate({
480
+ enabled: true,
481
+ clock,
482
+ criticalFailFastMs: 60_000,
483
+ // Large edit floor so a queued useful edit SITS in the driver's floor
484
+ // sleep long enough for a later critical edit to coalesce onto it BEFORE
485
+ // the driver dequeues — the exact race the fix closes.
486
+ editFloorMs: 10_000,
487
+ })
488
+
489
+ // 1. Warm the message so its edit floor is armed (a cold message would fire
490
+ // the first edit at t=0 with no floor sleep, leaving no window to coalesce
491
+ // into). This send happens BEFORE any ban.
492
+ const pWarm = gate.gate(fn('warm'), { messageId: 42, editPayload: 'warm' })
493
+ await flush()
494
+ await pWarm
495
+ expect(calls.map((c) => c.label)).toEqual(['warm'])
496
+
497
+ // 2. A 10-minute flood ban (>> the 60s ceiling) opens on the global scope.
498
+ gate.openFloodWindow('global', 600_000)
499
+
500
+ // 3. A USEFUL edit to the same message starts the driver and enters the
501
+ // floor sleep (floor not yet cleared). It is NOT critical, so the
502
+ // driver-start opts capture priorityClass='useful'.
503
+ const pUseful = gate
504
+ .gate(fn('useful'), { messageId: 42, editPayload: 'u', priorityClass: 'useful' })
505
+ .catch((e) => e)
506
+ await flush()
507
+
508
+ // 4. A CRITICAL edit to the SAME message arrives mid-ban and COALESCES onto
509
+ // the pending useful edit (shared promise). With the driver-start-opts
510
+ // bug this critical work rode the unbounded admit and blocked for the
511
+ // whole 10-minute ban; the fix upgrades the pending edit's priority so
512
+ // the driver reads `critical` and fails fast.
513
+ const pCritical = gate
514
+ .gate(fn('critical'), { messageId: 42, editPayload: 'c', priorityClass: 'critical' })
515
+ .catch((e) => e)
516
+ await flush()
517
+
518
+ // 5. Walk PAST the edit floor but NOT past the ban. On the fixed code the
519
+ // driver wakes, sees the coalesced critical class, and fails fast.
520
+ await clock.advance(10_001)
521
+
522
+ const caughtCritical = await pCritical
523
+ const caughtUseful = await pUseful
524
+
525
+ // The coalesced (shared) promise rejects with a structured flood-wait —
526
+ // it did NOT block unbounded for the remaining ~10 minutes of the ban.
527
+ expect(isFloodWaitActiveError(caughtCritical)).toBe(true)
528
+ expect(isFloodWaitActiveError(caughtUseful)).toBe(true)
529
+ const e = caughtCritical as { untilTs: number; retryAfterSec: number; error_code: number }
530
+ expect(e.error_code).toBe(429)
531
+ expect(e.untilTs).toBe(600_000)
532
+ // Only the warm edit ever probed the API; the coalesced edit never did.
533
+ expect(calls.map((c) => c.label)).toEqual(['warm'])
534
+ expect(gate.stats().global.failedFast).toBe(1)
535
+ expect(gate.stats().global.sent).toBe(1)
536
+ })
537
+ })
538
+
408
539
  describe('send-gate PR2: opening a window from a 429, and persistence hook', () => {
409
540
  it('opens scope windows via onWindowOpen when a non-edit send throws FLOOD_WAIT_ACTIVE', async () => {
410
541
  const clock = new FakeClock()
@@ -87,15 +87,34 @@ describe('send-gate: feature flag', () => {
87
87
  expect(calls.map((c) => c.label)).toEqual(['v1', 'v2'])
88
88
  })
89
89
 
90
- it('sendGateEnabledFromEnv follows the SWITCHROOM_*=== "1" convention', () => {
91
- expect(sendGateEnabledFromEnv({} as NodeJS.ProcessEnv)).toBe(false)
90
+ it('sendGateEnabledFromEnv is ON BY DEFAULT (escape hatch, not opt-in)', () => {
91
+ // Unset → enabled. This assertion is load-bearing: it fails if the default
92
+ // is ever flipped back to off.
93
+ expect(sendGateEnabledFromEnv({} as NodeJS.ProcessEnv)).toBe(true)
92
94
  expect(
93
- sendGateEnabledFromEnv({ SWITCHROOM_TELEGRAM_SEND_GATE: '0' } as unknown as NodeJS.ProcessEnv),
94
- ).toBe(false)
95
- expect(
96
- sendGateEnabledFromEnv({ SWITCHROOM_TELEGRAM_SEND_GATE: '1' } as unknown as NodeJS.ProcessEnv),
95
+ sendGateEnabledFromEnv({ SWITCHROOM_TELEGRAM_SEND_GATE: undefined } as NodeJS.ProcessEnv),
97
96
  ).toBe(true)
98
97
  })
98
+
99
+ it('sendGateEnabledFromEnv disables only on explicit off values (safety valve)', () => {
100
+ for (const off of ['0', 'false', 'off', 'no', 'FALSE', 'Off', ' no ', ' 0 ']) {
101
+ expect(
102
+ sendGateEnabledFromEnv({
103
+ SWITCHROOM_TELEGRAM_SEND_GATE: off,
104
+ } as unknown as NodeJS.ProcessEnv),
105
+ ).toBe(false)
106
+ }
107
+ })
108
+
109
+ it('sendGateEnabledFromEnv stays enabled for any other value', () => {
110
+ for (const on of ['1', 'true', 'on', 'yes', 'enabled', '', 'anything']) {
111
+ expect(
112
+ sendGateEnabledFromEnv({
113
+ SWITCHROOM_TELEGRAM_SEND_GATE: on,
114
+ } as unknown as NodeJS.ProcessEnv),
115
+ ).toBe(true)
116
+ }
117
+ })
99
118
  })
100
119
 
101
120
  describe('send-gate: global bucket', () => {