switchroom 0.7.13 → 0.7.15

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.
@@ -258,6 +258,7 @@ import { runPipeline } from '../secret-detect/pipeline.js'
258
258
  import { StagingMap } from '../secret-detect/staging.js'
259
259
  import { maskToken } from '../secret-detect/mask.js'
260
260
  import { defaultVaultWrite, defaultVaultList } from '../secret-detect/vault-write.js'
261
+ import { parseVaultCliError, renderVaultCliError } from '../secret-detect/vault-error.js'
261
262
  import { detectSecrets } from '../secret-detect/index.js'
262
263
  import { classifyAdminGate } from '../admin-commands/index.js'
263
264
  import {
@@ -1390,6 +1391,9 @@ type PendingVaultOp =
1390
1391
  }
1391
1392
  // Issue #228: waiting for confirmation before revoking a grant.
1392
1393
  | { kind: 'revoke_confirm'; grantId: string; agent: string; keys: string[]; startedAt: number }
1394
+ // Issue #969 P1a: user tapped "Rename" on a vault_request_save card;
1395
+ // the next message becomes the new key name for the staged save.
1396
+ | { kind: 'rename-vault-save'; stageId: string; startedAt: number }
1393
1397
  const VAULT_INPUT_TTL_MS = 5 * 60 * 1000
1394
1398
  const pendingVaultOps = new Map<string, PendingVaultOp>()
1395
1399
 
@@ -1426,6 +1430,40 @@ interface DeferredSecret {
1426
1430
  }
1427
1431
  const deferredSecrets = new Map<string, DeferredSecret>()
1428
1432
 
