switchroom 0.14.27 → 0.14.28

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.
@@ -24611,6 +24611,20 @@ var init_bridge = __esm(async () => {
24611
24611
  },
24612
24612
  required: ["chat_id", "key"]
24613
24613
  }
24614
+ },
24615
+ {
24616
+ name: "request_secret",
24617
+ description: "Ask the operator to PROVIDE a secret you do not have, securely \u2014 NEVER ask the user to paste a token/key/password as a normal chat message. Use this when you need a credential that is not in the vault (a `vault:<key>` reference is missing/empty, or you know an upcoming task needs one you lack). Renders a Telegram card with [Provide securely] [Decline]; on tap, the operator sends the value once and the gateway DELETES their message instantly and writes it straight to the vault \u2014 the raw value is never echoed back to you. You receive only `vault:<key>`. This is the sibling of `vault_request_save` (use that when the user already handed YOU a value to store) and `vault_request_access` (use that when the key exists but you lack read access). After firing this tool, END YOUR TURN cleanly \u2014 a fresh inbound message arrives once the operator provides (or declines) the secret. Do NOT call this for keys you already have, and do NOT spam (one open request per key; the operator sees every card).",
24618
+ inputSchema: {
24619
+ type: "object",
24620
+ properties: {
24621
+ chat_id: { type: "string", description: "Chat to render the card in (use the chat_id of the user message that triggered the workflow)." },
24622
+ key: { type: "string", description: "Vault key to store the provided secret under. Use lowercase namespaced snake_case, e.g. `coolify/api-token`." },
24623
+ reason: { type: "string", description: 'REQUIRED in practice \u2014 one-line human-readable rationale rendered on the card (e.g. "to trigger a redeploy on Coolify"). Omitting it makes the operator more likely to Decline.' },
24624
+ message_thread_id: { type: "string", description: "Forum topic thread ID. Auto-applied from the last inbound message if not specified." }
24625
+ },
24626
+ required: ["chat_id", "key"]
24627
+ }
24614
24628
  }
24615
24629
  ];
24616
24630
  mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_SCHEMAS }));
@@ -355,6 +355,7 @@ import { defaultVaultWrite, defaultVaultList, defaultVaultWritePosture } from '.
355
355
  import { parseVaultCliError, renderVaultCliError } from '../secret-detect/vault-error.js'
356
356
  import { recentDenialsFromAuditLog, type RecentDenial } from './recent-denials.js'
357
357
  import { detectSecrets } from '../secret-detect/index.js'
358
+ import { redact } from '../secret-detect/redact.js'
358
359
  import { classifyAdminGate } from '../admin-commands/index.js'
