From 43cc5f87660d8fb848c924546800a6cbe6c3e06f Mon Sep 17 00:00:00 2001 From: Daisy Hollman Date: Fri, 20 Mar 2026 22:44:08 +0000 Subject: [PATCH] feat(telegram,discord): permission-relay capability + bidirectional handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the plugin side of anthropics/claude-cli-internal#23061 (permission prompts over channels). Capability: both servers now declare experimental["claude/channel/permission"] which tells CC they can relay permission requests. This capability asserts the server authenticates the replier — gate()/access.allowFrom filters non-allowlisted senders before handleInbound runs. Outbound (CC → user): setNotificationHandler for notifications/claude/channel/permission_request formats the tool name, description, and input preview into a human-readable message and sends it to every allowlisted DM. Groups are excluded — the security thread resolution was "single-user mode for official plugins." Inbound (user → CC): PERMISSION_REPLY_RE intercept in handleInbound catches "yes xxxxx" / "no xxxxx" replies, emits the structured notifications/claude/channel/permission event with {request_id, behavior}, reacts with checkmark/cross, and returns without relaying the text to Claude as a chat message. The regex is inlined from channelPermissions.ts (no cross-repo dep). IDs are lowercased at the plugin boundary per the case-insensitive spec. Version bumped 0.0.1 → 0.0.2 so the plugin reconciler picks up the change. :house: Remote-Dev: homespace --- .../discord/.claude-plugin/plugin.json | 2 +- external_plugins/discord/server.ts | 73 ++++++++++++++++++- .../telegram/.claude-plugin/plugin.json | 2 +- external_plugins/telegram/server.ts | 72 +++++++++++++++++- 4 files changed, 145 insertions(+), 4 deletions(-) diff --git a/external_plugins/discord/.claude-plugin/plugin.json b/external_plugins/discord/.claude-plugin/plugin.json index 7447381..9a93fd7 100644 --- a/external_plugins/discord/.claude-plugin/plugin.json +++ b/external_plugins/discord/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "discord", "description": "Discord channel for Claude Code \u2014 messaging bridge with built-in access control. Manage pairing, allowlists, and policy via /discord:access.", - "version": "0.0.1", + "version": "0.0.2", "keywords": [ "discord", "messaging", diff --git a/external_plugins/discord/server.ts b/external_plugins/discord/server.ts index 078c29a..dd85cae 100644 --- a/external_plugins/discord/server.ts +++ b/external_plugins/discord/server.ts @@ -16,6 +16,7 @@ import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js' +import { z } from 'zod' import { Client, GatewayIntentBits, @@ -58,6 +59,12 @@ if (!TOKEN) { } const INBOX_DIR = join(STATE_DIR, 'inbox') +// Permission-reply spec from anthropics/claude-cli-internal +// src/services/mcp/channelPermissions.ts — inlined (no CC repo dep). +// 5 lowercase letters a-z minus 'l'. Case-insensitive for phone autocorrect. +// Strict: no bare yes/no (conversational), no prefix/suffix chatter. +const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i + const client = new Client({ intents: [ GatewayIntentBits.DirectMessages, @@ -417,7 +424,18 @@ function safeAttName(att: Attachment): string { const mcp = new Server( { name: 'discord', version: '1.0.0' }, { - capabilities: { tools: {}, experimental: { 'claude/channel': {} } }, + capabilities: { + tools: {}, + experimental: { + 'claude/channel': {}, + // Permission-relay opt-in (anthropics/claude-cli-internal#23061). + // Declaring this asserts we authenticate the replier — which we do: + // gate()/access.allowFrom already drops non-allowlisted senders before + // handleInbound runs. A server that can't authenticate the replier + // should NOT declare this. + 'claude/channel/permission': {}, + }, + }, instructions: [ 'The sender reads Discord, not this session. Anything you want them to see must go through the reply tool — your transcript output never reaches their chat.', '', @@ -432,6 +450,41 @@ const mcp = new Server( }, ) +// Receive permission_request from CC → format → send to all allowlisted DMs. +// Groups are intentionally excluded — the security thread resolution was +// "single-user mode for official plugins." Anyone in access.allowFrom +// already passed explicit pairing; group members haven't. +mcp.setNotificationHandler( + z.object({ + method: z.literal('notifications/claude/channel/permission_request'), + params: z.object({ + request_id: z.string(), + tool_name: z.string(), + description: z.string(), + input_preview: z.string(), + }), + }), + async ({ params }) => { + const { request_id, tool_name, description, input_preview } = params + const access = loadAccess() + const text = + `🔐 Permission request [${request_id}]\n` + + `${tool_name}: ${description}\n` + + `${input_preview}\n\n` + + `Reply "yes ${request_id}" to allow or "no ${request_id}" to deny.` + for (const userId of access.allowFrom) { + void (async () => { + try { + const user = await client.users.fetch(userId) + await user.send(text) + } catch (e) { + process.stderr.write(`permission_request send to ${userId} failed: ${e}\n`) + } + })() + } + }, +) + mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { @@ -661,6 +714,24 @@ async function handleInbound(msg: Message): Promise { const chat_id = msg.channelId + // Permission-reply intercept: if this looks like "yes xxxxx" for a + // pending permission request, emit the structured event instead of + // relaying as chat. The sender is already gate()-approved at this point + // (non-allowlisted senders were dropped above), so we trust the reply. + const permMatch = PERMISSION_REPLY_RE.exec(msg.content) + if (permMatch) { + void mcp.notification({ + method: 'notifications/claude/channel/permission', + params: { + request_id: permMatch[2]!.toLowerCase(), + behavior: permMatch[1]!.toLowerCase().startsWith('y') ? 'allow' : 'deny', + }, + }) + const emoji = permMatch[1]!.toLowerCase().startsWith('y') ? '✅' : '❌' + void msg.react(emoji).catch(() => {}) + return + } + // Typing indicator — signals "processing" until we reply (or ~10s elapses). if ('sendTyping' in msg.channel) { void msg.channel.sendTyping().catch(() => {}) diff --git a/external_plugins/telegram/.claude-plugin/plugin.json b/external_plugins/telegram/.claude-plugin/plugin.json index ac3472e..9e28053 100644 --- a/external_plugins/telegram/.claude-plugin/plugin.json +++ b/external_plugins/telegram/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "telegram", "description": "Telegram channel for Claude Code \u2014 messaging bridge with built-in access control. Manage pairing, allowlists, and policy via /telegram:access.", - "version": "0.0.1", + "version": "0.0.2", "keywords": [ "telegram", "messaging", diff --git a/external_plugins/telegram/server.ts b/external_plugins/telegram/server.ts index 8acd52a..5fa2f04 100644 --- a/external_plugins/telegram/server.ts +++ b/external_plugins/telegram/server.ts @@ -15,6 +15,7 @@ import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js' +import { z } from 'zod' import { Bot, InputFile, type Context } from 'grammy' import type { ReactionTypeEmoji } from 'grammy/types' import { randomBytes } from 'crypto' @@ -51,6 +52,12 @@ if (!TOKEN) { } const INBOX_DIR = join(STATE_DIR, 'inbox') +// Permission-reply spec from anthropics/claude-cli-internal +// src/services/mcp/channelPermissions.ts — inlined (no CC repo dep). +// 5 lowercase letters a-z minus 'l'. Case-insensitive for phone autocorrect. +// Strict: no bare yes/no (conversational), no prefix/suffix chatter. +const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i + const bot = new Bot(TOKEN) let botUsername = '' @@ -337,7 +344,18 @@ const PHOTO_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']) const mcp = new Server( { name: 'telegram', version: '1.0.0' }, { - capabilities: { tools: {}, experimental: { 'claude/channel': {} } }, + capabilities: { + tools: {}, + experimental: { + 'claude/channel': {}, + // Permission-relay opt-in (anthropics/claude-cli-internal#23061). + // Declaring this asserts we authenticate the replier — which we do: + // gate()/access.allowFrom already drops non-allowlisted senders before + // handleInbound runs. A server that can't authenticate the replier + // should NOT declare this. + 'claude/channel/permission': {}, + }, + }, instructions: [ 'The sender reads Telegram, not this session. Anything you want them to see must go through the reply tool — your transcript output never reaches their chat.', '', @@ -352,6 +370,36 @@ const mcp = new Server( }, ) +// Receive permission_request from CC → format → send to all allowlisted DMs. +// Groups are intentionally excluded — the security thread resolution was +// "single-user mode for official plugins." Anyone in access.allowFrom +// already passed explicit pairing; group members haven't. +mcp.setNotificationHandler( + z.object({ + method: z.literal('notifications/claude/channel/permission_request'), + params: z.object({ + request_id: z.string(), + tool_name: z.string(), + description: z.string(), + input_preview: z.string(), + }), + }), + async ({ params }) => { + const { request_id, tool_name, description, input_preview } = params + const access = loadAccess() + const text = + `🔐 Permission request [${request_id}]\n` + + `${tool_name}: ${description}\n` + + `${input_preview}\n\n` + + `Reply "yes ${request_id}" to allow or "no ${request_id}" to deny.` + for (const chat_id of access.allowFrom) { + void bot.api.sendMessage(chat_id, text).catch(e => { + process.stderr.write(`permission_request send to ${chat_id} failed: ${e}\n`) + }) + } + }, +) + mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { @@ -559,6 +607,28 @@ async function handleInbound( const chat_id = String(ctx.chat!.id) const msgId = ctx.message?.message_id + // Permission-reply intercept: if this looks like "yes xxxxx" for a + // pending permission request, emit the structured event instead of + // relaying as chat. The sender is already gate()-approved at this point + // (non-allowlisted senders were dropped above), so we trust the reply. + const permMatch = PERMISSION_REPLY_RE.exec(text) + if (permMatch) { + void mcp.notification({ + method: 'notifications/claude/channel/permission', + params: { + request_id: permMatch[2]!.toLowerCase(), + behavior: permMatch[1]!.toLowerCase().startsWith('y') ? 'allow' : 'deny', + }, + }) + if (msgId != null) { + const emoji = permMatch[1]!.toLowerCase().startsWith('y') ? '✅' : '❌' + void bot.api.setMessageReaction(chat_id, msgId, [ + { type: 'emoji', emoji: emoji as ReactionTypeEmoji['emoji'] }, + ]).catch(() => {}) + } + return + } + // Typing indicator — signals "processing" until we reply (or ~5s elapses). void bot.api.sendChatAction(chat_id, 'typing').catch(() => {})