Skip to content

feat(contacts): container-level filtering + unified contact privacy#66

Merged
omarshahine merged 2 commits into
omarshahine:mainfrom
prashantkamani:contacts-container-filter
May 15, 2026
Merged

feat(contacts): container-level filtering + unified contact privacy#66
omarshahine merged 2 commits into
omarshahine:mainfrom
prashantkamani:contacts-container-filter

Conversation

@prashantkamani
Copy link
Copy Markdown
Contributor

Summary

Wire ItemFilter in ContactsCLI to filter by CNContainer (account name), matching the existing pattern in CalendarCLI and ReminderCLI. Previously, contacts-cli checked config.contacts.enabled but ignored mode/items — every agent saw every contact account regardless of profile configuration.

This PR adds three layers of contact isolation:

  • Container filtering: list, search, groups, get, update, delete, and create all respect the profile's contacts allowlist/blocklist
  • Unified contact privacy: scoped operations use unifyResults = false to prevent Apple's unified contacts from leaking data across account boundaries
  • Search optimization: name predicate + container post-filter replaces the previous enumerate-all-then-search-in-memory approach

What's new

Swift CLI (ContactsCLI.swift)

  • containers subcommand — lists all contact account containers with name, ID, and type (local/exchange/cardDAV). Respects profile filtering.
  • Container filtering for all verbs — list, search, get, update, delete, create, and groups all enforce the profile's contacts.mode and contacts.items against CNContainer account names via ContactAccessMode strategy pattern.
  • --container flag on create — target a specific account container when creating contacts. Validated against the profile allowlist.
  • Unified contact privacy — when mode != all, all data-returning operations use CNContactFetchRequest.unifyResults = false. This prevents Apple's contact unification from merging data across account boundaries.
  • Scoped get with relatedContacts — when getting a contact in scoped mode, the response includes relatedContacts: the same person's entries from other authorized accounts. Preserves the "contact" singular schema for backward compatibility.
  • Backing ID enforcement — in scoped mode, update and delete reject multi-source unified IDs via validateScopedContactAccess(). The user must pass a backing ID (as returned by list/search/get).
  • Exchange ghost group resolutionallAccountContainers() filters out Exchange default "Contacts" lists that appear as both containers and groups. resolveAccountContainer() walks Exchange ghost groups to the real parent account.
  • Search optimization — scoped search uses predicateForContacts(matchingName:) with unifyResults = false, then post-filters by container. Falls back to in-memory email/phone matching for non-name queries.

JS handler layer

  • lib/handlers/contact.jscontainers case calls contacts-cli containers
  • lib/schemas.jscontainers added to action enum, container property added for create targeting
  • lib/tool-args.jsbuildContactCreateArgs passes --container flag when specified
  • commands/contacts.md — updated argument-hint, "List Containers" section, --container on create examples

Tests & evals

  • ContainerFilterTests.swift — 3 pure-logic tests (zero TCC dependency). Tests contactAccessMode routing and ContactAccessMode enum. The framework-dependent integration functions (resolveAccountContainer, isMultiSourceUnifiedId, etc.) are tested via CLI functional tests, not unit tests — to avoid CI hangs on runners without contacts TCC permission.
  • mcp-server/test/tool-args.test.js — 2 new tests: buildContactCreateArgs with/without container
  • evals/scenarios/tool-call-correctness.yaml — 2 new scenarios: contact-containers, contact-create-with-container
  • evals/fixtures/contact/containers-results.json — new fixture
  • evals/tests/safety.test.jscontainers added to contact action coverage set

Docs & version

  • README.md — contacts filtering docs, containers CLI example, profile example
  • docs/multi-agent-setup.md — contacts profile examples, family-only profile
  • Version 3.7.22 across all 5 package files via scripts/bump-version.sh

Code layout

File Lines changed Role
swift/Sources/ContactsCLI/ContactsCLI.swift +513 / -157 All Swift production changes
swift/Tests/ContactsCLITests/ContainerFilterTests.swift +47 (new) 3 pure-logic tests (zero TCC)
swift/Tests/MailCLITests/MIMEBuilderTests.swift +4 / -2 Fix timezone-dependent test (unrelated, pre-existing)
lib/handlers/contact.js +3 containers case
lib/schemas.js +5 / -2 containers in enum, container property
lib/tool-args.js +1 --container on create
commands/contacts.md +12 Containers docs, --container examples
mcp-server/test/tool-args.test.js +18 Container arg tests
evals/scenarios/tool-call-correctness.yaml +14 2 new scenarios
evals/fixtures/contact/containers-results.json +19 (new) Container fixture
evals/tests/safety.test.js +1 / -1 Coverage set update
README.md +10 / -3 Contacts filtering docs
docs/multi-agent-setup.md +23 / -2 Contacts profile examples
**/package.json, plugin.json, marketplace.json 5 files Version 3.7.22
mcp-server/dist/server.js (generated) Rebuilt

Total (excl. generated dist): 18 files, 672 insertions, 173 deletions.

Design decisions

Why filter by CNContainer (account), not by CNGroup?

