cmux/Sources/SocketControlSettings.swift
Lawrence Chen 51a67e31fd
Socket access control: process ancestry check (#58)
* Socket access control: process ancestry check + file permissions

Redesign socket control modes from (off, notifications, full) to
(off, cmuxOnly, allowAll):

- cmuxOnly (default): uses LOCAL_PEERPID + sysctl process tree walk to
  verify the connecting process is a descendant of cmux. External
  processes (SSH, other terminals) are rejected.
- allowAll: hidden mode accessible only via CMUX_SOCKET_MODE=allowAll
  env var, skips ancestry check. Legacy "full"/"notifications" env
  values map here for backward compat.
- off: disables socket entirely.

Security hardening:
- Server: chmod 0600 on socket after bind (owner-only access)
- CLI: stat() ownership check before connect (reject fake sockets)

Removes per-command allow-list (isCommandAllowed) — once a process
passes the ancestry check, all commands are available.

Includes migration for persisted UserDefaults values and env var
aliases (cmux_only, cmux-only, allow_all, allow-all).

* Add /sync-branch skill for submodule + main sync
2026-02-18 01:09:24 -08:00

115 lines
3.5 KiB
Swift

import Foundation
enum SocketControlMode: String, CaseIterable, Identifiable {
case off
case cmuxOnly
/// Allow any local process to connect (no ancestry check).
/// Only accessible via CMUX_SOCKET_MODE=allowAll env var not shown in the UI.
case allowAll
var id: String { rawValue }
/// Cases shown in the Settings UI. `allowAll` is intentionally excluded.
static var uiCases: [SocketControlMode] { [.off, .cmuxOnly] }
var displayName: String {
switch self {
case .off:
return "Off"
case .cmuxOnly:
return "cmux processes only"
case .allowAll:
return "Allow all processes"
}
}
var description: String {
switch self {
case .off:
return "Disable the local control socket."
case .cmuxOnly:
return "Only processes started inside cmux terminals can send commands."
case .allowAll:
return "Allow any local process to connect (no ancestry check)."
}
}
}
struct SocketControlSettings {
static let appStorageKey = "socketControlMode"
static let legacyEnabledKey = "socketControlEnabled"
/// Map old persisted rawValues to the new enum.
static func migrateMode(_ raw: String) -> SocketControlMode {
switch raw {
case "off": return .off
case "cmuxOnly": return .cmuxOnly
// Legacy values:
case "notifications", "full": return .cmuxOnly
default: return defaultMode
}
}
static var defaultMode: SocketControlMode {
return .cmuxOnly
}
static func socketPath() -> String {
if let override = ProcessInfo.processInfo.environment["CMUX_SOCKET_PATH"], !override.isEmpty {
return override
}
#if DEBUG
return "/tmp/cmux-debug.sock"
#else
return "/tmp/cmux.sock"
#endif
}
static func envOverrideEnabled() -> Bool? {
guard let raw = ProcessInfo.processInfo.environment["CMUX_SOCKET_ENABLE"], !raw.isEmpty else {
return nil
}
switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return nil
}
}
static func envOverrideMode() -> SocketControlMode? {
guard let raw = ProcessInfo.processInfo.environment["CMUX_SOCKET_MODE"], !raw.isEmpty else {
return nil
}
let cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
switch cleaned {
case "off": return .off
case "cmuxonly", "cmux_only", "cmux-only": return .cmuxOnly
case "allowall", "allow_all", "allow-all": return .allowAll
// Legacy env var values map to allowAll so existing test scripts keep working
case "notifications", "full": return .allowAll
default: return SocketControlMode(rawValue: cleaned)
}
}
static func effectiveMode(userMode: SocketControlMode) -> SocketControlMode {
if let overrideEnabled = envOverrideEnabled() {
if !overrideEnabled {
return .off
}
if let overrideMode = envOverrideMode() {
return overrideMode
}
return userMode == .off ? .cmuxOnly : userMode
}
if let overrideMode = envOverrideMode() {
return overrideMode
}
return userMode
}
}