feat(contacts): container-level filtering + unified contact privacy#65
Closed
prashantkamani wants to merge 1 commit into
Closed
feat(contacts): container-level filtering + unified contact privacy#65prashantkamani wants to merge 1 commit into
prashantkamani wants to merge 1 commit into
Conversation
…v3.7.21) 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. Changes: - Add containers subcommand (lists account containers with name/ID/type) - Add ContactAccessMode strategy pattern for scope dispatch - Filter list/search/get/update/delete/create/groups by allowed containers - Add --container flag on create for targeting a specific account - Unified contact privacy: unifyResults=false prevents cross-account data leakage - Scoped get returns relatedContacts (same person in other allowed accounts) - Backing ID enforcement: reject multi-source unified IDs in scoped mode - Exchange ghost group resolution in allAccountContainers() - Search optimization: name predicate + container post-filter - validateScopedContactAccess() helper for get/update/delete - applyContactMutations() helper with TCC-guarded notes - Fix compiler warning: remove unnecessary try? on non-throwing predicate - Fix timezone-dependent test in MIMEBuilderTests (unrelated, pre-existing) - Version bump 3.7.20 → 3.7.21 15 new unit tests (dynamic discovery, no hardcoded IDs). 125 tests in 9 suites — all pass. Zero warnings.
60b2d1b to
83bf316
Compare
Contributor
Author
|
Closing — missing updates to the shared handler layer ( |
prashantkamani
added a commit
to prashantkamani/Apple-PIM-Agent-Plugin
that referenced
this pull request
May 13, 2026
…er (v3.7.22) Complete the JS handler layer that was missing from PR omarshahine#65: - Add containers case to lib/handlers/contact.js - Add containers to schema enum + container property in lib/schemas.js - Add --container flag to buildContactCreateArgs in lib/tool-args.js - Update commands/contacts.md with containers docs + examples - Add MCP tests for container arg (with/without) - Add eval scenarios for containers action + create-with-container - Update safety coverage set for new action - Add containers-results.json eval fixture - Version bump to 3.7.22 via scripts/bump-version.sh - Rebuild mcp-server/dist/server.js Co-Authored-By: Claude Opus 4.6 (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
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. Each contact is returned as a backing contact tied to exactly one account.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). This prevents ambiguous writes that could affect contacts in disallowed accounts.containers(matching: nil)returns Exchange default "Contacts" lists as containers.allAccountContainers()filters these by excluding any container whose ID also appears ingroups(matching: nil).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. Eliminates the O(all contacts) enumerate-all path for name searches.Code layout
swift/Sources/ContactsCLI/ContactsCLI.swiftswift/Tests/ContactsCLITests/ContainerFilterTests.swiftswift/Tests/MailCLITests/MIMEBuilderTests.swiftREADME.mddocs/multi-agent-setup.md**/package.json,plugin.json,marketplace.jsonTotal: 10 files, 774 insertions, 169 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: "show only contacts from my Work Exchange account" works regardless of how the user has organized groups within that account.
Why
unifyResults = false?Apple's
unifiedContacts(matching:)merges data from ALL linked backing contacts across ALL containers — including ones the profile blocks. A child agent with access only to the family iCloud account would see work phone numbers and work emails if the contact happens to be linked. 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 a reviewer reads, avoids scatteredif mode != .allconditionals, and eliminates the risk of a check being missed in one command but present in others.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. The classifier (
isMultiSourceUnifiedId) checkspredicateForContainerOfContact— empty result means multi-source unified, non-empty means backing. Single-source contacts (only in one container) have unified == backing, so they pass through cleanly.Why not deduplicate search results by unified parent?
With
unifyResults = false, one person with backing contacts in multiple allowed containers appears as multiple search results. We chose not to merge them at the CLI level because: (1) the caller may want to distinguish which account a contact is in, (2) merging requires resolving which backing's data takes precedence, and (3) therelatedContactsfield ongetalready exposes the linkage. A future enhancement could add a--deduplicateflag.Known limitations
--field nameflag could skip the fallback entirely.notesentitlement: Error 134092 affects all Exchange contact writes from unsigned/ad-hoc-signed binaries. Any save operation triggers CoreData's_newStringForIndexingon the notes field, even when notes are not being modified. This is a pre-existing limitation. Filing as a separate issue.relatedContactsfromgetto see the full picture. CLI-level deduplication is a future enhancement.Test plan
Unit tests (swift test)
✅ 125 tests in 9 suites — all pass (0.60s)
CLI functional tests (real Contacts data, profile-based)
containersreturns all accountslistwith allowlist profilelistwith blocklist profilesearchwith restricted profilesearchby email with allowlistgetby allowed backing IDgroupsfiltered by profilelistmode=all (no profile)CLI negative tests
getbacking ID from blocked containersearchfor contact only in blocked containercreatein blocked containerCLI performance tests
Unified contact privacy tests
Agent-initiated tests (via OpenClaw gateway)
Build verification
swift build -c release— clean, zero warnings~/.local/bin/contacts-clivia symlinkmainat43c7ac2— no conflictsRelated
com.apple.developer.contacts.notesentitlement)ItemFilterpattern used by CalendarCLI and ReminderCLI🤖 Generated with Claude Code