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.
- package/dist/agent-scheduler/index.js +57 -9
- package/dist/auth-broker/index.js +174 -72
- package/dist/cli/autoaccept-poll.js +23 -0
- package/dist/cli/drive-write-pretool.mjs +24 -1
- package/dist/cli/foreground-hog-pretool.mjs +264 -0
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +9 -2
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +986 -3131
- package/dist/host-control/main.js +216 -2863
- package/dist/vault/approvals/kernel-server.js +67 -1
- package/dist/vault/broker/server.js +98 -45
- package/package.json +1 -1
- package/profiles/coding/CLAUDE.md.hbs +2 -0
- package/profiles/default/CLAUDE.md.hbs +2 -0
- package/skills/switchroom-architecture/telegram.md +0 -1
- package/telegram-plugin/auth-snapshot-format.ts +37 -5
- package/telegram-plugin/auto-fallback-fleet.ts +29 -1
- package/telegram-plugin/bridge/bridge.ts +2 -0
- package/telegram-plugin/dist/bridge/bridge.js +51 -3
- package/telegram-plugin/dist/gateway/gateway.js +1251 -2368
- package/telegram-plugin/dist/server.js +67 -3
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +21 -2
- package/telegram-plugin/gateway/auth-broker-client.ts +1 -0
- package/telegram-plugin/gateway/auth-command.ts +14 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +12 -0
- package/telegram-plugin/gateway/forward-origin.ts +235 -0
- package/telegram-plugin/gateway/gateway.ts +445 -83
- package/telegram-plugin/gateway/throttle-tier-wiring.ts +268 -0
- package/telegram-plugin/history.ts +106 -6
- package/telegram-plugin/inline-keyboard-callbacks.ts +94 -0
- package/telegram-plugin/model-unavailable.ts +61 -13
- package/telegram-plugin/outbound-field-redact.ts +69 -0
- package/telegram-plugin/render/render.ts +32 -14
- package/telegram-plugin/render/rich-render.ts +40 -32
- package/telegram-plugin/scoped-approval.ts +11 -2
- package/telegram-plugin/secret-detect/chunker.ts +18 -4
- package/telegram-plugin/secret-detect/index.ts +12 -56
- package/telegram-plugin/send-gate-degraded.test.ts +131 -0
- package/telegram-plugin/send-gate.test.ts +25 -6
- package/telegram-plugin/send-gate.ts +82 -8
- package/telegram-plugin/session-tail.ts +82 -7
- package/telegram-plugin/stream-controller.ts +3 -2
- package/telegram-plugin/subagent-watcher.ts +71 -16
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +36 -5
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +72 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
- package/telegram-plugin/tests/forward-origin.test.ts +309 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/history.test.ts +272 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +164 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
- package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +6 -4
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
- package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
- package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
- package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
- package/telegram-plugin/tests/single-mode-stream-reply.test.ts +5 -3
- package/telegram-plugin/tests/status-accent.test.ts +5 -3
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +20 -20
- package/telegram-plugin/tests/stream-reply-handler.test.ts +5 -2
- package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
- package/telegram-plugin/tests/throttle-tier-wiring.test.ts +290 -0
- package/telegram-plugin/tests/throttle-tier.test.ts +278 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
- package/telegram-plugin/throttle-tier.ts +226 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +8 -7
- package/telegram-plugin/worktree-watch-cwds.ts +194 -5
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2
|
-
//
|
|
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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
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`
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
|
50
|
+
if (raw == null) return true;
|
|
46
51
|
const v = raw.trim().toLowerCase();
|
|
47
|
-
return v === "
|
|
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
|
|
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
|
-
*
|
|
73
|
+
* Kill-switch-gated transform for the live send path.
|
|
69
74
|
*
|
|
70
|
-
* -
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* -
|
|
74
|
-
*
|
|
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
|
-
*
|
|
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
|
-
* -
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
|
91
|
-
|
|
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:
|
|
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', () => {
|