Skip to content

Add secure macOS iMessage plugin - #71

Open
maximedegreve wants to merge 4 commits into
mainfrom
maximedegreve-add-imessage-plugin
Open

maximedegreve wants to merge 4 commits into
mainfrom
maximedegreve-add-imessage-plugin

Conversation

@maximedegreve

@maximedegreve maximedegreve commented Aug 12, 2026 •

Copy link
Copy Markdown

Summary

  • add a local-only macOS iMessage MCP server with read, search, and text-reply tools
  • register the plugin in the built-in marketplace and add setup/access skills
  • add focused coverage for authorization, modern self-chat identity modeling, spoofable service handling, typed message decoding, AppleScript safety, MCP behavior, and secure state writes

Security and permissions

  • opens ~/Library/Messages/chat.db with SQLite read-only and query-only modes
  • sends through Messages.app with a fixed AppleScript; message text and chat IDs are passed as argv rather than interpolated into source
  • identifies self from authenticated iMessage accounts plus local destination identities confirmed on both incoming and outgoing iMessages; ordinary recipients, one-sided values, short codes, and SMS/RCS data cannot become self
  • denies all other direct senders by default and requires explicit direct allowlists
  • requires an exact participant snapshot for each allowed group and fails closed when membership changes
  • disables SMS/MMS/RCS by default because sender IDs can be spoofed; enabling requires an explicit risk acknowledgement
  • stores access policy atomically with 0600 file and 0700 directory permissions under a Copilot-specific Application Support path
  • treats message content as untrusted input, does not poll in the background, does not expose an external server, and supports outbound text only

Users must grant Full Disk Access to the application running Copilot CLI for reads and Automation permission to control Messages.app for sends.

Upstream attribution

The security design and typedstream parser are derived from Anthropic's Apache-2.0 iMessage plugin at anthropics/claude-plugins-official commit c54b5608d9be1910de9a5b91c2d15bf6673b9c35. The plugin includes the upstream Apache License 2.0 copyright notice and a modification notice, uses Copilot-specific paths/configuration, and removes upstream outbound branding.

Validation

  • PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s plugins/imessage/tests -v (16 tests)
  • privacy-safe live validation against the local Messages database: exactly one direct iMessage self-chat authorized; no groups, SMS/RCS, or ordinary direct chats authorized
  • MCP stdio initialize, tools/list, imessage_status, and imessage_chats smoke tests
  • Python AST and JSON manifest parsing
  • copilot --plugin-dir ./plugins/imessage plugin list
  • secure configuration CLI permissions and SMS/RCS acknowledgement checks
  • git diff --check

Caveats

  • requires macOS, Messages.app, and Python 3.10+
  • message search covers the plain message.text column; newer binary attributedBody content is decoded for history on a best-effort basis but is not searchable
  • incoming messages do not proactively initiate Copilot sessions; access occurs only through explicit MCP tool calls

maximedegreve and others added 2 commits August 12, 2026 15:07
Add a macOS-only local MCP server with deny-by-default access controls, read-only Messages history, safe AppleScript sends, marketplace registration, documentation, and focused tests.\n\nAdapt the security model from Anthropic's Apache-2.0 iMessage plugin with attribution and Copilot-specific state and branding.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI balanced review requested due to automatic review settings August 12, 2026 14:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a local macOS iMessage MCP plugin with access controls, configuration workflows, documentation, and tests.

Changes:

  • Implements local message reading, search, and replies.
  • Adds secure policy configuration and Copilot skills.
  • Registers, documents, licenses, and tests the plugin.
Show a summary per file
File Description
README.md Documents MCP support and licensing.
.github/plugin/marketplace.json Registers the iMessage plugin.
plugins/imessage/.gitignore Excludes Python cache files.
plugins/imessage/.mcp.json Configures the MCP server.
plugins/imessage/LICENSE Adds Apache 2.0 license.
plugins/imessage/NOTICE Records upstream attribution.
plugins/imessage/README.md Documents installation and security.
plugins/imessage/plugin.json Defines plugin metadata.
plugins/imessage/server/__init__.py Initializes the server package.
plugins/imessage/server/imessage.py Implements storage, authorization, queries, and sending.
plugins/imessage/server/imessage_mcp.py Implements MCP and configuration CLI.
plugins/imessage/skills/imessage-configure/SKILL.md Guides secure configuration.
plugins/imessage/skills/imessage-messaging/SKILL.md Guides safe messaging workflows.
plugins/imessage/tests/test_imessage.py Tests security and MCP behavior.

Review details

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Suppressed comments (1)

plugins/imessage/server/imessage.py:503

  • Search has the same service-boundary gap: authorized chat IDs scope the query, but matching rows are never filtered by their resolved m.service/handle service. A spoofable SMS/RCS row joined to an otherwise authorized chat can therefore appear while SMS/RCS is disabled; pass the effective service policy into this query and filter before ordering/limiting.
                    WHERE cmj.chat_id IN ({placeholders})
                      AND instr(lower(COALESCE(m.text, '')), lower(?)) > 0
  • Files reviewed: 14/14 changed files
  • Comments generated: 6
  • Review effort level: Balanced

Comment on lines +716 to +719
yield remaining[:split]
remaining = remaining[split:]
if remaining.startswith("\n") or remaining.startswith(" "):
remaining = remaining[1:]
Comment on lines +203 to +208
if method == "initialize":
requested = request.get("params", {}).get(
"protocolVersion", "2025-06-18"
)
result = {
"protocolVersion": requested,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The server echoes any client-supplied protocol version, including unsupported or non-string values, thereby claiming compatibility it does not implement. MCP negotiation requires returning the requested version only when supported and otherwise a version the server supports; advertise the implementation's supported version instead.

Comment on lines +338 to +340
def _policy_update(store: ConfigStore, **changes: Any) -> AccessPolicy:
policy = replace(store.load(), **changes)
store.save(policy)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

load() followed by save() is not an atomic read-modify-write operation; os.replace only prevents partial files. Concurrent helper commands can both read the same policy and the last writer can silently lose—or reintroduce—an allowlist change. Serialize the complete load/update/save transaction with a lock, and compute set/group changes only after acquiring it.

Comment on lines +638 to +642
return [
chat.public_dict()
for chat in self.database.list_chats(scan_limit=None)
if self.chat_allowed(chat, policy)
][:limit]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A request for at most 100 chats still loads every chat and runs _chat's participant query for each one before slicing. Large Messages databases therefore turn a small listing into an unbounded N+1 scan. Iterate recent chats in pages and stop once limit authorized entries have been collected, continuing to another page only when filtering leaves the page short.

Comment on lines +321 to +331
try:
request = json.loads(raw_line)
if not isinstance(request, dict):
raise ValueError("JSON-RPC message must be an object.")
response = server.dispatch(request)
except (json.JSONDecodeError, ValueError) as error:
response = {
"jsonrpc": "2.0",
"id": None,
"error": {"code": -32700, "message": str(error)},
}
JOIN chat_message_join AS cmj ON cmj.message_id = m.ROWID
JOIN chat AS c ON c.ROWID = cmj.chat_id
LEFT JOIN handle AS h ON h.ROWID = m.handle_id
WHERE cmj.chat_id = ?
maximedegreve and others added 2 commits August 12, 2026 15:27
Report privacy-safe authorized and self-chat counts from the status tool, explain the fail-closed empty state, and cover live owner-alias reloads without an MCP restart.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Recognize local destination identities only when they occur on both incoming and outgoing authenticated iMessages for a trusted account. This supports modern Messages self-chats while excluding recipients, one-sided values, short codes, and SMS/RCS data.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants