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.
- package/dist/cli/switchroom.js +20 -4
- package/dist/host-control/main.js +2 -2
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +15 -0
- package/telegram-plugin/card-format.ts +7 -4
- package/telegram-plugin/dist/bridge/bridge.js +14 -0
- package/telegram-plugin/dist/gateway/gateway.js +2131 -1729
- package/telegram-plugin/dist/server.js +14 -0
- package/telegram-plugin/gateway/gateway.ts +457 -12
- package/telegram-plugin/history.ts +16 -4
- package/telegram-plugin/permission-title.ts +48 -0
- package/telegram-plugin/secret-detect/patterns.ts +8 -0
- package/telegram-plugin/secret-detect/redact.ts +76 -0
- package/telegram-plugin/tests/card-format.test.ts +16 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +80 -0
- package/telegram-plugin/tests/gateway-request-secret.test.ts +78 -0
- package/telegram-plugin/tests/history.test.ts +59 -0
- package/telegram-plugin/tests/permission-title.test.ts +68 -0
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +35 -0
- package/telegram-plugin/tests/secret-detect-sanctum.test.ts +115 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +15 -0
- package/telegram-plugin/uat/scenarios/jtbd-request-secret-dm.test.ts +101 -0
- package/telegram-plugin/worker-activity-feed.ts +5 -2
|
@@ -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
|
-
//
|
|
15920
|
-
//
|
|
15921
|
-
//
|
|
15922
|
-
//
|
|
15923
|
-
//
|
|
15924
|
-
const
|
|
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
|
-
|
|
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 {
|