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