Containers map 1:1 to synced accounts (iCloud, Exchange, CardDAV). Every contact belongs to exactly one container. Groups are organizational labels within a container — they're optional, can overlap, and some accounts (e.g. Yahoo, Gmail without labels) have no groups at all. Filtering by container gives uniform coverage.

Why unifyResults = false?

Apple's unifiedContacts(matching:) merges data from ALL linked backing contacts across ALL containers — including ones the profile blocks. Setting unifyResults = false on CNContactFetchRequest returns individual backing contacts, each tied to exactly one container. The privacy boundary becomes the container, not the unified card.

Why ContactAccessMode strategy pattern?

Each command resolves let mode = contactAccessMode(config: config) once at the top of run(), then switches on it: .fullAccess first (original code path, unchanged), .scopedContainers(let allowedIds) second (new filtering). This keeps the original behavior visible as the first branch and avoids scattered conditionals.

Why reject unified IDs for update/delete?

A unified ID resolves to a merged view across potentially blocked containers. Writing to it could modify a backing contact the caller doesn't have access to. Single-source contacts (only in one container) have unified == backing, so they pass through cleanly.

Why pure-logic tests only (no CNContactStore in tests)?

Tests use zero CNContactStore calls, following the same pure-logic pattern as DateParsingTests, BatchCreateEventValidationTests, and ParsingHelpersTests. This avoids CI hangs on macos-latest runners without contacts TCC permission. Container filtering logic is already tested by 21 ItemFilterTests in PIMConfig. The framework-dependent integration paths are verified via CLI functional tests run locally.

Known limitations

  • Exchange contact writes fail without notes entitlement: Any CNSaveRequest on an Exchange-backed contact triggers CoreData error 134092 (NSManagedObjectMergeError). The root cause is CNContact._newStringForIndexing, which faults the note property during willSave to build a search index — even when the save does not modify notes. Without the com.apple.developer.contacts.notes entitlement (restricted to App Store / enterprise-signed binaries), this fault throws. The error surfaces as a merge conflict wrapped in NSUnderlyingErrorKey chains. isMergeConflict() detects it and retries once, but Exchange writes will fail on retry too since the entitlement is missing. This is a pre-existing limitation affecting all unsigned/ad-hoc-signed builds. Filing as a separate issue.
  • Multiple backings per person: When one person has contacts in multiple allowed accounts, search returns all backings separately. CLI-level deduplication is a future enhancement.
  • Search performance on broad queries: Scoped search for common patterns (single letters) falls back to enumerating all contacts from allowed containers for email/phone matching. Name-specific queries use Apple's predicate and are fast.

Test results

Unit tests (swift test)

111 tests in 9 suites — all pass (0.020s)

MCP server tests (cd mcp-server && npm test)

64 tests in 7 files — all pass (352ms)

Eval suite (npm run eval)

140 tests in 4 files — all pass

Build verification

  • swift build -c release — zero warnings
  • scripts/check-versions.sh — all 5 sources agree on 3.7.22
  • Rebased on upstream main at 43c7ac2

Related

  • Upstream issue to file: Exchange 134092 notes faulting (pre-existing, needs com.apple.developer.contacts.notes entitlement)
  • Follows the same ItemFilter pattern used by CalendarCLI and ReminderCLI

🤖 Generated with Claude Code

prashantkamani and others added 2 commits May 13, 2026 00:15
…v3.7.22)

Wire ItemFilter in ContactsCLI to filter by CNContainer (account name),
matching the existing pattern in CalendarCLI and ReminderCLI.

- containers subcommand: lists all contact account containers
- Container filtering for all verbs via ContactAccessMode strategy pattern
- --container flag on create for targeting specific accounts
- Unified contact privacy: unifyResults=false prevents cross-account data leaks
- Scoped get with relatedContacts for authorized cross-account linkage
- Backing ID enforcement: update/delete reject multi-source unified IDs
- Exchange ghost group resolution in allAccountContainers()
- Search optimization: name predicate + container post-filter
- JS handler layer: containers action, schema, tool-args, command docs
- MCP tests + eval scenarios for containers action
- Pure-logic Swift tests (zero TCC dependency for CI safety)
- Version bump to 3.7.22 across all 5 package files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- CreateContact: when scoped mode active and --container omitted,
  resolve the system default container and validate it against the
  allowlist; auto-pick if exactly one allowed; otherwise require
  --container. Previously the system default could silently bypass
  the allowlist (e.g. iCloud default while only Exchange allowed).

- UpdateContact (scoped path): move CNContactFetchRequest inside the
  merge-conflict retry loop. Previously the warning said "Re-fetching
  and retrying..." but the retry actually reused the stale fetch.

- applyContactMutations: propagate parse errors instead of swallowing
  with try?. Invalid JSON in --emails / --phones / --addresses /
  --urls / --social-profiles / --instant-messages / --relations /
  --dates / --birthday now fails the update instead of writing an
  empty array (data loss).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@omarshahine omarshahine merged commit 6d1c78f into omarshahine:main May 15, 2026
5 checks passed
@prashantkamani prashantkamani deleted the contacts-container-filter branch May 16, 2026 04:04
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.

2 participants