import Foundation import Security enum SocketControlMode: String, CaseIterable, Identifiable { case off case cmuxOnly case automation case password /// Full open access (all local users/processes) with no ancestry or password gate. case allowAll var id: String { rawValue } static var uiCases: [SocketControlMode] { [.off, .cmuxOnly, .automation, .password, .allowAll] } var displayName: String { switch self { case .off: return "Off" case .cmuxOnly: return "cmux processes only" case .automation: return "Automation mode" case .password: return "Password mode" case .allowAll: return "Full open access" } } 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 .automation: return "Allow external local automation clients from this macOS user (no ancestry check)." case .password: return "Require socket authentication with a password stored in your keychain." case .allowAll: return "Allow any local process and user to connect with no auth. Unsafe." } } var socketFilePermissions: UInt16 { switch self { case .allowAll: return 0o666 case .off, .cmuxOnly, .automation, .password: return 0o600 } } var requiresPasswordAuth: Bool { self == .password } } enum SocketControlPasswordStore { static let service = "com.cmuxterm.app.socket-control" static let account = "local-socket-password" private static var baseQuery: [String: Any] { [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, ] } static func configuredPassword( environment: [String: String] = ProcessInfo.processInfo.environment ) -> String? { if let envPassword = environment[SocketControlSettings.socketPasswordEnvKey], !envPassword.isEmpty { return envPassword } return try? loadPassword() } static func hasConfiguredPassword( environment: [String: String] = ProcessInfo.processInfo.environment ) -> Bool { guard let configured = configuredPassword(environment: environment) else { return false } return !configured.isEmpty } static func verify( password candidate: String, environment: [String: String] = ProcessInfo.processInfo.environment ) -> Bool { guard let expected = configuredPassword(environment: environment), !expected.isEmpty else { return false } return expected == candidate } static func loadPassword() throws -> String? { var query = baseQuery query[kSecReturnData as String] = true query[kSecMatchLimit as String] = kSecMatchLimitOne var result: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &result) if status == errSecItemNotFound { return nil } guard status == errSecSuccess else { throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) } guard let data = result as? Data else { return nil } return String(data: data, encoding: .utf8) } static func savePassword(_ password: String) throws { let normalized = password.trimmingCharacters(in: .newlines) if normalized.isEmpty { try clearPassword() return } let data = Data(normalized.utf8) var lookup = baseQuery lookup[kSecReturnData as String] = true lookup[kSecMatchLimit as String] = kSecMatchLimitOne var existing: CFTypeRef? let lookupStatus = SecItemCopyMatching(lookup as CFDictionary, &existing) switch lookupStatus { case errSecSuccess: let attrsToUpdate: [String: Any] = [ kSecValueData as String: data ] let updateStatus = SecItemUpdate(baseQuery as CFDictionary, attrsToUpdate as CFDictionary) guard updateStatus == errSecSuccess else { throw NSError(domain: NSOSStatusErrorDomain, code: Int(updateStatus)) } case errSecItemNotFound: var add = baseQuery add[kSecValueData as String] = data add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly let addStatus = SecItemAdd(add as CFDictionary, nil) guard addStatus == errSecSuccess else { throw NSError(domain: NSOSStatusErrorDomain, code: Int(addStatus)) } default: throw NSError(domain: NSOSStatusErrorDomain, code: Int(lookupStatus)) } } static func clearPassword() throws { let status = SecItemDelete(baseQuery as CFDictionary) guard status == errSecSuccess || status == errSecItemNotFound else { throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) } } } struct SocketControlSettings { static let appStorageKey = "socketControlMode" static let legacyEnabledKey = "socketControlEnabled" static let allowSocketPathOverrideKey = "CMUX_ALLOW_SOCKET_OVERRIDE" static let socketPasswordEnvKey = "CMUX_SOCKET_PASSWORD" static let launchTagEnvKey = "CMUX_TAG" static let baseDebugBundleIdentifier = "com.cmuxterm.app.debug" private static func normalizeMode(_ raw: String) -> String { raw .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() .replacingOccurrences(of: "_", with: "") .replacingOccurrences(of: "-", with: "") } private static func parseMode(_ raw: String) -> SocketControlMode? { switch normalizeMode(raw) { case "off": return .off case "cmuxonly": return .cmuxOnly case "automation": return .automation case "password": return .password case "allowall", "openaccess", "fullopenaccess": return .allowAll // Legacy values from the old socket mode model. case "notifications": return .automation case "full": return .allowAll default: return nil } } /// Map persisted values to the current enum values. static func migrateMode(_ raw: String) -> SocketControlMode { parseMode(raw) ?? defaultMode } static var defaultMode: SocketControlMode { return .cmuxOnly } private static var isDebugBuild: Bool { #if DEBUG true #else false #endif } static func launchTag( environment: [String: String] = ProcessInfo.processInfo.environment ) -> String? { guard let raw = environment[launchTagEnvKey] else { return nil } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } static func shouldBlockUntaggedDebugLaunch( environment: [String: String] = ProcessInfo.processInfo.environment, bundleIdentifier: String? = Bundle.main.bundleIdentifier, isDebugBuild: Bool = SocketControlSettings.isDebugBuild ) -> Bool { guard isDebugBuild else { return false } if isRunningUnderXCTest(environment: environment) { return false } // XCUITest launches the app as a separate process without XCTest env vars, // so isRunningUnderXCTest() misses it. Check for any CMUX_UI_TEST_ env var. if environment.keys.contains(where: { $0.hasPrefix("CMUX_UI_TEST_") }) { return false } guard let bundleIdentifier = bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines), !bundleIdentifier.isEmpty else { return false } if bundleIdentifier.hasPrefix("\(baseDebugBundleIdentifier).") { return false } guard bundleIdentifier == baseDebugBundleIdentifier else { return false } return launchTag(environment: environment) == nil } static func isRunningUnderXCTest(environment: [String: String]) -> Bool { let indicators = [ "XCTestConfigurationFilePath", "XCTestBundlePath", "XCTestSessionIdentifier", "XCInjectBundleInto", ] return indicators.contains { key in guard let value = environment[key] else { return false } return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } } static func socketPath( environment: [String: String] = ProcessInfo.processInfo.environment, bundleIdentifier: String? = Bundle.main.bundleIdentifier, isDebugBuild: Bool = SocketControlSettings.isDebugBuild ) -> String { let fallback = defaultSocketPath(bundleIdentifier: bundleIdentifier, isDebugBuild: isDebugBuild) guard let override = environment["CMUX_SOCKET_PATH"], !override.isEmpty else { return fallback } if shouldHonorSocketPathOverride( environment: environment, bundleIdentifier: bundleIdentifier, isDebugBuild: isDebugBuild ) { return override } return fallback } static func defaultSocketPath(bundleIdentifier: String?, isDebugBuild: Bool) -> String { if bundleIdentifier == "com.cmuxterm.app.nightly" { return "/tmp/cmux-nightly.sock" } if isDebugLikeBundleIdentifier(bundleIdentifier) || isDebugBuild { return "/tmp/cmux-debug.sock" } if isStagingBundleIdentifier(bundleIdentifier) { return "/tmp/cmux-staging.sock" } return "/tmp/cmux.sock" } static func shouldHonorSocketPathOverride( environment: [String: String], bundleIdentifier: String?, isDebugBuild: Bool ) -> Bool { if isTruthy(environment[allowSocketPathOverrideKey]) { return true } if isDebugLikeBundleIdentifier(bundleIdentifier) || isStagingBundleIdentifier(bundleIdentifier) { return true } return isDebugBuild } static func isDebugLikeBundleIdentifier(_ bundleIdentifier: String?) -> Bool { guard let bundleIdentifier else { return false } return bundleIdentifier == "com.cmuxterm.app.debug" || bundleIdentifier.hasPrefix("com.cmuxterm.app.debug.") } static func isStagingBundleIdentifier(_ bundleIdentifier: String?) -> Bool { guard let bundleIdentifier else { return false } return bundleIdentifier == "com.cmuxterm.app.staging" || bundleIdentifier.hasPrefix("com.cmuxterm.app.staging.") } static func isTruthy(_ raw: String?) -> Bool { guard let raw else { return false } switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { case "1", "true", "yes", "on": return true default: return false } } static func envOverrideEnabled( environment: [String: String] = ProcessInfo.processInfo.environment ) -> Bool? { guard let raw = 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( environment: [String: String] = ProcessInfo.processInfo.environment ) -> SocketControlMode? { guard let raw = environment["CMUX_SOCKET_MODE"], !raw.isEmpty else { return nil } return parseMode(raw) } static func effectiveMode( userMode: SocketControlMode, environment: [String: String] = ProcessInfo.processInfo.environment ) -> SocketControlMode { if let overrideEnabled = envOverrideEnabled(environment: environment) { if !overrideEnabled { return .off } if let overrideMode = envOverrideMode(environment: environment) { return overrideMode } return userMode == .off ? .cmuxOnly : userMode } if let overrideMode = envOverrideMode(environment: environment) { return overrideMode } return userMode } }