9fd04b0ce4
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.
266 lines
11 KiB
Swift
266 lines
11 KiB
Swift
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) })
|
||
}
|