feat(privacy-host): add signed native calendar contacts and reminders host

Add the owner-only AF_UNIX Reyna CLI privacy host, strict signed-app installation, and typed native routing for Calendar, Contacts, and Reminders.\n\nAdd bounded system-status paths and config-only direct local-service wrappers. Preserve MacMiniMCP pending explicit cutover approval.\n\nApple Notes is intentionally deferred: no native Notes operations, Apple Events declaration, or Automation helper are included; legacy Notes handling remains untouched.
This commit is contained in:
Adolfo Reyna
2026-08-03 20:27:54 -04:00
parent 6e2117188e
commit 9fd04b0ce4
56 changed files with 14239 additions and 50 deletions
@@ -0,0 +1,265 @@
import Foundation
import Darwin
// MARK: - Pure, testable validation types
struct LStatInfo {
var uid: uid_t
var mode: mode_t // full st_mode
var isSymlink: Bool
var isDir: Bool
var exists: Bool
}
/// Authoritative result of an lstat call: present, absent (ENOENT), or failed with errno.
enum LStatResult {
case present(LStatInfo)
case absent
case failed(errnoCode: Int32)
}
/// New authoritative provider that never swallows errors.
typealias LStatResultProvider = (String) -> LStatResult
/// Legacy optional provider kept for existing pure tests: nil == absent (ENOENT).
/// New code should use LStatResultProvider.
typealias LStatProvider = (String) -> LStatInfo?
// MARK: - Trust boundary documentation
/*
Trust boundary for socket parent-chain validation – tiered model:
Tiers (evaluated per existing component, fail-closed on lstat errors):
1) Platform-trusted ancestors – explicit allowlist ONLY:
"/", "/Users", "/private", "/var", "/tmp",
"/private/tmp", "/var/tmp", "/private/var", "/private/var/tmp"
Hard-coded in `platformTrustedRootPaths`.
- lstat non-symlink dir (except /var and /tmp which are known macOS symlinks and allowed as symlink)
- uid 0
- non-tmp platform paths ("/", "/Users", "/private", "/private/var"): no group/other write (mode & 022 == 0), 0755 allowed
- tmp platform paths ("/private/tmp", "/var/tmp", "/private/var/tmp", plus "/tmp","/var" as dirs): uid 0 only, may be 1777 sticky
2) User-owned intermediate ancestors (e.g. $HOME = /Users/<user>, ~/Library, ~/Library/Application Support, ...):
- not a symlink
- a directory
- owned by current uid (getuid())
- no group/other *write* (mode & 022 == 0)
-> allows 0700, 0750, 0755 (standard macOS home is 0750 = rwxr-x---) but rejects 0770/0777 or any writable bit
Reason: home 0750 is default on some installs; privacy is still enforced by tier 3.
3) Dedicated runtime socket parent – the immediate parent dir of the socket (e.g. .../reyna-cli/privacy):
- not a symlink
- a directory
- owned by current uid
- strictly no group/other bits at all (mode & 077 == 0) => 0700 family only, rejects 0750/0755
+ socket file itself must be 0600 (enforced in SocketServer bind/chmod)
- Never trust arbitrary root-owned intermediate paths outside explicit allowlist.
This is the fix for: home 0750 was incorrectly rejected (validator required 0700 for all user components),
causing "Refusing socket path: parent component /Users/<user> has group/other permissions: 750".
Now tier 2 allows 0750 for home/intermediates, tier 3 keeps 0700 for the privacy dir.
*/
let platformTrustedRootPaths: Set<String> = [
"/",
"/Users",
"/private",
"/var",
"/tmp",
"/private/tmp",
"/var/tmp",
"/private/var",
"/private/var/tmp"
]
// Symlink-allowed platform paths – macOS ships /tmp -> private/tmp and /var -> private/var
let platformSymlinkAllowedPaths: Set<String> = [
"/tmp",
"/var"
]
func isRootTrustedPath(_ p: String) -> Bool {
return platformTrustedRootPaths.contains(p)
}
func isSymlinkAllowedPlatformPath(_ p: String) -> Bool {
return platformSymlinkAllowedPaths.contains(p)
}
func rejectIfDotComponentsPure(in socketPath: String) throws {
let url = URL(fileURLWithPath: socketPath)
for comp in url.pathComponents {
if comp == "." || comp == ".." {
throw NSError(domain: "SocketServer", code: 20, userInfo: [NSLocalizedDescriptionKey: "Socket path must not contain '.' or '..' components: \(socketPath)"])
}
}
let standardized = url.standardized.path
if standardized != socketPath {
let stdComps = URL(fileURLWithPath: standardized).pathComponents
let origComps = url.pathComponents
if stdComps != origComps {
for c in stdComps {
if c == "." || c == ".." {
throw NSError(domain: "SocketServer", code: 21, userInfo: [NSLocalizedDescriptionKey: "Socket path contains invalid components after standardization"])
}
}
}
}
}
// MARK: - Core validation using authoritative provider
private func lstatInfo(from st: stat) -> LStatInfo {
let isSymlink = (st.st_mode & S_IFMT) == S_IFLNK
let isDir = (st.st_mode & S_IFMT) == S_IFDIR
// For symlink itself, isDir should be false so caller can distinguish
return LStatInfo(uid: st.st_uid, mode: st.st_mode, isSymlink: isSymlink, isDir: isSymlink ? false : isDir, exists: true)
}
/// Single-component authoritative validator reused by both chain validation and ensureParentDirectories.
/// This is the sole place that encodes trusted-root vs user-owned policy.
/// Tiers:
/// 1) platform trusted (explicit allowlist) – uid 0, dir, no g/o write except tmp exemptions, symlink allowed only for /tmp / /var
/// 2) user-owned intermediate ancestors – uid current, not symlink, dir, mode & 022 == 0 (allows 0700/0750/0755, rejects writable)
/// 3) dedicated runtime parent (immediate socket parent) – uid current, not symlink, dir, mode & 077 == 0 (requires 0700 family)
///
/// - For symlink: only /tmp and /var may be symlink (macOS aliases), else reject.
/// - For non-dir file: always reject (including /tmp /var as dir target).
func validateSingleLStatInfoOrThrow(path: String, info: LStatInfo, currentUID: uid_t, isDedicatedRuntimeParent: Bool = false) throws {
if info.isSymlink {
if isSymlinkAllowedPlatformPath(path) {
return
}
throw NSError(domain: "SocketServer", code: 23, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) is a symlink"])
}
if !info.isDir {
// A regular file (or other non-dir) at any parent component, including /tmp /var, must reject
throw NSError(domain: "SocketServer", code: 10, userInfo: [NSLocalizedDescriptionKey: "Parent path exists but is not a directory: \(path)"])
}
if path == "/" || isRootTrustedPath(path) {
if path == "/tmp" || path == "/var" {
if info.uid != 0 {
throw NSError(domain: "SocketServer", code: 24, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) not owned by current uid"])
}
return
}
if path == "/private/tmp" || path == "/var/tmp" || path == "/private/var/tmp" {
if info.uid != 0 {
throw NSError(domain: "SocketServer", code: 24, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) not owned by current uid"])
}
return
}
if info.uid != 0 {
throw NSError(domain: "SocketServer", code: 24, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) not owned by current uid"])
}
if (info.mode & 0o022) != 0 {
throw NSError(domain: "SocketServer", code: 25, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) has group/other permissions: \(String(info.mode & 0o777, radix: 8))"])
}
return
}
if info.uid != currentUID {
throw NSError(domain: "SocketServer", code: 24, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) not owned by current uid"])
}
let perms = info.mode & 0o777
if isDedicatedRuntimeParent {
// Tier 3: dedicated runtime must be exactly 0700 family – no group/other bits
if (perms & 0o077) != 0 {
throw NSError(domain: "SocketServer", code: 25, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) has group/other permissions: \(String(perms, radix: 8))"])
}
} else {
// Tier 2: intermediate user-owned – no group/other write (allows 0750/0755, rejects 0770/0777)
if (perms & 0o022) != 0 {
throw NSError(domain: "SocketServer", code: 25, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) has group/other permissions: \(String(perms, radix: 8))"])
}
}
}
func validateParentChainPureResultProvider(socketPath: String, currentUID: uid_t, provider: LStatResultProvider) throws {
try rejectIfDotComponentsPure(in: socketPath)
let parentURL = URL(fileURLWithPath: socketPath).deletingLastPathComponent()
let parentPath = parentURL.path
if parentPath.isEmpty || parentPath == "/" { return }
let comps = parentURL.pathComponents // starts with "/"
var cur = ""
for comp in comps {
if comp == "/" {
cur = "/"
continue
}
if cur == "/" {
cur = "/" + comp
} else if cur.isEmpty {
cur = comp
} else {
cur = cur + "/" + comp
}
let result = provider(cur)
switch result {
case .absent:
continue
case .failed(let errnoCode):
throw NSError(domain: "SocketServer", code: 22, userInfo: [NSLocalizedDescriptionKey: "lstat failed for parent component \(cur): \(String(cString: strerror(errnoCode)))"])
case .present(let info):
let isDedicated = (cur == parentPath)
try validateSingleLStatInfoOrThrow(path: cur, info: info, currentUID: currentUID, isDedicatedRuntimeParent: isDedicated)
}
}
}
/// Pure parent-chain validator with injectable lstat and uid (legacy nil==ENOENT shim).
///
/// - Parameters:
/// - socketPath: absolute socket path
/// - currentUID: uid of current process
/// - provider: returns LStatInfo? (nil if ENOENT, else info). Must use lstat, not stat.
/// - Throws: on policy violation
func validateParentChainPure(socketPath: String, currentUID: uid_t, provider: LStatProvider) throws {
// Adapt legacy optional provider into authoritative result provider
let adapted: LStatResultProvider = { path in
if let info = provider(path) {
return .present(info)
} else {
return .absent
}
}
try validateParentChainPureResultProvider(socketPath: socketPath, currentUID: currentUID, provider: adapted)
}
// MARK: - Live lstat adapter
/// Authoritative live lstat that never swallows non-ENOENT errors.
func liveLStatResultProvider(path: String) -> LStatResult {
var st = stat()
if lstat(path, &st) != 0 {
if errno == ENOENT { return .absent }
return .failed(errnoCode: errno)
}
return .present(lstatInfo(from: st))
}
/// Legacy optional lstat provider. Now fail-closed: returns nil ONLY for ENOENT, and for
/// other errors returns a present but invalid sentinel that will cause validation to reject
/// (never treated as missing). Prefer liveLStatResultProvider.
func liveLStatProvider(path: String) -> LStatInfo? {
switch liveLStatResultProvider(path: path) {
case .absent:
return nil
case .present(let info):
return info
case .failed:
// Fail-closed sentinel: not a directory, wrong uid, triggers rejection if misused directly
// We return an info that will be rejected as non-directory
return LStatInfo(uid: uid_t.max, mode: 0, isSymlink: false, isDir: false, exists: true)
}
}
func validateExistingParentChainLive(for socketPath: String) throws {
let currentUID = getuid()
// Single authoritative scan using liveLStatResultProvider; no pre-scan duplicate, no nil-swallow
try validateParentChainPureResultProvider(socketPath: socketPath, currentUID: currentUID, provider: { liveLStatResultProvider(path: $0) })
}