Skip to content

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

Closed
prashantkamani wants to merge 1 commit into
omarshahine:mainfrom
prashantkamani:contacts-container-filter
Closed

feat(contacts): container-level filtering + unified contact privacy#65
prashantkamani wants to merge 1 commit 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

  • 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. Each contact is returned as a backing contact tied to exactly one account.
  • 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). This prevents ambiguous writes that could affect contacts in disallowed accounts.
  • Exchange ghost group resolutioncontainers(matching: nil) returns Exchange default "Contacts" lists as containers. allAccountContainers() filters these by excluding any container whose ID also appears in groups(matching: nil). 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. Eliminates the O(all contacts) enumerate-all path for name searches.

Code layout

File Lines changed Role
swift/Sources/ContactsCLI/ContactsCLI.swift +518 / -151 All production changes
swift/Tests/ContactsCLITests/ContainerFilterTests.swift +220 (new) 15 unit tests for container filtering + scoped resolution
swift/Tests/MailCLITests/MIMEBuilderTests.swift +4 / -2 Fix timezone-dependent test (unrelated, pre-existing)
README.md +10 / -3 Contacts filtering docs, containers CLI example, profile example
docs/multi-agent-setup.md +23 / -2 Contacts profile examples, family-only profile, merge semantics
**/package.json, plugin.json, marketplace.json 5 files Version bump 3.7.20 → 3.7.21

Total: 10 files, 774 insertions, 169 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: "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. 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 a reviewer reads, avoids scattered if mode != .all conditionals, 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) checks predicateForContainerOfContact — 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) the relatedContacts field on get already exposes the linkage. A future enhancement could add a --deduplicate flag.

Known limitations

  • 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 (~3s). A --field name flag could skip the fallback entirely.
  • Exchange contact writes fail without notes entitlement: Error 134092 affects all Exchange contact writes from unsigned/ad-hoc-signed binaries. Any save operation triggers CoreData's _newStringForIndexing on the notes field, even when notes are not being modified. This is a pre-existing limitation. Filing as a separate issue.
  • Multiple backings per person: When one person has contacts in multiple allowed accounts, search returns all backings separately. The agent or wrapper must use relatedContacts from get to 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)

Suite Tests Status
BatchCreateEventValidationTests 5 ✅ pass
BatchCreateReminderValidationTests 5 ✅ pass
DateParsingTests (Calendar) 17 ✅ pass
DateParsingTests (Mail) 9 ✅ pass
DateParsingTests (Reminder) 8 ✅ pass
OutputFormatTests 6 ✅ pass
ParsingHelpersTests 13 ✅ pass
RecurrenceParsingTests (Calendar) 10 ✅ pass
RecurrenceParsingTests (Reminder) 10 ✅ pass
ScriptHelpersTests 4 ✅ pass
ConfigFormatter 8 ✅ pass
ConfigLoader 3 ✅ pass
ConfigLoader - env isolation 5 ✅ pass
ConfigWriter 7 ✅ pass
Container Filtering 15 pass (new — dynamic discovery, no hardcoded IDs)
ItemFilter 9 ✅ pass
MIMEBuilder 14 ✅ pass
SecretsStore 8 ✅ pass
SMTPClient 5 ✅ pass

CLI functional tests (real Contacts data, profile-based)

# Test Result
1 containers returns all accounts ✅ pass
2 list with allowlist profile ✅ contacts returned
3 list with blocklist profile ✅ blocked contacts excluded
4 search with restricted profile ✅ only allowed-container matches
5 search by email with allowlist ✅ found in allowed container
6 get by allowed backing ID ✅ contact returned
7 groups filtered by profile ✅ only allowed-container groups
8 list mode=all (no profile) ✅ all contacts returned

CLI negative tests

# Test Result
1 get backing ID from blocked container ✅ "not in your allowed accounts"
2 search for contact only in blocked container ✅ 0 results
3 create in blocked container ✅ access denied
4 contacts disabled via profile ✅ "disabled by PIM configuration"
5 empty allowlist returns nothing ✅ 0 contacts

CLI performance tests

# Test Time
1 list all (mode=all) 2.89s
2 list filtered (3 containers) 3.19s
3 search by name (mode=all) 3.03s
4 search by name (filtered) 3.88s
5 containers subcommand 0.32s

Unified contact privacy tests

# Test Result
1 Restricted profile search → no cross-account email leak ✅ only allowed-container emails
2 Scoped get → relatedContacts=0 when no other allowed backings ✅ pass
3 Scoped get → relatedContacts populated when multiple allowed backings ✅ pass
4 Get backing ID in mode=all → unchanged behavior ✅ pass
5 Search with broad profile → separate backing per container ✅ multiple entries
6 Get with unified (multi-source) ID in scoped mode → rejected ✅ "Use a specific contact ID"
7 List in scoped mode → all returned IDs are backing IDs ✅ all have :ABPerson suffix

Agent-initiated tests (via OpenClaw gateway)

# Test Result Gateway trace
1 Owner agent searches → sees contacts from all allowed containers ✅ multiple results policy=allowed
2 Restricted agent searches → sees only shared-container contacts ✅ 1 result policy=allowed
3 Restricted agent searches for contact in blocked container ✅ 0 results policy=allowed, filtered by CLI
4 Natural language: "What is X's phone number?" (shared contact) ✅ phone returned end-to-end via search
5 Natural language: "What is Y's phone number?" (blocked contact) ✅ "not found" end-to-end, 0 results

Build verification

  • swift build -c release — clean, zero warnings
  • Release binary at ~/.local/bin/contacts-cli via symlink
  • No stray debug output, test profiles, or PII in committed files
  • Rebased on upstream main at 43c7ac2 — no conflicts
  • Version bumped to 3.7.21 across all 5 package files
  • CI: Linux build (contacts-cli is macOS-only — should compile but skip runtime tests)
  • Reviewer: spot-check that no account names leak in error messages

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

…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.
@prashantkamani prashantkamani force-pushed the contacts-container-filter branch from 60b2d1b to 83bf316 Compare May 12, 2026 11:07
@prashantkamani
Copy link
Copy Markdown
Contributor Author

Closing — missing updates to the shared handler layer (lib/handlers/contact.js, lib/schemas.js, lib/tool-args.js), command documentation (commands/contacts.md), and MCP server dist rebuild (mcp-server/dist/server.js). The containers action is only wired in the Swift CLI but not in the JS handler/schema/MCP layers. Will resubmit with all layers updated.

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>
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.

1 participant