359
360
  import {
360
361
  startSubagentWatcher,
@@ -371,7 +372,7 @@ import { maybeRenderUpdateAnnouncement } from './update-announce.js'
371
372
  import { createIssuesCardHandle, type IssuesCardHandle } from '../issues-card.js'
372
373
  import { startIssuesWatcher, type IssuesWatcherHandle } from '../issues-watcher.js'
373
374
  import { list as listIssues, resolve as resolveIssue } from '../../src/issues/index.js'
374
- import { formatPermissionCardBody, describeGrant } from '../permission-title.js'
375
+ import { formatPermissionCardBody, describeGrant, naturalAction, formatPermissionResumeMessage } from '../permission-title.js'
375
376
  import { resolveScopedAllowChoices, isRulePersisted } from '../permission-rule.js'
376
377
  import { synthesizeAllowRuleDiff, extractAddedAllowRule } from '../permission-diff.js'
377
378
  import {
@@ -2158,6 +2159,60 @@ function resumeReactionAfterVerdict(): void {
2158
2159
  ?.setThinking()
2159
2160
  }
2160
2161
 
2162
+ /**
2163
+ * Post the agent-voiced "got your verdict — continuing" message the
2164
+ * instant the operator answers a permission card. Travels right beside
2165
+ * `resumeReactionAfterVerdict()` at every operator-driven verdict site so
2166
+ * the legible signal (a distinct message naming the work) can't drift away
2167
+ * from the reaction flip — the same 5-paths-drift hazard #2019 guards.
2168
+ *
2169
+ * The card edit + 🙏→working reaction are easy to miss (a reaction lands on
2170
+ * the turn's triggering message far up the chat; the card footnote is a
2171
+ * one-liner). This message is the thing the operator actually sees.
2172
+ *
2173
+ * Posts to the suspended turn's chat/thread (`currentTurn` still points at
2174
+ * it mid-gate — the same controller `resumeReactionAfterVerdict` addresses)
2175
+ * so it lands in the conversation the gate belongs to; falls back to the
2176
+ * configured operator chats when there's no active turn (e.g. a swept turn
2177
+ * at TTL-sweep time). Kill-switch: `SWITCHROOM_RESUME_MSG=0`.
2178
+ */
2179
+ function postPermissionResumeMessage(opts: {
2180
+ behavior: 'allow' | 'deny'
2181
+ action: string
2182
+ timeoutMinutes?: number
2183
+ }): void {
2184
+ if (process.env.SWITCHROOM_RESUME_MSG === '0') return
2185
+ const text = formatPermissionResumeMessage({
2186
+ agentName: process.env.SWITCHROOM_AGENT_NAME ?? null,
2187
+ behavior: opts.behavior,
2188
+ action: opts.action,
2189
+ timeoutMinutes: opts.timeoutMinutes,
2190
+ })
2191
+ const turn = currentTurn
2192
+ const targets: Array<{ chatId: string; threadId: number | undefined }> =
2193
+ turn != null
2194
+ ? [{ chatId: turn.sessionChatId, threadId: turn.sessionThreadId }]
2195
+ : loadAccess().allowFrom.map(chatId => ({
2196
+ chatId,
2197
+ threadId: resolveAgentOutboundTopic({
2198
+ kind: 'permission',
2199
+ turnInitiated: false,
2200
+ originThreadId: undefined,
2201
+ }),
2202
+ }))
2203
+ for (const { chatId, threadId } of targets) {
2204
+ // allow-raw-bot-api: wrapped in swallowingApiCall (retry policy); thread-aware send
2205
+ void swallowingApiCall(
2206
+ () =>
2207
+ bot.api.sendMessage(chatId, text, {
2208
+ parse_mode: 'HTML',
2209
+ ...(threadId != null ? { message_thread_id: threadId } : {}),
2210
+ }),
2211
+ { chat_id: chatId, verb: 'permission-resume', ...(threadId != null ? { threadId } : {}) },
2212
+ )
2213
+ }
2214
+ }
2215
+
2161
2216
  function resolveThreadId(chat_id: string, explicit?: string | number | null): number | undefined {
2162
2217
  if (explicit != null) return Number(explicit)
2163
2218
  return chatThreadMap.get(chat_id)
@@ -3066,6 +3121,11 @@ const pendingStateReaper = setInterval(() => {
3066
3121
  // The auto-deny un-parks the suspended turn — flip 🙏 → working so
3067
3122
  // it doesn't sit on the awaiting glyph (or stall) after the timeout.
3068
3123
  resumeReactionAfterVerdict()
3124
+ postPermissionResumeMessage({
3125
+ behavior: 'deny',
3126
+ action: naturalAction(v.tool_name, v.input_preview),
3127
+ timeoutMinutes: Math.round(PERMISSION_TTL_MS / 60000),
3128
+ })
3069
3129
  process.stderr.write(
3070
3130
  `telegram gateway: permission TTL expired — auto-deny request=${k} ` +
3071
3131
  `tool=${v.tool_name} (no operator response in ` +
@@ -5080,6 +5140,7 @@ const ALLOWED_TOOLS = new Set([
5080
5140
  'send_sticker', 'send_gif',
5081
5141
  'vault_request_save',
5082
5142
  'vault_request_access',
5143
+ 'request_secret',
5083
5144
  ])
5084
5145
 
5085
5146
  async function executeToolCall(tool: string, args: Record<string, unknown>): Promise<unknown> {
@@ -5123,6 +5184,8 @@ async function executeToolCall(tool: string, args: Record<string, unknown>): Pro
5123
5184
  return executeVaultRequestSave(args)
5124
5185
  case 'vault_request_access':
5125
5186
  return executeVaultRequestAccess(args)
5187
+ case 'request_secret':
5188
+ return executeRequestSecret(args)
5126
5189
  default:
5127
5190
  throw new Error(`unknown tool: ${tool}`)
5128
5191
  }
@@ -5170,6 +5233,32 @@ async function executeUpdateChecklist(args: Record<string, unknown>): Promise<{
5170
5233
  return { content: [{ type: 'text', text: `checklist updated (id: ${message_id})` }] }
5171
5234
  }
5172
5235
 
5236
+ /**
5237
+ * Outbound secret scrub (#2044). The agent is trusted, but it can still
5238
+ * echo a secret it read from a file, env, or a not-yet-vaulted value into
5239
+ * a reply — and outbound text had no redaction at all. This masks any
5240
+ * detected secret in agent-authored text BEFORE it is logged (the stderr
5241
+ * previews), forwarded (sent to Telegram), or stored (recordOutbound).
5242
+ *
5243
+ * It runs the SAME `detectSecrets` engine as the inbound gate, so a
5244
+ * pattern added once (e.g. the Sanctum `<id>|<token>` shape, #2043) covers
5245
+ * both directions. Applied at the entry of every agent-free-text tool
5246
+ * (reply / stream_reply / edit_message), mutating the text in place so all
5247
+ * downstream consumers — voice scrub, dedup key, chunker, answer-stream
5248
+ * diffing, history record — see the masked value. This is the same
5249
+ * in-place-mutation contract the voice scrub already relies on, which
5250
+ * keeps the answer-stream's incremental edit diffing consistent (it always
5251
+ * compares redacted-against-redacted).
5252
+ */
5253
+ function redactOutboundText(text: string, site: string): string {
5254
+ const masked = redact(text)
5255
+ if (masked !== text) {
5256
+ // Never log the secret — only that a mask fired, and where.
5257
+ process.stderr.write(`telegram gateway: outbound secret masked site=${site}\n`)
5258
+ }
5259
+ return masked
5260
+ }
5261
+
5173
5262
  async function executeReply(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
5174
5263
  // #1664 — pin the turn this reply belongs to at entry. The
5175
5264
  // finalAnswerDelivered write near the end of this function runs after
@@ -5183,6 +5272,11 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
5183
5272
  const rawText = args.text as string | undefined
5184
5273
  if (rawText == null || rawText === '') throw new Error('reply: text is required and cannot be empty')
5185
5274
  let text = repairEscapedWhitespace(rawText)
5275
+ // Outbound secret scrub (#2044): mask any secret the agent echoed BEFORE
5276
+ // the stderr preview below, the dedup key, the send, and the history
5277
+ // record. Mutates `text` so every downstream consumer sees the masked
5278
+ // value, exactly like the voice scrub that follows.
5279
+ text = redactOutboundText(text, 'reply')
5186
5280
  // Voice scrub (#1683): replace em / en dashes with commas / periods.
5187
5281
  // Runs BEFORE outboundDedup so retries see the scrubbed key, and
5188
5282
  // BEFORE markdownToHtml so code-block content is correctly parked
@@ -5935,6 +6029,12 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
5935
6029
  if (!args.chat_id) throw new Error('stream_reply: chat_id is required')
5936
6030
  if (args.text == null || args.text === '') throw new Error('stream_reply: text is required and cannot be empty')
5937
6031
 
6032
+ // Outbound secret scrub (#2044): mask before the dedup key, the draft
6033
+ // stream sends, and the history record. stream_reply carries the FULL
6034
+ // text-so-far on every call, so redacting each call keeps the answer-
6035
+ // stream's incremental diffing comparing redacted-against-redacted.
6036
+ args.text = redactOutboundText(args.text as string, 'stream_reply')
6037
+
5938
6038
  // Voice scrub (PR #1683 follow-up). Modern Claude on the fleet
5939
6039
  // uses the answer-stream / draft-stream path for multi-paragraph
5940
6040
  // replies — the model emits via stream_reply and the original
@@ -6777,6 +6877,297 @@ async function executeVaultRequestSave(args: Record<string, unknown>): Promise<{
6777
6877
  }
6778
6878
  }
6779
6879
 
6880
+ // ─── #2045 request_secret — agent asks the operator to PROVIDE a secret ───
6881
+ //
6882
+ // The third member of the agent-vault tool family. `vault_request_save`
6883
+ // = agent HAS a value and asks to save it; `vault_request_access` = agent
6884
+ // needs read access to an existing key; `request_secret` = agent needs a
6885
+ // value it does NOT have. The operator provides it through a secure card:
6886
+ // they tap [Provide securely], send the value once, and the gateway deletes
6887
+ // the message instantly + writes it straight to the vault. The raw value is
6888
+ // never recorded to history, never logged, never returned to the agent —
6889
+ // the agent only ever references `vault:<key>`. This removes the reason an
6890
+ // agent would ever ask a user to paste a secret as a normal chat message.
6891
+ interface PendingSecretRequest {
6892
+ agent: string
6893
+ chat_id: string
6894
+ key: string
6895
+ reason?: string
6896
+ staged_at: number
6897
+ card_message_id?: number
6898
+ }
6899
+ // stageId -> request (lives until tapped or TTL).
6900
+ const pendingSecretRequests = new Map<string, PendingSecretRequest>()
6901
+ // chat_id -> the armed capture: the operator's NEXT message in this chat is
6902
+ // the value for `key`. Set when [Provide securely] is tapped.
6903
+ interface ArmedSecretCapture { key: string; agent: string; stageId: string; armed_at: number }
6904
+ const armedSecretCaptures = new Map<string, ArmedSecretCapture>()
6905
+ const PENDING_SECRET_REQUEST_TTL_MS = 30 * 60_000 // card lifetime
6906
+ const ARMED_SECRET_CAPTURE_TTL_MS = 10 * 60_000 // window to send the value after tapping
6907
+
6908
+ function sweepSecretRequests(): void {
6909
+ const now = Date.now()
6910
+ for (const [k, v] of pendingSecretRequests) {
6911
+ if (now - v.staged_at > PENDING_SECRET_REQUEST_TTL_MS) pendingSecretRequests.delete(k)
6912
+ }
6913
+ for (const [k, v] of armedSecretCaptures) {
6914
+ if (now - v.armed_at > ARMED_SECRET_CAPTURE_TTL_MS) armedSecretCaptures.delete(k)
6915
+ }
6916
+ }
6917
+
6918
+ function buildSecretRequestKeyboard(stageId: string): { inline_keyboard: Array<Array<{ text: string; callback_data: string }>> } {
6919
+ return {
6920
+ inline_keyboard: [
6921
+ [
6922
+ { text: '🔐 Provide securely', callback_data: `vsp:provide:${stageId}` },
6923
+ { text: '🚫 Decline', callback_data: `vsp:decline:${stageId}` },
6924
+ ],
6925
+ ],
6926
+ }
6927
+ }
6928
+
6929
+ function renderSecretRequestCard(req: PendingSecretRequest): string {
6930
+ const lines: string[] = [
6931
+ `🔒 <b>${escapeHtmlForTg(req.agent)}</b> needs a secret:`,
6932
+ `<code>${escapeHtmlForTg(req.key)}</code>`,
6933
+ ]
6934
+ if (req.reason) lines.push(`<i>${escapeHtmlForTg(req.reason)}</i>`)
6935
+ lines.push(
6936
+ '',
6937
+ 'Tap <b>Provide securely</b>, then send the value as your next message. I’ll delete it instantly and store it in the vault — it is never shown in chat or to the agent.',
6938
+ )
6939
+ return lines.join('\n')
6940
+ }
6941
+
6942
+ /**
6943
+ * `request_secret` tool — agent surfaces a card asking the operator to
6944
+ * provide a missing secret. No `value` arg: the value arrives via secure
6945
+ * capture (the operator's next message after they tap [Provide securely]).
6946
+ */
6947
+ async function executeRequestSecret(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
6948
+ const chat_id = args.chat_id as string
6949
+ if (!chat_id) throw new Error('request_secret: chat_id is required')
6950
+ const key = args.key as string
6951
+ if (!key || typeof key !== 'string') throw new Error('request_secret: key is required')
6952
+ const reason = typeof args.reason === 'string' ? args.reason : undefined
6953
+ assertAllowedChat(chat_id)
6954
+ if (!VAULT_KEY_REGEX.test(key)) {
6955
+ throw new Error(`request_secret: key must match ${VAULT_KEY_REGEX_LABEL}`)
6956
+ }
6957
+ const agentSlug = process.env.SWITCHROOM_AGENT_NAME || 'agent'
6958
+
6959
+ // Dedupe: one open request per (chat, key). Drop any prior stage for
6960
+ // the same target so the operator never sees stacked cards.
6961
+ for (const [sid, p] of pendingSecretRequests) {
6962
+ if (p.chat_id === chat_id && p.key === key) pendingSecretRequests.delete(sid)
6963
+ }
6964
+
6965
+ const stageId = randomBytes(4).toString('hex')
6966
+ const pending: PendingSecretRequest = { agent: agentSlug, chat_id, key, reason, staged_at: Date.now() }
6967
+ pendingSecretRequests.set(stageId, pending)
6968
+ sweepSecretRequests()
6969
+
6970
+ const text = renderSecretRequestCard(pending)
6971
+ const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
6972
+ const sent = await retryWithThreadFallback<{ message_id: number }>(
6973
+ robustApiCall,
6974
+ (tid) =>
6975
+ lockedBot.api.sendMessage(chat_id, text, {
6976
+ parse_mode: 'HTML',
6977
+ reply_markup: buildSecretRequestKeyboard(stageId),
6978
+ ...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
6979
+ }),
6980
+ { threadId, chat_id, verb: 'request_secret.card' },
6981
+ )
6982
+ pending.card_message_id = sent.message_id
6983
+
6984
+ return {
6985
+ content: [
6986
+ {
6987
+ type: 'text',
6988
+ text: `request_secret: card sent (stage_id=${stageId}, key=${key}). END YOUR TURN now and wait — a fresh inbound message arrives once the operator provides (or declines) the secret. Do NOT ask them to paste it as a normal message; the card handles it securely.`,
6989
+ },
6990
+ ],
6991
+ }
6992
+ }
6993
+
6994
+ /**
6995
+ * Write a provided secret to the vault. Mirrors the vrs:save write branch:
6996
+ * posture-attested broker put under telegram-id mode (no passphrase), else
6997
+ * the cached-passphrase CLI path. Returns the same {ok, output} shape.
6998
+ */
6999
+ async function writeRequestedSecret(key: string, value: string, chat_id: string): Promise<{ ok: boolean; output: string }> {
7000
+ if (VAULT_APPROVAL_AUTH_MODE === 'telegram-id') {
7001
+ return await defaultVaultWritePosture(key, value)
7002
+ }
7003
+ const cached = vaultPassphraseCache.get(chat_id)
7004
+ if (!cached || cached.expiresAt <= Date.now()) {
7005
+ return { ok: false, output: 'VAULT-LOCKED: run /vault unlock once in this chat, then have the agent re-request.' }
7006
+ }
7007
+ return defaultVaultWrite(key, value, cached.passphrase)
7008
+ }
7009
+
7010
+ /**
7011
+ * Secure value capture. Called early in handleInbound: if this chat is
7012
+ * armed (operator tapped [Provide securely]), the inbound message IS the
7013
+ * secret value. Delete it, write to the vault under the requested key, tell
7014
+ * the agent it's available, and STOP — never record / log / forward the raw
7015
+ * value. Returns true if it consumed the message (caller must return).
7016
+ */
7017
+ async function captureProvidedSecret(
7018
+ ctx: Context,
7019
+ chat_id: string,
7020
+ msgId: number | undefined,
7021
+ value: string,
7022
+ ): Promise<boolean> {
7023
+ const armed = armedSecretCaptures.get(chat_id)
7024
+ if (!armed || Date.now() - armed.armed_at > ARMED_SECRET_CAPTURE_TTL_MS) {
7025
+ if (armed) armedSecretCaptures.delete(chat_id)
7026
+ return false
7027
+ }
7028
+ armedSecretCaptures.delete(chat_id)
7029
+ const pending = pendingSecretRequests.get(armed.stageId)
7030
+ pendingSecretRequests.delete(armed.stageId)
7031
+
7032
+ // Delete the raw message FIRST — surfaces a warning if it fails.
7033
+ if (msgId != null) await deleteSensitiveMessage(chat_id, msgId, 'provided secret value')
7034
+
7035
+ const write = await writeRequestedSecret(armed.key, value, chat_id)
7036
+ if (!write.ok) {
7037
+ const parsed = parseVaultCliError(write.output)
7038
+ const rendered = renderVaultCliError(parsed, { verb: 'save', key: armed.key })
7039
+ const body = rendered.suppressRaw ? rendered.html : `⚠️ vault write failed:\n<pre>${escapeHtmlForTg(write.output)}</pre>`
7040
+ await switchroomReply(ctx, `${body}\n\n<i>The secret was NOT saved. The agent can re-request with <code>request_secret</code>.</i>`, { html: true })
7041
+ // Wake the agent too (review #2047 finding #3): it ended its turn
7042
+ // waiting for a synthetic inbound; without this it stalls silently.
7043
+ const fts = Date.now()
7044
+ const failMsg: InboundMessage = {
7045
+ type: 'inbound',
7046
+ chatId: chat_id,
7047
+ messageId: fts,
7048
+ user: 'vault-broker',
7049
+ userId: 0,
7050
+ ts: fts,
7051
+ text: `⚠️ The secret you requested for \`vault:${armed.key}\` could NOT be saved (vault write failed). Do not assume it is available; tell the operator or try request_secret again.`,
7052
+ meta: { source: 'secret_provide_failed', agent: armed.agent, key: armed.key, stage_id: armed.stageId },
7053
+ }
7054
+ const fdelivered = ipcServer.sendToAgent(armed.agent, failMsg)
7055
+ if (fdelivered) markClaudeBusyForInbound(failMsg)
7056
+ else pendingInboundBuffer.push(armed.agent, failMsg)
7057
+ return true
7058
+ }
7059
+
7060
+ await switchroomReply(
7061
+ ctx,
7062
+ `✅ saved as <code>vault:${escapeHtmlForTg(armed.key)}</code> (masked: <code>${escapeHtmlForTg(maskToken(value))}</code>). The agent can now reference it.`,
7063
+ { html: true },
7064
+ )
7065
+
7066
+ // Resume the requesting agent with a synthetic inbound — mirrors the
7067
+ // vault_grant_approved injection. The value is NOT included; only the key.
7068
+ const ts = Date.now()
7069
+ const synthetic: InboundMessage = {
7070
+ type: 'inbound',
7071
+ chatId: chat_id,
7072
+ messageId: ts,
7073
+ user: 'vault-broker',
7074
+ userId: 0,
7075
+ ts,
7076
+ text:
7077
+ `✅ Operator provided the secret you requested. It is saved as ` +
7078
+ `\`vault:${armed.key}\` — reference it the usual way. Resume the task ` +
7079
+ `that was waiting on this credential. Do NOT ask the operator to paste it.`,
7080
+ meta: {
7081
+ source: 'secret_provided',
7082
+ agent: armed.agent,
7083
+ key: armed.key,
7084
+ stage_id: armed.stageId,
7085
+ },
7086
+ }
7087
+ const delivered = ipcServer.sendToAgent(armed.agent, synthetic)
7088
+ if (delivered) markClaudeBusyForInbound(synthetic)
7089
+ else pendingInboundBuffer.push(armed.agent, synthetic)
7090
+ process.stderr.write(
7091
+ `telegram gateway: secret_provided injection agent=${armed.agent} key=${armed.key} stage=${armed.stageId} delivered=${delivered}\n`,
7092
+ )
7093
+ return true
7094
+ }
7095
+
7096
+ /**
7097
+ * `vsp:` callbacks — agent-requested-secret card.
7098
+ * vsp:provide:<stageId> — arm capture: operator's next message is the value
7099
+ * vsp:decline:<stageId> — drop the request; tell the agent it was declined
7100
+ */
7101
+ async function handleSecretRequestCallback(ctx: Context, data: string): Promise<void> {
7102
+ const senderId = String(ctx.from?.id ?? '')
7103
+ const access = loadAccess()
7104
+ if (!access.allowFrom.includes(senderId)) {
7105
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
7106
+ return
7107
+ }
7108
+ const parts = data.split(':')
7109
+ const action = parts[1]
7110
+ const stageId = parts[2] ?? ''
7111
+ const pending = pendingSecretRequests.get(stageId)
7112
+ if (!pending) {
7113
+ await ctx.answerCallbackQuery({ text: 'This request expired.' }).catch(() => {})
7114
+ return
7115
+ }
7116
+
7117
+ if (action === 'provide') {
7118
+ armedSecretCaptures.set(pending.chat_id, {
7119
+ key: pending.key,
7120
+ agent: pending.agent,
7121
+ stageId,
7122
+ armed_at: Date.now(),
7123
+ })
7124
+ await ctx.answerCallbackQuery({ text: 'Send the value now — it auto-deletes.' }).catch(() => {})
7125
+ if (pending.card_message_id != null) {
7126
+ await ctx.api
7127
+ .editMessageText(
7128
+ pending.chat_id,
7129
+ pending.card_message_id,
7130
+ `🔐 Send the value for <code>${escapeHtmlForTg(pending.key)}</code> as your next message — a single message, exactly as-is (don't add other text). I’ll delete it instantly and store it in the vault.`,
7131
+ { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
7132
+ )
7133
+ .catch(() => {})
7134
+ }
7135
+ return
7136
+ }
7137
+
7138
+ if (action === 'decline') {
7139
+ pendingSecretRequests.delete(stageId)
7140
+ armedSecretCaptures.delete(pending.chat_id)
7141
+ await ctx.answerCallbackQuery({ text: 'Declined.' }).catch(() => {})
7142
+ if (pending.card_message_id != null) {
7143
+ await ctx.api
7144
+ .editMessageText(pending.chat_id, pending.card_message_id, `🚫 Declined — <code>${escapeHtmlForTg(pending.key)}</code> not provided.`, {
7145
+ parse_mode: 'HTML',
7146
+ reply_markup: { inline_keyboard: [] },
7147
+ })
7148
+ .catch(() => {})
7149
+ }
7150
+ // Tell the agent so it stops waiting.
7151
+ const ts = Date.now()
7152
+ const synthetic: InboundMessage = {
7153
+ type: 'inbound',
7154
+ chatId: pending.chat_id,
7155
+ messageId: ts,
7156
+ user: 'vault-broker',
7157
+ userId: 0,
7158
+ ts,
7159
+ text: `🚫 Operator declined your request for \`vault:${pending.key}\`. Proceed without it or ask how they'd like to handle the task.`,
7160
+ meta: { source: 'secret_declined', agent: pending.agent, key: pending.key, stage_id: stageId },
7161
+ }
7162
+ const delivered = ipcServer.sendToAgent(pending.agent, synthetic)
7163
+ if (delivered) markClaudeBusyForInbound(synthetic)
7164
+ else pendingInboundBuffer.push(pending.agent, synthetic)
7165
+ return
7166
+ }
7167
+
7168
+ await ctx.answerCallbackQuery().catch(() => {})
7169
+ }
7170
+
6780
7171
  /**
6781
7172
  * Issue #1012 — render the agent-initiated ACL request approval card.
6782
7173
  * Same visual language as the recent-denials one-tap allow (#969 P2b)
@@ -6991,6 +7382,9 @@ async function executeEditMessage(args: Record<string, unknown>): Promise<unknow
6991
7382
  const editConfigMode = editAccess.parseMode ?? 'html'
6992
7383
  const editFormat = (args.format as string | undefined) ?? editConfigMode
6993
7384
  let editRawText = repairEscapedWhitespace(args.text as string)
7385
+ // Outbound secret scrub (#2044): an edit must not re-introduce a raw
7386
+ // secret into a live bubble or the history row. Mask before scrub/send.
7387
+ editRawText = redactOutboundText(editRawText, 'edit_message')
6994
7388
  // Voice scrub (#1683): same em-dash scrub as the reply path. Edits
6995
7389
  // are how silent-anchor and progress-update mutate already-sent
6996
7390
  // bubbles, so without this an edit can re-introduce dashes the
@@ -8170,6 +8564,14 @@ function handleSessionEvent(ev: SessionEvent): void {
8170
8564
  const backstopThreadId = threadId
8171
8565
  const backstopCtrl = ctrl
8172
8566
 
8567
+ // Outbound secret scrub (#2044). Turn-flush delivers the model's
8568
+ // terminal answer prose when it skipped reply/stream_reply — that
8569
+ // is arbitrary agent free-text, sent via sendMessage/editMessageText
8570
+ // and previewed to stderr below, so it needs the same mask as the
8571
+ // three reply tools. Mirror the voice scrub: mask before the send,
8572
+ // the preview, and recordOutbound.
8573
+ capturedText = redactOutboundText(capturedText, 'turn_flush')
8574
+
8173
8575
  // Voice scrub (PR #1683 follow-up). Turn-flush is the path
8174
8576
  // that fires when the model emits raw transcript text WITHOUT
8175
8577
  // calling reply / stream_reply. That captured text bypasses
@@ -9216,6 +9618,11 @@ async function handleInbound(
9216
9618
  behavior,
9217
9619
  })
9218
9620
  resumeReactionAfterVerdict()
9621
+ const ftDetails = pendingPermissions.get(request_id)
9622
+ postPermissionResumeMessage({
9623
+ behavior,
9624
+ action: ftDetails ? naturalAction(ftDetails.tool_name, ftDetails.input_preview) : '',
9625
+ })
9219
9626
  if (msgId != null) {
9220
9627
  const emoji = behavior === 'allow' ? '✅' : '❌'
9221
9628
  void bot.api.setMessageReaction(chat_id, msgId, [
@@ -9525,6 +9932,22 @@ async function handleInbound(
9525
9932
  const isQueuedPrefix = parsedQueue.queued
9526
9933
  let effectiveText = isSteerPrefix ? parsedSteer.body : (isQueuedPrefix ? parsedQueue.body : text)
9527
9934
 
9935
+ // --- #2045 provided-secret capture ---
9936
+ // If this chat is armed (operator tapped [Provide securely] on a
9937
+ // request_secret card), THIS message is the secret value: delete it,
9938
+ // write it to the vault, resume the agent, and STOP. Runs before secret
9939
+ // detection, recordInbound, and the IPC broadcast so the raw value is
9940
+ // never recorded, logged, or forwarded.
9941
+ if (armedSecretCaptures.has(chat_id)) {
9942
+ // Capture the RAW message text, not effectiveText — the secret value
9943
+ // must be vaulted verbatim, without the steer/queue prefix-stripping or
9944
+ // trim that effectiveText applies (review #2047 finding #1). A
9945
+ // credential that legitimately starts with `/s` or has surrounding
9946
+ // whitespace would otherwise be mangled.
9947
+ const consumed = await captureProvidedSecret(ctx, chat_id, msgId ?? undefined, text)
9948
+ if (consumed) return
9949
+ }
9950
+
9528
9951
  // --- Secret detection + vault-scrub ---
9529
9952
  // If the user pasted a secret, intercept BEFORE we record to history or
9530
9953
  // broadcast to the agent: write to vault, delete the Telegram message,
@@ -12109,6 +12532,10 @@ async function handlePermissionSlash(ctx: Context, behavior: 'allow' | 'deny'):
12109
12532
  // Forward to connected bridges — same IPC the button handler uses.
12110
12533
  dispatchPermissionVerdict({ type: 'permission', requestId: request_id, behavior })
12111
12534
  resumeReactionAfterVerdict()
12535
+ postPermissionResumeMessage({
12536
+ behavior,
12537
+ action: naturalAction(details.tool_name, details.input_preview),
12538
+ })
12112
12539
  pendingPermissions.delete(request_id)
12113
12540
  process.stderr.write(
12114
12541
  `[telegram gateway] slash-${behavior} request_id=${request_id} tool=${details.tool_name} by=${senderId}\n`,
@@ -15482,6 +15909,14 @@ bot.on('callback_query:data', async ctx => {
15482
15909
  return
15483
15910
  }
15484
15911
 
15912
+ // #2045: agent-requested-secret card.
15913
+ // vsp:provide:<stageId> — arm capture; operator's next message is the value
15914
+ // vsp:decline:<stageId> — drop the request, notify the agent
15915
+ if (data.startsWith('vsp:')) {
15916
+ await handleSecretRequestCallback(ctx, data)
15917
+ return
15918
+ }
15919
+
15485
15920
  // Issue #969 P2b: vault recent-denial one-tap approval.
15486
15921
  // vrd:<agent>:<key> — mint a 30-day read-grant for the agent + key
15487
15922
  // Posted by /vault audit <agent> in the "Recent denials" section.
@@ -15763,6 +16198,10 @@ bot.on('callback_query:data', async ctx => {
15763
16198
  // below). Un-park 🙏 → working immediately so the operator sees the
15764
16199
  // agent continue while hostd writes the durable rule.
15765
16200
  resumeReactionAfterVerdict()
16201
+ postPermissionResumeMessage({
16202
+ behavior: 'allow',
16203
+ action: naturalAction(details.tool_name, details.input_preview),
16204
+ })
15766
16205
 
15767
16206
  // (3) Decide the persistence path. tryHostdDispatch returns
15768
16207
  // "not-configured" when host_control is disabled or the per-agent
@@ -15914,18 +16353,20 @@ bot.on('callback_query:data', async ctx => {
15914
16353
  return
15915
16354
  }
15916
16355
 
15917
- // Forward permission decision to connected bridges
16356
+ // Forward permission decision to connected bridges. Capture the work
16357
+ // phrase BEFORE deleting the pending entry — postPermissionResumeMessage
16358
+ // (fired in synthInbound below) names the resumed work.
16359
+ const resumeAction = (() => {
16360
+ const d = pendingPermissions.get(request_id)
16361
+ return d ? naturalAction(d.tool_name, d.input_preview) : ''
16362
+ })()
15918
16363
  pendingPermissions.delete(request_id)
15919
- // Deterministic "▶️ resuming…" beat (framework-posted, not model text):
15920
- // the verdict un-parks the suspended turn, so confirm to the operator
15921
- // that the agent received it and is continuing closing the "is it
15922
- // working or did my tap do nothing?" gap. Allow and deny both resume the
15923
- // turn (deny just hands claude a refusal it then handles).
15924
- const resumeAgent = process.env.SWITCHROOM_AGENT_NAME
15925
- const resumeBeat = resumeAgent
15926
- ? `▶️ ${escapeHtmlForTg(resumeAgent)} resuming…`
15927
- : '▶️ resuming…'
15928
- const label = `${behavior === 'allow' ? '✅ Allowed' : '❌ Denied'} · ${resumeBeat}`
16364
+ // The card collapses to a plain verdict label. The distinct agent-voiced
16365
+ // "got it, continuing: …" message (posted on resume below) now carries
16366
+ // the "is it working or did my tap do nothing?" signal the old
16367
+ // `▶️ resuming…` card footnote used to and names the work, which the
16368
+ // footnote never did. Keeps the card terse and the resume legible.
16369
+ const label = behavior === 'allow' ? '✅ Allowed' : '❌ Denied'
15929
16370
  // HTML-escape the source text — same hazard as the scope-commit and
15930
16371
  // recent-denial paths above. The permission card body
15931
16372
  // (formatPermissionCardBody) appends claude-supplied `description`
@@ -15956,6 +16397,10 @@ bot.on('callback_query:data', async ctx => {
15956
16397
  // Un-park the status reaction: 🙏 → working, re-arming the stall
15957
16398
  // watchdog that setAwaiting() suspended.
15958
16399
  resumeReactionAfterVerdict()
16400
+ postPermissionResumeMessage({
16401
+ behavior: behavior as 'allow' | 'deny',
16402
+ action: resumeAction,
16403
+ })
15959
16404
  },
15960
16405
  })
15961
16406
  })
@@ -27,6 +27,7 @@
27
27
 
28
28
  import { chmodSync, mkdirSync } from 'fs'
29
29
  import { join } from 'path'
30
+ import { redact } from './secret-detect/redact.js'
30
31
 
31
32
  /**
32
33
  * `bun:sqlite` is a Bun built-in — Vite/Node loaders can't resolve it
@@ -300,6 +301,10 @@ export function recordInbound(args: RecordInboundArgs): void {
300
301
  (chat_id, thread_id, message_id, role, user, user_id, ts, text, attachment_kind, group_id, reply_to_message_id, reply_to_text)
301
302
  VALUES (?, ?, ?, 'user', ?, ?, ?, ?, ?, NULL, ?, ?)
302
303
  `)
304
+ // Defense-in-depth: never persist a detected secret to the message store.
305
+ // The inbound gate (server.ts handleInbound) already deletes + vaults a
306
+ // high-confidence hit before reaching here, so for caught secrets this is
307
+ // a no-op; it's the backstop for any shape the gate's pattern set misses.
303
308
  stmt.run(
304
309
  args.chat_id,
305
310
  args.thread_id ?? null,
@@ -307,10 +312,10 @@ export function recordInbound(args: RecordInboundArgs): void {
307
312
  args.user ?? null,
308
313
  args.user_id ?? null,
309
314
  args.ts,
310
- args.text,
315
+ redact(args.text),
311
316
  args.attachment_kind ?? null,
312
317
  args.reply_to_message_id ?? null,
313
- args.reply_to_text ?? null,
318
+ args.reply_to_text != null ? redact(args.reply_to_text) : (args.reply_to_text ?? null),
314
319
  )
315
320
  }
316
321
 
@@ -356,9 +361,14 @@ export function recordOutbound(args: RecordOutboundArgs): void {
356
361
  )
357
362
  }
358
363
  }) as (...args: unknown[]) => unknown)
364
+ // Outbound redaction: the agent→user direction has no other secret
365
+ // scrub, so this is the chokepoint that keeps an agent-echoed secret out
366
+ // of the message store (e.g. an agent quoting a token it read from a file
367
+ // or a not-yet-vaulted value). Masks the secret bytes in place; the
368
+ // surrounding reply text is preserved.
359
369
  const rows: Array<[number, string, string | null]> = args.message_ids.map((id, i) => [
360
370
  id,
361
- args.texts[i] ?? '',
371
+ redact(args.texts[i] ?? ''),
362
372
  args.attachment_kinds?.[i] ?? null,
363
373
  ])
364
374
  tx(rows)
@@ -387,7 +397,9 @@ export function recordEdit(args: RecordEditArgs): void {
387
397
  SET text = ?
388
398
  WHERE chat_id = ? AND message_id = ?
389
399
  `)
390
- .run(args.text, args.chat_id, args.message_id)
400
+ // Same outbound chokepoint as recordOutbound — an edit must not
401
+ // reintroduce a raw secret into the stored row.
402
+ .run(redact(args.text), args.chat_id, args.message_id)
391
403
  }
392
404
 
393
405
  export interface RecordReactionArgs {