diff --git a/external_plugins/imessage/package.json b/external_plugins/imessage/package.json index 56da89a..ca2f13f 100644 --- a/external_plugins/imessage/package.json +++ b/external_plugins/imessage/package.json @@ -8,7 +8,8 @@ "start": "bun install --no-summary && bun server.ts" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.0" + "@modelcontextprotocol/sdk": "^1.0.0", + "zod": "^3.23.8" }, "devDependencies": { "@types/bun": "^1.3.10" diff --git a/external_plugins/imessage/server.ts b/external_plugins/imessage/server.ts index 190a5ce..11df501 100644 --- a/external_plugins/imessage/server.ts +++ b/external_plugins/imessage/server.ts @@ -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() + 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