From de3fc45777bc022543aaeba80b6bb71152a61a39 Mon Sep 17 00:00:00 2001 From: Andrew Moore Date: Sat, 8 Aug 2026 17:19:44 -0700 Subject: [PATCH] Merge nucleic/vivid-glass-urchin-xoym into main --- .../RunnerHost/LocalNetworkPermission.swift | 77 ++++++++++++++----- 1 file changed, 56 insertions(+), 21 deletions(-) diff --git a/Sources/RunnerHost/LocalNetworkPermission.swift b/Sources/RunnerHost/LocalNetworkPermission.swift index 55acf7a..a157fc9 100644 --- a/Sources/RunnerHost/LocalNetworkPermission.swift +++ b/Sources/RunnerHost/LocalNetworkPermission.swift @@ -50,6 +50,8 @@ public enum LocalNetworkPermission { case probeProducedNoReport /// A subnet argument is not an IPv4 address or CIDR block. case invalidSubnet(String) + /// A child process could not be started or waited on. + case spawnFailed(command: String, code: Int32) public var description: String { switch self { @@ -74,6 +76,8 @@ public enum LocalNetworkPermission { entries it cannot parse, which would leave the allowlist looking configured \ while granting nothing. """ + case .spawnFailed(let command, let code): + return "could not run \(command): \(String(cString: strerror(code))) (\(code))" } } } @@ -117,27 +121,15 @@ public enum LocalNetworkPermission { let commands = LocalNetworkPolicy.writeCommandLines(subnets: subnets) - for arguments in LocalNetworkPolicy.writeArguments(subnets: subnets) { + for (index, arguments) in LocalNetworkPolicy.writeArguments(subnets: subnets).enumerated() { let sudoArguments = (allowPasswordPrompt ? [] : ["-n"]) + ["/usr/bin/defaults"] + arguments - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/sudo") - process.arguments = sudoArguments - // Stdio is deliberately inherited rather than piped. sudo reads the - // password from /dev/tty and would work either way, but its prompt - // and any "not in the sudoers file" complaint belong in front of - // the operator, not captured and paraphrased. - try process.run() - process.waitUntilExit() - - guard process.terminationStatus == 0 else { - let index = LocalNetworkPolicy.writeArguments(subnets: subnets) - .firstIndex(of: arguments) ?? 0 + let exitCode = try runInForeground("/usr/bin/sudo", sudoArguments) + guard exitCode == 0 else { if !allowPasswordPrompt { throw PermissionError.needsPassword(commands: commands) } - throw PermissionError.writeFailed( - command: commands[index], exitCode: process.terminationStatus) + throw PermissionError.writeFailed(command: commands[index], exitCode: exitCode) } } @@ -147,11 +139,54 @@ public enum LocalNetworkPermission { /// Reboots the host. Only ever called from an explicit confirmation — the /// allowlist is read at boot, so nothing else makes it take effect. public static func reboot() throws { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/sudo") - process.arguments = ["/sbin/shutdown", "-r", "now"] - try process.run() - process.waitUntilExit() + _ = try runInForeground("/usr/bin/sudo", ["/sbin/shutdown", "-r", "now"]) + } + + /// Runs a command with this process's stdio *and its process group*, and + /// returns its exit status. + /// + /// The process group is the whole reason this is not `Foundation.Process`. + /// `Process` starts the child as its own process-group leader, so for the + /// controlling terminal the child is a *background* job — and the terminal + /// driver defends itself against those. `sudo`'s `tcsetattr` to turn echo + /// off raises `SIGTTOU` and fails, so the password is typed in the clear; + /// its read of the tty raises `SIGTTIN`, so Return never reaches `sudo` and + /// the line editor just echoes a newline. Both symptoms, one cause. + /// + /// `posix_spawn` with no `POSIX_SPAWN_SETPGROUP` leaves the child in our + /// process group, which is the terminal's foreground group, so `sudo` gets + /// the terminal it expects. Stdio is inherited for the same reason it + /// always was: the prompt and any "not in the sudoers file" complaint + /// belong in front of the operator, not captured and paraphrased. + private static func runInForeground(_ executable: String, _ arguments: [String]) throws -> Int32 + { + var argv: [UnsafeMutablePointer?] = ([executable] + arguments).map { strdup($0) } + argv.append(nil) + var envp: [UnsafeMutablePointer?] = ProcessInfo.processInfo.environment.map { + strdup("\($0.key)=\($0.value)") + } + envp.append(nil) + defer { + for pointer in argv { free(pointer) } + for pointer in envp { free(pointer) } + } + + var pid: pid_t = 0 + let spawned = posix_spawn(&pid, executable, nil, nil, argv, envp) + guard spawned == 0 else { + throw PermissionError.spawnFailed(command: executable, code: spawned) + } + + var status: Int32 = 0 + while waitpid(pid, &status, 0) < 0 { + guard errno == EINTR else { + throw PermissionError.spawnFailed(command: executable, code: errno) + } + } + + // WIFEXITED and friends are C macros, so Swift does not import them. + let terminatingSignal = status & 0x7F + return terminatingSignal == 0 ? (status >> 8) & 0xFF : 128 + terminatingSignal } // MARK: - Interactive: the system prompt