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:
@@ -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) })
|
||||
}
|
||||
Reference in New Issue
Block a user