mirror of
https://github.com/anthropics/claude-plugins-official.git
synced 2026-05-09 05:12:39 +00:00
feat(imessage): port permission-relay + lifecycle fixes from telegram
Brings the imessage channel to parity with recent telegram/discord hardening: - Permission-relay capability: declare claude/channel/permission, handle inbound permission_request notifications by fanning out to allowlisted DM chats + self-chat, intercept "yes/no <id>" replies after the gate check and emit structured permission events instead of relaying as chat. Groups excluded per single-user-mode policy. - Global unhandledRejection/uncaughtException handlers so the server logs instead of dying silently. - IMESSAGE_STATE_DIR env override for the state directory. - .unref() on both setInterval timers so they don't block shutdown. - stdin EOF / SIGTERM / SIGINT shutdown handler that closes chat.db and exits cleanly instead of leaving a zombie poll loop. Adds zod as a direct dep (already transitively present via the MCP SDK) for the notification handler schema.
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
||||
ListToolsRequestSchema,
|
||||
CallToolRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
import { z } from 'zod'
|
||||
import { Database } from 'bun:sqlite'
|
||||
import { spawnSync } from 'child_process'
|
||||
import { randomBytes } from 'crypto'
|
||||
@@ -34,10 +35,25 @@ const APPEND_SIGNATURE = process.env.IMESSAGE_APPEND_SIGNATURE !== 'false'
|
||||
const SIGNATURE = '\nSent by Claude'
|
||||
const CHAT_DB = join(homedir(), 'Library', 'Messages', 'chat.db')
|
||||
|
||||
const STATE_DIR = join(homedir(), '.claude', 'channels', 'imessage')
|
||||
const STATE_DIR = process.env.IMESSAGE_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'imessage')
|
||||
const ACCESS_FILE = join(STATE_DIR, 'access.json')
|
||||
const APPROVED_DIR = join(STATE_DIR, 'approved')
|
||||
|
||||
// Last-resort safety net — without these the process dies silently on any
|
||||
// unhandled promise rejection. With them it logs and keeps serving tools.
|
||||
process.on('unhandledRejection', err => {
|
||||
process.stderr.write(`imessage channel: unhandled rejection: ${err}\n`)
|
||||
})
|
||||
process.on('uncaughtException', err => {
|
||||
process.stderr.write(`imessage 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
|
||||
|
||||
let db: Database
|
||||
try {
|
||||
db = new Database(CHAT_DB, { readonly: true })
|
||||
@@ -377,7 +393,7 @@ function checkApprovals(): void {
|
||||
}
|
||||
}
|
||||
|
||||
if (!STATIC) setInterval(checkApprovals, 5000)
|
||||
if (!STATIC) setInterval(checkApprovals, 5000).unref()
|
||||
|
||||
// --- sending -----------------------------------------------------------------
|
||||
|
||||
@@ -476,7 +492,18 @@ function renderMsg(r: Row): string {
|
||||
const mcp = new Server(
|
||||
{ name: 'imessage', 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 delivers. Self-chat is the owner by definition. A
|
||||
// server that can't authenticate the replier should NOT declare this.
|
||||
'claude/channel/permission': {},
|
||||
},
|
||||
},
|
||||
instructions: [
|
||||
'The sender reads iMessage, not this session. Anything you want them to see must go through the reply tool — your transcript output never reaches their chat.',
|
||||
'',
|
||||
@@ -491,6 +518,46 @@ 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. Self-chat is
|
||||
// always included (owner).
|
||||
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.`
|
||||
// allowFrom holds handle IDs, not chat GUIDs — resolve via qChatsForHandle.
|
||||
// Include SELF addresses so the owner's self-chat gets the prompt even
|
||||
// when allowFrom is empty (default config).
|
||||
const handles = new Set([...access.allowFrom.map(h => h.toLowerCase()), ...SELF])
|
||||
const targets = new Set<string>()
|
||||
for (const h of handles) {
|
||||
for (const { guid } of qChatsForHandle.all(h)) targets.add(guid)
|
||||
}
|
||||
for (const guid of targets) {
|
||||
const err = sendText(guid, text)
|
||||
if (err) {
|
||||
process.stderr.write(`imessage channel: permission_request send to ${guid} failed: ${err}\n`)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [
|
||||
{
|
||||
@@ -595,6 +662,22 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => {
|
||||
|
||||
await mcp.connect(new StdioServerTransport())
|
||||
|
||||
// When Claude Code closes the MCP connection, stdin gets EOF. Without this
|
||||
// the poll interval keeps the process alive forever as a zombie holding the
|
||||
// chat.db handle open.
|
||||
let shuttingDown = false
|
||||
function shutdown(): void {
|
||||
if (shuttingDown) return
|
||||
shuttingDown = true
|
||||
process.stderr.write('imessage channel: shutting down\n')
|
||||
try { db.close() } catch {}
|
||||
process.exit(0)
|
||||
}
|
||||
process.stdin.on('end', shutdown)
|
||||
process.stdin.on('close', shutdown)
|
||||
process.on('SIGTERM', shutdown)
|
||||
process.on('SIGINT', shutdown)
|
||||
|
||||
// --- inbound poll ------------------------------------------------------------
|
||||
|
||||
// Start at current MAX(ROWID) — only deliver what arrives after boot.
|
||||
@@ -615,7 +698,7 @@ function poll(): void {
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(poll, 1000)
|
||||
setInterval(poll, 1000).unref()
|
||||
|
||||
function expandTilde(p: string): string {
|
||||
return p.startsWith('~/') ? join(homedir(), p.slice(2)) : p
|
||||
@@ -670,6 +753,26 @@ function handleInbound(r: Row): void {
|
||||
}
|
||||
}
|
||||
|
||||
// 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; self-chat is the owner),
|
||||
// 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',
|
||||
},
|
||||
})
|
||||
const emoji = permMatch[1]!.toLowerCase().startsWith('y') ? '✅' : '❌'
|
||||
const err = sendText(r.chat_guid, emoji)
|
||||
if (err) process.stderr.write(`imessage channel: permission ack send failed: ${err}\n`)
|
||||
return
|
||||
}
|
||||
|
||||
// attachment.filename is an absolute path (sometimes tilde-prefixed) —
|
||||
// already on disk, no download. Include the first image inline.
|
||||
let imagePath: string | undefined
|
||||
|
||||
Reference in New Issue
Block a user