Просмотр исходного кода

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.
Kenneth Lien 5 месяцев назад
Родитель
Сommit
bfed4635f5
2 измененных файлов с 109 добавлено и 5 удалено
  1. 2 1
      external_plugins/imessage/package.json
  2. 107 4
      external_plugins/imessage/server.ts

+ 2 - 1
external_plugins/imessage/package.json

@@ -8,7 +8,8 @@
     "start": "bun install --no-summary && bun server.ts"
     "start": "bun install --no-summary && bun server.ts"
   },
   },
   "dependencies": {
   "dependencies": {
-    "@modelcontextprotocol/sdk": "^1.0.0"
+    "@modelcontextprotocol/sdk": "^1.0.0",
+    "zod": "^3.23.8"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@types/bun": "^1.3.10"
     "@types/bun": "^1.3.10"

+ 107 - 4
external_plugins/imessage/server.ts

@@ -22,6 +22,7 @@ import {
   ListToolsRequestSchema,
   ListToolsRequestSchema,
   CallToolRequestSchema,
   CallToolRequestSchema,
 } from '@modelcontextprotocol/sdk/types.js'
 } from '@modelcontextprotocol/sdk/types.js'
+import { z } from 'zod'
 import { Database } from 'bun:sqlite'
 import { Database } from 'bun:sqlite'
 import { spawnSync } from 'child_process'
 import { spawnSync } from 'child_process'
 import { randomBytes } from 'crypto'
 import { randomBytes } from 'crypto'
@@ -34,10 +35,25 @@ const APPEND_SIGNATURE = process.env.IMESSAGE_APPEND_SIGNATURE !== 'false'
 const SIGNATURE = '\nSent by Claude'
 const SIGNATURE = '\nSent by Claude'
 const CHAT_DB = join(homedir(), 'Library', 'Messages', 'chat.db')
 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 ACCESS_FILE = join(STATE_DIR, 'access.json')
 const APPROVED_DIR = join(STATE_DIR, 'approved')
 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
 let db: Database
 try {
 try {
   db = new Database(CHAT_DB, { readonly: true })
   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 -----------------------------------------------------------------
 // --- sending -----------------------------------------------------------------
 
 
@@ -476,7 +492,18 @@ function renderMsg(r: Row): string {
 const mcp = new Server(
 const mcp = new Server(
   { name: 'imessage', version: '1.0.0' },
   { 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: [
     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.',
       '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 () => ({
 mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
   tools: [
   tools: [
     {
     {
@@ -595,6 +662,22 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => {
 
 
 await mcp.connect(new StdioServerTransport())
 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 ------------------------------------------------------------
 // --- inbound poll ------------------------------------------------------------
 
 
 // Start at current MAX(ROWID) — only deliver what arrives after boot.
 // 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 {
 function expandTilde(p: string): string {
   return p.startsWith('~/') ? join(homedir(), p.slice(2)) : p
   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) —
   // attachment.filename is an absolute path (sometimes tilde-prefixed) —
   // already on disk, no download. Include the first image inline.
   // already on disk, no download. Include the first image inline.
   let imagePath: string | undefined
   let imagePath: string | undefined