switchroom 0.18.12 → 0.18.14

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 (76) hide show
  1. package/dist/agent-scheduler/index.js +57 -9
  2. package/dist/auth-broker/index.js +174 -72
  3. package/dist/cli/autoaccept-poll.js +23 -0
  4. package/dist/cli/drive-write-pretool.mjs +24 -1
  5. package/dist/cli/foreground-hog-pretool.mjs +264 -0
  6. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  7. package/dist/cli/notion-write-pretool.mjs +9 -2
  8. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  9. package/dist/cli/switchroom.js +986 -3131
  10. package/dist/host-control/main.js +216 -2863
  11. package/dist/vault/approvals/kernel-server.js +67 -1
  12. package/dist/vault/broker/server.js +98 -45
  13. package/package.json +1 -1
  14. package/profiles/coding/CLAUDE.md.hbs +2 -0
  15. package/profiles/default/CLAUDE.md.hbs +2 -0
  16. package/skills/switchroom-architecture/telegram.md +0 -1
  17. package/telegram-plugin/auth-snapshot-format.ts +37 -5
  18. package/telegram-plugin/auto-fallback-fleet.ts +29 -1
  19. package/telegram-plugin/bridge/bridge.ts +2 -0
  20. package/telegram-plugin/dist/bridge/bridge.js +51 -3
  21. package/telegram-plugin/dist/gateway/gateway.js +1251 -2368
  22. package/telegram-plugin/dist/server.js +67 -3
  23. package/telegram-plugin/format.ts +19 -0
  24. package/telegram-plugin/gateway/approval-hold.ts +21 -2
  25. package/telegram-plugin/gateway/auth-broker-client.ts +1 -0
  26. package/telegram-plugin/gateway/auth-command.ts +14 -0
  27. package/telegram-plugin/gateway/callback-query-handlers.ts +12 -0
  28. package/telegram-plugin/gateway/forward-origin.ts +235 -0
  29. package/telegram-plugin/gateway/gateway.ts +445 -83
  30. package/telegram-plugin/gateway/throttle-tier-wiring.ts +268 -0
  31. package/telegram-plugin/history.ts +106 -6
  32. package/telegram-plugin/inline-keyboard-callbacks.ts +94 -0
  33. package/telegram-plugin/model-unavailable.ts +61 -13
  34. package/telegram-plugin/outbound-field-redact.ts +69 -0
  35. package/telegram-plugin/render/render.ts +32 -14
  36. package/telegram-plugin/render/rich-render.ts +40 -32
  37. package/telegram-plugin/scoped-approval.ts +11 -2
  38. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  39. package/telegram-plugin/secret-detect/index.ts +12 -56
  40. package/telegram-plugin/send-gate-degraded.test.ts +131 -0
  41. package/telegram-plugin/send-gate.test.ts +25 -6
  42. package/telegram-plugin/send-gate.ts +82 -8
  43. package/telegram-plugin/session-tail.ts +82 -7
  44. package/telegram-plugin/stream-controller.ts +3 -2
  45. package/telegram-plugin/subagent-watcher.ts +71 -16
  46. package/telegram-plugin/tests/approval-hold-outcome.test.ts +36 -5
  47. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +72 -0
  48. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  49. package/telegram-plugin/tests/forward-origin.test.ts +309 -0
  50. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  51. package/telegram-plugin/tests/history.test.ts +272 -0
  52. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  53. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +164 -0
  54. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  55. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  56. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  57. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +6 -4
  58. package/telegram-plugin/tests/render/render.test.ts +88 -0
  59. package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
  60. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  61. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  62. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  63. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  64. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +5 -3
  65. package/telegram-plugin/tests/status-accent.test.ts +5 -3
  66. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +20 -20
  67. package/telegram-plugin/tests/stream-reply-handler.test.ts +5 -2
  68. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  69. package/telegram-plugin/tests/throttle-tier-wiring.test.ts +290 -0
  70. package/telegram-plugin/tests/throttle-tier.test.ts +278 -0
  71. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  72. package/telegram-plugin/throttle-tier.ts +226 -0
  73. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +8 -7
  74. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  75. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  76. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -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 {
@@ -1,21 +1,24 @@
1
- // Flag-gated wiring for the Bot API 10.1 rich renderer (parse.ts + render.ts)
2
- // into the live outbound send path.
1
+ // Wiring for the Bot API 10.1 rich renderer (parse.ts + render.ts) into the
2
+ // live outbound send path.
3
3
  //
4
4
  // The renderer (`render/render.ts`, PR #2930) and parser (`render/parse.ts`)
5
5
  // have full unit coverage but were, until this module, wired into NOTHING —
6
- // no outbound message ever flowed through them. This module is the single,
7
- // feature-flagged bridge: the gateway's rich send path (stream-controller.ts)
8
- // runs the assistant's raw markdown through `parse → renderSafe` before it
9
- // reaches `sendRichMessage`, but ONLY when the flag is on.
6
+ // no outbound message ever flowed through them. This module is the single
7
+ // bridge: the gateway's rich send path (stream-controller.ts) runs the
8
+ // assistant's raw markdown through `parse → renderSafe` before it reaches
9
+ // `sendRichMessage`, unless the escape hatch below disables it.
10
10
  //
11
- // Feature flag — `SWITCHROOM_RICH_RENDER`, default OFF:
12
- // Mirrors the `SWITCHROOM_VISIBLE_ANSWER_STREAM` convention
13
- // (`answer-stream-flag.ts`): an env var read at runtime, default off, opted
14
- // in PER AGENT via the `env:` block in `switchroom.yaml` (propagated into
15
- // the container `environment:` by `src/agents/compose.ts`). When off,
16
- // `maybeRenderOutbound` returns the input untouched, so the live send path
17
- // is byte-for-byte unchanged — no agent's behaviour moves until an operator
18
- // explicitly flips the flag for a specific agent. Accepts `1/true/on/yes`.
11
+ // Escape hatch — `SWITCHROOM_RICH_RENDER`, default ON:
12
+ // ON BY DEFAULT in every install — an escape hatch, not an opt-in feature,
13
+ // mirroring the send gate's convention (`sendGateEnabledFromEnv` in
14
+ // `send-gate.ts`, default-on since #3153; also `midTurnFloorEnabled`,
15
+ // `SWITCHROOM_RATE_LIMIT_OVERAGE=0`). Enabled unless the var is explicitly
16
+ // set to a falsey/off value (`0`/`false`/`off`/`no`, case-insensitive,
17
+ // trimmed) — per agent via the `env:` block in `switchroom.yaml`
18
+ // (propagated into the container `environment:` by
19
+ // `src/agents/compose.ts`). When disabled, `maybeRenderOutbound` returns
20
+ // the input untouched, so the live send path is byte-for-byte the
21
+ // pre-renderer behaviour (raw transcript markdown to `sendRichMessage`).
19
22
  //
20
23
  // Why route through `renderSafe` and not bare `render`:
21
24
  // `renderSafe` guarantees the returned body is never a rich-markdown string
@@ -38,17 +41,19 @@ import { RICH_MESSAGE_MAX_CHARS, splitMarkdownChunks } from "../format.js";
38
41
  */
39
42
  export const PLAIN_TEXT_MAX_CHARS = 4096;
40
43
 
41
- /** Parse the `SWITCHROOM_RICH_RENDER` flag value. Default OFF; accepts the
42
- * same truthy tokens as the other switchroom env flags. Pure so the default
43
- * + parsing are unit-testable. */
44
+ /** Parse the `SWITCHROOM_RICH_RENDER` kill-switch value. Default ON; disabled
45
+ * only by an explicit falsey/off token (`0`/`false`/`off`/`no`,
46
+ * case-insensitive, trimmed) — the same disable vocabulary as
47
+ * `sendGateEnabledFromEnv`. Unset, empty, or unrecognised values → ON. Pure
48
+ * so the default + parsing are unit-testable. */
44
49
  export function parseRichRenderEnabled(raw: string | undefined): boolean {
45
- if (raw == null) return false;
50
+ if (raw == null) return true;
46
51
  const v = raw.trim().toLowerCase();
47
- return v === "1" || v === "true" || v === "on" || v === "yes";
52
+ return !(v === "0" || v === "false" || v === "off" || v === "no");
48
53
  }
49
54
 
50
55
  /** Is the rich renderer enabled in this process? Reads the env flag live so a
51
- * test can set/unset it per-case; defaults OFF. */
56
+ * test can set/unset it per-case; defaults ON (escape hatch, not opt-in). */
52
57
  export function richRenderEnabled(
53
58
  env: NodeJS.ProcessEnv = process.env,
54
59
  ): boolean {
@@ -65,13 +70,14 @@ export function renderOutbound(
65
70
  }
66
71
 
67
72
  /**
68
- * Flag-gated transform for the live send path.
73
+ * Kill-switch-gated transform for the live send path.
69
74
  *
70
- * - flag OFF (default): returns the input untouched, `mode: "markdown"` —
71
- * identical to the pre-existing behaviour (raw transcript markdown sent
72
- * straight to `sendRichMessage`). No behavioural change for any agent.
73
- * - flag ON: returns `parse → renderSafe` output. `mode: "plain"` signals
74
- * the caller to send WITHOUT the rich wrapper (oversized/unsafe content).
75
+ * - enabled (default): returns `parse → renderSafe` output. `mode: "plain"`
76
+ * signals the caller to send WITHOUT the rich wrapper (oversized/unsafe
77
+ * content).
78
+ * - disabled (`SWITCHROOM_RICH_RENDER=0`): returns the input untouched,
79
+ * `mode: "markdown"` — identical to the pre-renderer behaviour (raw
80
+ * transcript markdown sent straight to `sendRichMessage`).
75
81
  */
76
82
  export function maybeRenderOutbound(
77
83
  text: string,
@@ -83,7 +89,7 @@ export function maybeRenderOutbound(
83
89
  }
84
90
 
85
91
  /**
86
- * Flag-gated, CAP-ENFORCING transform for the live send path.
92
+ * Kill-switch-gated, CAP-ENFORCING transform for the live send path.
87
93
  *
88
94
  * `maybeRenderOutbound` returns ONE `RenderResult` and can only ever fit a
89
95
  * body into a single wire message. But `renderSafe`'s markdown re-escaping
@@ -104,11 +110,13 @@ export function maybeRenderOutbound(
104
110
  * plain degradation would have thrown away: the smaller pieces individually
105
111
  * escape under `maxLen` and come back as `markdown`.
106
112
  *
107
- * - flag OFF (default): `[{ text, mode: "markdown", degradations: [] }]` —
108
- * a single passthrough piece, identical to `maybeRenderOutbound`.
109
- * - flag ON, body fits: `[renderSafe(...)]` — a single piece, identical to
110
- * `maybeRenderOutbound` (byte-for-byte for the common case).
111
- * - flag ON, body oversize: 2+ cap-respecting pieces in send order.
113
+ * - disabled (`SWITCHROOM_RICH_RENDER=0`):
114
+ * `[{ text, mode: "markdown", degradations: [] }]` — a single passthrough
115
+ * piece, identical to `maybeRenderOutbound`.
116
+ * - enabled (default), body fits: `[renderSafe(...)]` — a single piece,
117
+ * identical to `maybeRenderOutbound` (byte-for-byte for the common case).
118
+ * - enabled (default), body oversize: 2+ cap-respecting pieces in send
119
+ * order.
112
120
  */
113
121
  export function renderOutboundChunks(
114
122
  text: 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', () => {