Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 68 additions & 4 deletions src/channels/telegram/approval-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -104,26 +104,58 @@ 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<Array<{ text: string }>> } };
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<Array<{ text: string }>> } };
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]!;
expect(args[0]).toBe(7);
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);
Expand Down Expand Up @@ -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);
Expand Down
69 changes: 48 additions & 21 deletions src/channels/telegram/approval-bridge.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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:<id>:y|n`.
* - the `callback_query` parser that decodes `appr:<id>:y|n|s|a`.
*
* Locked invariants — pinned by the colocated test file:
*
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
Expand Down Expand Up @@ -310,23 +322,38 @@ export class ApprovalBridge {
}
}

function buildKeyboard(approvalId: string): {
function buildKeyboard(request: ApprovalRequest): {
inline_keyboard: Array<Array<{ text: string; callback_data: string }>>;
} {
return {
inline_keyboard: [
[
{
text: "✅ Approve",
callback_data: `${CALLBACK_PREFIX}${approvalId}:y`,
},
{
text: "❌ Deny",
callback_data: `${CALLBACK_PREFIX}${approvalId}:n`,
},
],
const rows: Array<Array<{ text: string; callback_data: string }>> = [
[
{
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 };
}

/**
Expand Down