mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-15 04:56:03 +00:00
* 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>
290 lines
11 KiB
Swift
290 lines
11 KiB
Swift
import Darwin
|
|
import Foundation
|
|
|
|
/// Coordinates native approval reads and writes with the TypeScript sidecar-lock protocol.
|
|
/// Target files are opened without following links; writes replace the directory entry atomically.
|
|
enum ExecApprovalsFileIO {
|
|
struct CurrentFile {
|
|
let data: Data
|
|
let linkCount: UInt64
|
|
}
|
|
|
|
private static let processLock = NSRecursiveLock()
|
|
private static let lockAttempts = 11
|
|
private static let lockRetryDelayMicroseconds: useconds_t = 20000
|
|
|
|
private struct LockHandle {
|
|
let descriptor: Int32
|
|
let url: URL
|
|
let device: UInt64
|
|
let inode: UInt64
|
|
let raw: Data
|
|
}
|
|
|
|
static func withLock<T>(
|
|
fileURL: URL,
|
|
trustedRoot: URL,
|
|
_ body: () throws -> T) throws -> T
|
|
{
|
|
self.processLock.lock()
|
|
defer { self.processLock.unlock() }
|
|
try self.assertSafeParentChain(of: fileURL, trustedRoot: trustedRoot)
|
|
let handle = try self.acquireLock(fileURL: fileURL, trustedRoot: trustedRoot)
|
|
defer { self.releaseLock(handle) }
|
|
return try body()
|
|
}
|
|
|
|
static func read(at url: URL, trustedRoot: URL) throws -> CurrentFile? {
|
|
try self.assertSafeParentChain(of: url, trustedRoot: trustedRoot)
|
|
guard let pathInfo = try self.currentMetadata(at: url) else { return nil }
|
|
let descriptor = open(url.path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW)
|
|
guard descriptor >= 0 else {
|
|
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
|
}
|
|
defer { close(descriptor) }
|
|
|
|
var descriptorInfo = stat()
|
|
guard fstat(descriptor, &descriptorInfo) == 0,
|
|
descriptorInfo.st_mode & mode_t(S_IFMT) == mode_t(S_IFREG),
|
|
descriptorInfo.st_dev == pathInfo.st_dev,
|
|
descriptorInfo.st_ino == pathInfo.st_ino
|
|
else {
|
|
throw self.error(15, "exec approvals file changed before read")
|
|
}
|
|
let data = try self.readAll(from: descriptor)
|
|
guard let finalInfo = try self.currentMetadata(at: url),
|
|
finalInfo.st_dev == descriptorInfo.st_dev,
|
|
finalInfo.st_ino == descriptorInfo.st_ino
|
|
else {
|
|
throw self.error(16, "exec approvals file changed during read")
|
|
}
|
|
return CurrentFile(data: data, linkCount: UInt64(descriptorInfo.st_nlink))
|
|
}
|
|
|
|
static func pathExistsNoFollow(_ url: URL) -> Bool {
|
|
var info = stat()
|
|
if lstat(url.path, &info) == 0 {
|
|
return true
|
|
}
|
|
return errno != ENOENT
|
|
}
|
|
|
|
static func assertSafeDirectory(at url: URL) throws {
|
|
var info = stat()
|
|
guard lstat(url.path, &info) == 0 else {
|
|
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
|
}
|
|
guard info.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR) else {
|
|
throw self.error(20, "unsafe exec approvals state directory")
|
|
}
|
|
}
|
|
|
|
static func write(_ data: Data, to url: URL, trustedRoot: URL) throws {
|
|
try self.assertSafeParentChain(of: url, trustedRoot: trustedRoot)
|
|
_ = try self.currentMetadata(at: url)
|
|
let temporaryURL = url.deletingLastPathComponent()
|
|
.appendingPathComponent(".exec-approvals.\(UUID().uuidString)")
|
|
let descriptor = open(
|
|
temporaryURL.path,
|
|
O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW,
|
|
S_IRUSR | S_IWUSR)
|
|
guard descriptor >= 0 else {
|
|
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
|
}
|
|
var closed = false
|
|
var renamed = false
|
|
defer {
|
|
if !closed {
|
|
close(descriptor)
|
|
}
|
|
if !renamed {
|
|
_ = unlink(temporaryURL.path)
|
|
}
|
|
}
|
|
try self.writeAll(data, to: descriptor)
|
|
guard close(descriptor) == 0 else {
|
|
closed = true
|
|
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
|
}
|
|
closed = true
|
|
guard rename(temporaryURL.path, url.path) == 0 else {
|
|
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
|
}
|
|
renamed = true
|
|
guard let saved = try self.currentMetadata(at: url), saved.st_nlink == 1 else {
|
|
throw self.error(17, "exec approvals atomic write retained shared links")
|
|
}
|
|
}
|
|
|
|
private static func acquireLock(fileURL: URL, trustedRoot: URL) throws -> LockHandle {
|
|
try FileManager().createDirectory(
|
|
at: fileURL.deletingLastPathComponent(),
|
|
withIntermediateDirectories: true)
|
|
try self.assertSafeParentChain(of: fileURL, trustedRoot: trustedRoot)
|
|
let realDirectory = fileURL.deletingLastPathComponent().resolvingSymlinksInPath()
|
|
let lockURL = realDirectory.appendingPathComponent("\(fileURL.lastPathComponent).lock")
|
|
var lockPayload: [String: Any] = [
|
|
"pid": Int(getpid()),
|
|
"createdAt": ISO8601DateFormatter().string(from: Date()),
|
|
"nonce": UUID().uuidString,
|
|
]
|
|
if let starttime = self.processStartTime(getpid()) {
|
|
lockPayload["starttime"] = starttime
|
|
}
|
|
let payload = try JSONSerialization.data(
|
|
withJSONObject: lockPayload,
|
|
options: [.prettyPrinted, .sortedKeys])
|
|
let raw = payload + Data([0x0A])
|
|
|
|
for attempt in 0..<self.lockAttempts {
|
|
let descriptor = open(
|
|
lockURL.path,
|
|
O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW,
|
|
S_IRUSR | S_IWUSR)
|
|
if descriptor >= 0 {
|
|
var info = stat()
|
|
guard fstat(descriptor, &info) == 0 else {
|
|
let error = POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
|
close(descriptor)
|
|
throw error
|
|
}
|
|
let handle = LockHandle(
|
|
descriptor: descriptor,
|
|
url: lockURL,
|
|
device: UInt64(info.st_dev),
|
|
inode: UInt64(info.st_ino),
|
|
raw: raw)
|
|
do {
|
|
try self.writeAll(raw, to: descriptor)
|
|
return handle
|
|
} catch {
|
|
close(descriptor)
|
|
self.removeLockIfOwned(handle, requirePayloadMatch: false)
|
|
throw error
|
|
}
|
|
}
|
|
guard errno == EEXIST else {
|
|
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
|
}
|
|
guard attempt + 1 < self.lockAttempts else { throw POSIXError(.ETIMEDOUT) }
|
|
// Match the synchronous TypeScript lock budget: ten 20 ms retries.
|
|
usleep(self.lockRetryDelayMicroseconds)
|
|
}
|
|
throw POSIXError(.ETIMEDOUT)
|
|
}
|
|
|
|
static func assertSafeParentChain(of target: URL, trustedRoot: URL) throws {
|
|
let root = trustedRoot.standardizedFileURL
|
|
let parent = target.deletingLastPathComponent().standardizedFileURL
|
|
let rootComponents = root.pathComponents
|
|
let parentComponents = parent.pathComponents
|
|
guard parentComponents.count >= rootComponents.count,
|
|
Array(parentComponents.prefix(rootComponents.count)) == rootComponents
|
|
else {
|
|
// Match the TypeScript allowOutsideRoot contract: an explicitly
|
|
// configured path outside the effective home is operator-trusted.
|
|
return
|
|
}
|
|
|
|
var cursor = root
|
|
for component in parentComponents.dropFirst(rootComponents.count) {
|
|
cursor.appendPathComponent(component, isDirectory: true)
|
|
var info = stat()
|
|
guard lstat(cursor.path, &info) == 0 else {
|
|
if errno == ENOENT {
|
|
return
|
|
}
|
|
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
|
}
|
|
guard info.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR) else {
|
|
throw self.error(19, "unsafe exec approvals parent path")
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func releaseLock(_ handle: LockHandle) {
|
|
close(handle.descriptor)
|
|
self.removeLockIfOwned(handle, requirePayloadMatch: true)
|
|
}
|
|
|
|
private static func removeLockIfOwned(_ handle: LockHandle, requirePayloadMatch: Bool) {
|
|
var current = stat()
|
|
guard lstat(handle.url.path, ¤t) == 0,
|
|
UInt64(current.st_dev) == handle.device,
|
|
UInt64(current.st_ino) == handle.inode
|
|
else { return }
|
|
if requirePayloadMatch {
|
|
guard let raw = try? Data(contentsOf: handle.url), raw == handle.raw else { return }
|
|
}
|
|
_ = unlink(handle.url.path)
|
|
}
|
|
|
|
private static func currentMetadata(at url: URL) throws -> stat? {
|
|
var info = stat()
|
|
guard lstat(url.path, &info) == 0 else {
|
|
if errno == ENOENT {
|
|
return nil
|
|
}
|
|
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
|
}
|
|
guard info.st_mode & mode_t(S_IFMT) == mode_t(S_IFREG) else {
|
|
throw self.error(14, "unsafe exec approvals file type")
|
|
}
|
|
return info
|
|
}
|
|
|
|
private static func readAll(from descriptor: Int32) throws -> Data {
|
|
var data = Data()
|
|
var buffer = [UInt8](repeating: 0, count: 64 * 1024)
|
|
while true {
|
|
let count = buffer.withUnsafeMutableBytes { bytes in
|
|
Darwin.read(descriptor, bytes.baseAddress, bytes.count)
|
|
}
|
|
if count == 0 {
|
|
return data
|
|
}
|
|
if count < 0 {
|
|
if errno == EINTR {
|
|
continue
|
|
}
|
|
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
|
}
|
|
data.append(contentsOf: buffer.prefix(count))
|
|
}
|
|
}
|
|
|
|
private static func writeAll(_ data: Data, to descriptor: Int32) throws {
|
|
try data.withUnsafeBytes { bytes in
|
|
guard let base = bytes.baseAddress else { return }
|
|
var offset = 0
|
|
while offset < bytes.count {
|
|
let count = Darwin.write(descriptor, base.advanced(by: offset), bytes.count - offset)
|
|
if count < 0 {
|
|
if errno == EINTR {
|
|
continue
|
|
}
|
|
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
|
}
|
|
offset += count
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func processStartTime(_ pid: pid_t) -> UInt64? {
|
|
guard pid > 0 else { return nil }
|
|
var info = kinfo_proc()
|
|
var size = MemoryLayout<kinfo_proc>.stride
|
|
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid]
|
|
guard sysctl(&mib, u_int(mib.count), &info, &size, nil, 0) == 0,
|
|
size > 0,
|
|
info.kp_proc.p_pid == pid,
|
|
info.kp_proc.p_starttime.tv_sec >= 0
|
|
else { return nil }
|
|
return UInt64(info.kp_proc.p_starttime.tv_sec)
|
|
}
|
|
|
|
private static func error(_ code: Int, _ message: String) -> NSError {
|
|
NSError(domain: "ExecApprovals", code: code, userInfo: [NSLocalizedDescriptionKey: message])
|
|
}
|
|
}
|