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:
Kenneth Lien
2026-03-23 20:10:34 -07:00
parent 0f8c170fa7
commit bfed4635f5
2 changed files with 109 additions and 5 deletions

View File

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