Files
openclaw-openclaw/apps/macos/Sources/OpenClaw/ShellExecutor.swift
Peter Steinberger 9b1c36d23c fix: prevent exec approval revocation races (#103515)
* fix(security): serialize exec approval mutations

* fix(security): preserve additive approval writes

* test(cli): expect normalized approval shape

* fix(security): preserve exec approval compatibility

* test(security): exercise locked approval initialization

* test(security): mock serialized approval helpers

* test(exec): derive enforced command path from plan

* fix(gateway): always return approval CAS conflicts

* fix(macos): serialize exec approvals writes

* fix(security): repair approval build errors

* fix(security): serialize exec approval mutations

* fix(security): fail closed on approval persistence errors

* test(security): cover detached approval persistence failures

* fix(security): harden exec approval state

* style(macos): format exec approval sources

* fix(security): complete exec approval hardening

Co-authored-by: Coy Geek <65363919+coygeek@users.noreply.github.com>

* fix(macos): preserve approved login-shell semantics

* fix(macos): keep login shell approvals one-shot

* fix(security): linearize exec authorization

Co-authored-by: Coy Geek <65363919+coygeek@users.noreply.github.com>

* fix(security): preserve durable approval basis

Co-authored-by: Coy Geek <65363919+coygeek@users.noreply.github.com>

* fix(security): bind exec grants to current policy

Co-authored-by: Coy Geek <65363919+coygeek@users.noreply.github.com>

* test(security): fix exec revocation fixtures

* test(security): align gateway approval fixtures

* fix(macos): return approval decisions

* chore(i18n): sync native approval strings

* test(security): align approval hardening fixtures

* test(node): authorize completed event fixture

* test(security): fix approval decision fixtures

* test(security): await durable approval visibility

* fix(exec): preserve concurrent approval grants

* fix(exec): address exact-head CI failures

* fix(exec): preserve concurrent approval promotions

* fix(exec): make Swift shutdown state explicit

* test(macos): handle approval read failures

* fix(macos): harden approval socket paths

* fix(macos): preserve exact shell payload bytes

* test(macos): make approval fixtures explicit

* test(macos): fix approval suite compilation

* fix(macos): bound approval socket JSONL reads

* chore: move exec approval note to release process

* chore: move exec approval note to release process

---------

Co-authored-by: Coy Geek <65363919+coygeek@users.noreply.github.com>
2026-07-10 21:35:05 +01:00

152 lines
5.1 KiB
Swift

import Foundation
import OpenClawIPC
enum ShellExecutor {
struct ShellResult: Sendable {
var stdout: String
var stderr: String
var exitCode: Int?
var timedOut: Bool
var success: Bool
var errorMessage: String?
}
private final class CompletionBox: @unchecked Sendable {
private let lock = NSLock()
private var finished = false
private let continuation: CheckedContinuation<ShellResult, Never>
init(continuation: CheckedContinuation<ShellResult, Never>) {
self.continuation = continuation
}
func finish(_ result: ShellResult) {
self.lock.lock()
defer { self.lock.unlock() }
guard !self.finished else { return }
self.finished = true
self.continuation.resume(returning: result)
}
}
private static func completedResult(
status: Int,
outTask: Task<Data, Never>,
errTask: Task<Data, Never>) async -> ShellResult
{
let out = await outTask.value
let err = await errTask.value
return ShellResult(
stdout: String(bytes: out, encoding: .utf8) ?? "",
stderr: String(bytes: err, encoding: .utf8) ?? "",
exitCode: status,
timedOut: false,
success: status == 0,
errorMessage: status == 0 ? nil : "exit \(status)")
}
static func runDetailed(
command: [String],
cwd: String?,
env: [String: String]?,
timeout: Double?) async -> ShellResult
{
guard !command.isEmpty else {
return ShellResult(
stdout: "",
stderr: "",
exitCode: nil,
timedOut: false,
success: false,
errorMessage: "empty command")
}
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = command
if let cwd {
process.currentDirectoryURL = URL(fileURLWithPath: cwd)
}
if let env {
process.environment = env
}
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
let outTask = Task { stdoutPipe.fileHandleForReading.readToEndSafely() }
let errTask = Task { stderrPipe.fileHandleForReading.readToEndSafely() }
if let timeout, timeout > 0 {
return await withCheckedContinuation { continuation in
let completion = CompletionBox(continuation: continuation)
process.terminationHandler = { terminatedProcess in
let status = Int(terminatedProcess.terminationStatus)
Task {
let result = await self.completedResult(
status: status,
outTask: outTask,
errTask: errTask)
completion.finish(result)
}
}
do {
try process.run()
} catch {
completion.finish(
ShellResult(
stdout: "",
stderr: "",
exitCode: nil,
timedOut: false,
success: false,
errorMessage: "failed to start: \(error.localizedDescription)"))
return
}
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + timeout) {
guard process.isRunning else { return }
process.terminate()
completion.finish(
ShellResult(
stdout: "",
stderr: "",
exitCode: nil,
timedOut: true,
success: false,
errorMessage: "timeout"))
}
}
}
do {
try process.run()
} catch {
return ShellResult(
stdout: "",
stderr: "",
exitCode: nil,
timedOut: false,
success: false,
errorMessage: "failed to start: \(error.localizedDescription)")
}
process.waitUntilExit()
return await self.completedResult(
status: Int(process.terminationStatus),
outTask: outTask,
errTask: errTask)
}
static func run(command: [String], cwd: String?, env: [String: String]?, timeout: Double?) async -> Response {
let result = await self.runDetailed(command: command, cwd: cwd, env: env, timeout: timeout)
let combined = result.stdout.isEmpty ? result.stderr : result.stdout
let payload = combined.isEmpty ? nil : Data(combined.utf8)
return Response(ok: result.success, message: result.errorMessage, payload: payload)
}
}