1433
+ /**
1434
+ * Agent-initiated save staging (issue #969 P1a). When an agent calls the
1435
+ * `vault_request_save` MCP tool, we stage the value here, render an
1436
+ * approval card to the user, and write to vault only on tap. The value
1437
+ * is held in gateway memory ONLY — never echoed back to the agent and
1438
+ * never logged.
1439
+ */
1440
+ interface PendingVaultRequestSave {
1441
+ /** Agent that requested the save (process.env.SWITCHROOM_AGENT_NAME). */
1442
+ agent: string
1443
+ /** Chat to edit when the user taps. */
1444
+ chat_id: string
1445
+ /** Card message id (filled in after we send the card). */
1446
+ card_message_id?: number
1447
+ /** Currently-suggested slug; may be renamed by the user. */
1448
+ key: string
1449
+ /** Storage shape — 'string' (default) or 'binary'. */
1450
+ kind: 'string' | 'binary'
1451
+ /** The secret value, held in memory until the user approves/discards. */
1452
+ value: string
1453
+ /** Optional rationale shown on the card. */
1454
+ why?: string
1455
+ /** Unix-ms timestamp; entries are reaped after VAULT_REQUEST_SAVE_TTL_MS. */
1456
+ staged_at: number
1457
+ }
1458
+ const pendingVaultRequestSaves = new Map<string, PendingVaultRequestSave>()
1459
+ const VAULT_REQUEST_SAVE_TTL_MS = 10 * 60 * 1000
1460
+ function sweepPendingVaultRequestSaves(): void {
1461
+ const cutoff = Date.now() - VAULT_REQUEST_SAVE_TTL_MS
1462
+ for (const [k, v] of pendingVaultRequestSaves) {
1463
+ if (v.staged_at < cutoff) pendingVaultRequestSaves.delete(k)
1464
+ }
1465
+ }
1466
+
1429
1467
  /**
1430
1468
  * Mint an approval-kernel decision row for a deferred-secret card
1431
1469
  * (MIGRATION.md §1). Best-effort: if the kernel/broker is unreachable, we
@@ -2373,6 +2411,7 @@ const ALLOWED_TOOLS = new Set([
2373
2411
  'send_checklist', 'update_checklist',
2374
2412
  'ask_user',
2375
2413
  'send_sticker', 'send_gif',
2414
+ 'vault_request_save',
2376
2415
  ])
2377
2416
 
2378
2417
  async function executeToolCall(tool: string, args: Record<string, unknown>): Promise<unknown> {
@@ -2412,6 +2451,8 @@ async function executeToolCall(tool: string, args: Record<string, unknown>): Pro
2412
2451
  return executeSendSticker(args)
2413
2452
  case 'send_gif':
2414
2453
  return executeSendGif(args)
2454
+ case 'vault_request_save':
2455
+ return executeVaultRequestSave(args)
2415
2456
  default:
2416
2457
  throw new Error(`unknown tool: ${tool}`)
2417
2458
  }
@@ -3387,6 +3428,105 @@ async function publishToTelegraph(
3387
3428
  return page.value.url
3388
3429
  }
3389
3430
 
3431
+ /**
3432
+ * Build the inline keyboard for the agent-initiated vault-save approval card.
3433
+ * Issue #969 P1a. Callback prefix `vrs:` (vault-request-save).
3434
+ *
3435
+ * Buttons must fit Telegram's 64-byte callback_data limit. Stage IDs are
3436
+ * 8 hex chars (32 bits of entropy), so each callback comfortably fits.
3437
+ */
3438
+ function buildVaultRequestSaveKeyboard(stageId: string): { inline_keyboard: Array<Array<{ text: string; callback_data: string }>> } {
3439
+ return {
3440
+ inline_keyboard: [
3441
+ [
3442
+ { text: '✅ Save once', callback_data: `vrs:save:${stageId}` },
3443
+ { text: '🚫 Discard', callback_data: `vrs:discard:${stageId}` },
3444
+ ],
3445
+ [
3446
+ { text: '✏️ Rename', callback_data: `vrs:rename:${stageId}` },
3447
+ ],
3448
+ ],
3449
+ }
3450
+ }
3451
+
3452
+ function renderVaultRequestSaveCard(req: PendingVaultRequestSave, agentSlug: string): string {
3453
+ const lines: string[] = []
3454
+ lines.push(`🔐 <b>${escapeHtmlForTg(agentSlug)}</b> wants to save a secret`)
3455
+ lines.push(`key: <code>${escapeHtmlForTg(req.key)}</code>`)
3456
+ if (req.why && req.why.length > 0) {
3457
+ lines.push(`why: <i>${escapeHtmlForTg(req.why)}</i>`)
3458
+ }
3459
+ lines.push('')
3460
+ lines.push(`<i>Tap Save to write to the host vault, Rename to change the key name, or Discard to drop it. The value is held in this chat's gateway memory until you decide.</i>`)
3461
+ return lines.join('\n')
3462
+ }
3463
+
3464
+ /**
3465
+ * `vault_request_save` tool — agent surfaces an approval card asking
3466
+ * the user to confirm saving a secret. The value is staged here and
3467
+ * written to vault only on user tap. See #969 P1a.
3468
+ */
3469
+ async function executeVaultRequestSave(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
3470
+ const chat_id = args.chat_id as string
3471
+ if (!chat_id) throw new Error('vault_request_save: chat_id is required')
3472
+ const key = args.key as string
3473
+ if (!key || typeof key !== 'string') throw new Error('vault_request_save: key is required')
3474
+ const value = args.value as string
3475
+ if (typeof value !== 'string' || value.length === 0) {
3476
+ throw new Error('vault_request_save: value is required and must be a non-empty string')
3477
+ }
3478
+ const why = typeof args.why === 'string' ? args.why : undefined
3479
+ const kindRaw = typeof args.kind === 'string' ? args.kind : 'string'
3480
+ if (kindRaw !== 'string' && kindRaw !== 'binary') {
3481
+ throw new Error('vault_request_save: kind must be "string" or "binary"')
3482
+ }
3483
+ assertAllowedChat(chat_id)
3484
+
3485
+ // Validate slug shape — vault keys must match a tight charset so the
3486
+ // host CLI hints render cleanly and reference resolution stays
3487
+ // predictable. Same shape as `validateVaultKey` in the broker, kept
3488
+ // local here to avoid pulling in the broker import.
3489
+ if (!/^[A-Za-z0-9_.-]{1,200}$/.test(key)) {
3490
+ throw new Error('vault_request_save: key must match [A-Za-z0-9_.-]{1,200}')
3491
+ }
3492
+
3493
+ const agentSlug = process.env.SWITCHROOM_AGENT_NAME || 'agent'
3494
+
3495
+ // Stage the request server-side. The value never leaves gateway memory
3496
+ // until the user approves.
3497
+ const stageId = randomBytes(4).toString('hex')
3498
+ const pending: PendingVaultRequestSave = {
3499
+ agent: agentSlug,
3500
+ chat_id,
3501
+ key,
3502
+ kind: kindRaw,
3503
+ value,
3504
+ why,
3505
+ staged_at: Date.now(),
3506
+ }
3507
+ pendingVaultRequestSaves.set(stageId, pending)
3508
+ sweepPendingVaultRequestSaves()
3509
+
3510
+ // Send the approval card.
3511
+ const text = renderVaultRequestSaveCard(pending, agentSlug)
3512
+ const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
3513
+ const sent = await lockedBot.api.sendMessage(chat_id, text, {
3514
+ parse_mode: 'HTML',
3515
+ reply_markup: buildVaultRequestSaveKeyboard(stageId),
3516
+ ...(threadId != null && Number.isFinite(threadId) ? { message_thread_id: threadId } : {}),
3517
+ })
3518
+ pending.card_message_id = sent.message_id
3519
+
3520
+ return {
3521
+ content: [
3522
+ {
3523
+ type: 'text',
3524
+ text: `vault_request_save: card sent (stage_id=${stageId}, key=${key}). The user must tap a button before the secret is persisted; do not assume success until you see the user's next message confirming the outcome.`,
3525
+ },
3526
+ ],
3527
+ }
3528
+ }
3529
+
3390
3530
  async function executeReact(args: Record<string, unknown>): Promise<unknown> {
3391
3531
  if (!args.chat_id) throw new Error('react: chat_id is required')
3392
3532
  if (!args.message_id) throw new Error('react: message_id is required')
@@ -4835,6 +4975,35 @@ async function handleInbound(
4835
4975
  if (codeBlockMatch) value = codeBlockMatch[1]!
4836
4976
  if (msgId != null) await deleteSensitiveMessage(chat_id, msgId, 'vault secret value')
4837
4977
  await executeVaultOp(ctx, chat_id, 'set', pendingVault.key, pendingVault.passphrase, value.trim())
4978
+ } else if (pendingVault.kind === 'rename-vault-save') {
4979
+ // Issue #969 P1a: user tapped Rename on a vault_request_save
4980
+ // card and is now telling us the new key name. Validate the
4981
+ // slug, update the staged entry, refresh the card.
4982
+ const newKey = text.trim()
4983
+ const staged = pendingVaultRequestSaves.get(pendingVault.stageId)
4984
+ if (!staged) {
4985
+ await switchroomReply(ctx, '⌛ That save card expired before you renamed. Ask the agent to re-issue.', { html: true })
4986
+ return
4987
+ }
4988
+ if (!/^[A-Za-z0-9_.-]{1,200}$/.test(newKey)) {
4989
+ // Re-arm the pending state so the user can try again.
4990
+ pendingVaultOps.set(chat_id, { ...pendingVault, startedAt: Date.now() })
4991
+ await switchroomReply(ctx, '⚠️ Key must match <code>[A-Za-z0-9_.-]</code> and be ≤ 200 chars. Send a different name.', { html: true })
4992
+ return
4993
+ }
4994
+ staged.key = newKey
4995
+ if (msgId != null) await deleteSensitiveMessage(chat_id, msgId, 'vault rename input')
4996
+ // Edit the card in place with the new suggested key + same buttons.
4997
+ if (staged.card_message_id != null) {
4998
+ await ctx.api
4999
+ .editMessageText(
5000
+ staged.chat_id,
5001
+ staged.card_message_id,
5002
+ renderVaultRequestSaveCard(staged, staged.agent),
5003
+ { parse_mode: 'HTML', reply_markup: buildVaultRequestSaveKeyboard(pendingVault.stageId) },
5004
+ )
5005
+ .catch(() => {})
5006
+ }
4838
5007
  }
4839
5008
  return
4840
5009
  }
