feat(contacts): container-level filtering + unified contact privacy#66
Merged
omarshahine merged 2 commits intoMay 15, 2026
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Wire
ItemFilterin ContactsCLI to filter byCNContainer(account name), matching the existing pattern in CalendarCLI and ReminderCLI. Previously, contacts-cli checkedconfig.contacts.enabledbut ignoredmode/items— every agent saw every contact account regardless of profile configuration.This PR adds three layers of contact isolation:
unifyResults = falseto prevent Apple's unified contacts from leaking data across account boundariesWhat's new
Swift CLI (
ContactsCLI.swift)containerssubcommand — lists all contact account containers with name, ID, and type (local/exchange/cardDAV). Respects profile filtering.contacts.modeandcontacts.itemsagainstCNContaineraccount names viaContactAccessModestrategy pattern.--containerflag on create — target a specific account container when creating contacts. Validated against the profile allowlist.mode != all, all data-returning operations useCNContactFetchRequest.unifyResults = false. This prevents Apple's contact unification from merging data across account boundaries.relatedContacts— when getting a contact in scoped mode, the response includesrelatedContacts: the same person's entries from other authorized accounts. Preserves the"contact"singular schema for backward compatibility.validateScopedContactAccess(). The user must pass a backing ID (as returned by list/search/get).allAccountContainers()filters out Exchange default "Contacts" lists that appear as both containers and groups.resolveAccountContainer()walks Exchange ghost groups to the real parent account.predicateForContacts(matchingName:)withunifyResults = false, then post-filters by container. Falls back to in-memory email/phone matching for non-name queries.JS handler layer
lib/handlers/contact.js—containerscase callscontacts-cli containerslib/schemas.js—containersadded to action enum,containerproperty added for create targetinglib/tool-args.js—buildContactCreateArgspasses--containerflag when specifiedcommands/contacts.md— updated argument-hint, "List Containers" section,--containeron create examplesTests & evals
ContainerFilterTests.swift— 3 pure-logic tests (zero TCC dependency). TestscontactAccessModerouting andContactAccessModeenum. 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:buildContactCreateArgswith/withoutcontainerevals/scenarios/tool-call-correctness.yaml— 2 new scenarios:contact-containers,contact-create-with-containerevals/fixtures/contact/containers-results.json— new fixtureevals/tests/safety.test.js—containersadded to contact action coverage setDocs & version
README.md— contacts filtering docs, containers CLI example, profile exampledocs/multi-agent-setup.md— contacts profile examples, family-only profilescripts/bump-version.shCode layout
swift/Sources/ContactsCLI/ContactsCLI.swiftswift/Tests/ContactsCLITests/ContainerFilterTests.swiftswift/Tests/MailCLITests/MIMEBuilderTests.swiftlib/handlers/contact.jscontainerscaselib/schemas.jscontainersin enum,containerpropertylib/tool-args.js--containeron createcommands/contacts.md--containerexamplesmcp-server/test/tool-args.test.jsevals/scenarios/tool-call-correctness.yamlevals/fixtures/contact/containers-results.jsonevals/tests/safety.test.jsREADME.mddocs/multi-agent-setup.md**/package.json,plugin.json,marketplace.jsonmcp-server/dist/server.jsTotal (excl. generated dist): 18 files, 672 insertions, 173 deletions.
Design decisions
Why filter by
CNContainer(account), not byCNGroup?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. SettingunifyResults = falseonCNContactFetchRequestreturns individual backing contacts, each tied to exactly one container. The privacy boundary becomes the container, not the unified card.Why
ContactAccessModestrategy pattern?Each command resolves
let mode = contactAccessMode(config: config)once at the top ofrun(), then switches on it:.fullAccessfirst (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
CNContactStorecalls, following the same pure-logic pattern asDateParsingTests,BatchCreateEventValidationTests, andParsingHelpersTests. This avoids CI hangs onmacos-latestrunners without contacts TCC permission. Container filtering logic is already tested by 21ItemFilterTestsin PIMConfig. The framework-dependent integration paths are verified via CLI functional tests run locally.Known limitations
notesentitlement: AnyCNSaveRequeston an Exchange-backed contact triggers CoreData error 134092 (NSManagedObjectMergeError). The root cause isCNContact._newStringForIndexing, which faults thenoteproperty duringwillSaveto build a search index — even when the save does not modify notes. Without thecom.apple.developer.contacts.notesentitlement (restricted to App Store / enterprise-signed binaries), this fault throws. The error surfaces as a merge conflict wrapped inNSUnderlyingErrorKeychains.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.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 warningsscripts/check-versions.sh— all 5 sources agree on 3.7.22mainat43c7ac2Related
com.apple.developer.contacts.notesentitlement)ItemFilterpattern used by CalendarCLI and ReminderCLI🤖 Generated with Claude Code