From f3abe0573ca1bdb00e70292a03138efb28602295 Mon Sep 17 00:00:00 2001 From: ceo Date: Sat, 22 Aug 2026 20:28:48 +0000 Subject: [PATCH] Add session grant buttons to Telegram approval keyboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TUI approval prompt supports granting a category (s) or shell command shape (a) for the remainder of the session, but the Telegram inline keyboard only offered Approve (y) and Deny (n). This forced Telegram users to re-approve every command individually even for repeated safe operations. Add two new inline keyboard rows when applicable: - "Grant category for session" (s) — uses existing canGrantCategory() - "Grant for session" (a) — uses existing canGrantShape() The ApprovalGate already supports grant scopes via recordGrant(); this change simply wires the bridge callbacks to pass them through resolve(). Fixes #230 --- src/channels/telegram/approval-bridge.test.ts | 72 +++++++++++++++++-- src/channels/telegram/approval-bridge.ts | 69 ++++++++++++------ 2 files changed, 116 insertions(+), 25 deletions(-) diff --git a/src/channels/telegram/approval-bridge.test.ts b/src/channels/telegram/approval-bridge.test.ts index c84be2e6..fd2fc857 100644 --- a/src/channels/telegram/approval-bridge.test.ts +++ b/src/channels/telegram/approval-bridge.test.ts @@ -92,7 +92,7 @@ function req(approvalId = "abc"): ApprovalRequest { function callback( approvalId: string, - kind: "y" | "n", + kind: "y" | "n" | "s" | "a", fromId: number = 42, ): InboundCallbackUpdate { return { @@ -104,9 +104,38 @@ function callback( } describe("ApprovalBridge.dispatch", () => { - it("sends a 2-button inline keyboard with the right callback_data", async () => { + it("sends inline keyboard with grant buttons when applicable", async () => { const h = makeHarness(); - await h.bridge.dispatch(req("abc"), 7); + const r = req("abc"); + r.commandShape = "git"; + await h.bridge.dispatch(r, 7); + + expect(h.api.sendMessage).toHaveBeenCalledTimes(1); + const opts = h.api.sendMessage.mock.calls[0]![2] as { reply_markup: { inline_keyboard: Array> } }; + const buttons = opts.reply_markup.inline_keyboard.flat().map(b => b.text); + // Shell commands get the shape (a) and category (s) grant rows + expect(buttons).toContain("✅ Approve"); + expect(buttons).toContain("❌ Deny"); + expect(buttons).toContain("🔓 Grant category for session"); + expect(buttons).toContain(`🔓 Grant "git" for session`); + }); + + it("omits grant buttons when not applicable (trust_config)", async () => { + const h = makeHarness(); + const r = req("abc"); + // trust_config is the only non-grantable category — no s or a buttons + r.category = "trust_config"; + await h.bridge.dispatch(r, 7); + + const opts = h.api.sendMessage.mock.calls[0]![2] as { reply_markup: { inline_keyboard: Array> } }; + const buttons = opts.reply_markup.inline_keyboard.flat().map(b => b.text); + expect(buttons).toEqual(["✅ Approve", "❌ Deny"]); + }); + + it("sends an inline keyboard with approve, deny, and grant buttons", async () => { + const h = makeHarness(); + const r = req("abc"); + await h.bridge.dispatch(r, 7); expect(h.api.sendMessage).toHaveBeenCalledTimes(1); const args = h.api.sendMessage.mock.calls[0]!; @@ -114,16 +143,19 @@ describe("ApprovalBridge.dispatch", () => { const text = args[1] as string; expect(text).toContain("Approval requested"); expect(text).toContain("os.shell.run"); - // R5: the ladder category is surfaced to the Telegram operator. expect(text).toContain("kind: shell command"); expect(text).toContain("git push"); const opts = args[2] as { reply_markup: unknown }; + // Shell requests get approve/deny + grant-category row (no shape button without commandShape) expect(opts.reply_markup).toEqual({ inline_keyboard: [ [ { text: "✅ Approve", callback_data: "appr:abc:y" }, { text: "❌ Deny", callback_data: "appr:abc:n" }, ], + [ + { text: "🔓 Grant category for session", callback_data: "appr:abc:s" }, + ], ], }); expect(h.bridge.pendingCount()).toBe(1); @@ -239,6 +271,38 @@ describe("ApprovalBridge.handleCallback", () => { expect(h.approvals.decisions).toEqual([]); }); + it("grants category on `s` callback", async () => { + const h = makeHarness(); + const r = req("abc"); + r.commandShape = "git"; + await h.bridge.dispatch(r, 7); + + await h.bridge.handleCallback(callback("abc", "s")); + + expect(h.approvals.decisions).toEqual([ + { approvalId: "abc", approved: true, grant: "category", reason: "telegram" }, + ]); + expect(h.api.answerCallbackQuery).toHaveBeenCalledWith("cb-1", { + text: "Approved (category granted for session)", + }); + }); + + it("grants shape on `a` callback", async () => { + const h = makeHarness(); + const r = req("abc"); + r.commandShape = "git"; + await h.bridge.dispatch(r, 7); + + await h.bridge.handleCallback(callback("abc", "a")); + + expect(h.approvals.decisions).toEqual([ + { approvalId: "abc", approved: true, grant: "shape", reason: "telegram" }, + ]); + expect(h.api.answerCallbackQuery).toHaveBeenCalledWith("cb-1", { + text: "Approved (shape granted for session)", + }); + }); + it("drops a malformed callback (wrong kind)", async () => { const h = makeHarness(); await h.bridge.dispatch(req("abc"), 7); diff --git a/src/channels/telegram/approval-bridge.ts b/src/channels/telegram/approval-bridge.ts index 2bdd14ab..35e8bced 100644 --- a/src/channels/telegram/approval-bridge.ts +++ b/src/channels/telegram/approval-bridge.ts @@ -1,5 +1,5 @@ -import type { ApprovalGate, ApprovalRequest } from "../../approval/index.js"; -import { formatApprovalCategory } from "../../approval/index.js"; +import type { ApprovalGate, ApprovalGrantScope, ApprovalRequest } from "../../approval/index.js"; +import { canGrantCategory, canGrantShape, formatApprovalCategory } from "../../approval/index.js"; import type { StructuredLogger } from "../../tracing/structured-logger.js"; import type { TelegramApi } from "./outbound-sender.js"; @@ -71,7 +71,7 @@ interface PendingState { * * - one outbound message per pending approval (sent via `dispatch`); * - one auto-deny timer per pending approval (default 8 min); - * - the `callback_query` parser that decodes `appr::y|n`. + * - the `callback_query` parser that decodes `appr::y|n|s|a`. * * Locked invariants — pinned by the colocated test file: * @@ -149,7 +149,7 @@ export class ApprovalBridge { const sent = await this.deps.api.sendMessage( chatId, formatApprovalText(request), - { reply_markup: buildKeyboard(request.approvalId) }, + { reply_markup: buildKeyboard(request) }, ); const id = (sent as { message_id?: number } | null)?.message_id; if (typeof id !== "number") { @@ -204,8 +204,18 @@ export class ApprovalBridge { const parts = data.slice(CALLBACK_PREFIX.length).split(":"); if (parts.length !== 2) return; const [approvalId, kind] = parts; - if (!approvalId || (kind !== "y" && kind !== "n")) return; - const approved = kind === "y"; + if (!approvalId || (kind !== "y" && kind !== "n" && kind !== "s" && kind !== "a")) return; + let approved: boolean; + let grant: ApprovalGrantScope | undefined; + if (kind === "y") { + approved = true; + } else if (kind === "n") { + approved = false; + } else { + // grant buttons: always approve, scope depends on kind + approved = true; + grant = kind === "s" ? "category" : "shape"; + } const pending = this.pending.get(approvalId); if (!pending) { @@ -221,10 +231,12 @@ export class ApprovalBridge { const resolved = this.deps.approvals.resolve({ approvalId, approved, + grant, reason: "telegram", }); - await this.acknowledge(update.id, approved ? "Approved" : "Denied"); + const grantLabel = grant === "category" ? " (category granted for session)" : grant === "shape" ? " (shape granted for session)" : ""; + await this.acknowledge(update.id, approved ? `Approved${grantLabel}` : "Denied"); if (resolved) { await this.editFinal( pending.chatId, @@ -310,23 +322,38 @@ export class ApprovalBridge { } } -function buildKeyboard(approvalId: string): { +function buildKeyboard(request: ApprovalRequest): { inline_keyboard: Array>; } { - return { - inline_keyboard: [ - [ - { - text: "✅ Approve", - callback_data: `${CALLBACK_PREFIX}${approvalId}:y`, - }, - { - text: "❌ Deny", - callback_data: `${CALLBACK_PREFIX}${approvalId}:n`, - }, - ], + const rows: Array> = [ + [ + { + text: "✅ Approve", + callback_data: `${CALLBACK_PREFIX}${request.approvalId}:y`, + }, + { + text: "❌ Deny", + callback_data: `${CALLBACK_PREFIX}${request.approvalId}:n`, + }, ], - }; + ]; + if (canGrantCategory(request)) { + rows.push([ + { + text: "🔓 Grant category for session", + callback_data: `${CALLBACK_PREFIX}${request.approvalId}:s`, + }, + ]); + } + if (canGrantShape(request)) { + rows.push([ + { + text: `🔓 Grant "${request.commandShape}" for session`, + callback_data: `${CALLBACK_PREFIX}${request.approvalId}:a`, + }, + ]); + } + return { inline_keyboard: rows }; } /**