Prechádzať zdrojové kódy

feat(telegram,discord): permission-relay capability + bidirectional handlers

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
Daisy Hollman 5 mesiacov pred
rodič
commit
daa84c99c8

+ 1 - 1
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",

+ 72 - 1
external_plugins/discord/server.ts

@@ -16,6 +16,7 @@ import {
   ListToolsRequestSchema,
   CallToolRequestSchema,
 } from '@modelcontextprotocol/sdk/types.js'
+import { z } from 'zod'
 import {
   Client,
   GatewayIntentBits,
@@ -67,6 +68,12 @@ process.on('uncaughtException', err => {
   process.stderr.write(`discord channel: uncaught exception: ${err}\n`)
 })
 
+// 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,
@@ -426,7 +433,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.',
       '',
@@ -441,6 +459,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: [
     {
@@ -689,6 +742,24 @@ async function handleInbound(msg: Message): Promise<void> {
 
   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(() => {})

+ 1 - 1
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",

+ 71 - 1
external_plugins/telegram/server.ts

@@ -15,6 +15,7 @@ import {
   ListToolsRequestSchema,
   CallToolRequestSchema,
 } from '@modelcontextprotocol/sdk/types.js'
+import { z } from 'zod'
 import { Bot, GrammyError, InputFile, type Context } from 'grammy'
 import type { ReactionTypeEmoji } from 'grammy/types'
 import { randomBytes } from 'crypto'
@@ -60,6 +61,12 @@ process.on('uncaughtException', err => {
   process.stderr.write(`telegram channel: uncaught exception: ${err}\n`)
 })
 
+// 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 = ''
 
@@ -346,7 +353,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.',
       '',
@@ -361,6 +379,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: [
     {
@@ -771,6 +819,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(() => {})