@@ -4871,7 +5040,15 @@ async function handleInbound(
4871
5040
  while (existing.has(slug)) slug = `${slugBase}_${n++}`
4872
5041
  const write = defaultVaultWrite(slug, staged.detection.matched_text, cached.passphrase)
4873
5042
  if (!write.ok) {
4874
- await switchroomReply(ctx, `<b>vault write failed:</b>\n${preBlock(write.output)}`, { html: true })
5043
+ // Route P0a markers (#969) through the structured renderer so
5044
+ // a "new key, needs operator approval" failure surfaces a
5045
+ // host-CLI hint instead of a raw stderr blob.
5046
+ const parsed = parseVaultCliError(write.output)
5047
+ const rendered = renderVaultCliError(parsed, { verb: 'save', key: slug })
5048
+ const body = rendered.suppressRaw
5049
+ ? rendered.html
5050
+ : `<b>vault write failed:</b>\n${preBlock(write.output)}`
5051
+ await switchroomReply(ctx, body, { html: true })
4875
5052
  return
4876
5053
  }
4877
5054
  secretStaging.delete(staged.chat_id, staged.message_id)
@@ -5939,16 +6116,37 @@ function runVaultCli(args: string[], passphrase: string, stdinValue?: string): {
5939
6116
  }
5940
6117
  }
