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.

🏠 Remote-Dev: homespace
This commit is contained in:
Daisy Hollman
2026-03-20 22:44:08 +00:00
parent d56d7b61f0
commit 43cc5f8766
4 changed files with 145 additions and 4 deletions

View File

@@ -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",

View File

@@ -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<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(() => {})

View File

@@ -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",

View File

@@ -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(() => {})