diff --git a/AGENTS.md b/AGENTS.md index 3689f105..0b0f441c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1135,13 +1135,13 @@ Categorisation lives at the call sites (fs tools resolve workspace/home/outside **Dangling-symlink containment.** `canonicalizeMutationTarget` classifies by where a write actually lands. For an existing path `realpath` follows links. For a **dangling** leaf symlink (`leak.txt` → a not-yet-existing file outside the workspace) `realpath` throws ENOENT; naively re-gluing the leaf to its parent would call it a workspace write, but `writeFile` follows the link and creates the file at the target. So on ENOENT we `lstat` the leaf, and if it is a symlink we resolve its target (recursively, chain-bounded) and classify that instead. Only a genuinely new file (non-symlink ENOENT leaf) uses the deepest-existing-ancestor fallback, whose missing suffix cannot contain symlinks because it does not exist. This is what makes the panel's "symlinks pointing outside still ask" copy true for broken links too. -**Level changes are machine-local (Telegram carve-out, by design).** `/privacy level` exists only in the TUI / CLI, not the Telegram inbound-handler. The ladder is a persistent trust posture for *this host* — it decides what runs unattended — so raising it should require access to the physical machine, not a remote chat. The Telegram operator still approves or denies each gated action per-request through `ApprovalBridge`; only the durable level is TUI/CLI-local. A compromised bot token therefore cannot silently raise the standing trust level. +**Level changes are machine-local (Telegram carve-out, by design).** `/privacy level` exists only in the TUI / CLI, not the Telegram inbound-handler. The ladder is a persistent trust posture for *this host* — it decides what runs unattended — so raising it should require access to the physical machine, not a remote chat. The Telegram operator decides each gated action through `ApprovalBridge` — per call, or for the rest of the session via a grant (see §"Session grants") — but the durable level stays TUI/CLI-local. A compromised bot token therefore cannot silently raise the standing trust level. Slash commands: `/privacy` opens the tab; `/privacy analytics on|off|status` drives analytics (`/analytics` stays as the top-level alias); `/privacy level 1..5` moves the ladder; `/privacy approve on|off` survives as the alias pair for levels 5 and 1. The approval prompt itself points here — its footer says approving with `y` grants one call and the ladder lives on the Privacy tab. ### Session grants (prompt-side approval, issue #79) -On top of the standing ladder the approval prompt offers two point exceptions, keyed at the prompt and scoped to the current session only. `[s]` ("allow this kind this session") approves the call and grants the whole `ApprovalCategory`; `[a]` ("allow all `` this session") approves and grants one shell command shape. `[y]` still approves the single call with no grant, `[n]`/`esc` deny. The grant is offered on the TUI `ApprovalModal` (keys routed in `app-key-bindings.ts`) and the CLI `run` stdin prompt; both surfaces have physical machine access, matching the level carve-out. Telegram/`ApprovalBridge` never grants: a remote channel approves per-request only, so a compromised bot token cannot raise session trust any more than it can raise the standing level. +On top of the standing ladder the approval prompt offers two point exceptions, keyed at the prompt and scoped to the current session only. `[s]` ("allow this kind this session") approves the call and grants the whole `ApprovalCategory`; `[a]` ("allow all `` this session") approves and grants one shell command shape. `[y]` still approves the single call with no grant, `[n]`/`esc` deny. The grant is offered on the TUI `ApprovalModal` (keys routed in `app-key-bindings.ts`), the CLI `run` stdin prompt, and — since issue #230 — the Telegram `ApprovalBridge`, as the `🔓 Grant …` rows of the inline keyboard. The first two surfaces have physical machine access, matching the level carve-out; Telegram does not, and #230 took that asymmetry on knowingly rather than by oversight: without a grant a remote operator re-approves every `git diff` one at a time, and the only escape left was raising the *standing* level to 5, which is strictly more permissive and durable. What the earlier "Telegram never grants" rule was protecting still holds: a Telegram grant is in-memory and session-scoped like any other, it cannot cover `trust_config`, it cannot bypass the hardline shell guard, and it cannot move the standing level — `/privacy level` stays TUI/CLI-only, so the durable posture still needs the machine. What genuinely changed: a remote chat can now raise trust *for the rest of one session*, which it previously could not. Grants live in-memory on the `ApprovalGate`, keyed by the id of the session that made them (`grantsBySession: Map`); they never persist to `config.json` (session-scoped is a safer default than a durable global toggle, which is what the issue asks for). Because grants are keyed by session id and `autoApproval` matches on `request.sessionId`, a request from a *different* session — a background-task turn on the scheduler, a second live session on the same runtime — shares the gate but never rides another session's grant: "session-scoped" is a structural fact, not a promise every caller remembers to keep. `clearSessionGrants()` additionally fires on `newSession()` / `switchSession()` as a belt-and-braces reset of the leaving session. The standing level is untouched: a grant is a point exception layered over the posture, not a move of the posture. The gate checks grants in `request()` **after** the standing level and **before** emitting a prompt (`autoApproval`), returning a distinct reason (`session grant` / `session grant: `). @@ -1157,7 +1157,7 @@ The shape is the guard's own normalised binary (basename, lowercased) that the s 6. **A write to the agent's own trust config is never silent below level 5.** `config.json` and `.env` map to `trust_config` (pinned at level 5) by realpath match, so the model cannot raise its own `approvalLevel` or swap a token without a prompt. Pinned by `fs-approval-scope.test.ts` (symlink / `..` / fresh-install / batch cases) and the level-4 `bootstrap.test.ts` case (`os.fs.write` to `config.json` prompts with category `trust_config` even where `fs_write_home` is silent). 7. **Persist first, then hot-apply, and say when they diverge.** `PrivacyOrchestrator.setApprovalLevel` writes `config.json` before touching the gate; if the hot-apply throws after a successful persist, the sticky error names the already-rewritten `config.json` so the operator knows the next boot picks the new value up. Levels clamp to [1, 5] at every runtime surface; the config parser rejects non-integers outright. 8. **The prompt carries its category to every host UX.** `ApprovalRequest.category` is forwarded so a host shows *why* the prompt fired, not just the tool name: the TUI `ApprovalModal`, the CLI `run` stdin prompt, and the Telegram `ApprovalBridge` render a human label (`formatApprovalCategory`, e.g. `file write · home`); the sidecar protocol adds an **optional** `category` to `ApprovalRequestPayload` (back-compat: pre-ladder hosts ignore it) and the HTTP `/api/events` SSE already streams the full request. The Tauri host UI is a separate TS consumer of the protocol type — surfacing the new field there is its own follow-up, but nothing is silently dropped on the wire. Pinned by `approval-modal.test.tsx`, `approval-bridge.test.ts`, and the label matrix in `approval-level.test.ts`. -9. **Session grants never bypass hardline or `trust_config`, and never persist.** A grant is a session-scoped, in-memory point exception layered over the standing level; it silences its category/shape by returning early in `ApprovalGate.request` before a prompt is emitted. It cannot bypass the hardline shell guard: hardline returns `block` in `shell.ts` **before** `requireApproval` reaches the gate, so a catastrophic command is stopped whether or not `shell` (or the `rm` shape) is granted. It cannot silence `trust_config`: `isGrantableCategory` excludes it on both the record path (`resolve`) and the auto-approve path (`request`), so a config/`.env` write always prompts even after a broad grant. Honest boundary: the `trust_config` guard covers the fs tools (`categorizeFsMutation`), not the shell. A write to config via a shell redirect (`echo >> config.json`) stays under the `shell` category and is silenced by a shell grant, the same as at standing level 4 (operator). Grants do not widen this pre-existing class; closing it means intercepting shell redirects, a separate piece of work. Grants are TUI/CLI-local (never from Telegram) and keyed by session id, so a request from another session — a background-task turn, a second live session on the same runtime — never rides a grant it did not make; `clearSessionGrants()` also drops the leaving session's grants on every session change. Pinned by `approval-gate.test.ts` (grant category / grant shape / trust_config refused / per-session isolation for category and shape / targeted-and-full clear / union snapshot / no-shape shell / `canGrant*` offer logic), `shell.test.ts` (interpreter shape suppression), the Privacy panel + reducer + orchestrator tests (the read-only grants view), and the two `bootstrap.test.ts` end-to-end cases (a category grant silences later shell commands while `rm -rf /` stays hardline-blocked; a shape grant silences one binary while another still prompts). +9. **Session grants never bypass hardline or `trust_config`, and never persist.** A grant is a session-scoped, in-memory point exception layered over the standing level; it silences its category/shape by returning early in `ApprovalGate.request` before a prompt is emitted. It cannot bypass the hardline shell guard: hardline returns `block` in `shell.ts` **before** `requireApproval` reaches the gate, so a catastrophic command is stopped whether or not `shell` (or the `rm` shape) is granted. It cannot silence `trust_config`: `isGrantableCategory` excludes it on both the record path (`resolve`) and the auto-approve path (`request`), so a config/`.env` write always prompts even after a broad grant. Honest boundary: the `trust_config` guard covers the fs tools (`categorizeFsMutation`), not the shell. A write to config via a shell redirect (`echo >> config.json`) stays under the `shell` category and is silenced by a shell grant, the same as at standing level 4 (operator). Grants do not widen this pre-existing class; closing it means intercepting shell redirects, a separate piece of work. Grants can be made from the TUI, the CLI, or the Telegram keyboard (see the §"Session grants" note on that asymmetry), and are keyed by session id, so a request from another session — a background-task turn, a second live session on the same runtime — never rides a grant it did not make; `clearSessionGrants()` also drops the leaving session's grants on every session change. Pinned by `approval-gate.test.ts` (grant category / grant shape / trust_config refused / per-session isolation for category and shape / targeted-and-full clear / union snapshot / no-shape shell / `canGrant*` offer logic), `shell.test.ts` (interpreter shape suppression), the Privacy panel + reducer + orchestrator tests (the read-only grants view), and the two `bootstrap.test.ts` end-to-end cases (a category grant silences later shell commands while `rm -rf /` stays hardline-blocked; a shape grant silences one binary while another still prompts). ## Durable tasks @@ -1524,7 +1524,7 @@ Telegram has its own dedicated session, persisted as a pointer in `/te When a `runtime.runTurn` call originated on Telegram (`{ origin: "telegram" }`), `ApprovalRouter` ([src/approval/approval-router.ts](src/approval/approval-router.ts)) routes the `ApprovalRequest` to `ApprovalBridge` ([approval-bridge.ts](src/channels/telegram/approval-bridge.ts)) instead of falling through to the host UI. The bridge: -- Sends a 2-button inline keyboard (`✅ Approve` / `❌ Deny`) to the owner's DM as plain text (no MarkdownV2 — escaping rules are easy to get wrong with tool names containing backticks / underscores). +- Sends an inline keyboard to the owner's DM as plain text (no MarkdownV2 — escaping rules are easy to get wrong with tool names containing backticks / underscores): `✅ Approve` / `❌ Deny`, plus a `🔓 Grant category for session` row when `canGrantCategory` allows one and a `🔓 Grant "" for session` row when `canGrantShape` does. The scope is re-checked against the retained request when the button comes back — `callback_data` is operator-supplied and a keyboard outlives the state it was built from — so a scope the gate would drop degrades to a plain approval and the toast never announces an exception that was not recorded. - Validates the callback `userId` against the live `ownerUserId` mirror — a stale callback from a previous owner is rejected. - Auto-denies after 8 minutes (`config.telegram.approvalTimeoutMs`) and edits the original message to `⏱ timed out — auto-denied` with the buttons removed. - Folds button-click / timeout / external-cancel into a single `approvals.resolve()` call; double-resolution is prevented by a `pending` map check. diff --git a/src/channels/telegram/approval-bridge.test.ts b/src/channels/telegram/approval-bridge.test.ts index c84be2e6..207ee266 100644 --- a/src/channels/telegram/approval-bridge.test.ts +++ b/src/channels/telegram/approval-bridge.test.ts @@ -4,6 +4,7 @@ import type { ApprovalDecision, ApprovalRequest, } from "../../approval/index.js"; +import { ApprovalGate } from "../../approval/index.js"; import { StructuredLogger } from "../../tracing/structured-logger.js"; import { @@ -90,9 +91,22 @@ function req(approvalId = "abc"): ApprovalRequest { }; } +type Keyboard = { + inline_keyboard: Array>; +}; + +/** The `reply_markup` of the Nth `sendMessage` call (default: the first). */ +function keyboardOf( + sendMessage: ReturnType, + call = 0, +): Keyboard { + const opts = sendMessage.mock.calls[call]![2] as { reply_markup: Keyboard }; + return opts.reply_markup; +} + function callback( approvalId: string, - kind: "y" | "n", + kind: "y" | "n" | "s" | "a", fromId: number = 42, ): InboundCallbackUpdate { return { @@ -104,9 +118,59 @@ 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); + // Exact match, not `toContain`: the row order and every + // `callback_data` are part of the wire contract. A shape row that + // emitted `:s` would grant the whole category instead of the one + // binary the operator agreed to. + expect(keyboardOf(h.api.sendMessage)).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" }, + ], + [ + { text: `🔓 Grant "git" for session`, callback_data: "appr:abc:a" }, + ], + ], + }); + }); + + 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 buttons = keyboardOf(h.api.sendMessage).inline_keyboard.flat(); + expect(buttons.map((b) => b.text)).toEqual(["✅ Approve", "❌ Deny"]); + }); + + it("bounds the button label when the command shape is oversize", async () => { + const h = makeHarness(); + const r = req("abc"); + r.commandShape = "x".repeat(80); + await h.bridge.dispatch(r, 7); + + const shapeRow = keyboardOf(h.api.sendMessage).inline_keyboard.at(-1)!; + expect(shapeRow[0]!.text).toBe(`🔓 Grant "${"x".repeat(31)}…" for session`); + expect(shapeRow[0]!.callback_data).toBe("appr:abc:a"); + }); + + 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 +178,18 @@ 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 }; - expect(opts.reply_markup).toEqual({ + // Shell requests get approve/deny + grant-category row (no shape button without commandShape) + expect(keyboardOf(h.api.sendMessage)).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 +305,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 (shell command 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 ("git" granted for session)`, + }); + }); + it("drops a malformed callback (wrong kind)", async () => { const h = makeHarness(); await h.bridge.dispatch(req("abc"), 7); @@ -278,6 +376,87 @@ describe("ApprovalBridge.handleCallback", () => { }); }); +describe("ApprovalBridge grants against a real ApprovalGate", () => { + /** + * Bridge wired to a real `ApprovalGate` instead of the recording stub. + * The stub accepts whatever decision it is handed, so it cannot show + * whether the toast agrees with the trust state the operator actually + * has; only the real gate can. + */ + function gateHarness(params: Omit): { + h: TestHarness; + gate: ApprovalGate; + decision: Promise; + request: ApprovalRequest; + } { + const emitted: ApprovalRequest[] = []; + const gate = new ApprovalGate({ emit: (r) => emitted.push(r) }); + const h = makeHarness({ approvals: gate }); + const decision = gate.request(params); + return { h, gate, decision, request: emitted[0]! }; + } + + it("approves without claiming a category grant the gate refuses", async () => { + const { h, gate, decision, request } = gateHarness({ + sessionId: "s-1", + tool: "trust.config.write", + category: "trust_config", + reason: "raise the standing approval level", + }); + await h.bridge.dispatch(request, 7); + + // Forged payload: the keyboard never offers `:s` for trust_config. + await h.bridge.handleCallback(callback(request.approvalId, "s")); + + expect((await decision).approved).toBe(true); + expect(gate.sessionGrants("s-1")).toEqual({ categories: [], shapes: [] }); + expect(h.api.answerCallbackQuery).toHaveBeenCalledWith("cb-1", { + text: "Approved", + }); + }); + + it("approves without claiming a shape grant when the request has no command shape", async () => { + const { h, gate, decision, request } = gateHarness({ + sessionId: "s-1", + tool: "os.shell.run", + category: "shell", + reason: "opaque command, no shape to grant", + preview: "bash -c 'echo hi'", + }); + await h.bridge.dispatch(request, 7); + + await h.bridge.handleCallback(callback(request.approvalId, "a")); + + expect((await decision).approved).toBe(true); + expect(gate.sessionGrants("s-1")).toEqual({ categories: [], shapes: [] }); + expect(h.api.answerCallbackQuery).toHaveBeenCalledWith("cb-1", { + text: "Approved", + }); + }); + + it("records the grant the gate accepts and names it in the toast", async () => { + const { h, gate, decision, request } = gateHarness({ + sessionId: "s-1", + tool: "os.shell.run", + category: "shell", + reason: "skill needs to run \"git push\"", + commandShape: "git", + }); + await h.bridge.dispatch(request, 7); + + await h.bridge.handleCallback(callback(request.approvalId, "a")); + + expect((await decision).approved).toBe(true); + expect(gate.sessionGrants("s-1")).toEqual({ + categories: [], + shapes: ["git"], + }); + expect(h.api.answerCallbackQuery).toHaveBeenCalledWith("cb-1", { + text: `Approved ("git" granted for session)`, + }); + }); +}); + describe("ApprovalBridge timeout", () => { it("auto-denies after timeoutMs and edits the message", async () => { const h = makeHarness(); diff --git a/src/channels/telegram/approval-bridge.ts b/src/channels/telegram/approval-bridge.ts index 2bdd14ab..c3f40bce 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"; @@ -20,6 +20,18 @@ const APPROVAL_TIMEOUT_MS_DEFAULT = 8 * 60 * 1000; */ const CALLBACK_PREFIX = "appr:"; +/** + * Longest `commandShape` rendered into a button label or a toast. Real + * shapes are argv[0] basenames (`git`, `npm`), but nothing upstream + * bounds the field. An oversize `reply_markup` is rejected by Telegram, + * and `dispatch` turns a send failure into an auto-deny — so an + * unbounded label could deny an approval the operator never saw. + */ +const SHAPE_LABEL_MAX = 32; + +/** The four button kinds encoded in `callback_data`. */ +type CallbackKind = "y" | "n" | "s" | "a"; + /** * Narrow shape of a `callback_query` update consumed by the bridge. * The grammy adapter projects the real grammy `Context` onto this @@ -61,17 +73,23 @@ export interface ApprovalBridgeDeps { interface PendingState { chatId: number; messageId: number; + /** + * The request the keyboard was built from. Retained so a decision is + * checked and described against the real request instead of the + * operator-supplied `callback_data` — see invariant 5. + */ + request: ApprovalRequest; cancelTimer: () => void; } /** - * Per-channel bridge that turns an `ApprovalRequest` into a 2-button + * Per-channel bridge that turns an `ApprovalRequest` into an * inline-keyboard message and routes the operator's reply back to * `ApprovalGate.resolve`. The bridge owns: * * - 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: * @@ -92,6 +110,17 @@ interface PendingState { * in-flight approvals comes from the caller's own abort signal * (`runtime.shutdown()` aborts every in-flight turn, which aborts * the gate via `ApprovalGate`'s `signal` parameter). + * 5. **A grant is re-checked, and the toast never overstates it.** The + * `s`/`a` rows only appear when `canGrantCategory` / `canGrantShape` + * allow them, but `callback_data` arrives from the operator's client + * and a keyboard can outlive the state it was built from — so the + * scope is checked again against the retained request before it + * reaches the gate, exactly as the TUI and CLI prompts do. A scope + * that no longer applies degrades to a plain approval: the gate + * would drop it anyway (`recordGrant` refuses `trust_config` and a + * shapeless shell request), and the toast, built from the request's + * own category/shape, then says "Approved" rather than announcing a + * standing exception that does not exist. * * Known UX gap (non-functional, deferred to slice 3): * @@ -149,7 +178,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") { @@ -181,7 +210,12 @@ export class ApprovalBridge { chatId, messageId, ); - this.pending.set(request.approvalId, { chatId, messageId, cancelTimer }); + this.pending.set(request.approvalId, { + chatId, + messageId, + request, + cancelTimer, + }); } /** @@ -204,8 +238,7 @@ 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; const pending = this.pending.get(approvalId); if (!pending) { @@ -218,13 +251,22 @@ export class ApprovalBridge { pending.cancelTimer(); this.pending.delete(approvalId); + // Both grant buttons approve; only the scope differs, and only if + // the retained request still supports it (invariant 5). + const approved = kind !== "n"; + const grant = grantScope(kind, pending.request); + const resolved = this.deps.approvals.resolve({ approvalId, approved, + grant, reason: "telegram", }); - await this.acknowledge(update.id, approved ? "Approved" : "Denied"); + await this.acknowledge( + update.id, + decisionToast(pending.request, approved, grant), + ); if (resolved) { await this.editFinal( pending.chatId, @@ -310,23 +352,78 @@ export class ApprovalBridge { } } -function buildKeyboard(approvalId: string): { +/** + * The scope a callback kind asks for, or `undefined` when this request + * cannot carry it. `y`/`n` never grant; `s`/`a` grant only what the + * keyboard was entitled to offer. + */ +function grantScope( + kind: CallbackKind, + request: ApprovalRequest, +): ApprovalGrantScope | undefined { + if (kind === "s" && canGrantCategory(request)) return "category"; + if (kind === "a" && canGrantShape(request)) return "shape"; + return undefined; +} + +/** + * Text for the button's spinner toast — the one surface that tells the + * operator what they just authorised, so it names the grant that was + * actually recorded and stays silent when none was. + */ +function decisionToast( + request: ApprovalRequest, + approved: boolean, + grant: ApprovalGrantScope | undefined, +): string { + if (!approved) return "Denied"; + if (!grant) return "Approved"; + const subject = + grant === "shape" && request.commandShape + ? `"${shapeLabel(request.commandShape)}"` + : formatApprovalCategory(request.category); + return `Approved (${subject} granted for session)`; +} + +/** `commandShape` clipped to something a narrow client can render. */ +function shapeLabel(shape: string): string { + return shape.length <= SHAPE_LABEL_MAX + ? shape + : `${shape.slice(0, SHAPE_LABEL_MAX - 1)}…`; +} + +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) && request.commandShape) { + rows.push([ + { + text: `🔓 Grant "${shapeLabel(request.commandShape)}" for session`, + callback_data: `${CALLBACK_PREFIX}${request.approvalId}:a`, + }, + ]); + } + return { inline_keyboard: rows }; } /** diff --git a/src/channels/telegram/inbound-handler.ts b/src/channels/telegram/inbound-handler.ts index a5cbf34a..be3e8d56 100644 --- a/src/channels/telegram/inbound-handler.ts +++ b/src/channels/telegram/inbound-handler.ts @@ -222,8 +222,8 @@ async function dispatchToRuntime( ctx.runtime.metrics?.recordTelegramMessage({ direction: "in" }); // Re-bind the approval router for this session/chat pair before any // turn step can request approval — `ApprovalRouter.setForSession` - // is the only path that turns a generic `ApprovalRequest` into a - // 2-button keyboard in this chat. Idempotent on the channel side. + // is the only path that turns a generic `ApprovalRequest` into an + // inline keyboard in this chat. Idempotent on the channel side. ctx.ensureApprovalSession?.(session.id, chatId); const controller = new AbortController(); ctx.inflight.set(chatId, controller);