5941
6118
 
6119
+ /**
6120
+ * Render a vault-CLI failure as Telegram HTML. Routes recognised P0a
6121
+ * stderr markers (VAULT-SANDBOX-CONTEXT / VAULT-NEEDS-APPROVAL /
6122
+ * VAULT-BROKER-UNREACHABLE / VAULT-BROKER-DENIED) through the structured
6123
+ * renderer; falls back to a raw pre-block for anything else.
6124
+ */
6125
+ function renderVaultOpFailure(
6126
+ verbLabel: 'list' | 'get' | 'set' | 'delete',
6127
+ cliOutput: string,
6128
+ key: string | undefined,
6129
+ ): string {
6130
+ const parsed = parseVaultCliError(cliOutput)
6131
+ // Map the gateway-internal op label onto the renderer's verb. 'delete'
6132
+ // surfaces in the host hint as `switchroom vault remove <key>` (the
6133
+ // canonical CLI name); 'list' has no key.
6134
+ const verb = verbLabel === 'delete' ? 'remove' : verbLabel
6135
+ const rendered = renderVaultCliError(parsed, { verb, key })
6136
+ if (rendered.suppressRaw) return rendered.html
6137
+ return `<b>vault ${verbLabel} failed:</b>\n${preBlock(cliOutput)}`
6138
+ }
6139
+
5942
6140
  async function executeVaultOp(ctx: Context, chatId: string, op: 'list' | 'get' | 'set' | 'delete', key: string | undefined, passphrase: string, setValue: string | undefined): Promise<void> {
5943
6141
  if (op === 'list') {
5944
6142
  const r = runVaultCli(['list'], passphrase)
5945
- if (!r.ok) { await switchroomReply(ctx, `<b>vault list failed:</b>\n${preBlock(r.output)}`, { html: true }); return }
6143
+ if (!r.ok) { await switchroomReply(ctx, renderVaultOpFailure('list', r.output, undefined), { html: true }); return }
5946
6144
  const keys = r.output.split('\n').filter(Boolean)
5947
6145
  if (keys.length === 0) { await switchroomReply(ctx, 'Vault is empty.', { html: true }) }
5948
6146
  else { await switchroomReply(ctx, `<b>Vault keys (${keys.length}):</b>\n${keys.map(k => `• <code>${escapeHtmlForTg(k)}</code>`).join('\n')}`, { html: true }) }
5949
6147
  } else if (op === 'get') {
5950
6148
  const r = runVaultCli(['get', key!], passphrase)
5951
- if (!r.ok) { await switchroomReply(ctx, `<b>vault get failed:</b>\n${preBlock(r.output)}`, { html: true }); return }
6149
+ if (!r.ok) { await switchroomReply(ctx, renderVaultOpFailure('get', r.output, key), { html: true }); return }
5952
6150
  await switchroomReply(ctx, `<code>${escapeHtmlForTg(key!)}</code> =\n<code>${escapeHtmlForTg(r.output)}</code>`, { html: true })
5953
6151
  } else if (op === 'set') {
5954
6152
  if (setValue === undefined) {
@@ -5957,11 +6155,11 @@ async function executeVaultOp(ctx: Context, chatId: string, op: 'list' | 'get' |
5957
6155
  return
5958
6156
  }
5959
6157
  const r = runVaultCli(['set', key!], passphrase, setValue)
5960
- if (!r.ok) { await switchroomReply(ctx, `<b>vault set failed:</b>\n${preBlock(r.output)}`, { html: true }) }
6158
+ if (!r.ok) { await switchroomReply(ctx, renderVaultOpFailure('set', r.output, key), { html: true }) }
5961
6159
  else { await switchroomReply(ctx, `✅ <code>${escapeHtmlForTg(key!)}</code> saved to vault.`, { html: true }) }
5962
6160
  } else if (op === 'delete') {
5963
6161
  const r = runVaultCli(['remove', key!], passphrase)
5964
- if (!r.ok) { await switchroomReply(ctx, `<b>vault delete failed:</b>\n${preBlock(r.output)}`, { html: true }) }
6162
+ if (!r.ok) { await switchroomReply(ctx, renderVaultOpFailure('delete', r.output, key), { html: true }) }
5965
6163
  else { await switchroomReply(ctx, `✅ <code>${escapeHtmlForTg(key!)}</code> removed from vault.`, { html: true }) }
5966
6164
  }
5967
6165
  }
@@ -7479,6 +7677,152 @@ function resolveAgentDirForName(agent: string): string | null {
7479
7677
  * Authorization mirrors the operator-event callback: only senders on the
7480
7678
  * configured allowlist get to act on the buttons.
7481
7679
  */
7680
+ /**
7681
+ * Issue #969 P1a — handle the agent-initiated vault-save approval card
7682
+ * (`vault_request_save` MCP tool).
7683
+ *
7684
+ * Callbacks:
7685
+ * vrs:save:<stageId> — confirm save; write to vault using broker put
7686
+ * with operator-passphrase attestation (#969 P1a)
7687
+ * so even new keys go through in one tap.
7688
+ * vrs:discard:<stageId> — drop the staged secret; never touches disk.
7689
+ * vrs:rename:<stageId> — set up a pending-op intercept so the user's
7690
+ * next message is taken as a new key name.
7691
+ */
7692
+ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promise<void> {
7693
+ const senderId = String(ctx.from?.id ?? '')
7694
+ const access = loadAccess()
7695
+ if (!access.allowFrom.includes(senderId)) {
7696
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
7697
+ return
7698
+ }
7699
+
7700
+ const parts = data.split(':')
7701
+ if (parts.length < 3) {
7702
+ await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
7703
+ return
7704
+ }
7705
+ const action = parts[1]
7706
+ const stageId = parts.slice(2).join(':')
7707
+ const pending = pendingVaultRequestSaves.get(stageId)
7708
+ if (!pending) {
7709
+ await ctx.answerCallbackQuery({ text: 'Card expired — ask the agent to re-send.' }).catch(() => {})
7710
+ if (ctx.callbackQuery?.message) {
7711
+ await ctx.api
7712
+ .editMessageText(
7713
+ ctx.callbackQuery.message.chat.id,
7714
+ ctx.callbackQuery.message.message_id,
7715
+ '⌛ <i>This vault-save card expired before you tapped. Ask the agent to re-issue if you still want to save.</i>',
7716
+ { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
7717
+ )
7718
+ .catch(() => {})
7719
+ }
7720
+ return
7721
+ }
7722
+
7723
+ if (action === 'discard') {
7724
+ pendingVaultRequestSaves.delete(stageId)
7725
+ await ctx.answerCallbackQuery({ text: '🚫 Discarded' }).catch(() => {})
7726
+ if (pending.card_message_id != null) {
7727
+ await ctx.api
7728
+ .editMessageText(
7729
+ pending.chat_id,
7730
+ pending.card_message_id,
7731
+ `🚫 <i>Discarded. The secret was not written to the vault.</i>`,
7732
+ { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
7733
+ )
7734
+ .catch(() => {})
7735
+ }
7736
+ return
7737
+ }
7738
+
7739
+ if (action === 'rename') {
7740
+ // Set up a pending-op intercept so the user's next message is read
7741
+ // as the new key name. Same shape as the existing /vault set value
7742
+ // capture (gateway.ts uses pendingVaultOps for this).
7743
+ pendingVaultOps.set(pending.chat_id, {
7744
+ kind: 'rename-vault-save',
7745
+ stageId,
7746
+ startedAt: Date.now(),
7747
+ } as PendingVaultOp)
7748
+ await ctx.answerCallbackQuery({ text: 'Send the new key name as your next message.' }).catch(() => {})
7749
+ return
7750
+ }
7751
+
7752
+ if (action === 'save') {
7753
+ // Acknowledge the tap immediately so Telegram doesn't show a
7754
+ // stale "spinning" state on the button while we run the write.
7755
+ await ctx.answerCallbackQuery({ text: '⏳ Saving…' }).catch(() => {})
7756
+
7757
+ // Fetch the cached passphrase for this chat. If the gateway hasn't
7758
+ // seen the user unlock the vault yet, we can't attest the write —
7759
+ // surface the unlock card via the same path the deferred-secret
7760
+ // flow uses (issue #44).
7761
+ const cached = vaultPassphraseCache.get(pending.chat_id)
7762
+ if (!cached || cached.expiresAt <= Date.now()) {
7763
+ if (pending.card_message_id != null) {
7764
+ await ctx.api
7765
+ .editMessageText(
7766
+ pending.chat_id,
7767
+ pending.card_message_id,
7768
+ `🔒 <b>Vault is locked.</b> Run <code>/vault unlock</code> (or any /vault command) to cache the passphrase, then tap Save again on the next card.`,
7769
+ { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
7770
+ )
7771
+ .catch(() => {})
7772
+ }
7773
+ pendingVaultRequestSaves.delete(stageId)
7774
+ return
7775
+ }
7776
+
7777
+ // Run the write. defaultVaultWrite spawns `switchroom vault set
7778
+ // <key>` with the passphrase env set; the CLI in turn forwards the
7779
+ // passphrase to the broker put as operator-attestation (#969 P1a),
7780
+ // which authorizes new-key creation.
7781
+ const write = defaultVaultWrite(pending.key, pending.value, cached.passphrase)
7782
+
7783
+ if (!write.ok) {
7784
+ // Route through the structured-error renderer from #969 P0b so
7785
+ // failures show the actionable host hint instead of a raw blob.
7786
+ const parsed = parseVaultCliError(write.output)
7787
+ const rendered = renderVaultCliError(parsed, { verb: 'save', key: pending.key })
7788
+ const body = rendered.suppressRaw
7789
+ ? rendered.html
7790
+ : `⚠️ vault write failed:\n<pre>${escapeHtmlForTg(write.output)}</pre>`
7791
+ if (pending.card_message_id != null) {
7792
+ await ctx.api
7793
+ .editMessageText(
7794
+ pending.chat_id,
7795
+ pending.card_message_id,
7796
+ `${body}\n\n<i>Tap a fresh card after fixing the underlying issue.</i>`,
7797
+ { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
7798
+ )
7799
+ .catch(() => {})
7800
+ }
7801
+ // Leave the staged secret in memory until TTL — operator might
7802
+ // retry by re-invoking the same MCP tool, but the value will be
7803
+ // re-staged with a new ID. Drop the current stage.
7804
+ pendingVaultRequestSaves.delete(stageId)
7805
+ return
7806
+ }
7807
+
7808
+ // Success — mask the value in the card for visual confirmation.
7809
+ pendingVaultRequestSaves.delete(stageId)
7810
+ if (pending.card_message_id != null) {
7811
+ await ctx.api
7812
+ .editMessageText(
7813
+ pending.chat_id,
7814
+ pending.card_message_id,
7815
+ `✅ saved as <code>vault:${escapeHtmlForTg(pending.key)}</code> (masked: <code>${escapeHtmlForTg(maskToken(pending.value))}</code>)\n<i>The agent can now reference this as <code>vault:${escapeHtmlForTg(pending.key)}</code>.</i>`,
7816
+ { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
7817
+ )
7818
+ .catch(() => {})
7819
+ }
7820
+ return
7821
+ }
7822
+
7823
+ await ctx.answerCallbackQuery({ text: 'Unknown action' }).catch(() => {})
7824
+ }
7825
+
7482
7826
  async function handleVaultDeferCallback(ctx: Context, data: string): Promise<void> {
7483
7827
  const senderId = String(ctx.from?.id ?? '')
7484
7828
  const access = loadAccess()
@@ -8087,13 +8431,26 @@ async function executeDeferredSecretSave(
8087
8431
 
8088
8432
  const write = defaultVaultWrite(slug, deferred.text, passphrase)
8089
8433
  if (!write.ok) {
8090
- // Keep the deferred entry so the user can retry by tapping again.
8434
+ // Classify the failure via the structured stderr markers emitted by
8435
+ // `switchroom vault` (issue #969 P0a). If it's a recognised marker,
8436
+ // render a clean actionable message instead of dumping the raw
8437
+ // "Vault file not found …" / "VAULT-NEEDS-APPROVAL …" blob the CLI
8438
+ // emits — that was the misleading-error half of #968.
8439
+ //
8440
+ // Keep the deferred entry so the user can retry by tapping again
8441
+ // once the underlying condition is fixed (broker started, host
8442
+ // approval granted, etc.).
8443
+ const parsed = parseVaultCliError(write.output)
8444
+ const rendered = renderVaultCliError(parsed, { verb: "save", key: slug })
8445
+ const body = rendered.suppressRaw
8446
+ ? rendered.html
8447
+ : `⚠️ vault write failed:\n<pre>${escapeHtmlForTg(write.output)}</pre>`
8091
8448
  if (cardMessageId != null) {
8092
8449
  await ctx.api
8093
8450
  .editMessageText(
8094
8451
  deferred.chat_id,
8095
8452
  cardMessageId,
8096
- `⚠️ vault write failed:\n<pre>${escapeHtmlForTg(write.output)}</pre>\n\nRe-tap to retry.`,
8453
+ `${body}\n\nRe-tap to retry.`,
8097
8454
  {
8098
8455
  parse_mode: 'HTML',
8099
8456
  reply_markup: buildDeferredSecretKeyboard(deferKey).inline_keyboard.length > 0
@@ -8587,12 +8944,134 @@ bot.command('vault', async ctx => {
8587
8944
  '/vault lock — lock the broker',
8588
8945
  '/vault grant — mint a capability token (inline wizard)',
8589
8946
  '/vault grants [agent] — list active capability grants (tap to revoke)',
8947
+ '/vault audit &lt;agent&gt; — unified view of an agent\'s vault access',
8590
8948
  '',
8591
8949
  'Your passphrase is cached in memory for 30 min after first use.',
8592
8950
  ].join('\n'), { html: true })
8593
8951
  return
8594
8952
  }
8595
8953
 
8954
+ // Issue #969 P2c: /vault audit <agent> — unified view of an agent's
8955
+ // vault access (grants + cron-declared schedule.secrets[]). Single
8956
+ // mental model for operators auditing the agent's credential surface.
8957
+ if (sub === 'audit') {
8958
+ const targetAgent = args[1]
8959
+ if (!targetAgent) {
8960
+ await switchroomReply(ctx, 'Usage: /vault audit &lt;agent&gt;', { html: true })
8961
+ return
8962
+ }
8963
+ // Sanity-check the agent name shape — same charset constraint as
8964
+ // assertSafeAgentName, applied here so we don't blindly shell-out
8965
+ // or read filesystem paths derived from user input.
8966
+ if (!/^[a-z][a-z0-9-]{0,62}$/i.test(targetAgent)) {
8967
+ await switchroomReply(ctx, '⚠️ Invalid agent name shape.', { html: true })
8968
+ return
8969
+ }
8970
+
8971
+ // 1. Fetch grants from broker, filtered to this agent.
8972
+ const grantsResult = await listGrantsViaBroker(targetAgent)
8973
+ let readGrants: Array<{ id: string; keys: string[]; expiresAt: number | null }> = []
8974
+ let writeGrants: Array<{ id: string; keys: string[]; expiresAt: number | null }> = []
8975
+ let grantsError: string | null = null
8976
+ if (grantsResult.kind === 'unreachable') {
8977
+ grantsError = 'broker unreachable'
8978
+ } else if (grantsResult.kind === 'error') {
8979
+ grantsError = grantsResult.msg
8980
+ } else {
8981
+ for (const g of grantsResult.grants) {
8982
+ if (g.key_allow.length > 0) {
8983
+ readGrants.push({ id: g.id, keys: g.key_allow, expiresAt: g.expires_at })
8984
+ }
8985
+ if (g.write_allow && g.write_allow.length > 0) {
8986
+ writeGrants.push({ id: g.id, keys: g.write_allow, expiresAt: g.expires_at })
8987
+ }
8988
+ }
8989
+ }
8990
+
8991
+ // 2. Load schedule[i].secrets[] from local config for this agent.
8992
+ let cronEntries: Array<{ index: number; secrets: string[]; cron?: string }> = []
8993
+ let configError: string | null = null
8994
+ try {
8995
+ const cfg = loadSwitchroomConfig()
8996
+ const agentCfg = cfg.agents?.[targetAgent]
8997
+ if (!agentCfg) {
8998
+ configError = `agent '${targetAgent}' not found in switchroom.yaml`
8999
+ } else {
9000
+ const schedule = (agentCfg as { schedule?: Array<{ secrets?: string[]; cron?: string }> }).schedule
9001
+ if (Array.isArray(schedule)) {
9002
+ schedule.forEach((entry, i) => {
9003
+ if (entry?.secrets && Array.isArray(entry.secrets) && entry.secrets.length > 0) {
9004
+ cronEntries.push({
9005
+ index: i,
9006
+ secrets: entry.secrets,
9007
+ cron: typeof entry.cron === 'string' ? entry.cron : undefined,
9008
+ })
9009
+ }
9010
+ })
9011
+ }
9012
+ }
9013
+ } catch (err) {
9014
+ configError = (err instanceof Error ? err.message : String(err))
9015
+ }
9016
+
9017
+ // 3. Compose the rendered audit view.
9018
+ const lines: string[] = [`<b>🔐 ${escapeHtmlForTg(targetAgent)} — vault access</b>`, '']
9019
+
9020
+ if (grantsError) {
9021
+ lines.push(`<i>Grants: ⚠️ ${escapeHtmlForTg(grantsError)}</i>`)
9022
+ lines.push('')
9023
+ } else {
9024
+ lines.push('<b>Read grants:</b>')
9025
+ if (readGrants.length === 0) {
9026
+ lines.push(' <i>none</i>')
9027
+ } else {
9028
+ for (const g of readGrants) {
9029
+ const expiry = g.expiresAt
9030
+ ? new Date(g.expiresAt * 1000).toISOString().slice(0, 10)
9031
+ : 'no expiry'
9032
+ const keysJoined = g.keys.join(', ')
9033
+ lines.push(` <code>${escapeHtmlForTg(g.id)}</code> · ${escapeHtmlForTg(keysJoined)} · expires ${expiry}`)
9034
+ }
9035
+ }
9036
+ lines.push('')
9037
+ lines.push('<b>Write grants:</b>')
9038
+ if (writeGrants.length === 0) {
9039
+ lines.push(' <i>none</i>')
9040
+ } else {
9041
+ for (const g of writeGrants) {
9042
+ const expiry = g.expiresAt
9043
+ ? new Date(g.expiresAt * 1000).toISOString().slice(0, 10)
9044
+ : 'no expiry'
9045
+ const keysJoined = g.keys.join(', ')
9046
+ lines.push(` <code>${escapeHtmlForTg(g.id)}</code> · ${escapeHtmlForTg(keysJoined)} · expires ${expiry}`)
9047
+ }
9048
+ }
9049
+ lines.push('')
9050
+ }
9051
+
9052
+ lines.push('<b>Cron-declared secrets:</b>')
9053
+ if (configError) {
9054
+ lines.push(` <i>⚠️ ${escapeHtmlForTg(configError)}</i>`)
9055
+ } else if (cronEntries.length === 0) {
9056
+ lines.push(' <i>none</i>')
9057
+ } else {
9058
+ for (const entry of cronEntries) {
9059
+ const cronLabel = entry.cron ? ` (<code>${escapeHtmlForTg(entry.cron)}</code>)` : ''
9060
+ const keysJoined = entry.secrets.join(', ')
9061
+ lines.push(` schedule[${entry.index}]${cronLabel}: ${escapeHtmlForTg(keysJoined)}`)
9062
+ }
9063
+ }
9064
+ lines.push('')
9065
+ lines.push(
9066
+ `<i>Summary: ${readGrants.length} read grant${readGrants.length === 1 ? '' : 's'}, ` +
9067
+ `${writeGrants.length} write grant${writeGrants.length === 1 ? '' : 's'}, ` +
9068
+ `${cronEntries.length} cron entr${cronEntries.length === 1 ? 'y' : 'ies'}.</i>`,
9069
+ )
9070
+
9071
+ await switchroomReply(ctx, lines.join('\n'), { html: true })
9072
+ return
9073
+ }
9074
+
8596
9075
  // Issue #228: /vault grants [agent] — list active grants grouped by agent
8597
9076
  if (sub === 'grants') {
8598
9077
  const agentFilter = args[1] // optional agent name filter
@@ -8955,6 +9434,15 @@ bot.on('callback_query:data', async ctx => {
8955
9434
  return
8956
9435
  }
8957
9436
 
9437
+ // Issue #969 P1a: agent-initiated vault-save approval card.
9438
+ // vrs:save:<stageId> — write the staged value, edit card to success
9439
+ // vrs:discard:<stageId> — drop the staged value, edit card to discarded
9440
+ // vrs:rename:<stageId> — prompt for a new key name, then resume
9441
+ if (data.startsWith('vrs:')) {
9442
+ await handleVaultRequestSaveCallback(ctx, data)
9443
+ return
9444
+ }
9445
+
8958
9446
  // ask_user callback (#574). aq:<idx>:<askId>. Same authorization
8959
9447
  // gate as permission buttons — only allowFrom users can answer.
8960
9448
  // Tapped option resolves the originating ask_user tool call's