From 03a685d5f612f890dbe63446dae2f762a3e7e65e Mon Sep 17 00:00:00 2001 From: Kenneth Lien Date: Thu, 26 Mar 2026 23:11:29 -0700 Subject: [PATCH 1/6] imessage: restrict permission relay to self-chat only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permission prompts were being broadcast to all allowlisted contacts plus every DM resolvable from the SELF address set. Two compounding bugs: 1. SELF was polluted by chat.last_addressed_handle, which on machines with SMS history returns short codes, business handles, and other contacts' numbers — not just the owner's addresses. One reporter's query returned 50 addresses (2 actually theirs) resolving to 148 DM chats, all of which received permission prompts. 2. Even with a clean SELF, the handler sent to allowFrom + SELF, so every allowlisted contact received the prompt and could reply to approve tool execution on the owner's machine. Fix: - Build SELF from message.account WHERE is_from_me=1 only - Send permission prompts to self-chat only, not allowFrom - Accept permission replies from self-chat only Fixes #1048 Fixes #1010 --- external_plugins/imessage/server.ts | 63 ++++++++++++++++------------- 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/external_plugins/imessage/server.ts b/external_plugins/imessage/server.ts index d63ad71..d94889c 100644 --- a/external_plugins/imessage/server.ts +++ b/external_plugins/imessage/server.ts @@ -149,12 +149,17 @@ const qAttachments = db.query(` WHERE maj.message_id = ? `) -// Your own addresses. message.account ("E:you@icloud.com" / "p:+1555...") is -// the identity you sent *from* on each row — but an Apple ID can be reachable -// at both an email and a phone, and account only shows whichever you sent -// from. chat.last_addressed_handle covers the rest: it's the per-chat "which -// of your addresses reaches this person" field, so it accumulates every -// identity you've actually used. Union both. +// Your own addresses, from message.account ("E:you@icloud.com" / "p:+1555...") +// on rows you sent. This is the identity you sent *from*. If your Apple ID is +// reachable at an address you've never sent from, it won't appear here — send +// one message from that identity to register it. +// +// DO NOT use chat.last_addressed_handle. Despite its docstring ("which of your +// addresses reaches this person"), on machines with SMS history it returns a +// polluted mix of short codes, business handles, and other contacts' numbers. +// See anthropics/claude-plugins-official#1010: one user's last_addressed_handle +// query returned 50 addresses, only 2 of which were actually theirs, and the +// permission-relay handler spammed 148 DM chats. const SELF = new Set() { type R = { addr: string } @@ -162,9 +167,6 @@ const SELF = new Set() for (const { addr } of db.query( `SELECT DISTINCT account AS addr FROM message WHERE is_from_me = 1 AND account IS NOT NULL AND account != '' LIMIT 50`, ).all()) SELF.add(norm(addr)) - for (const { addr } of db.query( - `SELECT DISTINCT last_addressed_handle AS addr FROM chat WHERE last_addressed_handle IS NOT NULL AND last_addressed_handle != '' LIMIT 50`, - ).all()) SELF.add(norm(addr)) } process.stderr.write(`imessage channel: self-chat addresses: ${[...SELF].join(', ') || '(none)'}\n`) @@ -496,11 +498,10 @@ const mcp = new Server( 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. + // Permission-relay opt-in. Declaring this asserts we authenticate the + // replier — which we do: prompts go to self-chat only and replies are + // accepted from self-chat only (see handleInbound). A server that + // can't authenticate the replier should NOT declare this. 'claude/channel/permission': {}, }, }, @@ -518,11 +519,13 @@ 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). +// Receive permission_request from CC → format → send to the owner's self-chat. +// +// Self-chat ONLY. Not allowFrom, not groups. A permission reply grants tool +// execution on the owner's machine — that authority belongs to the owner +// alone. Allowlisted contacts can chat with Claude but must not be able to +// approve Bash commands on someone else's laptop. +// See anthropics/claude-plugins-official#1048, #1010. mcp.setNotificationHandler( z.object({ method: z.literal('notifications/claude/channel/permission_request'), @@ -535,7 +538,6 @@ mcp.setNotificationHandler( }), async ({ params }) => { const { request_id, tool_name, description, input_preview } = params - const access = loadAccess() // input_preview is unbearably long for Write/Edit; show only for Bash // where the command itself is the dangerous part. const preview = tool_name === 'Bash' ? `${input_preview}\n\n` : '\n' @@ -544,14 +546,17 @@ mcp.setNotificationHandler( `${tool_name}: ${description}\n` + preview + `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 h of SELF) { for (const { guid } of qChatsForHandle.all(h)) targets.add(guid) } + if (targets.size === 0) { + process.stderr.write( + `imessage channel: permission_request ${request_id} not relayed — no self-chat found. ` + + `Send yourself an iMessage to create one.\n`, + ) + return + } for (const guid of targets) { const err = sendText(guid, text) if (err) { @@ -758,10 +763,10 @@ 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) + // relaying as chat. Self-chat ONLY — mirrors the self-chat-only send + // side above. Allowlisted contacts can chat but cannot approve tool + // execution on the owner's machine. + const permMatch = isSelfChat ? PERMISSION_REPLY_RE.exec(text) : null if (permMatch) { void mcp.notification({ method: 'notifications/claude/channel/permission', From c29338f2762a2c6e531c2bb781841319a538ed63 Mon Sep 17 00:00:00 2001 From: Kenneth Lien Date: Thu, 26 Mar 2026 23:11:49 -0700 Subject: [PATCH 2/6] imessage: drop whitespace-only messages from tapbacks/receipts Tapback reactions and read receipts synced from linked devices arrive as chat.db rows with whitespace-only text. The existing empty-check used falsy comparison which doesn't catch ' ' or invisible chars, causing unsolicited replies to reaction taps. Fixes #1041 --- external_plugins/imessage/server.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/external_plugins/imessage/server.ts b/external_plugins/imessage/server.ts index d94889c..5421f3a 100644 --- a/external_plugins/imessage/server.ts +++ b/external_plugins/imessage/server.ts @@ -725,7 +725,10 @@ function handleInbound(r: Row): void { const text = messageText(r) const hasAttachments = r.cache_has_attachments === 1 - if (!text && !hasAttachments) return + // Tapbacks, read receipts, and other sync noise from linked devices land + // as rows with whitespace-only text or bare attachment flags. trim() so + // they don't trigger unsolicited replies. See #1041. + if (!text.trim() && !hasAttachments) return // Never deliver our own sends. In self-chat the is_from_me=1 rows are empty // sent-receipts anyway — the content lands on the is_from_me=0 copy below. From 8dfc27925804e1a1b51eaf1afc53892ebc77ae71 Mon Sep 17 00:00:00 2001 From: Kenneth Lien Date: Thu, 26 Mar 2026 23:12:19 -0700 Subject: [PATCH 3/6] imessage: harden echo filter normalization The self-chat echo filter matches outbound text against what chat.db stores on round-trip. Three divergence sources caused false negatives and duplicate bubbles: - Signature suffix: "\nSent by Claude" is appended on send, but the \n may not round-trip identically through attributedBody - Emoji variation selectors (U+FE00-FE0F) and ZWJ (U+200D): chat.db can add or drop these on emoji characters - Smart quotes: macOS auto-substitutes straight quotes on the way in Strip/normalize all three in echoKey() before the existing whitespace collapse. Fixes #1024 --- external_plugins/imessage/server.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/external_plugins/imessage/server.ts b/external_plugins/imessage/server.ts index 5421f3a..1f2bb98 100644 --- a/external_plugins/imessage/server.ts +++ b/external_plugins/imessage/server.ts @@ -418,7 +418,14 @@ const ECHO_WINDOW_MS = 15000 const echo = new Map() function echoKey(raw: string): string { - return raw.trim().replace(/\s+/g, ' ').slice(0, 120) + return raw + .replace(/\s*Sent by Claude\s*$/, '') // strip signature before \s collapse eats the \n + .replace(/[\u200d\ufe00-\ufe0f]/g, '') // ZWJ + emoji variation selectors (chat.db adds/drops these) + .replace(/[\u2018\u2019]/g, "'") // smart → straight single quote + .replace(/[\u201c\u201d]/g, '"') // smart → straight double quote + .trim() + .replace(/\s+/g, ' ') + .slice(0, 120) } function trackEcho(chatGuid: string, key: string): void { From c4274521decb8ced6f2bdcff5fb32273ed3296d9 Mon Sep 17 00:00:00 2001 From: Kenneth Lien Date: Thu, 26 Mar 2026 23:16:27 -0700 Subject: [PATCH 4/6] imessage: trim comment cruft --- external_plugins/imessage/server.ts | 43 ++++++++++------------------- 1 file changed, 14 insertions(+), 29 deletions(-) diff --git a/external_plugins/imessage/server.ts b/external_plugins/imessage/server.ts index 1f2bb98..21f9fc3 100644 --- a/external_plugins/imessage/server.ts +++ b/external_plugins/imessage/server.ts @@ -150,16 +150,9 @@ const qAttachments = db.query(` `) // Your own addresses, from message.account ("E:you@icloud.com" / "p:+1555...") -// on rows you sent. This is the identity you sent *from*. If your Apple ID is -// reachable at an address you've never sent from, it won't appear here — send -// one message from that identity to register it. -// -// DO NOT use chat.last_addressed_handle. Despite its docstring ("which of your -// addresses reaches this person"), on machines with SMS history it returns a -// polluted mix of short codes, business handles, and other contacts' numbers. -// See anthropics/claude-plugins-official#1010: one user's last_addressed_handle -// query returned 50 addresses, only 2 of which were actually theirs, and the -// permission-relay handler spammed 148 DM chats. +// on rows you sent. Don't supplement with chat.last_addressed_handle — on +// machines with SMS history that column is polluted with short codes and +// other people's numbers, not just your own identities. const SELF = new Set() { type R = { addr: string } @@ -419,10 +412,10 @@ const echo = new Map() function echoKey(raw: string): string { return raw - .replace(/\s*Sent by Claude\s*$/, '') // strip signature before \s collapse eats the \n - .replace(/[\u200d\ufe00-\ufe0f]/g, '') // ZWJ + emoji variation selectors (chat.db adds/drops these) - .replace(/[\u2018\u2019]/g, "'") // smart → straight single quote - .replace(/[\u201c\u201d]/g, '"') // smart → straight double quote + .replace(/\s*Sent by Claude\s*$/, '') + .replace(/[\u200d\ufe00-\ufe0f]/g, '') // ZWJ + variation selectors — chat.db is inconsistent about these + .replace(/[\u2018\u2019]/g, "'") + .replace(/[\u201c\u201d]/g, '"') .trim() .replace(/\s+/g, ' ') .slice(0, 120) @@ -526,13 +519,9 @@ const mcp = new Server( }, ) -// Receive permission_request from CC → format → send to the owner's self-chat. -// -// Self-chat ONLY. Not allowFrom, not groups. A permission reply grants tool -// execution on the owner's machine — that authority belongs to the owner -// alone. Allowlisted contacts can chat with Claude but must not be able to -// approve Bash commands on someone else's laptop. -// See anthropics/claude-plugins-official#1048, #1010. +// Permission prompts go to self-chat only. A "yes" grants tool execution on +// this machine — that authority is the owner's alone, not allowlisted +// contacts'. mcp.setNotificationHandler( z.object({ method: z.literal('notifications/claude/channel/permission_request'), @@ -732,9 +721,8 @@ function handleInbound(r: Row): void { const text = messageText(r) const hasAttachments = r.cache_has_attachments === 1 - // Tapbacks, read receipts, and other sync noise from linked devices land - // as rows with whitespace-only text or bare attachment flags. trim() so - // they don't trigger unsolicited replies. See #1041. + // trim() catches tapbacks/receipts synced from other devices — those land + // as whitespace-only rows. if (!text.trim() && !hasAttachments) return // Never deliver our own sends. In self-chat the is_from_me=1 rows are empty @@ -771,11 +759,8 @@ 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. Self-chat ONLY — mirrors the self-chat-only send - // side above. Allowlisted contacts can chat but cannot approve tool - // execution on the owner's machine. + // Permission replies: emit the structured event instead of relaying as + // chat. Owner-only — same gate as the send side. const permMatch = isSelfChat ? PERMISSION_REPLY_RE.exec(text) : null if (permMatch) { void mcp.notification({ From 60c3fc36ede027ae31403dda501c99ac96f4afd7 Mon Sep 17 00:00:00 2001 From: Kenneth Lien Date: Thu, 26 Mar 2026 23:41:39 -0700 Subject: [PATCH 5/6] imessage: drop SMS/RCS by default, opt-in via IMESSAGE_ALLOW_SMS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SMS sender IDs are spoofable; iMessage is Apple-ID-authenticated and end-to-end encrypted. The plugin previously treated both identically, so a forged SMS from the owner's own number would match SELF, bypass the access gate, and inherit owner-level trust — including permission approval. handleInbound now drops anything with service != 'iMessage' unless IMESSAGE_ALLOW_SMS=true. Default is the safe path; users who want SMS can opt in after reading the warning in README. --- external_plugins/imessage/README.md | 1 + external_plugins/imessage/server.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/external_plugins/imessage/README.md b/external_plugins/imessage/README.md index 4c246ec..dcd40e1 100644 --- a/external_plugins/imessage/README.md +++ b/external_plugins/imessage/README.md @@ -62,6 +62,7 @@ Handles are phone numbers (`+15551234567`) or Apple ID emails (`them@icloud.com` | Variable | Default | Effect | | --- | --- | --- | | `IMESSAGE_APPEND_SIGNATURE` | `true` | Appends `\nSent by Claude` to outbound messages. Set to `false` to disable. | +| `IMESSAGE_ALLOW_SMS` | `false` | Accept inbound SMS/RCS in addition to iMessage. **Off by default because SMS sender IDs are spoofable** — a forged SMS from your own number would otherwise bypass access control. Only enable if you understand the risk. | | `IMESSAGE_ACCESS_MODE` | — | Set to `static` to disable runtime pairing and read `access.json` only. | | `IMESSAGE_STATE_DIR` | `~/.claude/channels/imessage` | Override where `access.json` and pairing state live. | diff --git a/external_plugins/imessage/server.ts b/external_plugins/imessage/server.ts index 21f9fc3..795ad2e 100644 --- a/external_plugins/imessage/server.ts +++ b/external_plugins/imessage/server.ts @@ -32,6 +32,10 @@ import { join, basename, sep } from 'path' const STATIC = process.env.IMESSAGE_ACCESS_MODE === 'static' const APPEND_SIGNATURE = process.env.IMESSAGE_APPEND_SIGNATURE !== 'false' +// SMS sender IDs are spoofable; iMessage is Apple-ID-authenticated. Default +// drops SMS/RCS so a forged sender can't reach the gate. Opt in only if you +// understand the risk. +const ALLOW_SMS = process.env.IMESSAGE_ALLOW_SMS === 'true' const SIGNATURE = '\nSent by Claude' const CHAT_DB = join(homedir(), 'Library', 'Messages', 'chat.db') @@ -104,6 +108,7 @@ type Row = { date: number is_from_me: number cache_has_attachments: number + service: string | null handle_id: string | null chat_guid: string chat_style: number | null @@ -113,7 +118,7 @@ const qWatermark = db.query<{ max: number | null }, []>('SELECT MAX(ROWID) AS ma const qPoll = db.query(` SELECT m.ROWID AS rowid, m.guid, m.text, m.attributedBody, m.date, m.is_from_me, - m.cache_has_attachments, h.id AS handle_id, c.guid AS chat_guid, c.style AS chat_style + m.cache_has_attachments, m.service, h.id AS handle_id, c.guid AS chat_guid, c.style AS chat_style FROM message m JOIN chat_message_join cmj ON cmj.message_id = m.ROWID JOIN chat c ON c.ROWID = cmj.chat_id @@ -124,7 +129,7 @@ const qPoll = db.query(` const qHistory = db.query(` SELECT m.ROWID AS rowid, m.guid, m.text, m.attributedBody, m.date, m.is_from_me, - m.cache_has_attachments, h.id AS handle_id, c.guid AS chat_guid, c.style AS chat_style + m.cache_has_attachments, m.service, h.id AS handle_id, c.guid AS chat_guid, c.style AS chat_style FROM message m JOIN chat_message_join cmj ON cmj.message_id = m.ROWID JOIN chat c ON c.ROWID = cmj.chat_id @@ -710,6 +715,7 @@ function expandTilde(p: string): string { function handleInbound(r: Row): void { if (!r.chat_guid) return + if (!ALLOW_SMS && r.service !== 'iMessage') return // style 45 = DM, 43 = group. Drop unknowns rather than risk routing a // group message through the DM gate and leaking a pairing code. From 22bd61d01feca3d17b70ebb933ba3ea82f6b2bc9 Mon Sep 17 00:00:00 2001 From: Kenneth Lien Date: Thu, 26 Mar 2026 23:43:51 -0700 Subject: [PATCH 6/6] imessage: bump to 0.1.0 --- external_plugins/imessage/.claude-plugin/plugin.json | 2 +- external_plugins/imessage/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/external_plugins/imessage/.claude-plugin/plugin.json b/external_plugins/imessage/.claude-plugin/plugin.json index eac5863..22ad96c 100644 --- a/external_plugins/imessage/.claude-plugin/plugin.json +++ b/external_plugins/imessage/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "imessage", "description": "iMessage channel for Claude Code \u2014 reads chat.db directly, sends via AppleScript. Built-in access control; manage pairing, allowlists, and policy via /imessage:access.", - "version": "0.0.1", + "version": "0.1.0", "keywords": [ "imessage", "messaging", diff --git a/external_plugins/imessage/package.json b/external_plugins/imessage/package.json index ca2f13f..e058879 100644 --- a/external_plugins/imessage/package.json +++ b/external_plugins/imessage/package.json @@ -1,6 +1,6 @@ { "name": "claude-channel-imessage", - "version": "0.0.1", + "version": "0.1.0", "license": "Apache-2.0", "type": "module", "bin": "./server.ts",