mirror of
https://github.com/anthropics/claude-plugins-official.git
synced 2026-03-21 11:53:08 +00:00
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:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "discord",
|
"name": "discord",
|
||||||
"description": "Discord channel for Claude Code \u2014 messaging bridge with built-in access control. Manage pairing, allowlists, and policy via /discord:access.",
|
"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": [
|
"keywords": [
|
||||||
"discord",
|
"discord",
|
||||||
"messaging",
|
"messaging",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
ListToolsRequestSchema,
|
ListToolsRequestSchema,
|
||||||
CallToolRequestSchema,
|
CallToolRequestSchema,
|
||||||
} from '@modelcontextprotocol/sdk/types.js'
|
} from '@modelcontextprotocol/sdk/types.js'
|
||||||
|
import { z } from 'zod'
|
||||||
import {
|
import {
|
||||||
Client,
|
Client,
|
||||||
GatewayIntentBits,
|
GatewayIntentBits,
|
||||||
@@ -58,6 +59,12 @@ if (!TOKEN) {
|
|||||||
}
|
}
|
||||||
const INBOX_DIR = join(STATE_DIR, 'inbox')
|
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({
|
const client = new Client({
|
||||||
intents: [
|
intents: [
|
||||||
GatewayIntentBits.DirectMessages,
|
GatewayIntentBits.DirectMessages,
|
||||||
@@ -417,7 +424,18 @@ function safeAttName(att: Attachment): string {
|
|||||||
const mcp = new Server(
|
const mcp = new Server(
|
||||||
{ name: 'discord', version: '1.0.0' },
|
{ 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: [
|
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.',
|
'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 () => ({
|
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||||
tools: [
|
tools: [
|
||||||
{
|
{
|
||||||
@@ -661,6 +714,24 @@ async function handleInbound(msg: Message): Promise<void> {
|
|||||||
|
|
||||||
const chat_id = msg.channelId
|
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).
|
// Typing indicator — signals "processing" until we reply (or ~10s elapses).
|
||||||
if ('sendTyping' in msg.channel) {
|
if ('sendTyping' in msg.channel) {
|
||||||
void msg.channel.sendTyping().catch(() => {})
|
void msg.channel.sendTyping().catch(() => {})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "telegram",
|
"name": "telegram",
|
||||||
"description": "Telegram channel for Claude Code \u2014 messaging bridge with built-in access control. Manage pairing, allowlists, and policy via /telegram:access.",
|
"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": [
|
"keywords": [
|
||||||
"telegram",
|
"telegram",
|
||||||
"messaging",
|
"messaging",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
ListToolsRequestSchema,
|
ListToolsRequestSchema,
|
||||||
CallToolRequestSchema,
|
CallToolRequestSchema,
|
||||||
} from '@modelcontextprotocol/sdk/types.js'
|
} from '@modelcontextprotocol/sdk/types.js'
|
||||||
|
import { z } from 'zod'
|
||||||
import { Bot, InputFile, type Context } from 'grammy'
|
import { Bot, InputFile, type Context } from 'grammy'
|
||||||
import type { ReactionTypeEmoji } from 'grammy/types'
|
import type { ReactionTypeEmoji } from 'grammy/types'
|
||||||
import { randomBytes } from 'crypto'
|
import { randomBytes } from 'crypto'
|
||||||
@@ -51,6 +52,12 @@ if (!TOKEN) {
|
|||||||
}
|
}
|
||||||
const INBOX_DIR = join(STATE_DIR, 'inbox')
|
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)
|
const bot = new Bot(TOKEN)
|
||||||
let botUsername = ''
|
let botUsername = ''
|
||||||
|
|
||||||
@@ -337,7 +344,18 @@ const PHOTO_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp'])
|
|||||||
const mcp = new Server(
|
const mcp = new Server(
|
||||||
{ name: 'telegram', version: '1.0.0' },
|
{ 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: [
|
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.',
|
'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 () => ({
|
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||||
tools: [
|
tools: [
|
||||||
{
|
{
|
||||||
@@ -559,6 +607,28 @@ async function handleInbound(
|
|||||||
const chat_id = String(ctx.chat!.id)
|
const chat_id = String(ctx.chat!.id)
|
||||||
const msgId = ctx.message?.message_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).
|
// Typing indicator — signals "processing" until we reply (or ~5s elapses).
|
||||||
void bot.api.sendChatAction(chat_id, 'typing').catch(() => {})
|
void bot.api.sendChatAction(chat_id, 'typing').catch(() => {})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user