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 }; } /**