Files
reyna-cli/native/ReynaCLIHost/Sources/ReynaCLIHostCore/SocketPathValidation.swift
T
Adolfo Reyna 9fd04b0ce4 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.
2026-08-03 20:27:54 -04:00

266 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) })
}