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.
- package/dist/agent-scheduler/index.js +8 -0
- package/dist/auth-broker/index.js +63 -65
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +9 -1
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +952 -3126
- package/dist/host-control/main.js +216 -2862
- package/dist/vault/approvals/kernel-server.js +67 -0
- package/dist/vault/broker/server.js +98 -44
- package/package.json +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +49 -3
- package/telegram-plugin/dist/gateway/gateway.js +656 -2326
- package/telegram-plugin/dist/server.js +65 -3
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +21 -2
- package/telegram-plugin/gateway/callback-query-handlers.ts +12 -0
- package/telegram-plugin/gateway/gateway.ts +221 -73
- package/telegram-plugin/history.ts +51 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +94 -0
- package/telegram-plugin/model-unavailable.ts +41 -11
- package/telegram-plugin/outbound-field-redact.ts +69 -0
- package/telegram-plugin/render/render.ts +32 -14
- 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/subagent-watcher.ts +71 -16
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +36 -5
- package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/history.test.ts +115 -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.test.ts +88 -0
- 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/subagent-watcher-fd-leak.test.ts +275 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
- 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
|
@@ -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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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 {
|
|
@@ -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', () => {
|