Merge pull request #299 from manaflow-ai/issue-296-access-control-modes

Add socket access control modes including password + full open access
This commit is contained in:
Lawrence Chen 2026-02-22 01:42:54 -08:00 committed by GitHub
commit 4ca14ea028
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 889 additions and 79 deletions

View file

@ -1,5 +1,6 @@
import Foundation
import Darwin
import Security
struct CLIError: Error, CustomStringConvertible {
let message: String
@ -235,6 +236,46 @@ enum CLIIDFormat: String {
}
}
private enum SocketPasswordResolver {
private static let service = "com.cmuxterm.app.socket-control"
private static let account = "local-socket-password"
static func resolve(explicit: String?) -> String? {
if let explicit = normalized(explicit), !explicit.isEmpty {
return explicit
}
if let env = normalized(ProcessInfo.processInfo.environment["CMUX_SOCKET_PASSWORD"]), !env.isEmpty {
return env
}
return loadFromKeychain()
}
private static func normalized(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .newlines)
return trimmed.isEmpty ? nil : trimmed
}
private static func loadFromKeychain() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else {
return nil
}
guard let data = result as? Data else {
return nil
}
return String(data: data, encoding: .utf8)
}
}
final class SocketClient {
private let path: String
private var socketFD: Int32 = -1
@ -402,6 +443,7 @@ struct CMUXCLI {
var jsonOutput = false
var idFormatArg: String? = nil
var windowId: String? = nil
var socketPasswordArg: String? = nil
var index = 1
while index < args.count {
@ -435,6 +477,14 @@ struct CMUXCLI {
index += 2
continue
}
if arg == "--password" {
guard index + 1 < args.count else {
throw CLIError(message: "--password requires a value")
}
socketPasswordArg = args[index + 1]
index += 2
continue
}
if arg == "-v" || arg == "--version" {
print(versionSummary())
return
@ -471,6 +521,14 @@ struct CMUXCLI {
try client.connect()
defer { client.close() }
if let socketPassword = SocketPasswordResolver.resolve(explicit: socketPasswordArg) {
let authResponse = try client.send(command: "auth \(socketPassword)")
if authResponse.hasPrefix("ERROR:"),
!authResponse.contains("Unknown command 'auth'") {
throw CLIError(message: authResponse)
}
}
let idFormat = try resolvedIDFormat(jsonOutput: jsonOutput, raw: idFormatArg)
// If the user explicitly targets a window, focus it first so commands route correctly.
@ -481,7 +539,7 @@ struct CMUXCLI {
switch command {
case "ping":
let response = try client.send(command: "ping")
let response = try sendV1Command("ping", client: client)
print(response)
case "capabilities":
@ -524,7 +582,7 @@ struct CMUXCLI {
print(jsonString(formatIDs(response, mode: idFormat)))
case "list-windows":
let response = try client.send(command: "list_windows")
let response = try sendV1Command("list_windows", client: client)
if jsonOutput {
let windows = parseWindows(response)
let payload = windows.map { item -> [String: Any] in
@ -543,7 +601,7 @@ struct CMUXCLI {
}
case "current-window":
let response = try client.send(command: "current_window")
let response = try sendV1Command("current_window", client: client)
if jsonOutput {
print(jsonString(["window_id": response]))
} else {
@ -551,21 +609,21 @@ struct CMUXCLI {
}
case "new-window":
let response = try client.send(command: "new_window")
let response = try sendV1Command("new_window", client: client)
print(response)
case "focus-window":
guard let target = optionValue(commandArgs, name: "--window") else {
throw CLIError(message: "focus-window requires --window")
}
let response = try client.send(command: "focus_window \(target)")
let response = try sendV1Command("focus_window \(target)", client: client)
print(response)
case "close-window":
guard let target = optionValue(commandArgs, name: "--window") else {
throw CLIError(message: "close-window requires --window")
}
let response = try client.send(command: "close_window \(target)")
let response = try sendV1Command("close_window \(target)", client: client)
print(response)
case "move-workspace-to-window":
@ -627,7 +685,7 @@ struct CMUXCLI {
if let unknown = remaining.first(where: { $0.hasPrefix("--") }) {
throw CLIError(message: "new-workspace: unknown flag '\(unknown)'. Known flags: --command <text>")
}
let response = try client.send(command: "new_workspace")
let response = try sendV1Command("new_workspace", client: client)
print(response)
if let commandText = commandOpt {
guard response.hasPrefix("OK ") else {
@ -772,11 +830,11 @@ struct CMUXCLI {
guard let direction = rem1.first else {
throw CLIError(message: "drag-surface-to-split requires a direction")
}
let response = try client.send(command: "drag_surface_to_split \(surface) \(direction)")
let response = try sendV1Command("drag_surface_to_split \(surface) \(direction)", client: client)
print(response)
case "refresh-surfaces":
let response = try client.send(command: "refresh_surfaces")
let response = try sendV1Command("refresh_surfaces", client: client)
print(response)
case "surface-health":
@ -892,7 +950,7 @@ struct CMUXCLI {
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"]))
case "current-workspace":
let response = try client.send(command: "current_workspace")
let response = try sendV1Command("current_workspace", client: client)
if jsonOutput {
print(jsonString(["workspace_id": response]))
} else {
@ -1016,11 +1074,11 @@ struct CMUXCLI {
let targetSurface = try resolveSurfaceId(surfaceArg, workspaceId: targetWorkspace, client: client)
let payload = "\(title)|\(subtitle)|\(body)"
let response = try client.send(command: "notify_target \(targetWorkspace) \(targetSurface) \(payload)")
let response = try sendV1Command("notify_target \(targetWorkspace) \(targetSurface) \(payload)", client: client)
print(response)
case "list-notifications":
let response = try client.send(command: "list_notifications")
let response = try sendV1Command("list_notifications", client: client)
if jsonOutput {
let notifications = parseNotifications(response)
let payload = notifications.map { item in
@ -1041,7 +1099,7 @@ struct CMUXCLI {
}
case "clear-notifications":
let response = try client.send(command: "clear_notifications")
let response = try sendV1Command("clear_notifications", client: client)
print(response)
case "claude-hook":
@ -1049,11 +1107,11 @@ struct CMUXCLI {
case "set-app-focus":
guard let value = commandArgs.first else { throw CLIError(message: "set-app-focus requires a value") }
let response = try client.send(command: "set_app_focus \(value)")
let response = try sendV1Command("set_app_focus \(value)", client: client)
print(response)
case "simulate-app-active":
let response = try client.send(command: "simulate_app_active")
let response = try sendV1Command("simulate_app_active", client: client)
print(response)
case "capture-pane",
@ -1133,6 +1191,14 @@ struct CMUXCLI {
}
}
private func sendV1Command(_ command: String, client: SocketClient) throws -> String {
let response = try client.send(command: command)
if response.hasPrefix("ERROR:") {
throw CLIError(message: response)
}
return response
}
private func resolvedIDFormat(jsonOutput: Bool, raw: String?) throws -> CLIIDFormat {
_ = jsonOutput
if let parsed = try CLIIDFormat.parse(raw) {
@ -4028,7 +4094,7 @@ struct CMUXCLI {
let subtitle = sanitizeNotificationField(completion.subtitle)
let body = sanitizeNotificationField(completion.body)
let payload = "\(title)|\(subtitle)|\(body)"
let response = try client.send(command: "notify_target \(workspaceId) \(surfaceId) \(payload)")
let response = try sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client)
print(response)
} else {
print("OK")
@ -4068,7 +4134,7 @@ struct CMUXCLI {
)
}
let response = try client.send(command: "notify_target \(workspaceId) \(surfaceId) \(payload)")
let response = try sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client)
_ = try? setClaudeStatus(
client: client,
workspaceId: workspaceId,
@ -4599,13 +4665,16 @@ struct CMUXCLI {
cmux - control cmux via Unix socket
Usage:
cmux [--socket PATH] [--window WINDOW] [--json] [--id-format refs|uuids|both] [--version] <command> [options]
cmux [--socket PATH] [--window WINDOW] [--password PASSWORD] [--json] [--id-format refs|uuids|both] [--version] <command> [options]
Handle Inputs:
For most v2-backed commands you can use UUIDs, short refs (window:1/workspace:2/pane:3/surface:4), or indexes.
`tab-action` also accepts `tab:<n>` in addition to `surface:<n>`.
Output defaults to refs; pass --id-format uuids or --id-format both to include UUIDs.
Socket Auth:
--password takes precedence, then CMUX_SOCKET_PASSWORD env var, then keychain password saved in Settings.
Commands:
version
ping

View file

@ -456,7 +456,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
if isRunningUnderXCTest(env) {
let raw = UserDefaults.standard.string(forKey: SocketControlSettings.appStorageKey)
?? SocketControlSettings.defaultMode.rawValue
let userMode = SocketControlMode(rawValue: raw) ?? SocketControlSettings.defaultMode
let userMode = SocketControlSettings.migrateMode(raw)
let mode = SocketControlSettings.effectiveMode(userMode: userMode)
if mode != .off {
TerminalController.shared.start(

View file

@ -1,16 +1,17 @@
import Foundation
import Security
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 automation
case password
/// Full open access (all local users/processes) with no ancestry or password gate.
case allowAll
var id: String { rawValue }
/// Cases shown in the Settings UI. `allowAll` is intentionally excluded.
static var uiCases: [SocketControlMode] { [.off, .cmuxOnly] }
static var uiCases: [SocketControlMode] { [.off, .cmuxOnly, .automation, .password, .allowAll] }
var displayName: String {
switch self {
@ -18,8 +19,12 @@ enum SocketControlMode: String, CaseIterable, Identifiable {
return "Off"
case .cmuxOnly:
return "cmux processes only"
case .automation:
return "Automation mode"
case .password:
return "Password mode"
case .allowAll:
return "Allow all processes"
return "Full open access"
}
}
@ -29,8 +34,126 @@ enum SocketControlMode: String, CaseIterable, Identifiable {
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 to connect (no ancestry check)."
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))
}
}
}
@ -39,19 +162,43 @@ struct SocketControlSettings {
static let appStorageKey = "socketControlMode"
static let legacyEnabledKey = "socketControlEnabled"
static let allowSocketPathOverrideKey = "CMUX_ALLOW_SOCKET_OVERRIDE"
static let socketPasswordEnvKey = "CMUX_SOCKET_PASSWORD"
/// Map old persisted rawValues to the new enum.
static func migrateMode(_ raw: String) -> SocketControlMode {
switch raw {
case "off": return .off
case "cmuxOnly": return .cmuxOnly
case "allowAll": return .allowAll
// Legacy values:
case "notifications", "full": return .cmuxOnly
default: return defaultMode
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
}
@ -135,8 +282,10 @@ struct SocketControlSettings {
}
}
static func envOverrideEnabled() -> Bool? {
guard let raw = ProcessInfo.processInfo.environment["CMUX_SOCKET_ENABLE"], !raw.isEmpty else {
static func envOverrideEnabled(
environment: [String: String] = ProcessInfo.processInfo.environment
) -> Bool? {
guard let raw = environment["CMUX_SOCKET_ENABLE"], !raw.isEmpty else {
return nil
}
@ -150,33 +299,30 @@ struct SocketControlSettings {
}
}
static func envOverrideMode() -> SocketControlMode? {
guard let raw = ProcessInfo.processInfo.environment["CMUX_SOCKET_MODE"], !raw.isEmpty else {
static func envOverrideMode(
environment: [String: String] = ProcessInfo.processInfo.environment
) -> SocketControlMode? {
guard let raw = 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)
}
return parseMode(raw)
}
static func effectiveMode(userMode: SocketControlMode) -> SocketControlMode {
if let overrideEnabled = envOverrideEnabled() {
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() {
if let overrideMode = envOverrideMode(environment: environment) {
return overrideMode
}
return userMode == .off ? .cmuxOnly : userMode
}
if let overrideMode = envOverrideMode() {
if let overrideMode = envOverrideMode(environment: environment) {
return overrideMode
}

View file

@ -312,6 +312,7 @@ class TerminalController {
if isRunning {
if self.socketPath == socketPath && acceptLoopAlive {
self.accessMode = accessMode
applySocketPermissions()
return
}
stop()
@ -351,8 +352,7 @@ class TerminalController {
return
}
// Restrict socket to owner only (0600)
chmod(socketPath, 0o600)
applySocketPermissions()
// Listen
guard listen(serverSocket, 5) >= 0 else {
@ -398,6 +398,104 @@ class TerminalController {
unlink(socketPath)
}
private func applySocketPermissions() {
let permissions = mode_t(accessMode.socketFilePermissions)
if chmod(socketPath, permissions) != 0 {
print("TerminalController: Failed to set socket permissions to \(String(permissions, radix: 8)) for \(socketPath)")
}
}
private func writeSocketResponse(_ response: String, to socket: Int32) {
let payload = response + "\n"
payload.withCString { ptr in
_ = write(socket, ptr, strlen(ptr))
}
}
private func passwordAuthRequiredResponse(for command: String) -> String {
let message = "Authentication required. Send auth <password> first."
guard command.hasPrefix("{"),
let data = command.data(using: .utf8),
let dict = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: Any] else {
return "ERROR: Authentication required — send auth <password> first"
}
let id = dict["id"]
return v2Error(id: id, code: "auth_required", message: message)
}
private func passwordLoginV1ResponseIfNeeded(for command: String, authenticated: inout Bool) -> String? {
let lowered = command.lowercased()
guard lowered == "auth" || lowered.hasPrefix("auth ") else {
return nil
}
guard SocketControlPasswordStore.hasConfiguredPassword() else {
return "ERROR: Password mode is enabled but no socket password is configured in Settings."
}
let provided: String
if lowered == "auth" {
provided = ""
} else {
provided = String(command.dropFirst(5))
}
guard !provided.isEmpty else {
return "ERROR: Missing password. Usage: auth <password>"
}
guard SocketControlPasswordStore.verify(password: provided) else {
return "ERROR: Invalid password"
}
authenticated = true
return "OK: Authenticated"
}
private func passwordLoginV2ResponseIfNeeded(for command: String, authenticated: inout Bool) -> String? {
guard command.hasPrefix("{"),
let data = command.data(using: .utf8),
let dict = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: Any] else {
return nil
}
let id = dict["id"]
let method = (dict["method"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard method == "auth.login" else {
return nil
}
guard let params = dict["params"] as? [String: Any],
let provided = params["password"] as? String else {
return v2Error(id: id, code: "invalid_params", message: "auth.login requires params.password")
}
guard SocketControlPasswordStore.hasConfiguredPassword() else {
return v2Error(
id: id,
code: "auth_unconfigured",
message: "Password mode is enabled but no socket password is configured in Settings."
)
}
guard SocketControlPasswordStore.verify(password: provided) else {
return v2Error(id: id, code: "auth_failed", message: "Invalid password")
}
authenticated = true
return v2Ok(id: id, result: ["authenticated": true])
}
private func authResponseIfNeeded(for command: String, authenticated: inout Bool) -> String? {
guard accessMode.requiresPasswordAuth else {
return nil
}
if let v2Response = passwordLoginV2ResponseIfNeeded(for: command, authenticated: &authenticated) {
return v2Response
}
if let v1Response = passwordLoginV1ResponseIfNeeded(for: command, authenticated: &authenticated) {
return v1Response
}
if !authenticated {
return passwordAuthRequiredResponse(for: command)
}
return nil
}
private nonisolated func acceptLoop() {
acceptLoopAlive = true
defer {
@ -447,7 +545,7 @@ class TerminalController {
defer { close(socket) }
// In cmuxOnly mode, verify the connecting process is a descendant of cmux.
// In allowAll mode (env-var only), skip the ancestry check.
// Other modes allow external clients and apply separate auth controls.
if accessMode == .cmuxOnly {
// Use pre-captured peer PID if available (captured in accept loop before
// the peer can disconnect), falling back to live lookup.
@ -477,6 +575,7 @@ class TerminalController {
var buffer = [UInt8](repeating: 0, count: 4096)
var pending = ""
var authenticated = false
while isRunning {
let bytesRead = read(socket, &buffer, buffer.count - 1)
@ -491,11 +590,13 @@ class TerminalController {
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continue }
let response = processCommand(trimmed)
let payload = response + "\n"
payload.withCString { ptr in
_ = write(socket, ptr, strlen(ptr))
if let authResponse = authResponseIfNeeded(for: trimmed, authenticated: &authenticated) {
writeSocketResponse(authResponse, to: socket)
continue
}
let response = processCommand(trimmed)
writeSocketResponse(response, to: socket)
}
}
}
@ -524,6 +625,9 @@ class TerminalController {
case "ping":
return "PONG"
case "auth":
return "OK: Authentication not required"
case "list_windows":
return listWindows()
@ -870,6 +974,14 @@ class TerminalController {
case "system.identify":
return v2Ok(id: id, result: v2Identify(params: params))
case "auth.login":
return v2Ok(
id: id,
result: [
"authenticated": true,
"required": accessMode.requiresPasswordAuth
]
)
// Windows
case "window.list":
@ -1220,6 +1332,7 @@ class TerminalController {
"system.ping",
"system.capabilities",
"system.identify",
"auth.login",
"window.list",
"window.current",
"window.focus",
@ -7719,6 +7832,7 @@ class TerminalController {
Available commands:
ping - Check if server is running
auth <password> - Authenticate this connection (required in password mode)
list_workspaces - List all workspaces with IDs
new_workspace - Create a new workspace
select_workspace <id|index> - Select workspace by ID or index (0-based)

View file

@ -2471,8 +2471,13 @@ struct SettingsView: View {
@State private var topBlurBaselineOffset: CGFloat?
@State private var settingsTitleLeadingInset: CGFloat = 92
@State private var showClearBrowserHistoryConfirmation = false
@State private var showOpenAccessConfirmation = false
@State private var pendingOpenAccessMode: SocketControlMode?
@State private var browserHistoryEntryCount: Int = 0
@State private var browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText
@State private var socketPasswordDraft = ""
@State private var socketPasswordStatusMessage: String?
@State private var socketPasswordStatusIsError = false
private var selectedWorkspacePlacement: NewWorkspacePlacement {
NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement
@ -2482,6 +2487,29 @@ struct SettingsView: View {
SocketControlSettings.migrateMode(socketControlMode)
}
private var socketModeSelection: Binding<String> {
Binding(
get: { socketControlMode },
set: { newValue in
let normalized = SocketControlSettings.migrateMode(newValue)
if normalized == .allowAll && selectedSocketControlMode != .allowAll {
pendingOpenAccessMode = normalized
showOpenAccessConfirmation = true
return
}
socketControlMode = normalized.rawValue
if normalized != .password {
socketPasswordStatusMessage = nil
socketPasswordStatusIsError = false
}
}
)
}
private var hasSocketPasswordConfigured: Bool {
SocketControlPasswordStore.hasConfiguredPassword()
}
private var browserHistorySubtitle: String {
switch browserHistoryEntryCount {
case 0:
@ -2503,6 +2531,37 @@ struct SettingsView: View {
return Double(min(max(reveal, 0), 1))
}
private func saveSocketPassword() {
let trimmed = socketPasswordDraft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
socketPasswordStatusMessage = "Enter a password first."
socketPasswordStatusIsError = true
return
}
do {
try SocketControlPasswordStore.savePassword(trimmed)
socketPasswordDraft = ""
socketPasswordStatusMessage = "Password saved to keychain."
socketPasswordStatusIsError = false
} catch {
socketPasswordStatusMessage = "Failed to save password (\(error.localizedDescription))."
socketPasswordStatusIsError = true
}
}
private func clearSocketPassword() {
do {
try SocketControlPasswordStore.clearPassword()
socketPasswordDraft = ""
socketPasswordStatusMessage = "Password cleared."
socketPasswordStatusIsError = false
} catch {
socketPasswordStatusMessage = "Failed to clear password (\(error.localizedDescription))."
socketPasswordStatusIsError = true
}
}
var body: some View {
ZStack(alignment: .top) {
ScrollView {
@ -2594,7 +2653,7 @@ struct SettingsView: View {
subtitle: selectedSocketControlMode.description,
controlWidth: pickerColumnWidth
) {
Picker("", selection: $socketControlMode) {
Picker("", selection: socketModeSelection) {
ForEach(SocketControlMode.uiCases) { mode in
Text(mode.displayName).tag(mode.rawValue)
}
@ -2606,7 +2665,50 @@ struct SettingsView: View {
SettingsCardDivider()
SettingsCardNote("Controls access to the local Unix socket for programmatic control. In \"cmux processes only\" mode, only processes spawned inside cmux terminals can connect.")
SettingsCardNote("Controls access to the local Unix socket for programmatic control. Choose a mode that matches your threat model.")
if selectedSocketControlMode == .password {
SettingsCardDivider()
SettingsCardRow(
"Socket Password",
subtitle: hasSocketPasswordConfigured
? "Stored in login keychain."
: "No password set. External clients will be blocked until one is configured."
) {
HStack(spacing: 8) {
SecureField("Password", text: $socketPasswordDraft)
.textFieldStyle(.roundedBorder)
.frame(width: 170)
Button(hasSocketPasswordConfigured ? "Change" : "Set") {
saveSocketPassword()
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(socketPasswordDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
if hasSocketPasswordConfigured {
Button("Clear") {
clearSocketPassword()
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
}
if let message = socketPasswordStatusMessage {
Text(message)
.font(.caption)
.foregroundStyle(socketPasswordStatusIsError ? Color.red : Color.secondary)
.padding(.horizontal, 14)
.padding(.bottom, 8)
}
}
if selectedSocketControlMode == .allowAll {
SettingsCardDivider()
Text("Warning: Full open access makes the control socket world-readable/writable on this Mac and disables auth checks. Use only for local debugging.")
.font(.caption)
.foregroundStyle(.red)
.padding(.horizontal, 14)
.padding(.vertical, 8)
}
SettingsCardNote("Overrides: CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE, and CMUX_SOCKET_PATH (set CMUX_ALLOW_SOCKET_OVERRIDE=1 for stable/nightly builds).")
}
@ -2962,6 +3064,21 @@ struct SettingsView: View {
} message: {
Text("This removes visited-page suggestions from the browser omnibar.")
}
.confirmationDialog(
"Enable full open access?",
isPresented: $showOpenAccessConfirmation,
titleVisibility: .visible
) {
Button("Enable Full Open Access", role: .destructive) {
socketControlMode = (pendingOpenAccessMode ?? .allowAll).rawValue
pendingOpenAccessMode = nil
}
Button("Cancel", role: .cancel) {
pendingOpenAccessMode = nil
}
} message: {
Text("This disables ancestry and password checks and opens the socket to all local users. Only enable when you understand the risk.")
}
}
private func resetAllSettings() {
@ -2981,6 +3098,11 @@ struct SettingsView: View {
newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
showOpenAccessConfirmation = false
pendingOpenAccessMode = nil
socketPasswordDraft = ""
socketPasswordStatusMessage = nil
socketPasswordStatusIsError = false
KeyboardShortcutSettings.resetAll()
shortcutResetToken = UUID()
}

View file

@ -356,6 +356,41 @@ final class TabManagerNotificationOrderingSourceTests: XCTestCase {
}
final class SocketControlSettingsTests: XCTestCase {
func testMigrateModeSupportsExpandedSocketModes() {
XCTAssertEqual(SocketControlSettings.migrateMode("off"), .off)
XCTAssertEqual(SocketControlSettings.migrateMode("cmuxOnly"), .cmuxOnly)
XCTAssertEqual(SocketControlSettings.migrateMode("automation"), .automation)
XCTAssertEqual(SocketControlSettings.migrateMode("password"), .password)
XCTAssertEqual(SocketControlSettings.migrateMode("allow-all"), .allowAll)
// Legacy aliases
XCTAssertEqual(SocketControlSettings.migrateMode("notifications"), .automation)
XCTAssertEqual(SocketControlSettings.migrateMode("full"), .allowAll)
}
func testSocketModePermissions() {
XCTAssertEqual(SocketControlMode.off.socketFilePermissions, 0o600)
XCTAssertEqual(SocketControlMode.cmuxOnly.socketFilePermissions, 0o600)
XCTAssertEqual(SocketControlMode.automation.socketFilePermissions, 0o600)
XCTAssertEqual(SocketControlMode.password.socketFilePermissions, 0o600)
XCTAssertEqual(SocketControlMode.allowAll.socketFilePermissions, 0o666)
}
func testInvalidEnvSocketModeDoesNotOverrideUserMode() {
XCTAssertNil(
SocketControlSettings.envOverrideMode(
environment: ["CMUX_SOCKET_MODE": "definitely-not-a-mode"]
)
)
XCTAssertEqual(
SocketControlSettings.effectiveMode(
userMode: .password,
environment: ["CMUX_SOCKET_MODE": "definitely-not-a-mode"]
),
.password
)
}
func testStableReleaseIgnoresAmbientSocketOverrideByDefault() {
let path = SocketControlSettings.socketPath(
environment: [

View file

@ -20,6 +20,9 @@ import subprocess
import sys
import tempfile
import time
import json
import glob
import plistlib
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cmux import cmux, cmuxError
@ -68,34 +71,169 @@ def _raw_send(sock, command: str, timeout: float = 3.0) -> str:
return data.decode().strip()
def _preferred_worktree_slug():
env_slug = os.environ.get("CMUX_TAG") or os.environ.get("CMUX_BRANCH_SLUG")
if env_slug:
return env_slug.strip().lower()
cwd = os.getcwd()
marker = "/worktrees/"
if marker in cwd:
tail = cwd.split(marker, 1)[1]
slug = tail.split("/", 1)[0].strip().lower()
if slug:
return slug
return ""
def _derived_app_candidates_for_current_worktree():
project_path = os.path.realpath(os.path.join(os.getcwd(), "GhosttyTabs.xcodeproj"))
info_paths = glob.glob(os.path.expanduser(
"~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*/info.plist"
))
matches = []
for info_path in info_paths:
try:
with open(info_path, "rb") as f:
info = plistlib.load(f)
except Exception:
continue
workspace_path = info.get("WorkspacePath")
if not workspace_path:
continue
if os.path.realpath(workspace_path) != project_path:
continue
derived_root = os.path.dirname(info_path)
app_path = os.path.join(derived_root, "Build/Products/Debug/cmux DEV.app")
if os.path.exists(app_path):
matches.append(app_path)
return matches
def _find_app():
r = subprocess.run(
["find", "/Users/cmux/Library/Developer/Xcode/DerivedData",
"-path", "*/Build/Products/Debug/cmux DEV.app", "-print", "-quit"],
capture_output=True, text=True, timeout=10
)
return r.stdout.strip()
explicit = os.environ.get("CMUX_APP_PATH")
if explicit and os.path.exists(explicit):
return explicit
preferred_slug = _preferred_worktree_slug()
if preferred_slug:
preferred_tmp = []
preferred_tmp.extend(glob.glob(f"/tmp/cmux-{preferred_slug}/Build/Products/Debug/cmux DEV*.app"))
preferred_tmp.extend(glob.glob(f"/private/tmp/cmux-{preferred_slug}/Build/Products/Debug/cmux DEV*.app"))
preferred_tmp = [p for p in preferred_tmp if os.path.exists(p)]
if preferred_tmp:
preferred_tmp.sort(key=os.path.getmtime, reverse=True)
return preferred_tmp[0]
direct_matches = _derived_app_candidates_for_current_worktree()
if direct_matches:
direct_matches.sort(key=os.path.getmtime, reverse=True)
return direct_matches[0]
home = os.path.expanduser("~")
derived_candidates = glob.glob(os.path.join(
home, "Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux DEV.app"
))
tmp_candidates = []
tmp_candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux DEV*.app"))
tmp_candidates.extend(glob.glob("/private/tmp/cmux-*/Build/Products/Debug/cmux DEV*.app"))
derived_candidates = [p for p in derived_candidates if os.path.exists(p)]
tmp_candidates = [p for p in tmp_candidates if os.path.exists(p)]
if preferred_slug:
preferred_derived = [p for p in derived_candidates if preferred_slug in p.lower()]
preferred_tmp = [p for p in tmp_candidates if preferred_slug in p.lower()]
if preferred_derived:
derived_candidates = preferred_derived
if preferred_tmp:
tmp_candidates = preferred_tmp
if derived_candidates:
derived_candidates.sort(key=os.path.getmtime, reverse=True)
return derived_candidates[0]
if tmp_candidates:
tmp_candidates.sort(key=os.path.getmtime, reverse=True)
return tmp_candidates[0]
return ""
def _find_cli(preferred_app_path: str = ""):
explicit = os.environ.get("CMUX_CLI_BIN") or os.environ.get("CMUX_CLI")
if explicit and os.path.exists(explicit) and os.access(explicit, os.X_OK):
return explicit
if preferred_app_path:
debug_dir = os.path.dirname(preferred_app_path)
sibling = os.path.join(debug_dir, "cmux")
if os.path.exists(sibling) and os.access(sibling, os.X_OK):
return sibling
candidates = []
home = os.path.expanduser("~")
candidates.extend(glob.glob(os.path.join(
home, "Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux"
)))
candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux"))
candidates.extend(glob.glob("/private/tmp/cmux-*/Build/Products/Debug/cmux"))
candidates = [p for p in candidates if os.path.exists(p) and os.access(p, os.X_OK)]
if not candidates:
return ""
preferred_slug = _preferred_worktree_slug()
if preferred_slug:
preferred = [p for p in candidates if preferred_slug in p.lower()]
if preferred:
candidates = preferred
candidates.sort(key=os.path.getmtime, reverse=True)
return candidates[0]
def _wait_for_socket(socket_path: str, timeout: float = 10.0) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
if os.path.exists(socket_path):
return True
try:
sock = _raw_connect(socket_path, timeout=0.3)
sock.close()
return True
except Exception:
pass
time.sleep(0.5)
return False
def _kill_cmux():
subprocess.run(["pkill", "-x", "cmux DEV"], capture_output=True)
def _kill_cmux(app_path: str = None):
if app_path:
exe = os.path.join(app_path, "Contents/MacOS/cmux DEV")
subprocess.run(["pkill", "-f", exe], capture_output=True)
else:
subprocess.run(["pkill", "-x", "cmux DEV"], capture_output=True)
time.sleep(1.5)
def _launch_cmux(app_path: str, socket_path: str, mode: str = None):
def _launch_cmux(app_path: str, socket_path: str, mode: str = None, extra_env: dict = None):
if os.path.exists(socket_path):
try:
os.unlink(socket_path)
except OSError:
pass
env_args = []
if mode:
env_args = ["--env", f"CMUX_SOCKET_MODE={mode}"]
subprocess.Popen(["open", "-a", app_path] + env_args)
launch_env = {
"CMUX_SOCKET_PATH": socket_path,
"CMUX_ALLOW_SOCKET_OVERRIDE": "1",
}
if extra_env:
launch_env.update(extra_env)
for key, value in launch_env.items():
env_args.extend(["--env", f"{key}={value}"])
subprocess.Popen(["open", "-na", app_path] + env_args)
if not _wait_for_socket(socket_path):
raise RuntimeError(f"Socket {socket_path} not created after launch")
time.sleep(8)
@ -249,8 +387,8 @@ fi
f.write(hook_line)
# Kill existing cmux, launch in cmuxOnly mode (default)
_kill_cmux()
_launch_cmux(app_path, socket_path)
_kill_cmux(app_path)
_launch_cmux(app_path, socket_path, mode="cmuxOnly")
# Wait for marker (the shell sources .zprofile on startup)
for _ in range(40):
@ -305,7 +443,7 @@ def test_allowall_mode_works(socket_path: str, app_path: str) -> TestResult:
"""Verify CMUX_SOCKET_MODE=allowAll bypasses ancestry check."""
result = TestResult("allowAll mode allows external")
try:
_kill_cmux()
_kill_cmux(app_path)
_launch_cmux(app_path, socket_path, mode="allowAll")
sock = _raw_connect(socket_path)
@ -321,6 +459,178 @@ def test_allowall_mode_works(socket_path: str, app_path: str) -> TestResult:
return result
def test_password_mode_requires_auth(socket_path: str, app_path: str) -> TestResult:
"""Verify password mode rejects unauthenticated commands."""
result = TestResult("Password mode requires auth")
password = f"cmux-pass-{os.getpid()}"
try:
_kill_cmux(app_path)
_launch_cmux(
app_path,
socket_path,
mode="password",
extra_env={"CMUX_SOCKET_PASSWORD": password}
)
sock = _raw_connect(socket_path)
response = _raw_send(sock, "ping")
sock.close()
if "Authentication required" in response:
result.success("Unauthenticated command rejected in password mode")
else:
result.failure(f"Unexpected response without auth: {response!r}")
except Exception as e:
result.failure(f"{type(e).__name__}: {e}")
return result
def test_password_mode_v1_auth_flow(socket_path: str, app_path: str) -> TestResult:
"""Verify v1 auth command unlocks the connection only with correct password."""
result = TestResult("Password mode v1 auth flow")
password = f"cmux-pass-{os.getpid()}"
try:
_kill_cmux(app_path)
_launch_cmux(
app_path,
socket_path,
mode="password",
extra_env={"CMUX_SOCKET_PASSWORD": password}
)
sock = _raw_connect(socket_path)
try:
wrong = _raw_send(sock, "auth wrong-password")
if "Invalid password" not in wrong:
result.failure(f"Expected invalid password error, got: {wrong!r}")
return result
ok = _raw_send(sock, f"auth {password}")
if "OK: Authenticated" not in ok:
result.failure(f"Expected auth success, got: {ok!r}")
return result
pong = _raw_send(sock, "ping")
if pong != "PONG":
result.failure(f"Expected PONG after auth, got: {pong!r}")
return result
finally:
sock.close()
result.success("v1 auth gate works")
except Exception as e:
result.failure(f"{type(e).__name__}: {e}")
return result
def test_password_mode_v2_auth_flow(socket_path: str, app_path: str) -> TestResult:
"""Verify v2 auth.login unlocks subsequent v2 requests."""
result = TestResult("Password mode v2 auth flow")
password = f"cmux-pass-{os.getpid()}"
try:
_kill_cmux(app_path)
_launch_cmux(
app_path,
socket_path,
mode="password",
extra_env={"CMUX_SOCKET_PASSWORD": password}
)
sock = _raw_connect(socket_path)
try:
unauth = _raw_send(sock, json.dumps({
"id": "1",
"method": "system.ping",
"params": {}
}))
unauth_obj = json.loads(unauth)
if unauth_obj.get("error", {}).get("code") != "auth_required":
result.failure(f"Expected auth_required, got: {unauth!r}")
return result
login = _raw_send(sock, json.dumps({
"id": "2",
"method": "auth.login",
"params": {"password": password}
}))
login_obj = json.loads(login)
if not login_obj.get("ok"):
result.failure(f"Expected auth.login success, got: {login!r}")
return result
pong = _raw_send(sock, json.dumps({
"id": "3",
"method": "system.ping",
"params": {}
}))
pong_obj = json.loads(pong)
pong_value = pong_obj.get("result", {}).get("pong")
if pong_value is not True:
result.failure(f"Expected pong=true after auth.login, got: {pong!r}")
return result
finally:
sock.close()
result.success("v2 auth.login gate works")
except Exception as e:
result.failure(f"{type(e).__name__}: {e}")
return result
def test_password_mode_cli_exit_code(socket_path: str, app_path: str) -> TestResult:
"""Verify CLI exits non-zero on auth-required and succeeds with --password."""
result = TestResult("Password mode CLI exit code")
password = f"cmux-pass-{os.getpid()}"
try:
cli_path = _find_cli(preferred_app_path=app_path)
if not cli_path:
result.failure("Could not find cmux CLI binary")
return result
_kill_cmux(app_path)
_launch_cmux(
app_path,
socket_path,
mode="password",
extra_env={"CMUX_SOCKET_PASSWORD": password}
)
no_auth = subprocess.run(
[cli_path, "--socket", socket_path, "ping"],
capture_output=True,
text=True,
timeout=10
)
combined = f"{no_auth.stdout}\n{no_auth.stderr}"
if no_auth.returncode == 0:
result.failure("CLI ping without password exited 0 in password mode")
return result
if "Authentication required" not in combined:
result.failure(f"Unexpected unauthenticated CLI output: {combined!r}")
return result
with_auth = subprocess.run(
[cli_path, "--socket", socket_path, "--password", password, "ping"],
capture_output=True,
text=True,
timeout=10
)
if with_auth.returncode != 0:
result.failure(
f"CLI ping with password failed: exit={with_auth.returncode} "
f"stdout={with_auth.stdout!r} stderr={with_auth.stderr!r}"
)
return result
if "PONG" not in with_auth.stdout:
result.failure(f"Expected PONG with password, got: {with_auth.stdout!r}")
return result
result.success("CLI exits non-zero for auth_required and succeeds with --password")
except Exception as e:
result.failure(f"{type(e).__name__}: {e}")
return result
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
@ -337,7 +647,11 @@ def run_tests():
return 1
print(f"App: {app_path}")
socket_path = _find_socket_path()
socket_path = f"/tmp/cmux-test-socket-access-{os.getpid()}.sock"
try:
os.unlink(socket_path)
except OSError:
pass
print(f"Socket: {socket_path}")
print()
@ -356,9 +670,9 @@ def run_tests():
print("-" * 50)
# Ensure cmux is running in cmuxOnly mode
_kill_cmux()
_kill_cmux(app_path)
print(" Launching cmux in cmuxOnly mode...")
_launch_cmux(app_path, socket_path)
_launch_cmux(app_path, socket_path, mode="cmuxOnly")
run_test(test_external_rejected, socket_path)
run_test(test_connection_closed_after_reject, socket_path)
@ -380,9 +694,19 @@ def run_tests():
run_test(test_allowall_mode_works, socket_path, app_path)
print()
# ── Phase 4: password mode auth gate ──
print("Phase 4: password mode — auth required + login flow")
print("-" * 50)
run_test(test_password_mode_requires_auth, socket_path, app_path)
run_test(test_password_mode_v1_auth_flow, socket_path, app_path)
run_test(test_password_mode_v2_auth_flow, socket_path, app_path)
run_test(test_password_mode_cli_exit_code, socket_path, app_path)
print()
# ── Cleanup: leave cmux in cmuxOnly mode ──
_kill_cmux()
_launch_cmux(app_path, socket_path)
_kill_cmux(app_path)
_launch_cmux(app_path, socket_path, mode="cmuxOnly")
# ── Summary ──
print("=" * 60)