From 9eae43bd37cdfbed42b57895e8491f93e47a03a6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Jul 2026 06:39:23 -0400 Subject: [PATCH] refactor(infra): move exec approvals into the shared SQLite state DB (#114063) * refactor(infra): move exec approvals into the shared SQLite state DB Delete the file-runtime exec-approvals store (exec-approvals.json + .lock sidecar machinery) on both runtimes and make the reserved exec_approvals_config singleton row canonical. Doctor owns the one-time import with claim/verify/receipt discipline; runtime fails closed with a doctor instruction while un-migrated legacy state exists. The wire CAS contract, socket semantics, and gateway auth-token derivations are unchanged. Kills the #113929 lock-contention bug class structurally and nets around -2.9k lines. * fix(infra): green CI gates and retire file-era exec approvals tests Break the migration-type import cycle with a leaf contract, regenerate the plugin-SDK API and native i18n baselines for the intentional surface change, drop unused exports, and replace the macOS file-era approvals test suite with SQLite-backed behavior coverage per the obsolete-internals test policy. * chore: green max-lines ratchet, native i18n baseline, and unused-export scan --- apps/.i18n/native-source.json | 22 +- .../Sources/OpenClaw/ExecApprovals.swift | 133 +- .../OpenClaw/ExecApprovalsFileIO.swift | 289 -- .../Sources/OpenClaw/ExecApprovalsStore.swift | 362 +-- .../ExecApprovalsStoreRefactorTests.swift | 541 +--- .../ExecApprovalsUIRollbackTests.swift | 67 +- .../ExecApprovalsLegacyMigrationGate.swift | 42 + .../ExecApprovalsSQLiteStore.swift | 506 ++++ .../OpenClawNativeStateSQLite.swift | 62 +- .../ExecApprovalsSQLiteStoreTests.swift | 326 +++ config/max-lines-baseline.txt | 1 - .../.generated/plugin-sdk-api-baseline.sha256 | 2 +- docs/cli/approvals.md | 23 +- docs/cli/node.md | 4 +- docs/cli/policy.md | 16 +- docs/nodes/index.md | 18 +- docs/platforms/mac/xpc.md | 4 +- docs/refactor/database-first.md | 16 +- docs/tools/exec-approvals-advanced.md | 7 +- docs/tools/exec-approvals.md | 45 +- .../src/doctor/exec-approval-findings.ts | 14 +- .../policy/src/doctor/exec-approval-rules.ts | 7 +- extensions/policy/src/doctor/fix-metadata.ts | 16 +- .../policy/src/doctor/policy-runtime.ts | 111 +- extensions/policy/src/doctor/register.test.ts | 45 + extensions/policy/src/exec-approvals-uri.ts | 8 + .../policy/src/policy-state-exec-approvals.ts | 11 +- .../check-database-first-legacy-stores.mjs | 49 +- src/agents/bash-tools.exec-run.ts | 2 +- .../bash-tools.exec.approval-id.test.ts | 14 +- .../bash-tools.exec.security-floor.test.ts | 9 +- src/agents/cli-runner.spawn.test.ts | 4 +- src/agents/cli-runner.test-helpers.ts | 21 +- src/cli/program/config-guard.ts | 4 + src/commands/doctor-security.test.ts | 30 +- src/commands/doctor.e2e-harness.ts | 4 + .../agent-runtime-identity-token.test.ts | 31 +- .../operator-approval-runtime-token.test.ts | 37 +- src/infra/exec-approvals-config.test.ts | 28 +- src/infra/exec-approvals-config.ts | 19 +- src/infra/exec-approvals-file-io.ts | 442 --- src/infra/exec-approvals-lock.ts | 241 -- src/infra/exec-approvals-migration-gate.ts | 53 + src/infra/exec-approvals-policy.test.ts | 22 +- src/infra/exec-approvals-resolver.ts | 4 +- src/infra/exec-approvals-sqlite.ts | 190 ++ .../exec-approvals-store.test-support.ts | 15 + src/infra/exec-approvals-store.test.ts | 2556 +++-------------- src/infra/exec-approvals-store.ts | 384 ++- src/infra/exec-approvals.ts | 19 +- src/infra/state-migrations.doctor.ts | 20 + .../state-migrations.exec-approvals.test.ts | 305 ++ src/infra/state-migrations.exec-approvals.ts | 571 ++++ .../state-migrations.exec-approvals.types.ts | 4 + src/infra/state-migrations.types.ts | 2 + src/node-host/invoke-system-run.test.ts | 10 +- src/node-host/invoke.test.ts | 5 +- src/plugin-sdk/exec-approvals-runtime.ts | 2 + .../openclaw-state-snapshot-sanitizer.test.ts | 106 + .../openclaw-state-snapshot-sanitizer.ts | 45 + ...check-database-first-legacy-stores.test.ts | 38 + 61 files changed, 3501 insertions(+), 4483 deletions(-) delete mode 100644 apps/macos/Sources/OpenClaw/ExecApprovalsFileIO.swift create mode 100644 apps/shared/OpenClawKit/Sources/OpenClawKit/ExecApprovalsLegacyMigrationGate.swift create mode 100644 apps/shared/OpenClawKit/Sources/OpenClawKit/ExecApprovalsSQLiteStore.swift create mode 100644 apps/shared/OpenClawKit/Tests/OpenClawKitTests/ExecApprovalsSQLiteStoreTests.swift create mode 100644 extensions/policy/src/exec-approvals-uri.ts delete mode 100644 src/infra/exec-approvals-file-io.ts delete mode 100644 src/infra/exec-approvals-lock.ts create mode 100644 src/infra/exec-approvals-migration-gate.ts create mode 100644 src/infra/exec-approvals-sqlite.ts create mode 100644 src/infra/exec-approvals-store.test-support.ts create mode 100644 src/infra/state-migrations.exec-approvals.test.ts create mode 100644 src/infra/state-migrations.exec-approvals.ts create mode 100644 src/infra/state-migrations.exec-approvals.types.ts create mode 100644 src/state/openclaw-state-snapshot-sanitizer.test.ts diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 3e9a8176189f..dc36db582237 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -32411,7 +32411,7 @@ }, { "kind": "conditional-branch", - "line": 15, + "line": 16, "path": "apps/macos/Sources/OpenClaw/ExecApprovals.swift", "source": "Allowlist", "surface": "apple", @@ -32419,7 +32419,7 @@ }, { "kind": "conditional-branch", - "line": 42, + "line": 43, "path": "apps/macos/Sources/OpenClaw/ExecApprovals.swift", "source": "Deny", "surface": "apple", @@ -32427,7 +32427,7 @@ }, { "kind": "conditional-branch", - "line": 44, + "line": 45, "path": "apps/macos/Sources/OpenClaw/ExecApprovals.swift", "source": "Always Allow", "surface": "apple", @@ -32435,7 +32435,7 @@ }, { "kind": "conditional-branch", - "line": 87, + "line": 80, "path": "apps/macos/Sources/OpenClaw/ExecApprovals.swift", "source": "Never Ask", "surface": "apple", @@ -32443,7 +32443,7 @@ }, { "kind": "conditional-branch", - "line": 88, + "line": 81, "path": "apps/macos/Sources/OpenClaw/ExecApprovals.swift", "source": "Ask on Allowlist Miss", "surface": "apple", @@ -32451,7 +32451,7 @@ }, { "kind": "conditional-branch", - "line": 89, + "line": 82, "path": "apps/macos/Sources/OpenClaw/ExecApprovals.swift", "source": "Always Ask", "surface": "apple", @@ -32459,7 +32459,7 @@ }, { "kind": "conditional-branch", - "line": 119, + "line": 112, "path": "apps/macos/Sources/OpenClaw/ExecApprovals.swift", "source": "Pattern cannot be empty.", "surface": "apple", @@ -32467,7 +32467,7 @@ }, { "kind": "conditional-branch", - "line": 121, + "line": 114, "path": "apps/macos/Sources/OpenClaw/ExecApprovals.swift", "source": "Path patterns only. Include '/', '~', or '\\'.", "surface": "apple", @@ -32475,7 +32475,7 @@ }, { "kind": "conditional-branch", - "line": 283, + "line": 170, "path": "apps/macos/Sources/OpenClaw/ExecApprovals.swift", "source": "This allowlist entry is inherited. Edit its owning scope and retry.", "surface": "apple", @@ -32483,7 +32483,7 @@ }, { "kind": "conditional-branch", - "line": 285, + "line": 172, "path": "apps/macos/Sources/OpenClaw/ExecApprovals.swift", "source": "Could not save exec approvals. Last known settings are shown; retry the change.", "surface": "apple", @@ -32491,7 +32491,7 @@ }, { "kind": "conditional-branch", - "line": 950, + "line": 802, "path": "apps/macos/Sources/OpenClaw/ExecApprovalsStore.swift", "source": "missing:\\(hash)", "surface": "apple", diff --git a/apps/macos/Sources/OpenClaw/ExecApprovals.swift b/apps/macos/Sources/OpenClaw/ExecApprovals.swift index 6be347c7689c..e22511ceece5 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovals.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovals.swift @@ -1,14 +1,15 @@ import Foundation +import OpenClawKit -enum ExecSecurity: String, CaseIterable, Codable, Identifiable, Sendable { - case deny - case allowlist - case full - - var id: String { - rawValue - } +typealias ExecSecurity = ExecApprovalsSecurity +typealias ExecAsk = ExecApprovalsAsk +typealias ExecAllowlistEntry = ExecApprovalsAllowlistEntry +typealias ExecApprovalsDefaults = ExecApprovalsDefaultsDocument +typealias ExecApprovalsAgent = ExecApprovalsAgentDocument +typealias ExecApprovalsSocketConfig = ExecApprovalsSocketDocument +typealias ExecApprovalsFile = ExecApprovalsDocument +extension ExecApprovalsSecurity { var title: String { switch self { case .deny: "Deny" @@ -73,15 +74,7 @@ enum ExecApprovalQuickMode: String, CaseIterable, Identifiable { } } -enum ExecAsk: String, CaseIterable, Codable, Identifiable, Sendable { - case off - case onMiss = "on-miss" - case always - - var id: String { - rawValue - } - +extension ExecApprovalsAsk { var title: String { switch self { case .off: "Never Ask" @@ -134,80 +127,6 @@ struct ExecAllowlistRejectedEntry: Equatable { let reason: ExecAllowlistPatternValidationReason } -struct ExecAllowlistEntry: Codable, Hashable, Identifiable, Sendable { - var id: String - var pattern: String - var source: String? - var commandText: String? - var argPattern: String? - var lastUsedAt: Double? - var lastUsedCommand: String? - var lastResolvedPath: String? - - init( - id: String = UUID().uuidString, - pattern: String, - source: String? = nil, - commandText: String? = nil, - argPattern: String? = nil, - lastUsedAt: Double? = nil, - lastUsedCommand: String? = nil, - lastResolvedPath: String? = nil) - { - self.id = id - self.pattern = pattern - self.source = source - self.commandText = commandText - self.argPattern = argPattern - self.lastUsedAt = lastUsedAt - self.lastUsedCommand = lastUsedCommand - self.lastResolvedPath = lastResolvedPath - } - - private enum CodingKeys: String, CodingKey { - case id - case pattern - case source - case commandText - case argPattern - case lastUsedAt - case lastUsedCommand - case lastResolvedPath - } - - init(from decoder: Decoder) throws { - if let container = try? decoder.singleValueContainer(), - let legacyPattern = try? container.decode(String.self) - { - self.init(pattern: legacyPattern.trimmingCharacters(in: .whitespacesAndNewlines)) - return - } - let container = try decoder.container(keyedBy: CodingKeys.self) - let decodedID = try container.decodeIfPresent(String.self, forKey: .id) - let id = decodedID.flatMap { $0.isEmpty ? nil : $0 } ?? UUID().uuidString - try self.init( - id: id, - pattern: container.decode(String.self, forKey: .pattern), - source: container.decodeIfPresent(String.self, forKey: .source), - commandText: container.decodeIfPresent(String.self, forKey: .commandText), - argPattern: container.decodeIfPresent(String.self, forKey: .argPattern), - lastUsedAt: container.decodeIfPresent(Double.self, forKey: .lastUsedAt), - lastUsedCommand: container.decodeIfPresent(String.self, forKey: .lastUsedCommand), - lastResolvedPath: container.decodeIfPresent(String.self, forKey: .lastResolvedPath)) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(self.id, forKey: .id) - try container.encode(self.pattern, forKey: .pattern) - try container.encodeIfPresent(self.source, forKey: .source) - try container.encodeIfPresent(self.argPattern, forKey: .argPattern) - try container.encodeIfPresent(self.lastUsedAt, forKey: .lastUsedAt) - try container.encodeIfPresent(self.lastUsedCommand, forKey: .lastUsedCommand) - try container.encodeIfPresent(self.lastResolvedPath, forKey: .lastResolvedPath) - } -} - struct ExecAllowlistUse: Sendable { let match: ExecAllowlistEntry let resolvedPath: String? @@ -223,38 +142,6 @@ struct ExecAllowlistEntryMatchKey: Hashable, Sendable { } } -struct ExecApprovalsDefaults: Codable, Sendable { - var security: ExecSecurity? - var ask: ExecAsk? - var askFallback: ExecSecurity? - var autoAllowSkills: Bool? -} - -struct ExecApprovalsAgent: Codable, Sendable { - var security: ExecSecurity? - var ask: ExecAsk? - var askFallback: ExecSecurity? - var autoAllowSkills: Bool? - var allowlist: [ExecAllowlistEntry]? - - var isEmpty: Bool { - self.security == nil && self.ask == nil && self.askFallback == nil && self - .autoAllowSkills == nil && (self.allowlist?.isEmpty ?? true) - } -} - -struct ExecApprovalsSocketConfig: Codable, Sendable { - var path: String? - var token: String? -} - -struct ExecApprovalsFile: Codable, Sendable { - var version: Int - var socket: ExecApprovalsSocketConfig? - var defaults: ExecApprovalsDefaults? - var agents: [String: ExecApprovalsAgent]? -} - struct ExecApprovalsSnapshot: Codable, Sendable { var path: String var exists: Bool diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalsFileIO.swift b/apps/macos/Sources/OpenClaw/ExecApprovalsFileIO.swift deleted file mode 100644 index 9e2fdd743a4c..000000000000 --- a/apps/macos/Sources/OpenClaw/ExecApprovalsFileIO.swift +++ /dev/null @@ -1,289 +0,0 @@ -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( - 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..= 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.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]) - } -} diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalsStore.swift b/apps/macos/Sources/OpenClaw/ExecApprovalsStore.swift index 292e06571b27..6a3b3152f75c 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovalsStore.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovalsStore.swift @@ -1,5 +1,6 @@ import CryptoKit import Foundation +import OpenClawKit import OSLog import Security @@ -11,31 +12,15 @@ enum ExecApprovalsStore { private static let defaultAsk: ExecAsk = .off private static let defaultAskFallback: ExecSecurity = .deny private static let defaultAutoAllowSkills = false - private static let secureStateDirPermissions = 0o700 - - /// Match the TypeScript writer's `.lock` protocol. Both processes - /// must cover the complete read-modify-write transaction or a stale native - /// usage update can restore policy that an administrator just revoked. - private static func withWriteLock(_ body: () throws -> T) throws -> T { - let fileURL = self.fileURL() - let trustedRoot = self.trustedRootURL() - try ExecApprovalsFileIO.assertSafeParentChain(of: fileURL, trustedRoot: trustedRoot) - try self.ensureSecureStateDirectory() - return try ExecApprovalsFileIO.withLock( - fileURL: fileURL, - trustedRoot: trustedRoot, - body) - } - - static func fileURL() -> URL { - self.stateDirURL().appendingPathComponent("exec-approvals.json") + static func databaseURL() -> URL { + ExecApprovalsSQLiteStore.databaseURL(stateDirectoryURL: self.stateDirURL()) } static func socketPath() -> String { self.stateDirURL().appendingPathComponent("exec-approvals.sock").path } - private static func trustedRootURL() -> URL { + private static func homeURL() -> URL { guard let configured = OpenClawEnv.path("OPENCLAW_HOME") else { return FileManager().homeDirectoryForCurrentUser } @@ -46,9 +31,9 @@ enum ExecApprovalsStore { private static func stateDirURL() -> URL { guard let configured = OpenClawEnv.path("OPENCLAW_STATE_DIR") else { - return self.trustedRootURL().appendingPathComponent(".openclaw", isDirectory: true) + return self.homeURL().appendingPathComponent(".openclaw", isDirectory: true) } - let home = self.trustedRootURL().path + let home = self.homeURL().path let expanded: String = if configured == "~" { home } else if configured.hasPrefix("~/") { @@ -108,190 +93,59 @@ enum ExecApprovalsStore { static func readSnapshot() -> ExecApprovalsSnapshot { do { - return try self.withWriteLock { - try self.readSnapshotUnlocked() - } + let record = try ExecApprovalsSQLiteStore.read(stateDirectoryURL: self.stateDirURL()) + return self.snapshot(record) } catch { - self.logger.warning("exec approvals snapshot lock failed: \(error.localizedDescription, privacy: .public)") + self.logger.warning("exec approvals snapshot read failed: \(error.localizedDescription, privacy: .public)") return ExecApprovalsSnapshot( - path: self.fileURL().path, - exists: ExecApprovalsFileIO.pathExistsNoFollow(self.fileURL()), + path: ExecApprovalsSQLiteStore.locator, + exists: false, hash: "", file: self.failClosedFallbackFile()) } } - private static func readSnapshotUnlocked() throws -> ExecApprovalsSnapshot { - let url = self.fileURL() - guard let current = try ExecApprovalsFileIO.read(at: url, trustedRoot: self.trustedRootURL()) else { + private static func snapshot(_ record: ExecApprovalsSQLiteRecord?) -> ExecApprovalsSnapshot { + guard let record else { return ExecApprovalsSnapshot( - path: url.path, + path: ExecApprovalsSQLiteStore.locator, exists: false, hash: self.hashRaw(nil), file: ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])) } - let raw = String(bytes: current.data, encoding: .utf8) ?? "" - let decoded = (try? self.decodeCurrentFile(current.data)) - .map(self.normalizeIncoming) ?? self.failClosedFallbackFile() return ExecApprovalsSnapshot( - path: url.path, + path: ExecApprovalsSQLiteStore.locator, exists: true, - hash: self.hashRaw(raw), - file: decoded) + hash: self.hashRaw(record.rawJSON), + file: self.normalizeIncoming(record.document)) } static func loadFile() -> ExecApprovalsFile { do { - return try self.withWriteLock { - self.loadFileUnlocked() - } + return try self.loadFileForMutation( + ExecApprovalsSQLiteStore.read(stateDirectoryURL: self.stateDirURL())) } catch { - self.logger.warning("exec approvals read lock failed: \(error.localizedDescription, privacy: .public)") + self.logger.warning("exec approvals read failed: \(error.localizedDescription, privacy: .public)") return self.failClosedFallbackFile() } } - private static func loadFileUnlocked() -> ExecApprovalsFile { - do { - return try self.loadFileForMutationUnlocked() - } catch { - self.logger.warning("exec approvals load failed: \(error.localizedDescription, privacy: .public)") - return self.failClosedFallbackFile() - } - } - - /// Existing unreadable files are policy state, not equivalent to an absent - /// file. Mutations must fail instead of replacing them with permissive defaults. - private static func loadFileForMutationUnlocked() throws -> ExecApprovalsFile { - let url = self.fileURL() - guard let current = try ExecApprovalsFileIO.read(at: url, trustedRoot: self.trustedRootURL()) else { - return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) - } - return try self.normalizeIncoming(self.decodeCurrentFile(current.data)) - } - - private static func decodeCurrentFile(_ data: Data) throws -> ExecApprovalsFile { - guard self.hasValidPersistedStructure(data) else { - throw NSError(domain: "ExecApprovals", code: 13, userInfo: [ - NSLocalizedDescriptionKey: "invalid exec approvals structure", - ]) - } - let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data) - guard decoded.version == 1 else { - throw NSError(domain: "ExecApprovals", code: 12, userInfo: [ - NSLocalizedDescriptionKey: "unsupported exec approvals version \(decoded.version)", - ]) - } - return decoded - } - - private static func fileNeedsAllowlistRewrite(_ data: Data) -> Bool { - guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let agents = root["agents"] as? [String: Any] - else { return false } - for case let agent as [String: Any] in agents.values { - guard let allowlist = agent["allowlist"] as? [Any] else { continue } - if allowlist.contains(where: { value in - guard let entry = value as? [String: Any] else { return true } - guard let rawID = entry["id"] as? String else { return true } - return rawID.isEmpty || entry["commandText"] != nil - }) { - return true - } - } - return false - } - - private static func hasValidPersistedStructure(_ data: Data) -> Bool { - guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let version = root["version"] as? NSNumber, - CFGetTypeID(version) != CFBooleanGetTypeID(), - version.doubleValue == 1 - else { return false } - - if let socket = root["socket"] { - guard let object = socket as? [String: Any], - self.hasOptionalString(object, key: "path"), - self.hasOptionalString(object, key: "token") - else { return false } - } - if let defaults = root["defaults"], !self.hasValidPolicyFields(defaults) { - return false - } - if let agents = root["agents"] { - guard let object = agents as? [String: Any] else { return false } - for value in object.values { - guard self.hasValidPolicyFields(value), let agent = value as? [String: Any] else { return false } - if let allowlist = agent["allowlist"] { - guard let entries = allowlist as? [Any], - entries.allSatisfy(self.hasValidAllowlistEntry) - else { return false } - } - } - } - return true - } - - private static func hasValidAllowlistEntry(_ value: Any) -> Bool { - if let pattern = value as? String { - return !pattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - guard let object = value as? [String: Any], - let pattern = object["pattern"] as? String, - !pattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - else { return false } - for key in ["id", "source", "commandText", "argPattern", "lastUsedCommand", "lastResolvedPath"] { - if let value = object[key], !(value is String) { - return false - } - } - if let lastUsedAt = object["lastUsedAt"] { - guard let number = lastUsedAt as? NSNumber, - CFGetTypeID(number) != CFBooleanGetTypeID(), - number.doubleValue.isFinite - else { return false } - } - return true - } - - private static func hasValidPolicyFields(_ value: Any) -> Bool { - guard let object = value as? [String: Any] else { return false } - if let security = object["security"] { - guard let raw = security as? String, ExecSecurity(rawValue: raw) != nil else { return false } - } - if let ask = object["ask"] { - guard let raw = ask as? String, ExecAsk(rawValue: raw) != nil else { return false } - } - if let fallback = object["askFallback"] { - guard let raw = fallback as? String, ExecSecurity(rawValue: raw) != nil else { return false } - } - if let autoAllowSkills = object["autoAllowSkills"], !(autoAllowSkills is Bool) { - return false - } - return true - } - - private static func hasOptionalString(_ object: [String: Any], key: String) -> Bool { - guard let value = object[key] else { return true } - return value is String - } - - private static func saveFileUnlocked(_ file: ExecApprovalsFile) throws { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(self.normalizeIncoming(file)) - let url = self.fileURL() - try self.ensureSecureStateDirectory() - try FileManager().createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true) - try ExecApprovalsFileIO.write(data, to: url, trustedRoot: self.trustedRootURL()) + private static func loadFileForMutation( + _ record: ExecApprovalsSQLiteRecord?) throws -> ExecApprovalsFile + { + self.normalizeIncoming( + record?.document ?? ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])) } static func ensureFile() -> ExecApprovalsFile { do { - return try self.withWriteLock { - try self.ensureFileUnlocked() + return try ExecApprovalsSQLiteStore.withImmediateTransaction( + stateDirectoryURL: self.stateDirURL()) + { record in + let ensured = self.ensureFile(record) + return ExecApprovalsSQLiteMutation( + value: ensured.file, + documentToWrite: ensured.needsWrite ? ensured.file : nil) } } catch { self.logger.error("exec approvals ensure failed: \(error.localizedDescription, privacy: .public)") @@ -299,17 +153,11 @@ enum ExecApprovalsStore { } } - private static func ensureFileUnlocked() throws -> ExecApprovalsFile { - try self.ensureSecureStateDirectory() - let url = self.fileURL() - let current = try ExecApprovalsFileIO.read(at: url, trustedRoot: self.trustedRootURL()) - let existed = current != nil - let needsAllowlistRewrite = current.map { self.fileNeedsAllowlistRewrite($0.data) } ?? false - let loaded = try current.map { try self.decodeCurrentFile($0.data) } - ?? ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) - let loadedHash = self.hashFile(loaded) - - var file = self.normalizeIncoming(loaded) + private static func ensureFile( + _ record: ExecApprovalsSQLiteRecord?) -> (file: ExecApprovalsFile, needsWrite: Bool) + { + var file = self.normalizeIncoming( + record?.document ?? ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])) if file.socket == nil { file.socket = ExecApprovalsSocketConfig(path: nil, token: nil) } @@ -324,10 +172,27 @@ enum ExecApprovalsStore { if file.agents == nil { file.agents = [:] } - if !existed || needsAllowlistRewrite || current?.linkCount != 1 || loadedHash != self.hashFile(file) { - try self.saveFileUnlocked(file) + let needsCanonicalRewrite = record.map { self.rawNeedsAllowlistRewrite($0.rawJSON) } ?? false + return (file, record?.document != file || needsCanonicalRewrite) + } + + private static func rawNeedsAllowlistRewrite(_ rawJSON: String) -> Bool { + guard let data = rawJSON.data(using: .utf8), + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let agents = root["agents"] as? [String: Any] + else { return false } + for case let agent as [String: Any] in agents.values { + guard let allowlist = agent["allowlist"] as? [Any] else { continue } + if allowlist.contains(where: { value in + guard let entry = value as? [String: Any], + let rawID = entry["id"] as? String + else { return true } + return rawID.isEmpty || entry["commandText"] != nil + }) { + return true + } } - return file + return false } static func saveFile( @@ -335,26 +200,28 @@ enum ExecApprovalsStore { ifBaseHash baseHash: String?) -> ExecApprovalsConditionalSaveResult { do { - return try self.withWriteLock { + return try ExecApprovalsSQLiteStore.withImmediateTransaction( + stateDirectoryURL: self.stateDirURL()) + { record in // A conditional write must not create or normalize policy state // before it proves the caller still owns the observed snapshot. - let snapshot = try self.readSnapshotUnlocked() + let snapshot = self.snapshot(record) let expected = baseHash?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if snapshot.exists { if snapshot.hash.isEmpty { - return .baseHashUnavailable + return ExecApprovalsSQLiteMutation(value: .baseHashUnavailable) } if expected.isEmpty { - return .baseHashRequired + return ExecApprovalsSQLiteMutation(value: .baseHashRequired) } if expected != snapshot.hash { - return .conflict + return ExecApprovalsSQLiteMutation(value: .conflict) } } else if !expected.isEmpty, expected != snapshot.hash { - return .conflict + return ExecApprovalsSQLiteMutation(value: .conflict) } - let current = try self.ensureFileUnlocked() + let current = self.ensureFile(record).file var normalized = self.normalizeIncoming(incoming) let socketPath = normalized.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) let token = normalized.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -366,9 +233,11 @@ enum ExecApprovalsStore { ? token! : current.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" normalized.socket = ExecApprovalsSocketConfig(path: resolvedPath, token: resolvedToken) - - try self.saveFileUnlocked(normalized) - return try .saved(self.readSnapshotUnlocked()) + let rawJSON = try ExecApprovalsSQLiteStore.serialize(normalized) + let saved = self.snapshot(ExecApprovalsSQLiteRecord( + rawJSON: rawJSON, + document: normalized)) + return ExecApprovalsSQLiteMutation(value: .saved(saved), documentToWrite: normalized) } } catch { self.logger.error("exec approvals conditional save failed: \(error.localizedDescription, privacy: .public)") @@ -389,8 +258,13 @@ enum ExecApprovalsStore { agentId: String?) -> Result { do { - let file = try self.withWriteLock { - try self.ensureFileUnlocked() + let file = try ExecApprovalsSQLiteStore.withImmediateTransaction( + stateDirectoryURL: self.stateDirURL()) + { record in + let ensured = self.ensureFile(record) + return ExecApprovalsSQLiteMutation( + value: ensured.file, + documentToWrite: ensured.needsWrite ? ensured.file : nil) } return .success(self.resolveFromFile(file, agentId: agentId)) } catch { @@ -407,21 +281,6 @@ enum ExecApprovalsStore { }.value } - /// Read-only resolve: loads file without writing (no ensureFile side effects). - /// Safe to call from background threads / off MainActor. - static func resolveReadOnly(agentId: String?) -> ExecApprovalsResolved { - let file: ExecApprovalsFile - do { - file = try self.withWriteLock { - self.loadFileUnlocked() - } - } catch { - self.logger.warning("exec approvals read-only lock failed: \(error.localizedDescription, privacy: .public)") - file = self.failClosedFallbackFile() - } - return self.resolveFromFile(file, agentId: agentId) - } - static func resolveDefaults(from file: ExecApprovalsFile) -> ExecApprovalsResolvedDefaults { let defaults = file.defaults ?? ExecApprovalsDefaults() return ExecApprovalsResolvedDefaults( @@ -449,7 +308,7 @@ enum ExecApprovalsStore { let socketPath = self.expandPath(file.socket?.path ?? self.socketPath()) let token = file.socket?.token ?? "" return ExecApprovalsResolved( - url: self.fileURL(), + url: self.databaseURL(), socketPath: socketPath, token: token, defaults: resolvedDefaults, @@ -566,8 +425,11 @@ extension ExecApprovalsStore { uniquingKeysWith: { first, _ in first }) do { - try self.withWriteLock { - var file = try self.ensureFileUnlocked() + try ExecApprovalsSQLiteStore.withImmediateTransaction( + stateDirectoryURL: self.stateDirURL()) + { record in + let ensured = self.ensureFile(record) + var file = ensured.file try self.assertCurrentExecutionAuthorization( file: file, agentId: commit.agentId, @@ -582,9 +444,9 @@ extension ExecApprovalsStore { agentId: commit.agentId, usesByKey: allUsesByKey, command: commit.command) - if grantsChanged || usesChanged { - try self.saveFileUnlocked(file) - } + return ExecApprovalsSQLiteMutation( + value: (), + documentToWrite: ensured.needsWrite || grantsChanged || usesChanged ? file : nil) } return .success(()) } catch { @@ -612,16 +474,19 @@ extension ExecApprovalsStore { uses.map { (self.allowlistEntryMatchKey($0.match), $0) }, uniquingKeysWith: { first, _ in first }) do { - try self.withWriteLock { - var file = try self.ensureFileUnlocked() - if self.applyAllowlistUsesUnlocked( + try ExecApprovalsSQLiteStore.withImmediateTransaction( + stateDirectoryURL: self.stateDirURL()) + { record in + let ensured = self.ensureFile(record) + var file = ensured.file + let changed = self.applyAllowlistUsesUnlocked( file: &file, agentId: agentId, usesByKey: usesByKey, command: command) - { - try self.saveFileUnlocked(file) - } + return ExecApprovalsSQLiteMutation( + value: (), + documentToWrite: ensured.needsWrite || changed ? file : nil) } return .success(()) } catch { @@ -888,10 +753,14 @@ extension ExecApprovalsStore { _ mutate: (inout ExecApprovalsFile) throws -> Void) -> Result { do { - try self.withWriteLock { - var file = try self.ensureFileUnlocked() + try ExecApprovalsSQLiteStore.withImmediateTransaction( + stateDirectoryURL: self.stateDirURL()) + { record in + var file = self.ensureFile(record).file try mutate(&file) - try self.saveFileUnlocked(file) + return ExecApprovalsSQLiteMutation( + value: (), + documentToWrite: self.normalizeIncoming(file)) } return .success(()) } catch let error as ExecApprovalsMutationError { @@ -913,23 +782,6 @@ extension ExecApprovalsStore { argPattern: entry.argPattern) } - private static func ensureSecureStateDirectory() throws { - let url = self.stateDirURL() - // Create with the final 0700 mode directly: a default-mode (0755) - // create followed by the chmod below leaves a transient window where - // the directory is world-listable and concurrent observers see the - // wrong permissions. - try FileManager().createDirectory( - at: url, - withIntermediateDirectories: true, - attributes: [.posixPermissions: self.secureStateDirPermissions]) - try ExecApprovalsFileIO.assertSafeDirectory(at: url) - try FileManager().setAttributes( - [.posixPermissions: self.secureStateDirPermissions], - ofItemAtPath: url.path) - try ExecApprovalsFileIO.assertSafeDirectory(at: url) - } - private static func generateToken() -> String { var bytes = [UInt8](repeating: 0, count: 24) let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) @@ -950,14 +802,6 @@ extension ExecApprovalsStore { return raw == nil ? "missing:\(hash)" : hash } - private static func hashFile(_ file: ExecApprovalsFile) -> String { - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - let data = (try? encoder.encode(file)) ?? Data() - let digest = SHA256.hash(data: data) - return digest.map { String(format: "%02x", $0) }.joined() - } - static func expandPath(_ raw: String) -> String { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let configuredHome = OpenClawEnv.path("OPENCLAW_HOME") diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift index 8cf2d405b427..f2d588087d9e 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift @@ -1,6 +1,6 @@ -import CryptoKit import Foundation import OpenClawKit +import SQLite3 import Testing @testable import OpenClaw @@ -89,16 +89,13 @@ struct ExecApprovalsStoreRefactorTests { } @Test - func `ensure file skips rewrite when unchanged`() async throws { + func `ensure store reuses socket token when unchanged`() async throws { try await self.withTempStateDir { _ in - _ = ExecApprovalsStore.ensureFile() - let url = ExecApprovalsStore.fileURL() - let firstIdentity = try Self.fileIdentity(at: url) + let first = ExecApprovalsStore.ensureFile() + let second = ExecApprovalsStore.ensureFile() - _ = ExecApprovalsStore.ensureFile() - let secondIdentity = try Self.fileIdentity(at: url) - - #expect(firstIdentity == secondIdentity) + #expect(first.socket?.token == "test-token") + #expect(second.socket?.token == first.socket?.token) } } @@ -115,7 +112,7 @@ struct ExecApprovalsStoreRefactorTests { } @Test - func `effective home owns the default approvals path`() async throws { + func `effective home owns the default approvals database and socket paths`() async throws { let root = self.realTemporaryDirectory .appendingPathComponent("openclaw-effective-home-\(UUID().uuidString)", isDirectory: true) let home = root.appendingPathComponent("home", isDirectory: true) @@ -127,8 +124,8 @@ struct ExecApprovalsStoreRefactorTests { "OPENCLAW_HOME": home.path, "OPENCLAW_STATE_DIR": nil, ]) { - #expect(ExecApprovalsStore.fileURL().path == stateDir.appendingPathComponent( - "exec-approvals.json").path) + #expect(ExecApprovalsStore.databaseURL().path == stateDir + .appendingPathComponent("state/openclaw.sqlite").path) #expect(ExecApprovalsStore.socketPath() == stateDir.appendingPathComponent( "exec-approvals.sock").path) let resolved = try ExecApprovalsStore.resolveResult(agentId: "main").get() @@ -138,17 +135,14 @@ struct ExecApprovalsStoreRefactorTests { } @Test - func `malformed existing file fails closed and rejects mutation`() async throws { + func `malformed SQLite document fails closed and rejects mutation`() async throws { try await self.withTempStateDir { _ in - let url = ExecApprovalsStore.fileURL() - let malformed = Data("{".utf8) - try malformed.write(to: url, options: [.atomic]) + try Self.replaceRawJSON("{") let resolved = ExecApprovalsStore.resolve(agentId: "main") #expect(resolved.agent.security == .deny) #expect(resolved.agent.ask == .off) #expect(resolved.agent.askFallback == .deny) - #expect(try Data(contentsOf: url) == malformed) let result = ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in entry.security = .full @@ -157,7 +151,6 @@ struct ExecApprovalsStoreRefactorTests { Issue.record("expected malformed-file mutation failure") return } - #expect(try Data(contentsOf: url) == malformed) let snapshot = ExecApprovalsStore.readSnapshot() #expect(snapshot.file.defaults?.security == .deny) @@ -180,14 +173,11 @@ struct ExecApprovalsStoreRefactorTests { #"{"version":1,"agents":{"main":{"allowlist":[{"pattern":"/usr/bin/foo","argPattern":0}]}}}"#, #"{"version":1,"agents":{"main":{"allowlist":[{"pattern":"/usr/bin/foo","argPattern":false}]}}}"#, ] { - let raw = Data(json.utf8) - let url = ExecApprovalsStore.fileURL() - try raw.write(to: url, options: [.atomic]) + try Self.replaceRawJSON(json) let resolved = ExecApprovalsStore.resolve(agentId: "main") #expect(resolved.agent.security == .deny) #expect(resolved.agent.ask == .off) - #expect(try Data(contentsOf: url) == raw) let result = ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in entry.security = .full @@ -196,7 +186,6 @@ struct ExecApprovalsStoreRefactorTests { Issue.record("expected invalid-structure mutation failure") return } - #expect(try Data(contentsOf: url) == raw) } } } @@ -204,7 +193,7 @@ struct ExecApprovalsStoreRefactorTests { @Test func `string source and arg pattern bytes remain cross-runtime compatible`() async throws { try await self.withTempStateDir { _ in - let raw = Data( + try Self.replaceRawJSON( """ { "version": 1, @@ -221,8 +210,7 @@ struct ExecApprovalsStoreRefactorTests { } } } - """.utf8) - try raw.write(to: ExecApprovalsStore.fileURL(), options: [.atomic]) + """) let resolved = try ExecApprovalsStore.resolveResult(agentId: "main").get() let entry = try #require(resolved.allowlist.first) @@ -233,7 +221,8 @@ struct ExecApprovalsStoreRefactorTests { #expect(entry.source == "external-policy") #expect(entry.argPattern == " ") - let persisted = try #require(ExecApprovalsStore.loadFile().agents?["main"]?.allowlist?.first) + let persisted = try #require( + ExecApprovalsStore.loadFile().agents?["main"]?.allowlist?.first) #expect(persisted.source == "external-policy") #expect(persisted.argPattern == " ") } @@ -253,224 +242,14 @@ struct ExecApprovalsStoreRefactorTests { } } - @Test - func `malformed default policy does not block custom state mutations`() async throws { - try await self.withTempHomeAndStateDir { home, _ in - let legacyDir = home.appendingPathComponent(".openclaw", isDirectory: true) - try FileManager().createDirectory(at: legacyDir, withIntermediateDirectories: true) - let legacyURL = legacyDir.appendingPathComponent("exec-approvals.json") - let malformed = Data(#"{"version":1,"agents":null}"#.utf8) - try malformed.write(to: legacyURL) - - let readOnly = ExecApprovalsStore.resolveReadOnly(agentId: "main") - #expect(readOnly.agent.security == .full) - #expect(readOnly.agent.ask == .off) - - let ensured = ExecApprovalsStore.ensureFile() - #expect(ensured.socket?.token?.isEmpty == false) - #expect(FileManager().fileExists(atPath: ExecApprovalsStore.fileURL().path)) - - let mutation = ExecApprovalsStore.updateDefaults { $0.security = .allowlist } - guard case .success = mutation else { - Issue.record("expected custom state mutation to succeed") - return - } - #expect(try Data(contentsOf: legacyURL) == malformed) - #expect(!FileManager().fileExists(atPath: "\(legacyURL.path).migrated")) - } - } - - @Test - func `symlinked default policy cannot seed the custom store`() async throws { - try await self.withTempHomeAndStateDir { home, _ in - let legacyDir = home.appendingPathComponent(".openclaw", isDirectory: true) - try FileManager().createDirectory(at: legacyDir, withIntermediateDirectories: true) - let linkedTarget = home.appendingPathComponent("linked-policy.json") - let permissive = Data(#"{"version":1,"defaults":{"security":"full","ask":"off"}}"#.utf8) - try permissive.write(to: linkedTarget) - let legacyURL = legacyDir.appendingPathComponent("exec-approvals.json") - try FileManager().createSymbolicLink(at: legacyURL, withDestinationURL: linkedTarget) - - let ensured = ExecApprovalsStore.ensureFile() - - #expect(ensured.socket?.token?.isEmpty == false) - #expect(FileManager().fileExists(atPath: ExecApprovalsStore.fileURL().path)) - #expect(try Data(contentsOf: linkedTarget) == permissive) - #expect(try FileManager().destinationOfSymbolicLink(atPath: legacyURL.path) == linkedTarget.path) - } - } - - @Test - func `symlinked current policy fails closed without altering its target`() async throws { - try await self.withTempStateDir { stateDir in - let url = ExecApprovalsStore.fileURL() - let target = stateDir.appendingPathComponent("linked-policy.json") - let permissive = Data(#"{"version":1,"defaults":{"security":"full","ask":"off"}}"#.utf8) - try permissive.write(to: target) - try FileManager().removeItem(at: url) - try FileManager().createSymbolicLink(at: url, withDestinationURL: target) - - let resolved = ExecApprovalsStore.resolve(agentId: "main") - #expect(resolved.agent.security == .deny) - #expect(resolved.agent.ask == .off) - - let mutation = ExecApprovalsStore.updateDefaults { $0.security = .allowlist } - guard case .failure(.unavailable) = mutation else { - Issue.record("expected symlink mutation failure") - return - } - #expect(try Data(contentsOf: target) == permissive) - #expect(try FileManager().destinationOfSymbolicLink(atPath: url.path) == target.path) - } - } - - @Test - func `symlinked approvals directory fails closed before load or update`() async throws { - let root = self.realTemporaryDirectory - .appendingPathComponent("openclaw-symlink-parent-\(UUID().uuidString)", isDirectory: true) - let home = root.appendingPathComponent("home", isDirectory: true) - let redirected = root.appendingPathComponent("redirected", isDirectory: true) - let linkedState = home.appendingPathComponent(".openclaw", isDirectory: true) - defer { try? FileManager().removeItem(at: root) } - try FileManager().createDirectory(at: home, withIntermediateDirectories: true) - try FileManager().createDirectory(at: redirected, withIntermediateDirectories: true) - try FileManager().createSymbolicLink(at: linkedState, withDestinationURL: redirected) - let target = redirected.appendingPathComponent("exec-approvals.json") - let permissive = Data(#"{"version":1,"defaults":{"security":"full","ask":"off"}}"#.utf8) - try permissive.write(to: target) - - try await self.withLockedEnv([ - "OPENCLAW_HOME": home.path, - "OPENCLAW_STATE_DIR": linkedState.path, - ]) { - let resolved = ExecApprovalsStore.resolve(agentId: "main") - #expect(resolved.agent.security == .deny) - #expect(resolved.agent.ask == .off) - guard case .failure(.unavailable) = ExecApprovalsStore.updateDefaults({ - $0.security = .allowlist - }) else { - Issue.record("expected symlink-parent mutation failure") - return - } - let persisted = try Data(contentsOf: target) - #expect(persisted == permissive) - #expect(!FileManager().fileExists(atPath: redirected.appendingPathComponent( - "exec-approvals.json.lock").path)) - } - } - - @Test - func `symlinked configured state directory outside home fails closed`() async throws { - let root = self.realTemporaryDirectory - .appendingPathComponent("openclaw-symlink-external-state-\(UUID().uuidString)", isDirectory: true) - let home = root.appendingPathComponent("home", isDirectory: true) - let redirected = root.appendingPathComponent("redirected", isDirectory: true) - let linkedState = root.appendingPathComponent("linked-state", isDirectory: true) - defer { try? FileManager().removeItem(at: root) } - try FileManager().createDirectory(at: home, withIntermediateDirectories: true) - try FileManager().createDirectory(at: redirected, withIntermediateDirectories: true) - try FileManager().createSymbolicLink(at: linkedState, withDestinationURL: redirected) - let target = redirected.appendingPathComponent("exec-approvals.json") - let permissive = Data(#"{"version":1,"defaults":{"security":"full","ask":"off"}}"#.utf8) - try permissive.write(to: target) - - try await self.withLockedEnv([ - "OPENCLAW_HOME": home.path, - "OPENCLAW_STATE_DIR": linkedState.path, - ]) { - let resolved = ExecApprovalsStore.resolve(agentId: "main") - #expect(resolved.agent.security == .deny) - #expect(resolved.agent.ask == .off) - guard case .failure(.unavailable) = ExecApprovalsStore.resolveResult(agentId: "main") else { - Issue.record("expected configured symlink state directory read failure") - return - } - guard case .failure(.unavailable) = ExecApprovalsStore.updateDefaults({ - $0.security = .allowlist - }) else { - Issue.record("expected configured symlink state directory mutation failure") - return - } - let persisted = try Data(contentsOf: target) - #expect(persisted == permissive) - #expect(!FileManager().fileExists(atPath: redirected.appendingPathComponent( - "exec-approvals.json.lock").path)) - } - } - - @Test - func `symlinked trusted home root remains supported`() async throws { - let root = self.realTemporaryDirectory - .appendingPathComponent("openclaw-symlink-root-\(UUID().uuidString)", isDirectory: true) - let realHome = root.appendingPathComponent("real-home", isDirectory: true) - let linkedHome = root.appendingPathComponent("linked-home", isDirectory: true) - let stateDir = linkedHome.appendingPathComponent(".openclaw", isDirectory: true) - defer { try? FileManager().removeItem(at: root) } - try FileManager().createDirectory(at: realHome, withIntermediateDirectories: true) - try Self.seedCurrentApprovalsFile(in: realHome.appendingPathComponent(".openclaw")) - try FileManager().createSymbolicLink(at: linkedHome, withDestinationURL: realHome) - - try await self.withLockedEnv([ - "OPENCLAW_HOME": linkedHome.path, - "OPENCLAW_STATE_DIR": stateDir.path, - ]) { - let resolved = try ExecApprovalsStore.resolveResult(agentId: "main").get() - #expect(resolved.agent.security == .full) - #expect(resolved.agent.ask == .off) - #expect(FileManager().fileExists(atPath: realHome.appendingPathComponent( - ".openclaw/exec-approvals.json").path)) - } - } - - @Test - func `non-file current policy fails closed and rejects mutation`() async throws { - try await self.withTempStateDir { _ in - let url = ExecApprovalsStore.fileURL() - try FileManager().removeItem(at: url) - try FileManager().createDirectory(at: url, withIntermediateDirectories: false) - - let resolved = ExecApprovalsStore.resolve(agentId: "main") - #expect(resolved.agent.security == .deny) - #expect(resolved.agent.ask == .off) - guard case .failure(.unavailable) = ExecApprovalsStore.updateDefaults({ - $0.security = .full - }) else { - Issue.record("expected non-file mutation failure") - return - } - var isDirectory = ObjCBool(false) - #expect(FileManager().fileExists(atPath: url.path, isDirectory: &isDirectory)) - #expect(isDirectory.boolValue) - } - } - - @Test - func `ensure breaks a shared hard link without altering its peer`() async throws { - try await self.withTempStateDir { stateDir in - let url = ExecApprovalsStore.fileURL() - let peer = stateDir.appendingPathComponent("linked-peer.json") - try FileManager().linkItem(at: url, to: peer) - let peerBefore = try Data(contentsOf: peer) - - _ = ExecApprovalsStore.ensureFile() - - #expect(try Data(contentsOf: peer) == peerBefore) - let targetLinks = try FileManager().attributesOfItem(atPath: url.path)[.referenceCount] as? NSNumber - let peerLinks = try FileManager().attributesOfItem(atPath: peer.path)[.referenceCount] as? NSNumber - #expect(targetLinks?.intValue == 1) - #expect(peerLinks?.intValue == 1) - } - } - @Test func `missing and present empty snapshots have distinct hashes`() async throws { - try await self.withTempHomeAndStateDir { _, stateDir in + try await self.withTempHomeAndStateDir { _, _ in let missing = ExecApprovalsStore.readSnapshot() #expect(!missing.exists) #expect(missing.hash.hasPrefix("missing:")) - try FileManager().createDirectory(at: stateDir, withIntermediateDirectories: true) - try Data().write(to: ExecApprovalsStore.fileURL()) + _ = ExecApprovalsStore.ensureFile() let empty = ExecApprovalsStore.readSnapshot() #expect(empty.exists) @@ -479,43 +258,10 @@ struct ExecApprovalsStoreRefactorTests { } } - @Test - func `missing file with held sidecar lock fails closed`() async throws { - try await self.withTempHomeAndStateDir { _, stateDir in - try FileManager().createDirectory(at: stateDir, withIntermediateDirectories: true) - let lockURL = stateDir.appendingPathComponent("exec-approvals.json.lock") - try Data("held".utf8).write(to: lockURL) - - let resolved = ExecApprovalsStore.resolve(agentId: "main") - - #expect(resolved.agent.security == .deny) - #expect(resolved.agent.ask == .off) - #expect(!FileManager().fileExists(atPath: ExecApprovalsStore.fileURL().path)) - #expect(FileManager().fileExists(atPath: lockURL.path)) - } - } - - @Test - func `valid permissive file with held sidecar lock fails closed`() async throws { - try await self.withTempStateDir { stateDir in - _ = try ExecApprovalsStore.updateDefaults { defaults in - defaults.security = .full - defaults.ask = .off - }.get() - let lockURL = stateDir.appendingPathComponent("exec-approvals.json.lock") - try Data("held".utf8).write(to: lockURL) - - let resolved = ExecApprovalsStore.resolve(agentId: "main") - - #expect(resolved.agent.security == .deny) - #expect(resolved.agent.ask == .off) - } - } - @Test func `native rewrites preserve non-sensitive metadata and arbitrary ids`() async throws { try await self.withTempStateDir { _ in - let raw = Data( + try Self.replaceRawJSON( """ { "version": 1, @@ -531,8 +277,7 @@ struct ExecApprovalsStoreRefactorTests { } } } - """.utf8) - try raw.write(to: ExecApprovalsStore.fileURL(), options: [.atomic]) + """) _ = ExecApprovalsStore.ensureFile() let entry = try #require(ExecApprovalsStore.loadFile().agents?["main"]?.allowlist?.first) @@ -548,8 +293,12 @@ struct ExecApprovalsStoreRefactorTests { commandText: "echo secret-token").get() let entries = try #require(ExecApprovalsStore.loadFile().agents?["main"]?.allowlist) #expect(entries.allSatisfy { $0.commandText == nil }) - let persisted = try String(contentsOf: ExecApprovalsStore.fileURL(), encoding: .utf8) - #expect(!persisted.contains("commandText")) + let record = try ExecApprovalsSQLiteStore.read( + stateDirectoryURL: ExecApprovalsStore.databaseURL() + .deletingLastPathComponent() + .deletingLastPathComponent()) + let persisted = try #require(record) + #expect(!persisted.rawJSON.contains("commandText")) } } @@ -1152,80 +901,8 @@ struct ExecApprovalsStoreRefactorTests { } } } + extension ExecApprovalsStoreRefactorTests { - @Test - func `ensure file keeps custom state isolated from default approvals`() async throws { - try await self.withTempHomeAndStateDir { home, stateDir in - let legacyDir = home.appendingPathComponent(".openclaw", isDirectory: true) - try FileManager().createDirectory( - at: legacyDir, - withIntermediateDirectories: true) - let legacySocket = legacyDir.appendingPathComponent("exec-approvals.sock").path - let legacyFile = legacyDir.appendingPathComponent("exec-approvals.json") - let legacyJson = """ - { - "version": 1, - "socket": { - "path": "\(legacySocket)", - "token": "legacy-token" - }, - "defaults": { - "security": "deny", - "ask": "always" - }, - "agents": { - "main": { - "allowlist": [{ "pattern": "git status" }] - } - } - } - """ - try Data(legacyJson.utf8).write(to: legacyFile) - let legacyBefore = try Data(contentsOf: legacyFile) - - let file = ExecApprovalsStore.ensureFile() - let targetURL = ExecApprovalsStore.fileURL() - - let expectedFileURL = stateDir - .appendingPathComponent("exec-approvals.json") - .standardizedFileURL - #expect(targetURL.path == expectedFileURL.path) - #expect(FileManager().fileExists(atPath: targetURL.path)) - #expect(file.socket?.path == ExecApprovalsStore.socketPath()) - #expect(file.socket?.token != "legacy-token") - #expect(file.agents?["main"]?.allowlist == nil) - #expect(try Data(contentsOf: legacyFile) == legacyBefore) - #expect(!FileManager().fileExists(atPath: "\(legacyFile.path).migrated")) - } - } - - @Test - func `ensure file keeps named profile isolated from default approvals`() async throws { - try await self.withTempHomeAndStateDir(profile: "work") { home, stateDir in - let defaultDir = home.appendingPathComponent(".openclaw", isDirectory: true) - try FileManager().createDirectory(at: defaultDir, withIntermediateDirectories: true) - let defaultFile = defaultDir.appendingPathComponent("exec-approvals.json") - let defaultJson = """ - { - "version": 1, - "socket": { "token": "default-profile-token" }, - "defaults": { "security": "full", "ask": "off" }, - "agents": {} - } - """ - try Data(defaultJson.utf8).write(to: defaultFile) - let defaultBefore = try Data(contentsOf: defaultFile) - - let file = ExecApprovalsStore.ensureFile() - - #expect(file.socket?.token != "default-profile-token") - #expect(FileManager().fileExists( - atPath: stateDir.appendingPathComponent("exec-approvals.json").path)) - #expect(try Data(contentsOf: defaultFile) == defaultBefore) - #expect(!FileManager().fileExists(atPath: "\(defaultFile.path).migrated")) - } - } - @Test func `add allowlist entries accepts basename pattern`() async throws { try await self.withTempStateDir { _ in @@ -1244,10 +921,8 @@ extension ExecApprovalsStoreRefactorTests { @Test func `ensure file migrates legacy pattern from resolved path`() async throws { try await self.withTempStateDir { _ in - let raw = Data( - #"{"version":1,"agents":{"main":{"allowlist":[{"pattern":"echo","lastResolvedPath":" /usr/bin/echo "}]}}}"# - .utf8) - try raw.write(to: ExecApprovalsStore.fileURL(), options: [.atomic]) + try Self.replaceRawJSON( + #"{"version":1,"agents":{"main":{"allowlist":[{"pattern":"echo","lastResolvedPath":" /usr/bin/echo "}]}}}"#) let ensured = ExecApprovalsStore.ensureFile() #expect(ensured.agents?["main"]?.allowlist?.map(\.pattern) == ["/usr/bin/echo"]) @@ -1255,81 +930,13 @@ extension ExecApprovalsStoreRefactorTests { } } - @Test - func `native writes wait for the shared sidecar lock`() async throws { - try await self.withTempStateDir { stateDir in - let lockURL = stateDir.appendingPathComponent("exec-approvals.json.lock") - let payload = try JSONSerialization.data(withJSONObject: [ - "pid": Int(getpid()), - "createdAt": ISO8601DateFormatter().string(from: Date()), - ]) - try payload.write(to: lockURL) - DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(100)) { - try? FileManager().removeItem(at: lockURL) - } - - let startedAt = Date() - _ = try ExecApprovalsStore.addAllowlistEntry( - agentId: "main", - pattern: "/bin/echo").get() - - #expect(Date().timeIntervalSince(startedAt) >= 0.075) - #expect(ExecApprovalsStore.resolve(agentId: "main").allowlist.map(\.pattern) == ["/bin/echo"]) - } - } - - @Test - func `native writer keeps lock from reused pid`() async throws { - try await self.withTempStateDir { stateDir in - let lockURL = stateDir.appendingPathComponent("exec-approvals.json.lock") - let payload = try JSONSerialization.data(withJSONObject: [ - "pid": Int(getpid()), - "createdAt": ISO8601DateFormatter().string(from: Date()), - "starttime": 0, - ]) - try payload.write(to: lockURL) - - _ = ExecApprovalsStore.addAllowlistEntry( - agentId: "main", - pattern: "/bin/echo") - - #expect(FileManager().fileExists(atPath: lockURL.path)) - #expect(try Data(contentsOf: lockURL) == payload) - #expect(ExecApprovalsStore.loadFile().agents?["main"]?.allowlist?.isEmpty != false) - } - } - - @Test - func `native writer keeps expired malformed lock`() async throws { - try await self.withTempStateDir { stateDir in - let lockURL = stateDir.appendingPathComponent("exec-approvals.json.lock") - try Data("{".utf8).write(to: lockURL) - try FileManager().setAttributes( - [.modificationDate: Date().addingTimeInterval(-31)], - ofItemAtPath: lockURL.path) - - let result = ExecApprovalsStore.addAllowlistEntry( - agentId: "main", - pattern: "/bin/echo") - - guard case .failure(.unavailable) = result else { - Issue.record("expected lock contention failure") - return - } - #expect(FileManager().fileExists(atPath: lockURL.path)) - #expect(try Data(contentsOf: lockURL) == Data("{".utf8)) - #expect(ExecApprovalsStore.loadFile().agents?["main"]?.allowlist?.isEmpty != false) - } - } - @Test func `entry scoped update persists a legacy missing id before editing`() async throws { try await self.withTempStateDir { _ in - let raw = Data( + try Self.replaceRawJSON( """ {"version":1,"agents":{"main":{"allowlist":[{"pattern":"/bin/echo"}]}}} - """.utf8) - try raw.write(to: ExecApprovalsStore.fileURL(), options: [.atomic]) + """) let resolved = ExecApprovalsStore.resolve(agentId: "main") guard let id = resolved.allowlist.first?.id else { @@ -1353,17 +960,21 @@ extension ExecApprovalsStoreRefactorTests { @Test func `legacy string entry receives a stable persisted id`() async throws { try await self.withTempStateDir { _ in - let raw = Data(#"{"version":1,"agents":{"main":{"allowlist":["/bin/echo"]}}}"#.utf8) - try raw.write(to: ExecApprovalsStore.fileURL(), options: [.atomic]) + try Self.replaceRawJSON( + #"{"version":1,"agents":{"main":{"allowlist":["/bin/echo"]}}}"#) let first = try #require(ExecApprovalsStore.ensureFile().agents?["main"]?.allowlist?.first) let second = try #require(ExecApprovalsStore.loadFile().agents?["main"]?.allowlist?.first) #expect(first.id == second.id) #expect(first.pattern == "/bin/echo") - let persisted = try String(contentsOf: ExecApprovalsStore.fileURL(), encoding: .utf8) - #expect(persisted.contains(first.id)) - #expect(!persisted.contains(#"["/bin/echo"]"#)) + let record = try ExecApprovalsSQLiteStore.read( + stateDirectoryURL: ExecApprovalsStore.databaseURL() + .deletingLastPathComponent() + .deletingLastPathComponent()) + let persisted = try #require(record) + #expect(persisted.rawJSON.contains(first.id)) + #expect(!persisted.rawJSON.contains(#"["/bin/echo"]"#)) } } @@ -1449,14 +1060,15 @@ extension ExecApprovalsStoreRefactorTests { } @Test - func `conditional save does not recreate deleted approval state`() async throws { + func `conditional save does not recreate a deleted approval row`() async throws { try await self.withTempStateDir { _ in _ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in entry.security = .allowlist entry.allowlist = [ExecAllowlistEntry(pattern: "/bin/echo")] }.get() let stale = ExecApprovalsStore.readSnapshot() - try FileManager().removeItem(at: ExecApprovalsStore.fileURL()) + let databaseURL = ExecApprovalsStore.databaseURL() + try FileManager().removeItem(at: databaseURL) let result = ExecApprovalsStore.saveFile(stale.file, ifBaseHash: stale.hash) @@ -1466,34 +1078,15 @@ extension ExecApprovalsStoreRefactorTests { default: Issue.record("expected deleted approval state to remain absent") } - #expect(!FileManager().fileExists(atPath: ExecApprovalsStore.fileURL().path)) + let stateDirectoryURL = databaseURL + .deletingLastPathComponent() + .deletingLastPathComponent() + #expect(try ExecApprovalsSQLiteStore.read( + stateDirectoryURL: stateDirectoryURL) == nil) } } - @Test - func `ensure file hardens state directory permissions`() async throws { - try await self.withTempStateDir { stateDir in - try FileManager().createDirectory(at: stateDir, withIntermediateDirectories: true) - try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: stateDir.path) - - _ = ExecApprovalsStore.ensureFile() - let attrs = try FileManager().attributesOfItem(atPath: stateDir.path) - let permissions = (attrs[.posixPermissions] as? NSNumber)?.intValue ?? -1 - #expect(permissions & 0o777 == 0o700) - } - } - - private static func fileIdentity(at url: URL) throws -> Int { - let attributes = try FileManager().attributesOfItem(atPath: url.path) - guard let identifier = (attributes[.systemFileNumber] as? NSNumber)?.intValue else { - struct MissingIdentifierError: Error {} - throw MissingIdentifierError() - } - return identifier - } - private static func seedCurrentApprovalsFile(in stateDir: URL) throws { - try FileManager().createDirectory(at: stateDir, withIntermediateDirectories: true) let file = ExecApprovalsFile( version: 1, socket: ExecApprovalsSocketConfig( @@ -1501,9 +1094,39 @@ extension ExecApprovalsStoreRefactorTests { token: "test-token"), defaults: nil, agents: [:]) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - try encoder.encode(file) - .write(to: stateDir.appendingPathComponent("exec-approvals.json")) + try ExecApprovalsSQLiteStore.write(file, stateDirectoryURL: stateDir) + } + + private static func replaceRawJSON(_ rawJSON: String) throws { + let databaseURL = ExecApprovalsStore.databaseURL() + var database: OpaquePointer? + guard sqlite3_open(databaseURL.path, &database) == SQLITE_OK, let database else { + throw SQLiteTestError.open + } + defer { sqlite3_close(database) } + var statement: OpaquePointer? + guard sqlite3_prepare_v2( + database, + "UPDATE exec_approvals_config SET raw_json = ? WHERE config_key = 'current'", + -1, + &statement, + nil) == SQLITE_OK, + let statement + else { + throw SQLiteTestError.prepare + } + defer { sqlite3_finalize(statement) } + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + guard sqlite3_bind_text(statement, 1, rawJSON, -1, transient) == SQLITE_OK, + sqlite3_step(statement) == SQLITE_DONE + else { + throw SQLiteTestError.update + } + } + + private enum SQLiteTestError: Error { + case open + case prepare + case update } } diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsUIRollbackTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsUIRollbackTests.swift index f57fa0eff649..b4f3d60c85c3 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsUIRollbackTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsUIRollbackTests.swift @@ -1,4 +1,6 @@ import Foundation +import OpenClawKit +import SQLite3 import Testing @testable import OpenClaw @@ -12,9 +14,9 @@ struct ExecApprovalsUIRollbackTests { defaults.security = .full defaults.ask = .off }.get() - let lockURL = stateDir.appendingPathComponent("exec-approvals.json.lock") - try Data("held".utf8).write(to: lockURL) - defer { try? FileManager().removeItem(at: lockURL) } + let record = try ExecApprovalsSQLiteStore.read(stateDirectoryURL: stateDir) + let rawJSON = try #require(record?.rawJSON) + try Self.replaceRawJSON("{", stateDirectoryURL: stateDir) let state = AppState( preview: true, @@ -26,7 +28,7 @@ struct ExecApprovalsUIRollbackTests { #expect(!state.execApprovalPolicyAvailable) #expect(state.execApprovalLoadError != nil) - try FileManager().removeItem(at: lockURL) + try Self.replaceRawJSON(rawJSON, stateDirectoryURL: stateDir) await state.recoverExecApprovalModeRead(maxAttempts: 1) #expect(state.execApprovalPolicyAvailable) @@ -45,9 +47,9 @@ struct ExecApprovalsUIRollbackTests { _ = try ExecApprovalsStore.addAllowlistEntry( agentId: "main", pattern: "/usr/bin/printf").get() - let lockURL = stateDir.appendingPathComponent("exec-approvals.json.lock") - try Data("held".utf8).write(to: lockURL) - defer { try? FileManager().removeItem(at: lockURL) } + let record = try ExecApprovalsSQLiteStore.read(stateDirectoryURL: stateDir) + let rawJSON = try #require(record?.rawJSON) + try Self.replaceRawJSON("{", stateDirectoryURL: stateDir) let model = ExecApprovalsSettingsModel( readRetryDelay: .zero, automaticReadRetryAttempts: 0) @@ -62,7 +64,7 @@ struct ExecApprovalsUIRollbackTests { #expect(!model.policyAvailable) #expect(model.readErrorMessage != nil) - try FileManager().removeItem(at: lockURL) + try Self.replaceRawJSON(rawJSON, stateDirectoryURL: stateDir) await model.retryUnavailableSettings(maxAttempts: 1) #expect(model.policyAvailable) @@ -187,7 +189,11 @@ struct ExecApprovalsUIRollbackTests { }.get() let model = ExecApprovalsSettingsModel() await model.loadSettings(for: "main") - try Data("{".utf8).write(to: ExecApprovalsStore.fileURL(), options: [.atomic]) + try Self.replaceRawJSON( + "{", + stateDirectoryURL: ExecApprovalsStore.databaseURL() + .deletingLastPathComponent() + .deletingLastPathComponent()) model.setSecurity(.full) @@ -213,7 +219,11 @@ struct ExecApprovalsUIRollbackTests { #expect(normalized == "/bin/echo") #expect(model.entry(for: entry.id)?.pattern == "/bin/echo") - try Data("{".utf8).write(to: ExecApprovalsStore.fileURL(), options: [.atomic]) + try Self.replaceRawJSON( + "{", + stateDirectoryURL: ExecApprovalsStore.databaseURL() + .deletingLastPathComponent() + .deletingLastPathComponent()) let rolledBack = model.updateEntry(pattern: "/bin/cat", id: entry.id) #expect(rolledBack == "/bin/echo") @@ -257,6 +267,43 @@ struct ExecApprovalsUIRollbackTests { try await body(stateDir) } } + + private static func replaceRawJSON( + _ rawJSON: String, + stateDirectoryURL: URL) throws + { + let databaseURL = ExecApprovalsSQLiteStore.databaseURL( + stateDirectoryURL: stateDirectoryURL) + var database: OpaquePointer? + guard sqlite3_open(databaseURL.path, &database) == SQLITE_OK, let database else { + throw SQLiteTestError.open + } + defer { sqlite3_close(database) } + var statement: OpaquePointer? + guard sqlite3_prepare_v2( + database, + "UPDATE exec_approvals_config SET raw_json = ? WHERE config_key = 'current'", + -1, + &statement, + nil) == SQLITE_OK, + let statement + else { + throw SQLiteTestError.prepare + } + defer { sqlite3_finalize(statement) } + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + guard sqlite3_bind_text(statement, 1, rawJSON, -1, transient) == SQLITE_OK, + sqlite3_step(statement) == SQLITE_DONE + else { + throw SQLiteTestError.update + } + } + + private enum SQLiteTestError: Error { + case open + case prepare + case update + } } private actor ExecApprovalsReadGate { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/ExecApprovalsLegacyMigrationGate.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/ExecApprovalsLegacyMigrationGate.swift new file mode 100644 index 000000000000..5e6e8cd48948 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/ExecApprovalsLegacyMigrationGate.swift @@ -0,0 +1,42 @@ +import Foundation + +enum ExecApprovalsLegacyMigrationGate { + private static let doctorClaimSuffix = ".doctor-importing" + + static func assertReady(stateDirectoryURL: URL) throws { + try self.assertReady( + stateDirectoryURL: stateDirectoryURL, + pathMayExist: self.pathMayExist) + } + + static func assertReady( + stateDirectoryURL: URL, + pathMayExist: (URL) -> Bool) throws + { + let sourceURL = stateDirectoryURL.appendingPathComponent( + "exec-approvals.json", + isDirectory: false) + let claimURL = URL(fileURLWithPath: sourceURL.path + self.doctorClaimSuffix) + // Probe source on both sides of the claim so neither Doctor's source -> claim + // rename nor its claim -> source recovery can pass through an absent window. + guard !pathMayExist(sourceURL), + !pathMayExist(claimURL), + !pathMayExist(sourceURL) + else { + throw NSError( + domain: "ai.openclaw.exec-approvals-store", + code: 1, + userInfo: [ + NSLocalizedDescriptionKey: + "Legacy exec approvals exist at \(sourceURL.path). " + + "Run `openclaw doctor --fix` before using exec approvals.", + ]) + } + } + + private static func pathMayExist(_ url: URL) -> Bool { + let fileManager = FileManager.default + return fileManager.fileExists(atPath: url.path) + || (try? fileManager.destinationOfSymbolicLink(atPath: url.path)) != nil + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/ExecApprovalsSQLiteStore.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/ExecApprovalsSQLiteStore.swift new file mode 100644 index 000000000000..8cf3aa499ea1 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/ExecApprovalsSQLiteStore.swift @@ -0,0 +1,506 @@ +import Foundation +import OpenClawNativeState + +public enum ExecApprovalsSecurity: String, CaseIterable, Codable, Identifiable, Sendable { + case deny + case allowlist + case full + + public var id: String { + self.rawValue + } +} + +public enum ExecApprovalsAsk: String, CaseIterable, Codable, Identifiable, Sendable { + case off + case onMiss = "on-miss" + case always + + public var id: String { + self.rawValue + } +} + +public struct ExecApprovalsAllowlistEntry: Codable, Hashable, Identifiable, Sendable { + public var id: String + public var pattern: String + public var source: String? + public var commandText: String? + public var argPattern: String? + public var lastUsedAt: Double? + public var lastUsedCommand: String? + public var lastResolvedPath: String? + + public init( + id: String = UUID().uuidString, + pattern: String, + source: String? = nil, + commandText: String? = nil, + argPattern: String? = nil, + lastUsedAt: Double? = nil, + lastUsedCommand: String? = nil, + lastResolvedPath: String? = nil) + { + self.id = id + self.pattern = pattern + self.source = source + self.commandText = commandText + self.argPattern = argPattern + self.lastUsedAt = lastUsedAt + self.lastUsedCommand = lastUsedCommand + self.lastResolvedPath = lastResolvedPath + } + + private enum CodingKeys: String, CodingKey { + case id + case pattern + case source + case commandText + case argPattern + case lastUsedAt + case lastUsedCommand + case lastResolvedPath + } + + public init(from decoder: Decoder) throws { + if let container = try? decoder.singleValueContainer(), + let legacyPattern = try? container.decode(String.self) + { + self.init(pattern: legacyPattern.trimmingCharacters(in: .whitespacesAndNewlines)) + return + } + let container = try decoder.container(keyedBy: CodingKeys.self) + let decodedID = try container.decodeIfPresent(String.self, forKey: .id) + let id = decodedID.flatMap { $0.isEmpty ? nil : $0 } ?? UUID().uuidString + try self.init( + id: id, + pattern: container.decode(String.self, forKey: .pattern), + source: container.decodeIfPresent(String.self, forKey: .source), + commandText: container.decodeIfPresent(String.self, forKey: .commandText), + argPattern: container.decodeIfPresent(String.self, forKey: .argPattern), + lastUsedAt: container.decodeIfPresent(Double.self, forKey: .lastUsedAt), + lastUsedCommand: container.decodeIfPresent(String.self, forKey: .lastUsedCommand), + lastResolvedPath: container.decodeIfPresent(String.self, forKey: .lastResolvedPath)) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: .id) + try container.encode(self.pattern, forKey: .pattern) + try container.encodeIfPresent(self.source, forKey: .source) + try container.encodeIfPresent(self.argPattern, forKey: .argPattern) + try container.encodeIfPresent(self.lastUsedAt, forKey: .lastUsedAt) + try container.encodeIfPresent(self.lastUsedCommand, forKey: .lastUsedCommand) + try container.encodeIfPresent(self.lastResolvedPath, forKey: .lastResolvedPath) + } +} + +public struct ExecApprovalsDefaultsDocument: Codable, Sendable, Equatable { + public var security: ExecApprovalsSecurity? + public var ask: ExecApprovalsAsk? + public var askFallback: ExecApprovalsSecurity? + public var autoAllowSkills: Bool? + + public init( + security: ExecApprovalsSecurity? = nil, + ask: ExecApprovalsAsk? = nil, + askFallback: ExecApprovalsSecurity? = nil, + autoAllowSkills: Bool? = nil) + { + self.security = security + self.ask = ask + self.askFallback = askFallback + self.autoAllowSkills = autoAllowSkills + } +} + +public struct ExecApprovalsAgentDocument: Codable, Sendable, Equatable { + public var security: ExecApprovalsSecurity? + public var ask: ExecApprovalsAsk? + public var askFallback: ExecApprovalsSecurity? + public var autoAllowSkills: Bool? + public var allowlist: [ExecApprovalsAllowlistEntry]? + + public init( + security: ExecApprovalsSecurity? = nil, + ask: ExecApprovalsAsk? = nil, + askFallback: ExecApprovalsSecurity? = nil, + autoAllowSkills: Bool? = nil, + allowlist: [ExecApprovalsAllowlistEntry]? = nil) + { + self.security = security + self.ask = ask + self.askFallback = askFallback + self.autoAllowSkills = autoAllowSkills + self.allowlist = allowlist + } + + public var isEmpty: Bool { + self.security == nil && self.ask == nil && self.askFallback == nil + && self.autoAllowSkills == nil && (self.allowlist?.isEmpty ?? true) + } +} + +public struct ExecApprovalsSocketDocument: Codable, Sendable, Equatable { + public var path: String? + public var token: String? + + public init(path: String? = nil, token: String? = nil) { + self.path = path + self.token = token + } +} + +public struct ExecApprovalsDocument: Codable, Sendable, Equatable { + public var version: Int + public var socket: ExecApprovalsSocketDocument? + public var defaults: ExecApprovalsDefaultsDocument? + public var agents: [String: ExecApprovalsAgentDocument]? + + public init( + version: Int, + socket: ExecApprovalsSocketDocument? = nil, + defaults: ExecApprovalsDefaultsDocument? = nil, + agents: [String: ExecApprovalsAgentDocument]? = nil) + { + self.version = version + self.socket = socket + self.defaults = defaults + self.agents = agents + } +} + +public struct ExecApprovalsSQLiteRecord: Sendable, Equatable { + public let rawJSON: String + public let document: ExecApprovalsDocument + + public init(rawJSON: String, document: ExecApprovalsDocument) { + self.rawJSON = rawJSON + self.document = document + } +} + +public struct ExecApprovalsSQLiteMutation { + public let value: Value + public let documentToWrite: ExecApprovalsDocument? + + public init(value: Value, documentToWrite: ExecApprovalsDocument? = nil) { + self.value = value + self.documentToWrite = documentToWrite + } +} + +public enum ExecApprovalsSQLiteStore { + public static let configKey = "current" + public static let locator = "state/openclaw.sqlite#exec_approvals_config" + static let mutationLeaseScope = "exec-approvals" + static let mutationLeaseKey = "mutation" + private static let busyTimeoutMilliseconds: Int32 = 30000 + + public static func databaseURL(stateDirectoryURL: URL) -> URL { + stateDirectoryURL + .appendingPathComponent("state", isDirectory: true) + .appendingPathComponent("openclaw.sqlite", isDirectory: false) + } + + public static func read(stateDirectoryURL: URL) throws -> ExecApprovalsSQLiteRecord? { + try ExecApprovalsLegacyMigrationGate.assertReady(stateDirectoryURL: stateDirectoryURL) + let database = try self.openDatabase(stateDirectoryURL: stateDirectoryURL) + return try database.withImmediateTransaction { + try database.ensureCanonicalTable(.execApprovalsConfig) + return try self.readRecord(database) + } + } + + public static func write( + _ document: ExecApprovalsDocument, + stateDirectoryURL: URL, + updatedAtMilliseconds: Int64 = Int64(Date().timeIntervalSince1970 * 1000)) throws + { + try self.withImmediateTransaction( + stateDirectoryURL: stateDirectoryURL, + updatedAtMilliseconds: updatedAtMilliseconds) + { _ in + ExecApprovalsSQLiteMutation(value: (), documentToWrite: document) + } + } + + public static func withImmediateTransaction( + stateDirectoryURL: URL, + updatedAtMilliseconds: Int64 = Int64(Date().timeIntervalSince1970 * 1000), + _ body: (ExecApprovalsSQLiteRecord?) throws -> ExecApprovalsSQLiteMutation) throws -> Value + { + try ExecApprovalsLegacyMigrationGate.assertReady(stateDirectoryURL: stateDirectoryURL) + let database = try self.openDatabase(stateDirectoryURL: stateDirectoryURL) + return try database.withImmediateTransaction { + try database.ensureCanonicalTable(.execApprovalsConfig) + let mutation = try body(self.readRecord(database)) + if let document = mutation.documentToWrite { + try self.assertMutationNotFenced(database) + try self.writeRecord( + database, + document: document, + updatedAtMilliseconds: updatedAtMilliseconds) + } + return mutation.value + } + } + + static func decode(_ rawJSON: String) throws -> ExecApprovalsDocument { + guard let data = rawJSON.data(using: .utf8), self.hasValidPersistedStructure(data) else { + throw OpenClawNativeStateError("Malformed exec approvals raw_json") + } + let document = try JSONDecoder().decode(ExecApprovalsDocument.self, from: data) + guard document.version == 1 else { + throw OpenClawNativeStateError( + "Unsupported exec approvals version \(document.version) in raw_json") + } + return document + } + + public static func serialize(_ document: ExecApprovalsDocument) throws -> String { + guard document.version == 1 else { + throw OpenClawNativeStateError("Exec approvals document version must be 1") + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(document) + guard let rawJSON = String(data: data, encoding: .utf8) else { + throw OpenClawNativeStateError("Could not encode exec approvals as UTF-8") + } + let persisted = rawJSON + "\n" + _ = try self.decode(persisted) + return persisted + } + + private static func openDatabase(stateDirectoryURL: URL) throws -> OpenClawNativeStateSQLite { + try OpenClawNativeStateSQLite( + databaseURL: self.databaseURL(stateDirectoryURL: stateDirectoryURL), + busyTimeoutMilliseconds: self.busyTimeoutMilliseconds) + } + + private static func readRecord( + _ database: OpenClawNativeStateSQLite) throws -> ExecApprovalsSQLiteRecord? + { + let statement = try database.prepare( + "SELECT raw_json FROM exec_approvals_config WHERE config_key = ?") + try statement.bindText(self.configKey, at: 1) + guard try statement.step() == .row else { return nil } + let rawJSON = try statement.requiredText(at: 0, field: "exec approvals raw_json") + guard try statement.step() == .done else { + throw OpenClawNativeStateError("Exec approvals singleton query returned multiple rows") + } + return try ExecApprovalsSQLiteRecord(rawJSON: rawJSON, document: self.decode(rawJSON)) + } + + private static func writeRecord( + _ database: OpenClawNativeStateSQLite, + document: ExecApprovalsDocument, + updatedAtMilliseconds: Int64) throws + { + let rawJSON = try self.serialize(document) + let projected = self.projectionDocument(document) + let agents = Array((projected.agents ?? [:]).values) + let statement = try database.prepare(""" + INSERT INTO exec_approvals_config ( + config_key, raw_json, socket_path, has_socket_token, + default_security, default_ask, default_ask_fallback, auto_allow_skills, + agent_count, allowlist_count, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(config_key) DO UPDATE SET + raw_json = excluded.raw_json, + socket_path = excluded.socket_path, + has_socket_token = excluded.has_socket_token, + default_security = excluded.default_security, + default_ask = excluded.default_ask, + default_ask_fallback = excluded.default_ask_fallback, + auto_allow_skills = excluded.auto_allow_skills, + agent_count = excluded.agent_count, + allowlist_count = excluded.allowlist_count, + updated_at_ms = excluded.updated_at_ms + """) + try statement.bindText(self.configKey, at: 1) + try statement.bindText(rawJSON, at: 2) + try self.bind(projected.socket?.path, to: statement, at: 3) + try statement.bindInt64(projected.socket?.token?.isEmpty == false ? 1 : 0, at: 4) + try self.bind(projected.defaults?.security?.rawValue, to: statement, at: 5) + try self.bind(projected.defaults?.ask?.rawValue, to: statement, at: 6) + try self.bind(projected.defaults?.askFallback?.rawValue, to: statement, at: 7) + if let autoAllowSkills = projected.defaults?.autoAllowSkills { + try statement.bindInt64(autoAllowSkills ? 1 : 0, at: 8) + } else { + try statement.bindNull(at: 8) + } + try statement.bindInt64(Int64(agents.count), at: 9) + try statement.bindInt64( + Int64(agents.reduce(0) { $0 + ($1.allowlist?.count ?? 0) }), + at: 10) + try statement.bindInt64(updatedAtMilliseconds, at: 11) + guard try statement.step() == .done else { + throw OpenClawNativeStateError("Exec approvals upsert did not complete") + } + } + + private static func assertMutationNotFenced( + _ database: OpenClawNativeStateSQLite) throws + { + let table = try database.prepare( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'state_leases'") + guard try table.step() == .row else { return } + + let statement = try database.prepare(""" + SELECT owner FROM state_leases + WHERE scope = ? AND lease_key = ? AND expires_at > ? + LIMIT 1 + """) + try statement.bindText(self.mutationLeaseScope, at: 1) + try statement.bindText(self.mutationLeaseKey, at: 2) + try statement.bindInt64(Int64(Date().timeIntervalSince1970 * 1000), at: 3) + // Expired rows intentionally do not fence writers: TTL expiry is the + // crash-release path when the deleting process cannot remove its lease. + guard try statement.step() != .row else { + throw OpenClawNativeStateError( + "Exec approvals cannot be changed while agent deletion is in progress; retry.") + } + } + + private static func projectionDocument( + _ document: ExecApprovalsDocument) -> ExecApprovalsDocument + { + var agents = document.agents ?? [:] + if let legacyDefault = agents.removeValue(forKey: "default") { + if let current = agents["main"] { + agents["main"] = self.mergeAgent(current: current, legacy: legacyDefault) + } else { + agents["main"] = legacyDefault + } + } + for (key, var agent) in agents { + agent.allowlist = agent.allowlist?.filter { + !$0.pattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + agents[key] = agent + } + let socketPath = document.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) + let socketToken = document.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) + return ExecApprovalsDocument( + version: 1, + socket: ExecApprovalsSocketDocument( + path: socketPath?.isEmpty == false ? socketPath : nil, + token: socketToken?.isEmpty == false ? socketToken : nil), + defaults: document.defaults, + agents: agents) + } + + private static func mergeAgent( + current: ExecApprovalsAgentDocument, + legacy: ExecApprovalsAgentDocument) -> ExecApprovalsAgentDocument + { + var seen = Set() + let allowlist = ((current.allowlist ?? []) + (legacy.allowlist ?? [])).filter { entry in + let pattern = entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !pattern.isEmpty else { return false } + let key = "\(pattern)\0\(entry.argPattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "")" + return seen.insert(key).inserted + } + return ExecApprovalsAgentDocument( + security: current.security ?? legacy.security, + ask: current.ask ?? legacy.ask, + askFallback: current.askFallback ?? legacy.askFallback, + autoAllowSkills: current.autoAllowSkills ?? legacy.autoAllowSkills, + allowlist: allowlist.isEmpty ? nil : allowlist) + } + + private static func bind( + _ value: String?, + to statement: OpenClawNativeStateSQLiteStatement, + at index: Int32) throws + { + if let value { + try statement.bindText(value, at: index) + } else { + try statement.bindNull(at: index) + } + } + + private static func hasValidPersistedStructure(_ data: Data) -> Bool { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let version = root["version"] as? NSNumber, + CFGetTypeID(version) != CFBooleanGetTypeID(), + version.doubleValue == 1 + else { return false } + if let socket = root["socket"] { + guard let object = socket as? [String: Any], + self.hasOptionalString(object, key: "path"), + self.hasOptionalString(object, key: "token") + else { return false } + } + if let defaults = root["defaults"], !self.hasValidPolicyFields(defaults) { + return false + } + if let agents = root["agents"] { + guard let object = agents as? [String: Any] else { return false } + for value in object.values { + guard self.hasValidPolicyFields(value), let agent = value as? [String: Any] else { + return false + } + if let allowlist = agent["allowlist"] { + guard let entries = allowlist as? [Any], + entries.allSatisfy(self.hasValidAllowlistEntry) + else { return false } + } + } + } + return true + } + + private static func hasValidAllowlistEntry(_ value: Any) -> Bool { + if let pattern = value as? String { + return !pattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + guard let object = value as? [String: Any], + let pattern = object["pattern"] as? String, + !pattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { return false } + for key in ["id", "source", "commandText", "argPattern", "lastUsedCommand", "lastResolvedPath"] { + if let value = object[key], !(value is String) { + return false + } + } + if let lastUsedAt = object["lastUsedAt"] { + guard let number = lastUsedAt as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID(), + number.doubleValue.isFinite + else { return false } + } + return true + } + + private static func hasValidPolicyFields(_ value: Any) -> Bool { + guard let object = value as? [String: Any] else { return false } + if let security = object["security"] { + guard let raw = security as? String, ExecApprovalsSecurity(rawValue: raw) != nil else { + return false + } + } + if let ask = object["ask"] { + guard let raw = ask as? String, ExecApprovalsAsk(rawValue: raw) != nil else { + return false + } + } + if let fallback = object["askFallback"] { + guard let raw = fallback as? String, ExecApprovalsSecurity(rawValue: raw) != nil else { + return false + } + } + if let autoAllowSkills = object["autoAllowSkills"], !(autoAllowSkills is Bool) { + return false + } + return true + } + + private static func hasOptionalString(_ object: [String: Any], key: String) -> Bool { + guard let value = object[key] else { return true } + return value is String + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawNativeState/OpenClawNativeStateSQLite.swift b/apps/shared/OpenClawKit/Sources/OpenClawNativeState/OpenClawNativeStateSQLite.swift index a9784b4b92d4..4de65fcd6b54 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawNativeState/OpenClawNativeStateSQLite.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawNativeState/OpenClawNativeStateSQLite.swift @@ -15,6 +15,7 @@ public struct OpenClawNativeStateError: Error, LocalizedError, Sendable { public enum OpenClawNativeStateCanonicalTable: Sendable { case deviceIdentities + case execApprovalsConfig case macosPortGuardianRecords } @@ -58,16 +59,17 @@ public final class OpenClawNativeStateSQLite: @unchecked Sendable { private struct CanonicalTable { let name: String - let indexName: String + let indexName: String? let createSQL: String let columns: [Column] let indexColumns: [IndexColumn] var objects: Set { - [ - SchemaObject(type: "index", name: self.indexName), - SchemaObject(type: "table", name: self.name), - ] + var objects = [SchemaObject(type: "table", name: self.name)] + if let indexName { + objects.append(SchemaObject(type: "index", name: indexName)) + } + return Set(objects) } } @@ -127,8 +129,42 @@ public final class OpenClawNativeStateSQLite: @unchecked Sendable { IndexColumn(name: "timestamp", descending: true), ]) + private static let execApprovalsConfig = CanonicalTable( + name: "exec_approvals_config", + indexName: nil, + createSQL: """ + CREATE TABLE IF NOT EXISTS exec_approvals_config ( + config_key TEXT NOT NULL PRIMARY KEY, + raw_json TEXT NOT NULL, + socket_path TEXT, + has_socket_token INTEGER NOT NULL, + default_security TEXT, + default_ask TEXT, + default_ask_fallback TEXT, + auto_allow_skills INTEGER, + agent_count INTEGER NOT NULL, + allowlist_count INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL + ) STRICT; + """, + columns: [ + Column(name: "config_key", type: "TEXT", notNull: true, primaryKeyPosition: 1, hidden: 0), + Column(name: "raw_json", type: "TEXT", notNull: true, primaryKeyPosition: 0, hidden: 0), + Column(name: "socket_path", type: "TEXT", notNull: false, primaryKeyPosition: 0, hidden: 0), + Column(name: "has_socket_token", type: "INTEGER", notNull: true, primaryKeyPosition: 0, hidden: 0), + Column(name: "default_security", type: "TEXT", notNull: false, primaryKeyPosition: 0, hidden: 0), + Column(name: "default_ask", type: "TEXT", notNull: false, primaryKeyPosition: 0, hidden: 0), + Column(name: "default_ask_fallback", type: "TEXT", notNull: false, primaryKeyPosition: 0, hidden: 0), + Column(name: "auto_allow_skills", type: "INTEGER", notNull: false, primaryKeyPosition: 0, hidden: 0), + Column(name: "agent_count", type: "INTEGER", notNull: true, primaryKeyPosition: 0, hidden: 0), + Column(name: "allowlist_count", type: "INTEGER", notNull: true, primaryKeyPosition: 0, hidden: 0), + Column(name: "updated_at_ms", type: "INTEGER", notNull: true, primaryKeyPosition: 0, hidden: 0), + ], + indexColumns: []) + private static let canonicalTables = [ OpenClawNativeStateSQLite.deviceIdentities, + OpenClawNativeStateSQLite.execApprovalsConfig, OpenClawNativeStateSQLite.macosPortGuardianRecords, ] @@ -311,6 +347,7 @@ public final class OpenClawNativeStateSQLite: @unchecked Sendable { private static func descriptor(_ table: OpenClawNativeStateCanonicalTable) -> CanonicalTable { switch table { case .deviceIdentities: self.deviceIdentities + case .execApprovalsConfig: self.execApprovalsConfig case .macosPortGuardianRecords: self.macosPortGuardianRecords } } @@ -378,7 +415,7 @@ public final class OpenClawNativeStateSQLite: @unchecked Sendable { throw OpenClawNativeStateError("\(table.name) must be a STRICT table") } guard try self.validRequiredIndex(table) else { - throw OpenClawNativeStateError("\(table.indexName) has an incompatible schema") + throw OpenClawNativeStateError("\(table.indexName ?? table.name) has an incompatible index schema") } } @@ -397,11 +434,12 @@ public final class OpenClawNativeStateSQLite: @unchecked Sendable { } private func validRequiredIndex(_ table: CanonicalTable) throws -> Bool { + guard let indexName = table.indexName else { return table.indexColumns.isEmpty } let list = try self.prepare("PRAGMA index_list('\(table.name)')") var found = false while try list.step() == .row { let name = try list.requiredText(at: 1, field: "index name") - if name == table.indexName { + if name == indexName { found = try list.int32(at: 2) == 0 && (list.requiredText(at: 3, field: "index origin")) == "c" && list.int32(at: 4) == 0 @@ -409,7 +447,7 @@ public final class OpenClawNativeStateSQLite: @unchecked Sendable { } guard found else { return false } - let details = try self.prepare("PRAGMA index_xinfo('\(table.indexName)')") + let details = try self.prepare("PRAGMA index_xinfo('\(indexName)')") var keyColumns: [IndexColumn] = [] while try details.step() == .row { guard details.int32(at: 5) == 1 else { continue } @@ -507,6 +545,14 @@ public final class OpenClawNativeStateSQLiteStatement { } } + public func bindNull(at index: Int32) throws { + try self.connection.withConnectionLock { + guard sqlite3_bind_null(self.statement, index) == SQLITE_OK else { + throw self.connection.databaseError(operation: "bind SQLite null") + } + } + } + public func bindDouble(_ value: Double, at index: Int32) throws { try self.connection.withConnectionLock { guard sqlite3_bind_double(self.statement, index, value) == SQLITE_OK else { diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ExecApprovalsSQLiteStoreTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ExecApprovalsSQLiteStoreTests.swift new file mode 100644 index 000000000000..f5c976361b93 --- /dev/null +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ExecApprovalsSQLiteStoreTests.swift @@ -0,0 +1,326 @@ +import Foundation +import SQLite3 +import Testing +@testable import OpenClawKit + +struct ExecApprovalsSQLiteStoreTests { + @Test + func `missing singleton row returns nil`() throws { + try self.withStateDirectory { stateDirectoryURL in + let record = try ExecApprovalsSQLiteStore.read(stateDirectoryURL: stateDirectoryURL) + #expect(record == nil) + } + } + + @Test + func `document round trips and update replaces singleton`() throws { + try self.withStateDirectory { stateDirectoryURL in + let original = Self.document(token: "first-token", agentCount: 1) + try ExecApprovalsSQLiteStore.write(original, stateDirectoryURL: stateDirectoryURL) + #expect(try ExecApprovalsSQLiteStore.read( + stateDirectoryURL: stateDirectoryURL)?.document == original) + + let updated = Self.document(token: "second-token", agentCount: 2) + try ExecApprovalsSQLiteStore.write(updated, stateDirectoryURL: stateDirectoryURL) + let stored = try ExecApprovalsSQLiteStore.read(stateDirectoryURL: stateDirectoryURL) + let record = try #require(stored) + #expect(record.document == updated) + #expect(record.rawJSON.contains("\"version\" : 1")) + #expect(record.rawJSON.contains("\"socket\"")) + #expect(record.rawJSON.contains("\"defaults\"")) + #expect(record.rawJSON.contains("\"agents\"")) + } + } + + @Test + func `write derives every projection from authoritative document`() throws { + try self.withStateDirectory { stateDirectoryURL in + let document = ExecApprovalsDocument( + version: 1, + socket: ExecApprovalsSocketDocument(path: "/tmp/openclaw.sock", token: "secret"), + defaults: ExecApprovalsDefaultsDocument( + security: .allowlist, + ask: .onMiss, + askFallback: .deny, + autoAllowSkills: true), + agents: [ + "main": ExecApprovalsAgentDocument(allowlist: [ + ExecApprovalsAllowlistEntry(id: "one", pattern: "/usr/bin/git"), + ExecApprovalsAllowlistEntry(id: "two", pattern: "/usr/bin/swift"), + ]), + "worker": ExecApprovalsAgentDocument(allowlist: [ + ExecApprovalsAllowlistEntry(id: "three", pattern: "/usr/bin/node"), + ]), + ]) + try ExecApprovalsSQLiteStore.write( + document, + stateDirectoryURL: stateDirectoryURL, + updatedAtMilliseconds: 123_456) + + let projection = try Self.readProjection( + ExecApprovalsSQLiteStore.databaseURL(stateDirectoryURL: stateDirectoryURL)) + #expect(projection == Projection( + socketPath: "/tmp/openclaw.sock", + hasSocketToken: 1, + defaultSecurity: "allowlist", + defaultAsk: "on-miss", + defaultAskFallback: "deny", + autoAllowSkills: 1, + agentCount: 2, + allowlistCount: 3, + updatedAtMilliseconds: 123_456)) + } + } + + @Test + func `active deletion lease fences writes until expiry`() throws { + try self.withStateDirectory { stateDirectoryURL in + let original = Self.document(token: "original", agentCount: 1) + try ExecApprovalsSQLiteStore.write(original, stateDirectoryURL: stateDirectoryURL) + let databaseURL = ExecApprovalsSQLiteStore.databaseURL( + stateDirectoryURL: stateDirectoryURL) + let expiresAt = Int64(Date().timeIntervalSince1970 * 1000) + 120_000 + try Self.execute(databaseURL, """ + CREATE TABLE schema_meta ( + meta_key TEXT NOT NULL PRIMARY KEY, + role TEXT NOT NULL, + schema_version INTEGER NOT NULL, + agent_id TEXT, + app_version TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) STRICT; + INSERT INTO schema_meta ( + meta_key, role, schema_version, agent_id, + app_version, created_at, updated_at + ) VALUES ('primary', 'global', 6, NULL, NULL, 1, 1); + CREATE TABLE state_leases ( + scope TEXT NOT NULL, + lease_key TEXT NOT NULL, + owner TEXT NOT NULL, + expires_at INTEGER, + heartbeat_at INTEGER, + payload_json TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (scope, lease_key) + ) STRICT; + INSERT INTO state_leases ( + scope, lease_key, owner, expires_at, heartbeat_at, + payload_json, created_at, updated_at + ) VALUES ( + '\(ExecApprovalsSQLiteStore.mutationLeaseScope)', + '\(ExecApprovalsSQLiteStore.mutationLeaseKey)', + 'typescript-deletion', \(expiresAt), 1, NULL, 1, 1 + ); + PRAGMA user_version = 6; + """) + + let replacement = Self.document(token: "replacement", agentCount: 2) + do { + try ExecApprovalsSQLiteStore.write( + replacement, + stateDirectoryURL: stateDirectoryURL) + Issue.record("Expected active agent deletion lease to fence the write") + } catch { + #expect(error.localizedDescription.contains("agent deletion is in progress; retry")) + } + #expect(try ExecApprovalsSQLiteStore.read( + stateDirectoryURL: stateDirectoryURL)?.document == original) + + try Self.execute(databaseURL, "UPDATE state_leases SET expires_at = 0") + try ExecApprovalsSQLiteStore.write( + replacement, + stateDirectoryURL: stateDirectoryURL) + #expect(try ExecApprovalsSQLiteStore.read( + stateDirectoryURL: stateDirectoryURL)?.document == replacement) + } + } + + @Test + func `projections use TypeScript normalization rules`() throws { + try self.withStateDirectory { stateDirectoryURL in + let shared = ExecApprovalsAllowlistEntry(id: "shared", pattern: " /USR/BIN/GIT ") + let document = ExecApprovalsDocument( + version: 1, + socket: ExecApprovalsSocketDocument(path: " /tmp/openclaw.sock ", token: " "), + agents: [ + "default": ExecApprovalsAgentDocument(allowlist: [shared]), + "main": ExecApprovalsAgentDocument(allowlist: [ + ExecApprovalsAllowlistEntry(id: "current", pattern: "/usr/bin/git"), + ]), + ]) + try ExecApprovalsSQLiteStore.write( + document, + stateDirectoryURL: stateDirectoryURL, + updatedAtMilliseconds: 42) + + let projection = try Self.readProjection( + ExecApprovalsSQLiteStore.databaseURL(stateDirectoryURL: stateDirectoryURL)) + #expect(projection.socketPath == "/tmp/openclaw.sock") + #expect(projection.hasSocketToken == 0) + #expect(projection.agentCount == 1) + #expect(projection.allowlistCount == 1) + } + } + + @Test + func `malformed raw json fails closed`() throws { + try self.withStateDirectory { stateDirectoryURL in + try ExecApprovalsSQLiteStore.write( + Self.document(token: "token", agentCount: 1), + stateDirectoryURL: stateDirectoryURL) + try Self.execute( + ExecApprovalsSQLiteStore.databaseURL(stateDirectoryURL: stateDirectoryURL), + "UPDATE exec_approvals_config SET raw_json = '{\"version\":2}'") + + #expect(throws: (any Error).self) { + try ExecApprovalsSQLiteStore.read(stateDirectoryURL: stateDirectoryURL) + } + } + } + + @Test + func `invalid allowlist pattern is rejected before write`() throws { + try self.withStateDirectory { stateDirectoryURL in + let invalid = ExecApprovalsDocument( + version: 1, + agents: [ + "main": ExecApprovalsAgentDocument(allowlist: [ + ExecApprovalsAllowlistEntry(pattern: " "), + ]), + ]) + + #expect(throws: (any Error).self) { + try ExecApprovalsSQLiteStore.write( + invalid, + stateDirectoryURL: stateDirectoryURL) + } + let record = try ExecApprovalsSQLiteStore.read(stateDirectoryURL: stateDirectoryURL) + #expect(record == nil) + } + } + + @Test(arguments: ["", ".doctor-importing"]) + func `legacy source or Doctor claim refuses SQLite access`(suffix: String) throws { + try self.withStateDirectory { stateDirectoryURL in + let legacyURL = stateDirectoryURL.appendingPathComponent( + "exec-approvals.json\(suffix)", + isDirectory: false) + try Data("{\"version\":1,\"agents\":{}}".utf8).write(to: legacyURL) + + do { + _ = try ExecApprovalsSQLiteStore.read(stateDirectoryURL: stateDirectoryURL) + Issue.record("Expected pending legacy approvals to refuse SQLite access") + } catch { + #expect(error.localizedDescription.contains("Run `openclaw doctor --fix`")) + } + } + } + + @Test + func `Doctor recovery rename cannot cross an absent probe window`() { + var probeResults = [false, false, true] + #expect(throws: (any Error).self) { + try ExecApprovalsLegacyMigrationGate.assertReady( + stateDirectoryURL: URL(fileURLWithPath: "/unused"), + pathMayExist: { _ in probeResults.removeFirst() }) + } + #expect(probeResults.isEmpty) + } + + private struct Projection: Equatable { + let socketPath: String? + let hasSocketToken: Int64 + let defaultSecurity: String? + let defaultAsk: String? + let defaultAskFallback: String? + let autoAllowSkills: Int64? + let agentCount: Int64 + let allowlistCount: Int64 + let updatedAtMilliseconds: Int64 + } + + private static func document(token: String, agentCount: Int) -> ExecApprovalsDocument { + let agents = Dictionary(uniqueKeysWithValues: (0.. Projection { + var database: OpaquePointer? + guard sqlite3_open(databaseURL.path, &database) == SQLITE_OK, let database else { + throw TestError.sqlite("Could not open test database") + } + defer { sqlite3_close(database) } + var statement: OpaquePointer? + let sql = """ + SELECT socket_path, has_socket_token, default_security, default_ask, + default_ask_fallback, auto_allow_skills, agent_count, + allowlist_count, updated_at_ms + FROM exec_approvals_config WHERE config_key = 'current' + """ + guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, + let statement, + sqlite3_step(statement) == SQLITE_ROW + else { + throw TestError.sqlite(String(cString: sqlite3_errmsg(database))) + } + defer { sqlite3_finalize(statement) } + return Projection( + socketPath: self.optionalText(statement, 0), + hasSocketToken: sqlite3_column_int64(statement, 1), + defaultSecurity: self.optionalText(statement, 2), + defaultAsk: self.optionalText(statement, 3), + defaultAskFallback: self.optionalText(statement, 4), + autoAllowSkills: sqlite3_column_type(statement, 5) == SQLITE_NULL + ? nil + : sqlite3_column_int64(statement, 5), + agentCount: sqlite3_column_int64(statement, 6), + allowlistCount: sqlite3_column_int64(statement, 7), + updatedAtMilliseconds: sqlite3_column_int64(statement, 8)) + } + + private static func optionalText(_ statement: OpaquePointer, _ column: Int32) -> String? { + sqlite3_column_text(statement, column).map { String(cString: $0) } + } + + private static func execute(_ databaseURL: URL, _ sql: String) throws { + var database: OpaquePointer? + guard sqlite3_open(databaseURL.path, &database) == SQLITE_OK, let database else { + throw TestError.sqlite("Could not open test database") + } + defer { sqlite3_close(database) } + guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else { + throw TestError.sqlite(String(cString: sqlite3_errmsg(database))) + } + } + + private func withStateDirectory( + _ body: (URL) throws -> Void) throws + { + let stateDirectoryURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: stateDirectoryURL, + withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: stateDirectoryURL) } + try body(stateDirectoryURL) + } + + private enum TestError: Error { + case sqlite(String) + } +} diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index d05053f3614c..36be0625c242 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -829,7 +829,6 @@ src/infra/diagnostic-events.ts src/infra/exec-approval-forwarder.ts src/infra/exec-approvals-allow-always.test.ts src/infra/exec-approvals-allowlist.ts -src/infra/exec-approvals-store.test.ts src/infra/exec-authorization-plan.ts src/infra/heartbeat-runner.returns-default-unset.test.ts src/infra/host-env-security.test.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index f11ceb225568..bf229f1e0ee7 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -71,7 +71,7 @@ d117ebba8cc490501725778676a9d75855872b6e5fe2b2f64b1270d4808a2277 module/health 182dc685f2103ff66c1a4839a48f4f40d2eeb0070cd74e449b47aacc4e6f1c22 module/hook-runtime f6e3c44e7d1090a97aca554a3c247219b8de78b3cb4399cac5efde8a0a6c1156 module/inbound-envelope 36721c58f479fe9ca32737f67850f4607bf8753ef6b193ff129e7a9e5fdbc6bd module/inbound-reply-dispatch -479ff50fc9f778d0062c3742ee2070f3c2722d8fedc45a3ea25a97da5676a14c module/infra-runtime +5aeb593609dbf7e104294d61c6f8081f94353126ed521c7413e62cd1143e18cf module/infra-runtime 2e717cccb3db127aed0287d4ea14c41a8e64e46d60c728638153e31e2fb0d296 module/ingress-effect-once 7c0bc9455678102a68cc80796ca04ba16f1fe9980335de9d58f82b830aef6876 module/interactive-runtime 9dd66baf2def46386ad4706380e57f9068fa9bb3878be2d9ba961ec2f46d3d87 module/json-store diff --git a/docs/cli/approvals.md b/docs/cli/approvals.md index ecb0b38f36e7..2681a04fd4f9 100644 --- a/docs/cli/approvals.md +++ b/docs/cli/approvals.md @@ -9,7 +9,7 @@ title: "Approvals" # `openclaw approvals` -Manage exec approvals for the **local host**, **gateway host**, or a **node host**. With no target flag, commands read/write the local approvals file on disk. Use `--gateway` to target the gateway, or `--node ` to target a specific node. +Manage exec approvals for the **local host**, **gateway host**, or a **node host**. With no target flag, commands read/write the local approvals document in shared SQLite state. Use `--gateway` to target the gateway, or `--node ` to target a specific node. Alias: `openclaw exec-approvals` @@ -17,7 +17,7 @@ Related: [Exec approvals](/tools/exec-approvals), [Nodes](/nodes) ## `openclaw exec-policy` -`openclaw exec-policy` is the **local-only** convenience command that keeps requested `tools.exec.*` config and the local host approvals file in sync in one step: +`openclaw exec-policy` is the **local-only** convenience command that keeps requested `tools.exec.*` config and the local host approvals document in sync in one step: ```bash openclaw exec-policy show @@ -33,9 +33,9 @@ Presets (`yolo`, `cautious`, `deny-all`) apply `host`, `security`, `ask`, and `a Scope: -- Updates the local config file and local approvals file together; does not push policy to the gateway or a node host. +- Updates the local config file and local approvals document together; does not push policy to the gateway or a node host. - `--host node` is rejected: node exec approvals are fetched from the node at runtime, so local `exec-policy` cannot synchronize them. Use `openclaw approvals set --node ` instead. -- `exec-policy show` marks `host=node` scopes as node-managed at runtime instead of deriving an effective policy from the local approvals file. +- `exec-policy show` marks `host=node` scopes as node-managed at runtime instead of deriving an effective policy from the local approvals document. For remote host approvals, use `openclaw approvals set --gateway` or `openclaw approvals set --node ` directly. @@ -59,9 +59,9 @@ Per-session `/exec` overrides are not included. Run `/exec` in the relevant sess Precedence: -- The host approvals file is the enforceable source of truth. +- The host approvals document is the enforceable source of truth. - Requested `tools.exec` policy can narrow or broaden intent, but the effective result is derived from host rules. -- `--node` combines the node host approvals file with gateway `tools.exec` policy (both apply at runtime). +- `--node` combines the node host approvals document with gateway `tools.exec` policy (both apply at runtime). - If gateway config is unavailable, the CLI falls back to the node approvals snapshot and notes that the final runtime policy could not be computed. ## Pending approvals @@ -134,9 +134,9 @@ openclaw approvals set --stdin <<'EOF' EOF ``` -For nodes that expose an OpenClaw approvals file, use the same body with `openclaw approvals set --node --stdin`. Host-native nodes require their owner-specific shape shown above. +For nodes that expose an OpenClaw approvals document, use the same body with `openclaw approvals set --node --stdin`. Host-native nodes require their owner-specific shape shown above. -This changes the **host approvals file** only. To keep the requested OpenClaw policy aligned, also set: +This changes the **host approvals document** only. To keep the requested OpenClaw policy aligned, also set: ```bash openclaw config set tools.exec.host gateway @@ -171,7 +171,7 @@ openclaw approvals allowlist remove "~/Projects/**/bin/rg" - `--gateway` - shared node RPC options: `--url`, `--token`, `--timeout`, `--json` -No target flag means the local approvals file on disk. +No target flag means the local approvals row in the shared state database. `allowlist add|remove` also supports `--agent ` (defaults to `"*"`, applying to all agents). @@ -180,7 +180,10 @@ No target flag means the local approvals file on disk. ## Notes - The node host must advertise `system.execApprovals.get/set` (macOS app, headless node host, or Windows companion). -- Approvals files are stored per host in the OpenClaw state dir: `$OPENCLAW_STATE_DIR/exec-approvals.json`, or `~/.openclaw/exec-approvals.json` when the variable is unset. +- Approvals are stored per host in + `$OPENCLAW_STATE_DIR/state/openclaw.sqlite#exec_approvals_config`, or + `~/.openclaw/state/openclaw.sqlite#exec_approvals_config` when the variable is + unset. The suffix identifies the singleton SQLite row. ## Related diff --git a/docs/cli/node.md b/docs/cli/node.md index 22e81e268e7b..0cfb3b20c6e3 100644 --- a/docs/cli/node.md +++ b/docs/cli/node.md @@ -247,8 +247,8 @@ it contains the device keypair and auth tokens. `system.run` is gated by local exec approvals: -- `$OPENCLAW_STATE_DIR/exec-approvals.json`, or - `~/.openclaw/exec-approvals.json` when the variable is unset +- `$OPENCLAW_STATE_DIR/state/openclaw.sqlite#exec_approvals_config`, or + `~/.openclaw/state/openclaw.sqlite#exec_approvals_config` when the variable is unset - [Exec approvals](/tools/exec-approvals) - `openclaw approvals --node ` (edit from the Gateway) diff --git a/docs/cli/policy.md b/docs/cli/policy.md index 4ca84d55b3ca..b567808a234d 100644 --- a/docs/cli/policy.md +++ b/docs/cli/policy.md @@ -194,7 +194,7 @@ Cross-cutting notes not obvious from the rule tables below: - `agents.workspace.denyTools` accepts `exec`, `process`, `write`, `edit`, `apply_patch`. The config tool-deny groups `group:fs` (file mutation) and `group:runtime` (shell/process) satisfy the equivalent posture. -- Exec-approvals checks read the live `exec-approvals.json` artifact only when +- Exec-approvals checks read the live SQLite approvals document only when an `execApprovals` rule is present; a missing or invalid artifact is unobservable evidence, not a synthetic pass. - Secret and auth-profile evidence records provider/source posture and @@ -430,9 +430,11 @@ allowlist such as `["all"]`. #### Exec approvals -Exec-approvals checks read the runtime `exec-approvals.json` artifact: -`~/.openclaw/exec-approvals.json` by default, or -`$OPENCLAW_STATE_DIR/exec-approvals.json` when `OPENCLAW_STATE_DIR` is set. +Exec-approvals checks read the runtime `exec_approvals_config` singleton row in +`~/.openclaw/state/openclaw.sqlite` by default, or the same database under +`$OPENCLAW_STATE_DIR/state` when `OPENCLAW_STATE_DIR` is set. Findings keep the +stable `oc://exec-approvals.json/...` URI scheme; it now denotes paths within +the authoritative JSON document stored in that row. Posture rules under `execApprovals.defaults.*` or `execApprovals.agents.*` require readable artifact evidence; a missing or invalid artifact reports as unobservable evidence rather than a best-effort pass. Once readable, omitted @@ -444,7 +446,7 @@ missing agent security inherits that default. Evidence includes `defaults`, | Policy field | Observed state | Use when | | ------------------------------------------- | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | -| `execApprovals.requireFile` | Active runtime `exec-approvals.json` path | Set to `true` to require the approvals artifact to exist and parse. | +| `execApprovals.requireFile` | Active runtime `exec_approvals_config` row | Set to `true` to require the approvals document to exist and parse. | | `execApprovals.defaults.allowSecurity` | `defaults.security`, defaulting to `full` | Allow only approved default approval security modes. | | `execApprovals.agents.allowSecurity` | `agents.*.security`, inheriting defaults | Allow only approved per-agent effective approval security modes. | | `execApprovals.agents.allowAutoAllowSkills` | `defaults.autoAllowSkills` and `agents.*.autoAllowSkills`, inheriting runtime defaults | Set to `false` to require strict manual allowlists without implicit skill CLI approval. | @@ -857,8 +859,8 @@ the interval. | `policy/secrets-insecure-provider` | A secret provider opts into insecure posture when policy denies it. | | `policy/auth-profile-invalid-metadata` | A config auth profile is missing valid provider or mode metadata. | | `policy/auth-profile-unapproved-mode` | A config auth profile mode is outside the policy allowlist. | -| `policy/exec-approvals-missing` | Policy requires `exec-approvals.json`, but the artifact is missing. | -| `policy/exec-approvals-invalid` | The configured exec approvals artifact cannot be parsed. | +| `policy/exec-approvals-missing` | Policy requires the SQLite exec approvals document, but its row is missing. | +| `policy/exec-approvals-invalid` | The configured SQLite exec approvals document cannot be parsed. | | `policy/exec-approvals-default-security-unapproved` | Exec approval defaults use a security mode outside the policy allowlist. | | `policy/exec-approvals-agent-security-unapproved` | A per-agent effective exec approval security mode is outside the allowlist. | | `policy/exec-approvals-auto-allow-skills-enabled` | An exec approval agent implicitly auto-allows skill CLIs when policy denies it. | diff --git a/docs/nodes/index.md b/docs/nodes/index.md index 9118b5741044..02b21f5e3b48 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -74,11 +74,11 @@ the watch app with the Gateway before enabling direct mode. Use a **node host** when your Gateway runs on one machine and you want commands to execute on another. The model still talks to the **gateway**; the gateway forwards `exec` calls to the **node host** when `host=node` is selected. -| Role | Responsibility | -| ------------ | ---------------------------------------------------------------- | -| Gateway host | Receives messages, runs the model, routes tool calls. | -| Node host | Executes `system.run`/`system.which` on the node machine. | -| Approvals | Enforced on the node host via `~/.openclaw/exec-approvals.json`. | +| Role | Responsibility | +| ------------ | ---------------------------------------------------------------------------------------- | +| Gateway host | Receives messages, runs the model, routes tool calls. | +| Node host | Executes `system.run`/`system.which` on the node machine. | +| Approvals | Enforced on the node host via `~/.openclaw/state/openclaw.sqlite#exec_approvals_config`. | Approval note: @@ -258,7 +258,8 @@ openclaw approvals allowlist add --node "/usr/bin/uname" openclaw approvals allowlist add --node "/usr/bin/sw_vers" ``` -Approvals live on the node host at `~/.openclaw/exec-approvals.json`. +Approvals live on the node host in +`~/.openclaw/state/openclaw.sqlite#exec_approvals_config`. ### Point exec at the node @@ -714,7 +715,7 @@ Notes: - On Windows node hosts in allowlist mode, shell-wrapper runs via `cmd.exe /c` require approval (allowlist entry alone does not auto-allow the wrapper form). - Node hosts ignore `PATH` overrides in `--env` and strip a large, maintained set of interpreter/shell startup variables (for example `NODE_OPTIONS`, `PYTHONPATH`, `BASH_ENV`, `DYLD_*`, `LD_*`) before running a command. If you need extra PATH entries, configure the node host service environment (or install tools in standard locations) instead of passing `PATH` via `--env`. - On macOS node mode, `system.run` is gated by exec approvals in the macOS app (Settings → Exec approvals). Ask/allowlist/full behave the same as the headless node host; denied prompts return `SYSTEM_RUN_DENIED`. -- On headless node host, `system.run` is gated by exec approvals (`~/.openclaw/exec-approvals.json`); on macOS specifically, see the exec-host routing env vars under [Headless node host](#headless-node-host-cross-platform) below. +- On headless node host, `system.run` is gated by the local SQLite exec approvals row; on macOS specifically, see the exec-host routing env vars under [Headless node host](#headless-node-host-cross-platform) below. ## Exec node binding @@ -758,7 +759,8 @@ Notes: - Pairing is still required (the Gateway will show a device pairing prompt). - Client instance metadata, signed device identity, and pairing auth use separate state records; see [Headless identity state](#headless-identity-state). -- Exec approvals are enforced locally via `~/.openclaw/exec-approvals.json` (see [Exec approvals](/tools/exec-approvals)). +- Exec approvals are enforced locally via + `~/.openclaw/state/openclaw.sqlite#exec_approvals_config` (see [Exec approvals](/tools/exec-approvals)). - On macOS, the headless node host executes `system.run` locally by default. Set `OPENCLAW_NODE_EXEC_HOST=app` to route `system.run` through the companion app exec host; add `OPENCLAW_NODE_EXEC_FALLBACK=0` to require the app host and fail closed if it is unavailable. - Add `--tls` / `--tls-fingerprint` when the Gateway WS uses TLS. diff --git a/docs/platforms/mac/xpc.md b/docs/platforms/mac/xpc.md index ebea01e2ad6d..63281993e4f7 100644 --- a/docs/platforms/mac/xpc.md +++ b/docs/platforms/mac/xpc.md @@ -58,7 +58,9 @@ Agent -> Gateway -> Node Service (WS) - PeekabooBridge: `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (DEBUG-only) may allow same-UID callers for local development. - All communication remains local-only; no network sockets are exposed. - TCC prompts originate only from the GUI app bundle; keep the signed bundle ID stable across rebuilds. -- Exec approvals socket hardening: file mode `0600`, shared token, peer-UID check (`getpeereid`), HMAC-SHA256 challenge/response, and a short TTL on requests. +- Exec approvals socket hardening: file mode `0600`, shared token stored in the + `exec_approvals_config` row of `state/openclaw.sqlite`, peer-UID check + (`getpeereid`), HMAC-SHA256 challenge/response, and a short TTL on requests. ## Related diff --git a/docs/refactor/database-first.md b/docs/refactor/database-first.md index fe727f4e41a2..610f75c296e9 100644 --- a/docs/refactor/database-first.md +++ b/docs/refactor/database-first.md @@ -153,10 +153,10 @@ without exceptions outside doctor/import/export/debug boundaries. - Doctor migration: `migrating`, intentionally. Doctor imports legacy JSON, JSONL, and retired sidecar stores into SQLite, records migration runs/sources, and removes successful sources. -- Exec approvals: `file-runtime`. TypeScript and macOS still read and write the - active state directory's `exec-approvals.json`; the reserved - `exec_approvals_config` schema has no runtime owner yet. A future cutover must - add same-state doctor import and move both runtimes together. +- Exec approvals: `sqlite-runtime`. TypeScript and macOS read and write the + `exec_approvals_config` singleton row in shared state. Doctor exclusively + imports the retired state-scoped JSON file, and runtime fails closed until + that one-time migration completes. - E2E scripts: `clean` for runtime coverage. Docker MCP seeding writes SQLite rows. The runtime-context Docker script creates legacy JSONL only inside the doctor migration seed and names the legacy session index path explicitly. @@ -472,9 +472,9 @@ The branch already has a real shared SQLite base: sidecars. `openclaw doctor --fix` validates and claims legacy sources, imports them into SQLite with migration receipts, verifies the canonical rows, and only then removes the claimed files. -- The shared schema reserves an `exec_approvals_config` singleton row, but the - runtime cutover remains pending. TypeScript and the macOS companion still use - the state-scoped JSON file and must move to SQLite together. +- Exec approvals use the shared `exec_approvals_config` singleton row in both + TypeScript and the macOS companion. The row's `raw_json` remains authoritative + for protocol CAS hashes; typed columns are write-time projections. - TypeScript device identity now uses typed `device_identities` rows, with doctor-only legacy JSON import kept outside the runtime owner. Device auth is still file-backed pending a coordinated schema and cross-runtime migration; @@ -2224,7 +2224,7 @@ Add a repo check that fails new runtime writes to legacy state paths: - `openrouter-models.json` - `auth-profiles.json` - `auth-state.json` -- `exec-approvals.json` +- `exec-approvals.json` (retired; Doctor-only import into `exec_approvals_config`) - `openclaw-workspace-state.json` - `workspace-state.json` - `workspace-attestations/*.attested` diff --git a/docs/tools/exec-approvals-advanced.md b/docs/tools/exec-approvals-advanced.md index 097c467a4c55..d2644fa2fe4b 100644 --- a/docs/tools/exec-approvals-advanced.md +++ b/docs/tools/exec-approvals-advanced.md @@ -110,7 +110,7 @@ automatically. ### Safe bins versus allowlist -| Topic | `tools.exec.safeBins` | Allowlist (`exec-approvals.json`) | +| Topic | `tools.exec.safeBins` | Allowlist (SQLite exec approvals document) | | ---------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------- | | Goal | Auto-allow narrow stdin filters | Explicitly trust specific executables | | Match type | Executable name + safe-bin argv policy | Resolved executable path glob, or bare command-name glob for PATH-invoked commands | @@ -123,7 +123,7 @@ Configuration location: - `safeBins` comes from config (`tools.exec.safeBins` or per-agent `agents.entries.*.tools.exec.safeBins`). - `safeBinTrustedDirs` comes from config (`tools.exec.safeBinTrustedDirs` or per-agent `agents.entries.*.tools.exec.safeBinTrustedDirs`). - `safeBinProfiles` comes from config (`tools.exec.safeBinProfiles` or per-agent `agents.entries.*.tools.exec.safeBinProfiles`). Per-agent profile keys override global keys. -- allowlist entries live in the host-local approvals file under `agents..allowlist` (or via Control UI / `openclaw approvals allowlist ...`). +- allowlist entries live in the host-local approvals document under `agents..allowlist` (or via Control UI / `openclaw approvals allowlist ...`). - `openclaw security audit` warns with `tools.exec.safe_bins_interpreter_unprofiled` when interpreter/runtime bins appear in `safeBins` without explicit profiles. - `openclaw doctor --fix` can scaffold missing custom `safeBinProfiles.` entries as `{}` (review and tighten afterward). Interpreter/runtime bins are not auto-scaffolded. @@ -393,7 +393,8 @@ Gateway -> Node Service (WS) Security notes: -- Unix socket mode `0600`, token stored in `exec-approvals.json`. +- Unix socket mode `0600`, token stored in the `exec_approvals_config` row of + `state/openclaw.sqlite`. - Same-UID peer check. - Challenge/response (nonce + HMAC token + request hash) + short TTL. diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index aec0a4f1cf98..4a32fef55435 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -23,7 +23,7 @@ Effective policy is the **stricter** of `tools.exec.*` and approvals defaults: approvals can only tighten config-derived security/ask, never loosen them. If an approvals field is omitted, the `tools.exec` value is used. Host exec also uses local approvals state on that machine - a -host-local `ask: "always"` in the execution host approvals file keeps +host-local `ask: "always"` in the execution host approvals document keeps prompting even if session or config defaults request `ask: "on-miss"`. @@ -51,11 +51,11 @@ Exec approvals are enforced locally on the execution host: ## Inspecting the effective policy -| Command | What it shows | -| ---------------------------------------------------------------- | -------------------------------------------------------------------------------------- | -| `openclaw approvals get` / `--gateway` / `--node ` | Requested policy, host policy sources, and the effective result. | -| `openclaw exec-policy show` | Local-machine merged view. | -| `openclaw exec-policy set` / `preset` | Synchronize the local requested policy with the local host approvals file in one step. | +| Command | What it shows | +| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `openclaw approvals get` / `--gateway` / `--node ` | Requested policy, host policy sources, and the effective result. | +| `openclaw exec-policy show` | Local-machine merged view. | +| `openclaw exec-policy set` / `preset` | Synchronize the local requested policy with the local host approvals document in one step. | Per-session `/exec` overrides are not included. Run `/exec` in the relevant session to inspect its current defaults. See [session overrides](/tools/exec#session-overrides-exec). @@ -79,24 +79,31 @@ message as a fallback. ## Settings and storage -Approvals live in a local JSON file on the execution host. When -`OPENCLAW_STATE_DIR` is set, the file follows that state directory; +Approvals live in the shared SQLite state database on the execution host. When +`OPENCLAW_STATE_DIR` is set, the database follows that state directory; otherwise it uses the default OpenClaw state directory: ```text -$OPENCLAW_STATE_DIR/exec-approvals.json +$OPENCLAW_STATE_DIR/state/openclaw.sqlite#exec_approvals_config # otherwise -~/.openclaw/exec-approvals.json +~/.openclaw/state/openclaw.sqlite#exec_approvals_config ``` +The `#exec_approvals_config` suffix is a display locator for the singleton +SQLite row, not part of the database filename. The row keeps the JSON document +shown below as its authoritative value, so CLI and Gateway compare-and-swap +hashes remain stable. + The default approval socket follows the same root: `$OPENCLAW_STATE_DIR/exec-approvals.sock`, or `~/.openclaw/exec-approvals.sock` when the variable is unset. State directories are independent trust scopes. When `OPENCLAW_STATE_DIR` -points somewhere else, OpenClaw never imports or archives -`~/.openclaw/exec-approvals.json`; configure approvals separately for the -custom state directory. Doctor also imports legacy +points somewhere else, OpenClaw never imports or archives approvals from the +default state directory; configure approvals separately for the custom state +directory. After upgrading from a file-backed release, stop the Gateway and run +`openclaw doctor --fix` once to import the active state directory's retired +`exec-approvals.json`. Doctor also imports legacy `plugin-binding-approvals.json` only when it belongs to the active state directory. @@ -230,7 +237,7 @@ Set globally under `tools.exec.commandHighlighting` or per agent under To run host exec without approval prompts, open **both** policy layers: requested exec policy in OpenClaw config (`tools.exec.*`) **and** -host-local approvals policy in the execution host approvals file. +host-local approvals policy in the execution host approvals document. Omitted `askFallback` defaults to `deny`. Set host `askFallback` to `full` explicitly when a no-UI approval prompt should fall back to allow. @@ -273,7 +280,7 @@ If you want a more conservative setup, tighten OpenClaw exec policy back to openclaw gateway restart ``` - + ```bash openclaw approvals set --stdin <<'EOF' { @@ -313,7 +320,7 @@ To set individual fields instead of a full preset, use ### Node host -Apply the same approvals file on the node instead: +Apply the same approvals document on the node instead: ```bash openclaw approvals set --node --stdin <<'EOF' @@ -341,11 +348,11 @@ EOF - `/exec security=full ask=off` changes only the current session. - `/elevated full` is a break-glass shortcut that skips exec approvals only - when both the requested policy and the host approvals file resolve to + when both the requested policy and the host approvals document resolve to `security: "full"` and `ask: "off"`. A stricter host file, such as `ask: "always"`, still prompts. -If the host approvals file stays stricter than config, the stricter host +If the host approvals document stays stricter than config, the stricter host policy still wins. ## Allowlist (per agent) @@ -453,7 +460,7 @@ shows last-used metadata per pattern so you can keep the list tidy. The target selector chooses **Gateway** (local approvals) or a **Node**. Nodes must advertise `system.execApprovals.get/set` (macOS app or headless node host). If a node does not advertise exec approvals yet, edit its -local approvals file directly. +local approvals document directly. Some node hosts, including the Windows companion, own a different approval policy format. Control UI shows these host-native policies read-only. Use the diff --git a/extensions/policy/src/doctor/exec-approval-findings.ts b/extensions/policy/src/doctor/exec-approval-findings.ts index 1a4b33d03133..6a0a960ef994 100644 --- a/extensions/policy/src/doctor/exec-approval-findings.ts +++ b/extensions/policy/src/doctor/exec-approval-findings.ts @@ -1,6 +1,10 @@ import type { HealthFinding } from "openclaw/plugin-sdk/health"; import { normalizeAgentId } from "openclaw/plugin-sdk/routing"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + EXEC_APPROVALS_POLICY_DOCUMENT_NAME, + EXEC_APPROVALS_POLICY_URI, +} from "../exec-approvals-uri.js"; import type { PolicyEvidence, PolicyExecApprovalEvidence } from "../policy-state.js"; import { execApprovalsPolicyShapeFinding } from "./access-shapes.js"; import { CHECK_IDS, POLICY_CHECK_IDS } from "./check-ids.js"; @@ -138,10 +142,10 @@ function execApprovalsFileFindings( { checkId: CHECK_IDS.policyExecApprovalsMissing, severity: "error", - message: "exec-approvals.json evidence is required by policy but was not found.", + message: `${EXEC_APPROVALS_POLICY_DOCUMENT_NAME} evidence is required by policy but was not found.`, source: "policy", path: params.displayName, - target: "oc://exec-approvals.json", + target: EXEC_APPROVALS_POLICY_URI, requirement: `oc://${params.policyDocName}/${ requireFile ? `${params.requirementBase}/requireFile` : params.requirementBase }`, @@ -165,7 +169,7 @@ function execApprovalsFileFindings( path: params.file.displayName, target: `oc://${params.file.ocDocName}`, requirement: `oc://${params.policyDocName}/${params.requirementBase}`, - fixHint: "Fix exec-approvals.json so it is valid JSON.", + fixHint: `Fix ${EXEC_APPROVALS_POLICY_DOCUMENT_NAME} so it is valid JSON.`, }, ]; } @@ -369,13 +373,13 @@ function execApprovalFinding( readonly fixHint: string; }, ): HealthFinding { - const target = entry?.source ?? "oc://exec-approvals.json"; + const target = entry?.source ?? EXEC_APPROVALS_POLICY_URI; return { checkId: params.checkId, severity: "error", message: params.message, source: "policy", - path: "exec-approvals.json", + path: EXEC_APPROVALS_POLICY_DOCUMENT_NAME, ocPath: target, target, requirement: params.requirement, diff --git a/extensions/policy/src/doctor/exec-approval-rules.ts b/extensions/policy/src/doctor/exec-approval-rules.ts index 76fc06f1dc9c..9483a7a3b4e2 100644 --- a/extensions/policy/src/doctor/exec-approval-rules.ts +++ b/extensions/policy/src/doctor/exec-approval-rules.ts @@ -1,6 +1,7 @@ import type { HealthFinding } from "openclaw/plugin-sdk/health"; import { normalizeAgentId } from "openclaw/plugin-sdk/routing"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { EXEC_APPROVALS_POLICY_URI, execApprovalsPolicyUri } from "../exec-approvals-uri.js"; import type { PolicyExecApprovalEvidence } from "../policy-state.js"; import { policyShapeFinding, unsupportedPolicyKey } from "./shape-helpers.js"; import { ocPathSegment } from "./utils.js"; @@ -112,8 +113,8 @@ export function execApprovalAllowlistRequirementKey( export function execApprovalAllowlistMissingTarget(agentId: string | undefined): string { return agentId === undefined - ? "oc://exec-approvals.json" - : `oc://exec-approvals.json/agents/${ocPathSegment(agentId)}/allowlist`; + ? EXEC_APPROVALS_POLICY_URI + : execApprovalsPolicyUri(`agents/${ocPathSegment(agentId)}/allowlist`); } export function formatExecApprovalAllowlistRequirement( @@ -170,7 +171,7 @@ export function syntheticExecApprovalAgentEntry(agentId: string): PolicyExecAppr return { id: `agent:${agentId}:runtime-defaults`, kind: "agent", - source: "oc://exec-approvals.json", + source: EXEC_APPROVALS_POLICY_URI, agentId, }; } diff --git a/extensions/policy/src/doctor/fix-metadata.ts b/extensions/policy/src/doctor/fix-metadata.ts index 10c5f8e78eb7..98c05c91ee5d 100644 --- a/extensions/policy/src/doctor/fix-metadata.ts +++ b/extensions/policy/src/doctor/fix-metadata.ts @@ -1,4 +1,5 @@ // Policy doctor fix metadata classifies findings before patch builders exist. +import { EXEC_APPROVALS_POLICY_DOCUMENT_NAME } from "../exec-approvals-uri.js"; import { CHECK_IDS, POLICY_CHECK_IDS } from "./check-ids.js"; type PolicyFixClass = "automatic" | "reviewRequired" | "manual" | "unsupported"; @@ -388,7 +389,10 @@ const POLICY_FIX_METADATA = [ CHECK_IDS.policyExecApprovalsMissing, "manual", "Restore an attributable exec-approvals evidence file.", - { policyPath: ["execApprovals", "requireFile"], configTargets: ["exec-approvals.json"] }, + { + policyPath: ["execApprovals", "requireFile"], + configTargets: [EXEC_APPROVALS_POLICY_DOCUMENT_NAME], + }, ), m(CHECK_IDS.policyExecApprovalsInvalid, "manual", "Repair the exec approvals evidence artifact."), m( @@ -397,7 +401,7 @@ const POLICY_FIX_METADATA = [ "Update reviewed default approval evidence or policy.", { policyPath: ["execApprovals", "defaults", "allowSecurity"], - configTargets: ["exec-approvals.json"], + configTargets: [EXEC_APPROVALS_POLICY_DOCUMENT_NAME], }, ), m( @@ -406,7 +410,7 @@ const POLICY_FIX_METADATA = [ "Update reviewed agent approval evidence or policy.", { policyPath: ["execApprovals", "agents", "allowSecurity"], - configTargets: ["exec-approvals.json"], + configTargets: [EXEC_APPROVALS_POLICY_DOCUMENT_NAME], }, ), m( @@ -415,7 +419,7 @@ const POLICY_FIX_METADATA = [ "Disable auto-allow skills in the approval owner surface.", { policyPath: ["execApprovals", "agents", "allowAutoAllowSkills"], - configTargets: ["exec-approvals.json"], + configTargets: [EXEC_APPROVALS_POLICY_DOCUMENT_NAME], }, ), m( @@ -424,7 +428,7 @@ const POLICY_FIX_METADATA = [ "Add expected approval patterns through approval review.", { policyPath: ["execApprovals", "agents", "allowlist", "expected"], - configTargets: ["exec-approvals.json"], + configTargets: [EXEC_APPROVALS_POLICY_DOCUMENT_NAME], }, ), m( @@ -433,7 +437,7 @@ const POLICY_FIX_METADATA = [ "Remove unexpected approval patterns through approval review.", { policyPath: ["execApprovals", "agents", "allowlist", "expected"], - configTargets: ["exec-approvals.json"], + configTargets: [EXEC_APPROVALS_POLICY_DOCUMENT_NAME], }, ), m( diff --git a/extensions/policy/src/doctor/policy-runtime.ts b/extensions/policy/src/doctor/policy-runtime.ts index ae2e85909648..97292ff22612 100644 --- a/extensions/policy/src/doctor/policy-runtime.ts +++ b/extensions/policy/src/doctor/policy-runtime.ts @@ -1,9 +1,13 @@ -import os from "node:os"; import { basename, isAbsolute, resolve } from "node:path"; import JSON5 from "json5"; +import { + readExecApprovalsSnapshot, + resolveExecApprovalsDisplayPath, +} from "openclaw/plugin-sdk/exec-approvals-runtime"; import type { HealthCheckContext, HealthFinding } from "openclaw/plugin-sdk/health"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { EXEC_APPROVALS_POLICY_DOCUMENT_NAME } from "../exec-approvals-uri.js"; import type { PolicyAuthProfileEvidence } from "../policy-state.js"; import { CHECK_IDS } from "./check-ids.js"; import { @@ -36,23 +40,18 @@ export async function readPolicyFile( } export async function readExecApprovalsFile( - ctx: HealthCheckContext, + _ctx: HealthCheckContext, ): Promise<{ raw: string; path: string; displayName: string; ocDocName: string } | null> { - const artifact = execApprovalsArtifactLocation(ctx); - try { - const fs = await loadFsPromisesModule(); - return { - raw: await fs.readFile(artifact.path, "utf-8"), - path: artifact.path, - displayName: artifact.displayName, - ocDocName: "exec-approvals.json", - }; - } catch (err) { - if (isNotFoundPathError(err)) { - return null; - } - throw err; + const snapshot = readExecApprovalsSnapshot(); + if (!snapshot.exists || snapshot.raw === null) { + return null; } + return { + raw: snapshot.raw, + path: snapshot.path, + displayName: snapshot.path, + ocDocName: EXEC_APPROVALS_POLICY_DOCUMENT_NAME, + }; } export async function readWorkspaceFile( @@ -71,57 +70,6 @@ export async function readWorkspaceFile( } } -function resolvePolicyArtifactPath(ctx: HealthCheckContext, fileName: string): string { - if (fileName.startsWith("~/") || fileName.startsWith("~\\")) { - const home = resolvePolicyArtifactHomeDir(); - if (home !== undefined) { - return resolve(home, fileName.slice(2)); - } - } - return resolveWorkspacePath(ctx, fileName); -} - -function resolvePolicyArtifactHomeDir(): string | undefined { - const explicitHome = normalizedEnvValue(process.env.OPENCLAW_HOME); - if (explicitHome !== undefined) { - if (explicitHome === "~" || explicitHome.startsWith("~/") || explicitHome.startsWith("~\\")) { - return resolvePolicyHomeRelativePath(explicitHome); - } - return resolve(explicitHome); - } - return resolveOsPolicyHomeDir(); -} - -function resolvePolicyHomeRelativePath(value: string): string { - const fallbackHome = resolveOsPolicyHomeDir(); - return fallbackHome === undefined - ? resolve(value) - : resolve(value.replace(/^~(?=$|[\\/])/, fallbackHome)); -} - -function resolveOsPolicyHomeDir(): string | undefined { - return ( - normalizedEnvValue(process.env.HOME) ?? - normalizedEnvValue(process.env.USERPROFILE) ?? - safeOsHomeDir() - ); -} - -function safeOsHomeDir(): string | undefined { - try { - return normalizedEnvValue(os.homedir()); - } catch { - return undefined; - } -} - -function normalizedEnvValue(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed === undefined || trimmed === "" || trimmed === "undefined" || trimmed === "null" - ? undefined - : trimmed; -} - function resolveWorkspacePath(ctx: HealthCheckContext, fileName: string): string { if (isAbsolute(fileName)) { return fileName; @@ -343,35 +291,8 @@ export function normalizePolicyChannelId(value: string): string { return value.trim().toLowerCase(); } -function canonicalExecApprovalsPath(): string { - return "~/.openclaw/exec-approvals.json"; -} - -function execApprovalsArtifactLocation(ctx: HealthCheckContext): { - readonly path: string; - readonly displayName: string; -} { - const stateDir = normalizedEnvValue(process.env.OPENCLAW_STATE_DIR); - if (stateDir !== undefined) { - const path = resolve(resolvePolicyStateDir(stateDir), "exec-approvals.json"); - return { path, displayName: path }; - } - return { - path: resolvePolicyArtifactPath(ctx, canonicalExecApprovalsPath()), - displayName: canonicalExecApprovalsPath(), - }; -} - export function execApprovalsDisplayName(): string { - const stateDir = normalizedEnvValue(process.env.OPENCLAW_STATE_DIR); - if (stateDir === undefined) { - return canonicalExecApprovalsPath(); - } - return resolve(resolvePolicyStateDir(stateDir), "exec-approvals.json"); -} - -function resolvePolicyStateDir(stateDir: string): string { - return stateDir.startsWith("~") ? resolvePolicyHomeRelativePath(stateDir) : resolve(stateDir); + return resolveExecApprovalsDisplayPath(); } function policyPathSetting(ctx: HealthCheckContext): string { diff --git a/extensions/policy/src/doctor/register.test.ts b/extensions/policy/src/doctor/register.test.ts index f40dc23bb30a..13a8de49200c 100644 --- a/extensions/policy/src/doctor/register.test.ts +++ b/extensions/policy/src/doctor/register.test.ts @@ -1,3 +1,48 @@ +import { vi } from "vitest"; + +vi.mock("openclaw/plugin-sdk/exec-approvals-runtime", async () => { + const nodeFs = await import("node:fs"); + const nodePath = await import("node:path"); + const displayPath = () => { + const stateDir = process.env.OPENCLAW_STATE_DIR?.trim(); + return stateDir + ? nodePath.join(stateDir, "state", "openclaw.sqlite#exec_approvals_config") + : "~/.openclaw/state/openclaw.sqlite#exec_approvals_config"; + }; + return { + resolveExecApprovalsDisplayPath: displayPath, + readExecApprovalsSnapshot: () => { + const fixtureRoot = + process.env.OPENCLAW_STATE_DIR?.trim() ?? process.env.OPENCLAW_HOME?.trim() ?? ""; + const directFixturePath = nodePath.join(fixtureRoot, "exec-approvals.json"); + const fixturePath = nodeFs.existsSync(directFixturePath) + ? directFixturePath + : nodePath.join(fixtureRoot, ".openclaw", "exec-approvals.json"); + try { + const raw = nodeFs.readFileSync(fixturePath, "utf8"); + return { + path: displayPath(), + exists: true, + raw, + file: { version: 1, agents: {} }, + hash: "fixture", + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + return { + path: displayPath(), + exists: false, + raw: null, + file: { version: 1, agents: {} }, + hash: "missing:fixture", + }; + } + }, + }; +}); + import "./register.base.test-utils.js"; import "./register.models-and-mcp.test-utils.js"; import "./register.ingress-and-secrets.test-utils.js"; diff --git a/extensions/policy/src/exec-approvals-uri.ts b/extensions/policy/src/exec-approvals-uri.ts new file mode 100644 index 000000000000..a0e81ed000f1 --- /dev/null +++ b/extensions/policy/src/exec-approvals-uri.ts @@ -0,0 +1,8 @@ +// Stable policy-finding URI identity that intentionally survives the SQLite migration. +// This names locations inside the approvals document; it is not a filesystem path. +export const EXEC_APPROVALS_POLICY_URI = "oc://exec-approvals.json"; +export const EXEC_APPROVALS_POLICY_DOCUMENT_NAME = EXEC_APPROVALS_POLICY_URI.slice("oc://".length); + +export function execApprovalsPolicyUri(relativePath?: string): string { + return relativePath ? `${EXEC_APPROVALS_POLICY_URI}/${relativePath}` : EXEC_APPROVALS_POLICY_URI; +} diff --git a/extensions/policy/src/policy-state-exec-approvals.ts b/extensions/policy/src/policy-state-exec-approvals.ts index a7abe8d70e4d..3343b1cd24c9 100644 --- a/extensions/policy/src/policy-state-exec-approvals.ts +++ b/extensions/policy/src/policy-state-exec-approvals.ts @@ -4,6 +4,7 @@ import { asBoolean as readBoolean, normalizeOptionalString as readString, } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { execApprovalsPolicyUri } from "./exec-approvals-uri.js"; import { ocPathSegment } from "./policy-state-helpers.js"; import type { PolicyExecApprovalEvidence } from "./policy-state-types.js"; @@ -26,12 +27,12 @@ export function scanPolicyExecApprovals(raw: string): readonly PolicyExecApprova "defaults", "defaults", defaults, - "oc://exec-approvals.json/defaults", + execApprovalsPolicyUri("defaults"), ), ); for (const agent of normalizedExecApprovalAgents(parsed.agents)) { - const agentSource = `oc://exec-approvals.json/agents/${ocPathSegment(agent.sourceAgentId)}`; + const agentSource = execApprovalsPolicyUri(`agents/${ocPathSegment(agent.sourceAgentId)}`); evidence.push( execApprovalPostureEvidence( `agent:${agent.agentId}`, @@ -42,9 +43,9 @@ export function scanPolicyExecApprovals(raw: string): readonly PolicyExecApprova ), ); for (const [index, entry] of agent.allowlistEntries.entries()) { - const allowlistSource = `oc://exec-approvals.json/agents/${ocPathSegment( - entry.sourceAgentId, - )}/allowlist/#${entry.index}`; + const allowlistSource = execApprovalsPolicyUri( + `agents/${ocPathSegment(entry.sourceAgentId)}/allowlist/#${entry.index}`, + ); evidence.push({ id: `agent:${agent.agentId}:allowlist:${index}`, kind: "allowlist", diff --git a/scripts/check-database-first-legacy-stores.mjs b/scripts/check-database-first-legacy-stores.mjs index 8fd4259d3554..8d96751d417c 100644 --- a/scripts/check-database-first-legacy-stores.mjs +++ b/scripts/check-database-first-legacy-stores.mjs @@ -107,6 +107,13 @@ const legacyRestartSentinelRuntimeImportSpecifiers = new Set([ "node:path", "path", ]); +const legacyExecApprovalsMigrationPath = "src/infra/state-migrations.exec-approvals.ts"; +const legacyExecApprovalsConfigPath = "src/infra/exec-approvals-config.ts"; +const legacyExecApprovalsRuntimePath = "src/infra/exec-approvals-store.ts"; +// Stable oc:// URI identity, not a filesystem path; see the owning module. +const stableExecApprovalsPolicyUriPath = "extensions/policy/src/exec-approvals-uri.ts"; +const legacyExecApprovalsFilenamePattern = + /(?:^|[/\\])exec-approvals\.json(?:\.doctor-importing)?$/u; const legacyStorePatterns = [ /\bsessions\.json\b/u, @@ -154,6 +161,7 @@ const allowedRuntimeMigrationPaths = [ "src/infra/state-migrations.managed-outgoing-images.ts", "src/infra/state-migrations.apns.ts", "src/infra/state-migrations.mcp-oauth.ts", + legacyExecApprovalsMigrationPath, legacyRestartSentinelMigrationPath, "src/infra/state-migrations.workspace-setup.ts", "src/infra/state-migrations.web-push.ts", @@ -487,6 +495,39 @@ function collectLegacyRestartSentinelBoundaryViolations(sourceFile, relativePath return violations; } +function collectLegacyExecApprovalsBoundaryViolations(sourceFile, relativePath) { + if ( + relativePath === legacyExecApprovalsMigrationPath || + relativePath === legacyExecApprovalsConfigPath || + relativePath === stableExecApprovalsPolicyUriPath + ) { + return []; + } + + const violations = []; + function visit(node) { + if (ts.isStringLiteralLike(node) && legacyExecApprovalsFilenamePattern.test(node.text)) { + violations.push({ + kind: "legacy exec approvals reference", + line: toLine(sourceFile, node), + }); + } + if ( + relativePath === legacyExecApprovalsRuntimePath && + ts.isImportDeclaration(node) && + legacyRestartSentinelRuntimeImportSpecifiers.has(importSource(node)) + ) { + violations.push({ + kind: "legacy exec approvals filesystem import", + line: toLine(sourceFile, node), + }); + } + ts.forEachChild(node, visit); + } + visit(sourceFile); + return violations; +} + function isHelperWriteModuleSource(source) { return ( source === "openclaw/plugin-sdk/file-access-runtime" || @@ -691,10 +732,10 @@ export function collectDatabaseFirstLegacyStoreViolations( ) { const relativePath = inputRelativePath.replaceAll("\\", "/"); const sourceFile = ts.createSourceFile(relativePath, content, ts.ScriptTarget.Latest, true); - const boundaryViolations = collectLegacyRestartSentinelBoundaryViolations( - sourceFile, - relativePath, - ); + const boundaryViolations = [ + ...collectLegacyRestartSentinelBoundaryViolations(sourceFile, relativePath), + ...collectLegacyExecApprovalsBoundaryViolations(sourceFile, relativePath), + ]; if (isAllowedLegacyOwnerPath(relativePath)) { return boundaryViolations; } diff --git a/src/agents/bash-tools.exec-run.ts b/src/agents/bash-tools.exec-run.ts index 2d9537131b15..ecadf582adf8 100644 --- a/src/agents/bash-tools.exec-run.ts +++ b/src/agents/bash-tools.exec-run.ts @@ -300,7 +300,7 @@ export function createExecTool( ) { security = "full"; } - // Keep local exec defaults in sync with exec-approvals.json when tools.exec.* is unset. + // Keep local exec defaults in sync with host approval state when tools.exec.* is unset. const requestedAsk = normalizeExecAsk(params.ask); const hostAsk = maxAsk(modePolicy.ask, approvalPolicy?.ask ?? modePolicy.ask); const trustedAsk = defaults?.messageProvider && hostAsk === "off" ? undefined : requestedAsk; diff --git a/src/agents/bash-tools.exec.approval-id.test.ts b/src/agents/bash-tools.exec.approval-id.test.ts index 12f4db38070a..7bb849425e46 100644 --- a/src/agents/bash-tools.exec.approval-id.test.ts +++ b/src/agents/bash-tools.exec.approval-id.test.ts @@ -8,8 +8,13 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { loadExecApprovals } from "../infra/exec-approvals.js"; +import { + loadExecApprovals, + saveExecApprovals, + type ExecApprovalsFile, +} from "../infra/exec-approvals.js"; import { sendMessage } from "../infra/outbound/message.js"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js"; import { buildSystemRunPreparePayload } from "../test-utils/system-run-prepare-payload.js"; import { createExecTool as createExecToolImpl } from "./bash-tools.exec.js"; @@ -197,9 +202,7 @@ function buildPreparedSystemRunPayload(rawInvokeParams: unknown) { } async function writeExecApprovalsConfig(config: Record) { - const approvalsPath = path.join(process.env.HOME ?? "", ".openclaw", "exec-approvals.json"); - await fs.mkdir(path.dirname(approvalsPath), { recursive: true }); - await fs.writeFile(approvalsPath, JSON.stringify(config, null, 2)); + saveExecApprovals(config as ExecApprovalsFile); } function acceptedApprovalResponse(params: unknown) { @@ -411,6 +414,7 @@ describe("exec approvals", () => { envSnapshot = captureEnv([ "HOME", "USERPROFILE", + "OPENCLAW_STATE_DIR", "OPENCLAW_BUNDLED_PLUGINS_DIR", "OPENCLAW_DISABLE_BUNDLED_PLUGINS", ]); @@ -419,6 +423,7 @@ describe("exec approvals", () => { setTestEnvValue("HOME", tempDir); // Windows uses USERPROFILE for os.homedir() setTestEnvValue("USERPROFILE", tempDir); + setTestEnvValue("OPENCLAW_STATE_DIR", path.join(tempDir, ".openclaw")); deleteTestEnvValue("OPENCLAW_BUNDLED_PLUGINS_DIR"); setTestEnvValue("OPENCLAW_DISABLE_BUNDLED_PLUGINS", "1"); vi.mocked(callGatewayTool).mockReset(); @@ -427,6 +432,7 @@ describe("exec approvals", () => { afterEach(() => { vi.clearAllMocks(); + closeOpenClawStateDatabaseForTest(); envSnapshot?.restore(); envSnapshot = undefined; }); diff --git a/src/agents/bash-tools.exec.security-floor.test.ts b/src/agents/bash-tools.exec.security-floor.test.ts index 3206187b84fb..959ad278e413 100644 --- a/src/agents/bash-tools.exec.security-floor.test.ts +++ b/src/agents/bash-tools.exec.security-floor.test.ts @@ -7,7 +7,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { saveExecApprovals, type ExecApprovalsFile } from "../infra/exec-approvals.js"; import type { ExecAutoReviewer } from "../infra/exec-auto-review.js"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js"; import { resetProcessRegistryForTests } from "./bash-process-registry.test-support.js"; import { createExecTool as createExecToolImpl } from "./bash-tools.exec.js"; @@ -35,10 +37,8 @@ function installAllowlistedGogFixture(root: string): string { return binDir; } -function writeExecApprovalsFixture(root: string, file: Record): void { - const stateDir = process.env.OPENCLAW_STATE_DIR ?? path.join(root, "state"); - fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync(path.join(stateDir, "exec-approvals.json"), `${JSON.stringify(file)}\n`); +function writeExecApprovalsFixture(_root: string, file: Record): void { + saveExecApprovals(file as ExecApprovalsFile); } function writeDenyExecApprovalsFixture(root: string): void { @@ -91,6 +91,7 @@ describe("exec security floor", () => { afterEach(() => { const dir = tempRoot; tempRoot = undefined; + closeOpenClawStateDatabaseForTest(); envSnapshot.restore(); if (dir) { fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 }); diff --git a/src/agents/cli-runner.spawn.test.ts b/src/agents/cli-runner.spawn.test.ts index d3015bd6b367..3109621ae997 100644 --- a/src/agents/cli-runner.spawn.test.ts +++ b/src/agents/cli-runner.spawn.test.ts @@ -51,7 +51,7 @@ import { requireArgAfter, requireRecord, requireRegexMatch, - withTempExecApprovalsFile, + withTempExecApprovalsState, withTempOpenClawHome, type PreparedCliRunContextOverrides, } from "./cli-runner.test-helpers.js"; @@ -3929,7 +3929,7 @@ describe("runCliAgent spawn path", () => { }; if (testCase.approvals) { - await withTempExecApprovalsFile(testCase.approvals, run); + await withTempExecApprovalsState(testCase.approvals, run); } else { await run(); } diff --git a/src/agents/cli-runner.test-helpers.ts b/src/agents/cli-runner.test-helpers.ts index 8e700bc64bd7..bcef4099bcf3 100644 --- a/src/agents/cli-runner.test-helpers.ts +++ b/src/agents/cli-runner.test-helpers.ts @@ -9,8 +9,12 @@ import { type DiagnosticEventPayload, type DiagnosticEventPrivateData, } from "../infra/diagnostic-events.js"; +import type { ExecApprovalsFile } from "../infra/exec-approvals-core.js"; +import { saveExecApprovals } from "../infra/exec-approvals-store.js"; +import { testing as execApprovalsStoreTesting } from "../infra/exec-approvals-store.test-support.js"; import type { CliBackendPlugin } from "../plugins/cli-backend.types.js"; import type { RunExit } from "../process/supervisor/types.js"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { withEnvAsync } from "../test-utils/env.js"; import type { PreparedCliRunContext } from "./cli-runner/types.js"; import type { RunCliAgentParams } from "./cli-runner/types.js"; @@ -350,20 +354,21 @@ export async function expectPathMissing(targetPath: string) { throw new Error(`expected ${targetPath} to be missing`); } -export async function withTempExecApprovalsFile( +export async function withTempExecApprovalsState( file: Record, run: () => Promise, ) { const home = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-exec-approvals-")); - await fs.promises.mkdir(path.join(home, ".openclaw"), { recursive: true }); - await fs.promises.writeFile( - path.join(home, ".openclaw", "exec-approvals.json"), - `${JSON.stringify(file)}\n`, - "utf-8", - ); + const stateDir = path.join(home, ".openclaw"); try { - await withEnvAsync({ HOME: home }, run); + await withEnvAsync({ HOME: home, OPENCLAW_STATE_DIR: stateDir }, async () => { + execApprovalsStoreTesting.reset(); + saveExecApprovals(file as ExecApprovalsFile); + await run(); + }); } finally { + closeOpenClawStateDatabaseForTest(); + execApprovalsStoreTesting.reset(); await fs.promises.rm(home, { recursive: true, force: true }); } } diff --git a/src/cli/program/config-guard.ts b/src/cli/program/config-guard.ts index c91aa91f602b..a5fd59f7aa9e 100644 --- a/src/cli/program/config-guard.ts +++ b/src/cli/program/config-guard.ts @@ -12,6 +12,7 @@ import { resolveStateDir, } from "../../config/paths.js"; import type { ConfigFileSnapshot } from "../../config/types.js"; +import { resolveExecApprovalsPath } from "../../infra/exec-approvals-config.js"; import { resolveRequiredHomeDir } from "../../infra/home-dir.js"; import { ExitError, type RuntimeEnv } from "../../runtime.js"; import { shouldMigrateStateFromPath } from "../argv.js"; @@ -126,10 +127,13 @@ function hasLegacyStateMigrationInputs(): boolean { path.join(stateDir, "plugin-state", "state.sqlite"), path.join(stateDir, "tasks", "runs.sqlite"), ]; + const legacyExecApprovalsPath = resolveExecApprovalsPath(process.env); return ( [ path.join(stateDir, "agent"), path.join(stateDir, "agents"), + legacyExecApprovalsPath, + `${legacyExecApprovalsPath}.doctor-importing`, path.join(stateDir, "plugins", "installs.json"), path.join(stateDir, "restart-sentinel.json"), path.join(stateDir, "restart-sentinel.json.doctor-importing"), diff --git a/src/commands/doctor-security.test.ts b/src/commands/doctor-security.test.ts index 6a629a2ac54e..3294a8acc707 100644 --- a/src/commands/doctor-security.test.ts +++ b/src/commands/doctor-security.test.ts @@ -1,8 +1,11 @@ // Doctor security tests cover security audit checks, config findings, and repair output. -import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import type { ExecApprovalsFile } from "../infra/exec-approvals-core.js"; +import { saveExecApprovals } from "../infra/exec-approvals-store.js"; +import { testing as execApprovalsStoreTesting } from "../infra/exec-approvals-store.test-support.js"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { withTempDir } from "../test-helpers/temp-dir.js"; const note = vi.hoisted(() => vi.fn()); @@ -37,6 +40,7 @@ describe("noteSecurityWarnings gateway exposure", () => { let prevToken: string | undefined; let prevPassword: string | undefined; let prevHome: string | undefined; + let prevStateDir: string | undefined; let prevServiceKind: string | undefined; beforeEach(() => { @@ -47,6 +51,7 @@ describe("noteSecurityWarnings gateway exposure", () => { prevToken = process.env.OPENCLAW_GATEWAY_TOKEN; prevPassword = process.env.OPENCLAW_GATEWAY_PASSWORD; prevHome = process.env.HOME; + prevStateDir = process.env.OPENCLAW_STATE_DIR; prevServiceKind = process.env.OPENCLAW_SERVICE_KIND; delete process.env.OPENCLAW_GATEWAY_TOKEN; delete process.env.OPENCLAW_GATEWAY_PASSWORD; @@ -69,6 +74,11 @@ describe("noteSecurityWarnings gateway exposure", () => { } else { process.env.HOME = prevHome; } + if (prevStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = prevStateDir; + } if (prevServiceKind === undefined) { delete process.env.OPENCLAW_SERVICE_KIND; } else { @@ -84,12 +94,16 @@ describe("noteSecurityWarnings gateway exposure", () => { ): Promise { await withTempDir({ prefix: "openclaw-doctor-security-" }, async (home) => { process.env.HOME = home; - await fs.mkdir(path.join(home, ".openclaw"), { recursive: true }); - await fs.writeFile( - path.join(home, ".openclaw", "exec-approvals.json"), - JSON.stringify(file, null, 2), - ); - await run(); + process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw"); + closeOpenClawStateDatabaseForTest(); + execApprovalsStoreTesting.reset(); + saveExecApprovals(file as ExecApprovalsFile); + try { + await run(); + } finally { + closeOpenClawStateDatabaseForTest(); + execApprovalsStoreTesting.reset(); + } }); } @@ -300,7 +314,7 @@ describe("noteSecurityWarnings gateway exposure", () => { await noteSecurityWarnings(cfg); const message = lastMessage(); expect(message).toContain("disables approval forwarding only"); - expect(message).toContain("exec-approvals.json"); + expect(message).toContain("state/openclaw.sqlite#exec_approvals_config"); expect(message).toContain("openclaw approvals get --gateway"); }); diff --git a/src/commands/doctor.e2e-harness.ts b/src/commands/doctor.e2e-harness.ts index 3844d7dd592b..f73529fb5946 100644 --- a/src/commands/doctor.e2e-harness.ts +++ b/src/commands/doctor.e2e-harness.ts @@ -225,6 +225,10 @@ function createLegacyStateMigrationDetectionResult(params?: { sourcePaths: [], hasLegacy: false, }, + execApprovals: { + sourcePath: "/tmp/state/exec-approvals.json", + hasLegacy: false, + }, sessions: { legacyDir: "/tmp/state/sessions", legacyStorePath: "/tmp/state/sessions/sessions.json", diff --git a/src/gateway/agent-runtime-identity-token.test.ts b/src/gateway/agent-runtime-identity-token.test.ts index 5090f39ed475..37a5a4de609f 100644 --- a/src/gateway/agent-runtime-identity-token.test.ts +++ b/src/gateway/agent-runtime-identity-token.test.ts @@ -2,6 +2,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { readExecApprovalsSnapshot } from "../infra/exec-approvals-store.js"; +import { testing as execApprovalsStoreTesting } from "../infra/exec-approvals-store.test-support.js"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { captureEnv, setTestEnvValue } from "../test-utils/env.js"; const envSnapshot = captureEnv(["HOME", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]); @@ -13,20 +16,16 @@ function useTempHome(): string { tempHomes.push(home); setTestEnvValue("HOME", home); setTestEnvValue("OPENCLAW_HOME", home); - setTestEnvValue("OPENCLAW_STATE_DIR", ""); + setTestEnvValue("OPENCLAW_STATE_DIR", path.join(home, ".openclaw")); + closeOpenClawStateDatabaseForTest(); + execApprovalsStoreTesting.reset(); return home; } -function execApprovalsPath(home: string): string { - return path.join(home, ".openclaw", "exec-approvals.json"); -} - -function readExecApprovals(home: string): { +function readExecApprovals(): { socket?: { token?: string }; } { - return JSON.parse(fs.readFileSync(execApprovalsPath(home), "utf8")) as { - socket?: { token?: string }; - }; + return readExecApprovalsSnapshot().file; } async function importRuntimeTokenModule(): Promise< @@ -37,6 +36,8 @@ async function importRuntimeTokenModule(): Promise< } afterEach(() => { + closeOpenClawStateDatabaseForTest(); + execApprovalsStoreTesting.reset(); vi.resetModules(); envSnapshot.restore(); for (const home of tempHomes.splice(0)) { @@ -46,7 +47,7 @@ afterEach(() => { describe("agent runtime identity token", () => { it("persists the local signing secret so tokens verify across processes", async () => { - const home = useTempHome(); + useTempHome(); const firstProcess = await importRuntimeTokenModule(); const token = await firstProcess.mintAgentRuntimeIdentityToken({ @@ -54,7 +55,7 @@ describe("agent runtime identity token", () => { sessionKey: "session-1", }); - const persistedToken = readExecApprovals(home).socket?.token; + const persistedToken = readExecApprovals().socket?.token; expect(persistedToken).toEqual(expect.any(String)); expect(persistedToken).not.toHaveLength(0); @@ -106,13 +107,13 @@ describe("agent runtime identity token", () => { }); it("does not mint local credentials while rejecting invalid presented tokens", async () => { - const home = useTempHome(); + useTempHome(); const runtimeToken = await importRuntimeTokenModule(); await expect( runtimeToken.verifyAgentRuntimeIdentityToken("not-a-valid-token"), ).resolves.toBeUndefined(); - expect(fs.existsSync(execApprovalsPath(home))).toBe(false); + expect(readExecApprovalsSnapshot().exists).toBe(false); }); it("rejects a token with a shortened signature", async () => { @@ -129,13 +130,13 @@ describe("agent runtime identity token", () => { }); it("rejects tokens minted from a different local state directory", async () => { - const firstHome = useTempHome(); + useTempHome(); const firstProcess = await importRuntimeTokenModule(); const token = await firstProcess.mintAgentRuntimeIdentityToken({ agentId: "main", sessionKey: "session-1", }); - expect(fs.existsSync(execApprovalsPath(firstHome))).toBe(true); + expect(readExecApprovals().socket?.token).toEqual(expect.any(String)); useTempHome(); const secondProcess = await importRuntimeTokenModule(); diff --git a/src/gateway/operator-approval-runtime-token.test.ts b/src/gateway/operator-approval-runtime-token.test.ts index fa1178c19ea7..8bf5c7bdf6ee 100644 --- a/src/gateway/operator-approval-runtime-token.test.ts +++ b/src/gateway/operator-approval-runtime-token.test.ts @@ -2,9 +2,13 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ExecApprovalsFile } from "../infra/exec-approvals-core.js"; +import { saveExecApprovals } from "../infra/exec-approvals-store.js"; +import { testing as execApprovalsStoreTesting } from "../infra/exec-approvals-store.test-support.js"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { captureEnv, setTestEnvValue } from "../test-utils/env.js"; -const envSnapshot = captureEnv(["HOME", "OPENCLAW_HOME"]); +const envSnapshot = captureEnv(["HOME", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]); const tempHomes: string[] = []; @@ -13,6 +17,9 @@ function useTempHome(): string { tempHomes.push(home); setTestEnvValue("HOME", home); setTestEnvValue("OPENCLAW_HOME", home); + setTestEnvValue("OPENCLAW_STATE_DIR", path.join(home, ".openclaw")); + closeOpenClawStateDatabaseForTest(); + execApprovalsStoreTesting.reset(); return home; } @@ -20,23 +27,15 @@ function execApprovalsPath(home: string): string { return path.join(home, ".openclaw", "exec-approvals.json"); } -function writeExecApprovalsToken(home: string, token: string): void { - fs.mkdirSync(path.join(home, ".openclaw"), { recursive: true }); - fs.writeFileSync( - execApprovalsPath(home), - `${JSON.stringify( - { - version: 1, - socket: { - path: "~/.openclaw/exec-approvals.sock", - token, - }, - agents: {}, - }, - null, - 2, - )}\n`, - ); +function writeExecApprovalsToken(_home: string, token: string): void { + saveExecApprovals({ + version: 1, + socket: { + path: "~/.openclaw/exec-approvals.sock", + token, + }, + agents: {}, + } satisfies ExecApprovalsFile); } async function importRuntimeTokenModule(): Promise< @@ -47,6 +46,8 @@ async function importRuntimeTokenModule(): Promise< } afterEach(() => { + closeOpenClawStateDatabaseForTest(); + execApprovalsStoreTesting.reset(); vi.resetModules(); envSnapshot.restore(); for (const home of tempHomes.splice(0)) { diff --git a/src/infra/exec-approvals-config.test.ts b/src/infra/exec-approvals-config.test.ts index 2dfe8dcd8558..c7a78255f196 100644 --- a/src/infra/exec-approvals-config.test.ts +++ b/src/infra/exec-approvals-config.test.ts @@ -1,7 +1,6 @@ // Covers exec approval config normalization and safe-bin policy. -import fs from "node:fs"; -import path from "node:path"; import { describe, expect, it } from "vitest"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { makeTempDir } from "./exec-approvals-test-helpers.js"; import { isSafeBinUsage, @@ -10,6 +9,7 @@ import { normalizeSafeBins, resolveExecApprovals, resolveExecApprovalsFromFile, + saveExecApprovals, type ExecApprovalsAgent, type ExecAllowlistEntry, type ExecApprovalsFile, @@ -22,22 +22,13 @@ describe("exec approvals wildcard agent", () => { try { process.env.OPENCLAW_HOME = dir; - const approvalsPath = path.join(dir, ".openclaw", "exec-approvals.json"); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync( - approvalsPath, - JSON.stringify( - { - version: 1, - agents: { - "*": { allowlist: [{ pattern: "/bin/hostname" }] }, - main: { allowlist: [{ pattern: "/usr/bin/uname" }] }, - }, - }, - null, - 2, - ), - ); + saveExecApprovals({ + version: 1, + agents: { + "*": { allowlist: [{ pattern: "/bin/hostname" }] }, + main: { allowlist: [{ pattern: "/usr/bin/uname" }] }, + }, + }); const resolved = resolveExecApprovals("main"); expect(resolved.allowlist.map((entry) => entry.pattern)).toEqual([ @@ -45,6 +36,7 @@ describe("exec approvals wildcard agent", () => { "/usr/bin/uname", ]); } finally { + closeOpenClawStateDatabaseForTest(); if (prevOpenClawHome === undefined) { delete process.env.OPENCLAW_HOME; } else { diff --git a/src/infra/exec-approvals-config.ts b/src/infra/exec-approvals-config.ts index 1b055fff1f78..a48589f972be 100644 --- a/src/infra/exec-approvals-config.ts +++ b/src/infra/exec-approvals-config.ts @@ -53,8 +53,8 @@ function resolveExecApprovalsStateDir(env: NodeJS.ProcessEnv = process.env): { }; } -export function resolveExecApprovalsPath(): string { - return path.join(resolveExecApprovalsStateDir().path, EXEC_APPROVALS_FILE); +export function resolveExecApprovalsPath(env: NodeJS.ProcessEnv = process.env): string { + return path.join(resolveExecApprovalsStateDir(env).path, EXEC_APPROVALS_FILE); } export function resolveExecApprovalsSocketPath(): string { @@ -63,15 +63,16 @@ export function resolveExecApprovalsSocketPath(): string { export function resolveExecApprovalsDisplayPath(): string { const stateDir = resolveExecApprovalsStateDir().displayPath; + const locator = path.join("state", "openclaw.sqlite#exec_approvals_config"); return stateDir === DEFAULT_EXEC_APPROVALS_STATE_DIR - ? `${stateDir}/${EXEC_APPROVALS_FILE}` - : path.join(stateDir, EXEC_APPROVALS_FILE); + ? `${stateDir}/${locator}` + : path.join(stateDir, locator); } export function resolveExecApprovalsTranscriptPath(): string { return process.env.OPENCLAW_STATE_DIR?.trim() - ? `$OPENCLAW_STATE_DIR/${EXEC_APPROVALS_FILE}` - : `${DEFAULT_EXEC_APPROVALS_STATE_DIR}/${EXEC_APPROVALS_FILE}`; + ? "$OPENCLAW_STATE_DIR/state/openclaw.sqlite#exec_approvals_config" + : `${DEFAULT_EXEC_APPROVALS_STATE_DIR}/state/openclaw.sqlite#exec_approvals_config`; } export function createFailClosedExecApprovalsFallback(): ExecApprovalsFile { @@ -152,7 +153,8 @@ function isValidPersistedExecApprovals(value: unknown): value is ExecApprovalsFi return true; } -export function parsePersistedExecApprovals(raw: string): ExecApprovalsFile { +/** Parse only structurally valid persisted approvals without inventing fallback policy. */ +export function tryParsePersistedExecApprovals(raw: string): ExecApprovalsFile | null { try { const parsed = JSON.parse(raw) as unknown; if (isValidPersistedExecApprovals(parsed)) { @@ -161,8 +163,7 @@ export function parsePersistedExecApprovals(raw: string): ExecApprovalsFile { } catch { // A partial Windows fallback write is existing state, not a missing policy. } - // Never let malformed persisted state inherit permissive product defaults. - return createFailClosedExecApprovalsFallback(); + return null; } function normalizeAllowlistPattern(value: string | undefined): string | null { diff --git a/src/infra/exec-approvals-file-io.ts b/src/infra/exec-approvals-file-io.ts deleted file mode 100644 index 11437474e112..000000000000 --- a/src/infra/exec-approvals-file-io.ts +++ /dev/null @@ -1,442 +0,0 @@ -// Secure filesystem access for the exec approval policy store. -import fs from "node:fs"; -import path from "node:path"; -import { sha256Hex } from "./crypto-digest.js"; -import { - normalizeExecApprovalsInternal, - parsePersistedExecApprovals, -} from "./exec-approvals-config.js"; -import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./exec-approvals-core.js"; -import { assertNoSymlinkParentsSync } from "./fs-safe-advanced.js"; -import { resolveRequiredHomeDir } from "./home-dir.js"; - -function hashExecApprovalsRaw(raw: string | null): string { - // Preserve existing hashes for present files so mixed-version native/CLI - // clients can still compare snapshots; only missing needs its own domain. - return raw === null ? `missing:${sha256Hex("")}` : sha256Hex(raw); -} - -export function hashExecApprovalsFile(file: ExecApprovalsFile): string { - return hashExecApprovalsRaw(`${JSON.stringify(file, null, 2)}\n`); -} - -export function isExecApprovalsTargetMissing(filePath: string): boolean { - try { - fs.lstatSync(filePath); - return false; - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - return true; - } - throw err; - } -} - -export function isExecApprovalsLockMissing(filePath: string): boolean { - try { - const dir = fs.realpathSync(path.dirname(filePath)); - return isExecApprovalsTargetMissing(`${path.join(dir, path.basename(filePath))}.lock`); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - return true; - } - throw err; - } -} - -function ensureDir(filePath: string) { - const dir = path.dirname(filePath); - assertNoExecApprovalsSymlinkParents(dir, resolveRequiredHomeDir()); - fs.mkdirSync(dir, { recursive: true }); - const dirStat = fs.lstatSync(dir); - if (!dirStat.isDirectory() || dirStat.isSymbolicLink()) { - throw new Error(`Refusing to use unsafe exec approvals directory: ${dir}`); - } - try { - fs.chmodSync(dir, 0o700); - } catch (err) { - if (process.platform !== "win32") { - throw err; - } - } - return dir; -} - -export function resolveCanonicalExecApprovalsTarget(filePath: string): string { - const dir = ensureDir(filePath); - return path.join(fs.realpathSync(dir), path.basename(filePath)); -} - -function assertNoExecApprovalsSymlinkParents(targetPath: string, trustedRoot: string): void { - try { - assertNoSymlinkParentsSync({ - rootDir: trustedRoot, - targetPath, - allowOutsideRoot: true, - messagePrefix: "Refusing to traverse symlink in exec approvals path", - }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw new UnsafeExecApprovalsPathError(message, { cause: err }); - } -} - -export class UnsafeExecApprovalsPathError extends Error {} - -function assertSafeExecApprovalsStat(filePath: string, stat: fs.Stats): void { - if (stat.isSymbolicLink()) { - throw new UnsafeExecApprovalsPathError( - `Refusing to write exec approvals via symlink: ${filePath}`, - ); - } - if (!stat.isFile()) { - throw new UnsafeExecApprovalsPathError( - `Refusing to use non-file exec approvals path: ${filePath}`, - ); - } -} - -function assertSafeExecApprovalsDestination(filePath: string): void { - try { - assertSafeExecApprovalsStat(filePath, fs.lstatSync(filePath)); - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== "ENOENT") { - throw err; - } - } -} - -function assertSafeExecApprovalsOverwriteFallback(filePath: string): void { - assertSafeExecApprovalsDestination(filePath); - try { - const stat = fs.statSync(filePath); - if (stat.nlink > 1) { - throw new Error(`Refusing copy fallback for hard-linked exec approvals file: ${filePath}`); - } - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== "ENOENT") { - throw err; - } - } -} - -type ExecApprovalsFallbackDestination = { - existed: boolean; - fd: number; - snapshot: Buffer | null; -}; - -function sameFilesystemEntry(left: fs.Stats, right: fs.Stats): boolean { - return left.dev === right.dev && left.ino === right.ino; -} - -type ExecApprovalsRawState = { exists: false; raw: null } | { exists: true; raw: string }; - -function readExecApprovalsRawState(filePath: string): ExecApprovalsRawState { - assertNoExecApprovalsSymlinkParents(path.dirname(filePath), resolveRequiredHomeDir()); - // Anchor policy bytes to one inode; otherwise a path swap can make the CAS - // hash describe a different file than the guarded approvals destination. - let before: fs.Stats; - try { - before = fs.lstatSync(filePath); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - return { exists: false, raw: null }; - } - throw err; - } - assertSafeExecApprovalsStat(filePath, before); - - const noFollowFlag = fs.constants.O_NOFOLLOW ?? 0; - let fd: number; - try { - fd = fs.openSync(filePath, fs.constants.O_RDONLY | noFollowFlag); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - throw new UnsafeExecApprovalsPathError( - `Refusing to read changed exec approvals path: ${filePath}`, - { cause: err }, - ); - } - if (code === "ELOOP") { - throw new UnsafeExecApprovalsPathError( - `Refusing to write exec approvals via symlink: ${filePath}`, - { cause: err }, - ); - } - throw err; - } - try { - const opened = fs.fstatSync(fd); - if (!opened.isFile() || !sameFilesystemEntry(before, opened)) { - throw new UnsafeExecApprovalsPathError( - `Refusing to read changed exec approvals path: ${filePath}`, - ); - } - const raw = fs.readFileSync(fd, "utf8"); - let after: fs.Stats; - try { - after = fs.lstatSync(filePath); - } catch (err) { - throw new UnsafeExecApprovalsPathError( - `Refusing to read changed exec approvals path: ${filePath}`, - { cause: err }, - ); - } - assertSafeExecApprovalsStat(filePath, after); - if (!sameFilesystemEntry(opened, after)) { - throw new UnsafeExecApprovalsPathError( - `Refusing to read changed exec approvals path: ${filePath}`, - ); - } - return { exists: true, raw }; - } finally { - fs.closeSync(fd); - } -} - -export function readExecApprovalsSnapshotFromPath(filePath: string): ExecApprovalsSnapshot { - const state = readExecApprovalsRawState(filePath); - if (!state.exists) { - return { - path: filePath, - exists: false, - raw: null, - file: normalizeExecApprovalsInternal({ version: 1, agents: {} }), - hash: hashExecApprovalsRaw(null), - }; - } - return { - path: filePath, - exists: true, - raw: state.raw, - file: parsePersistedExecApprovals(state.raw), - hash: hashExecApprovalsRaw(state.raw), - }; -} - -function readExecApprovalsFallbackSnapshotFromFd(fd: number): Buffer { - const chunks: Buffer[] = []; - const buffer = Buffer.alloc(64 * 1024); - let position = 0; - while (true) { - const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, position); - if (bytesRead === 0) { - break; - } - chunks.push(Buffer.from(buffer.subarray(0, bytesRead))); - position += bytesRead; - } - return Buffer.concat(chunks); -} - -function validateExecApprovalsFallbackFd(filePath: string, fd: number): fs.Stats { - const linkStat = fs.lstatSync(filePath); - if (linkStat.isSymbolicLink()) { - throw new Error(`Refusing to write exec approvals via symlink: ${filePath}`); - } - const pathStat = fs.statSync(filePath); - const fdStat = fs.fstatSync(fd); - if (!fdStat.isFile()) { - throw new Error(`Refusing copy fallback for non-file exec approvals path: ${filePath}`); - } - if (fdStat.nlink > 1) { - throw new Error(`Refusing copy fallback for hard-linked exec approvals file: ${filePath}`); - } - if (!sameFilesystemEntry(pathStat, fdStat)) { - throw new Error(`Refusing copy fallback after exec approvals path changed: ${filePath}`); - } - return fdStat; -} - -function openExistingExecApprovalsFallbackDestination( - filePath: string, -): ExecApprovalsFallbackDestination { - const noFollowFlag = fs.constants.O_NOFOLLOW ?? 0; - const fd = fs.openSync(filePath, fs.constants.O_RDWR | noFollowFlag, 0o600); - try { - validateExecApprovalsFallbackFd(filePath, fd); - return { - existed: true, - fd, - snapshot: readExecApprovalsFallbackSnapshotFromFd(fd), - }; - } catch (err) { - try { - fs.closeSync(fd); - } catch { - // best-effort after validation failure - } - throw err; - } -} - -function createExecApprovalsFallbackDestination( - filePath: string, -): ExecApprovalsFallbackDestination { - const noFollowFlag = fs.constants.O_NOFOLLOW ?? 0; - try { - const fd = fs.openSync( - filePath, - fs.constants.O_RDWR | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollowFlag, - 0o600, - ); - try { - validateExecApprovalsFallbackFd(filePath, fd); - return { existed: false, fd, snapshot: null }; - } catch (err) { - try { - fs.closeSync(fd); - } catch { - // best-effort after validation failure - } - throw err; - } - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "EEXIST") { - return openExistingExecApprovalsFallbackDestination(filePath); - } - throw err; - } -} - -function openExecApprovalsFallbackDestination(filePath: string): ExecApprovalsFallbackDestination { - try { - return openExistingExecApprovalsFallbackDestination(filePath); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - return createExecApprovalsFallbackDestination(filePath); - } - throw err; - } -} - -function writeExecApprovalsFallbackBuffer(fd: number, contents: Buffer): void { - fs.ftruncateSync(fd, 0); - let written = 0; - while (written < contents.length) { - written += fs.writeSync(fd, contents, written, contents.length - written, written); - } - fs.ftruncateSync(fd, contents.length); - try { - fs.fchmodSync(fd, 0o600); - } catch { - // best-effort on platforms without chmod - } -} - -function restoreExecApprovalsFallbackDestination( - filePath: string, - destination: ExecApprovalsFallbackDestination, -): void { - if (!destination.existed) { - try { - const pathStat = fs.statSync(filePath); - const fdStat = fs.fstatSync(destination.fd); - if (sameFilesystemEntry(pathStat, fdStat)) { - fs.rmSync(filePath, { force: true }); - } - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== "ENOENT") { - throw err; - } - } - return; - } - writeExecApprovalsFallbackBuffer(destination.fd, destination.snapshot ?? Buffer.alloc(0)); -} - -function copyExecApprovalsFallback(tempPath: string, filePath: string): void { - const contents = fs.readFileSync(tempPath); - const destination = openExecApprovalsFallbackDestination(filePath); - try { - writeExecApprovalsFallbackBuffer(destination.fd, contents); - validateExecApprovalsFallbackFd(filePath, destination.fd); - } catch (copyErr) { - try { - restoreExecApprovalsFallbackDestination(filePath, destination); - } catch (restoreErr) { - throw new Error( - `Failed to restore exec approvals after copy fallback failure for ${filePath}: ${String( - copyErr, - )}`, - { cause: restoreErr }, - ); - } - throw copyErr; - } finally { - fs.closeSync(destination.fd); - } -} - -function renameExecApprovalsWithFallback(tempPath: string, filePath: string): void { - try { - fs.renameSync(tempPath, filePath); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - // Windows can reject rename-overwrite when another process has a transient - // handle on the target approvals file. - if (code !== "EPERM" && code !== "EEXIST") { - throw err; - } - assertSafeExecApprovalsOverwriteFallback(filePath); - copyExecApprovalsFallback(tempPath, filePath); - fs.rmSync(tempPath, { force: true }); - } -} - -// Coerce legacy/corrupted allowlists into `ExecAllowlistEntry[]` before we spread -// entries to add ids (spreading strings creates {"0":"l","1":"s",...}). -export function hardenUnchangedExecApprovals(filePath: string): boolean { - ensureDir(filePath); - assertSafeExecApprovalsDestination(filePath); - let stat: fs.Stats; - try { - stat = fs.statSync(filePath); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - return false; - } - throw err; - } - if (stat.nlink > 1) { - return false; - } - try { - fs.chmodSync(filePath, 0o600); - } catch { - // best-effort on platforms without chmod - } - return true; -} - -// Contract: approvals mutations must stay fully synchronous (no fs.promises). -// loadExecApprovals reads locklessly when a same-process async holder owns the -// lock, which is only safe because no write can be mid-flight in the thread -// pool while synchronous JS runs. An async writer here would break that. -export function writeExecApprovalsRaw(filePath: string, raw: string) { - const dir = ensureDir(filePath); - assertSafeExecApprovalsDestination(filePath); - const tempPath = path.join(dir, `.exec-approvals.${process.pid}.${crypto.randomUUID()}.tmp`); - let tempWritten = false; - try { - fs.writeFileSync(tempPath, raw, { mode: 0o600, flag: "wx" }); - try { - fs.chmodSync(tempPath, 0o600); - } catch { - // best-effort on platforms without chmod - } - tempWritten = true; - renameExecApprovalsWithFallback(tempPath, filePath); - } finally { - if (tempWritten && fs.existsSync(tempPath)) { - fs.rmSync(tempPath, { force: true }); - } - } - try { - fs.chmodSync(filePath, 0o600); - } catch { - // best-effort on platforms without chmod - } -} diff --git a/src/infra/exec-approvals-lock.ts b/src/infra/exec-approvals-lock.ts deleted file mode 100644 index 2bc9c99ce039..000000000000 --- a/src/infra/exec-approvals-lock.ts +++ /dev/null @@ -1,241 +0,0 @@ -// Serializes sync and async access to the exec approval policy store. -import fs from "node:fs"; -import { resolveGlobalMap } from "../shared/global-singleton.js"; -import { getFileLockProcessStartTime } from "../shared/pid-alive.js"; -import { resolveExecApprovalsPath } from "./exec-approvals-config.js"; -import { - isExecApprovalsLockMissing, - isExecApprovalsTargetMissing, - resolveCanonicalExecApprovalsTarget, -} from "./exec-approvals-file-io.js"; -import { withFileLock } from "./file-lock.js"; -import { isLockOwnerDefinitelyStale } from "./stale-lock-file.js"; - -const EXEC_APPROVALS_LOCK_OPTIONS = { - retries: { - retries: 10, - factor: 2, - minTimeout: 25, - maxTimeout: 500, - randomize: true, - }, - stale: 30_000, - // Approval policy is an authorization boundary. A pathname recheck followed - // by stale-lock unlink cannot prove that a fresh owner was not substituted. - staleRecovery: "fail-closed", -} as const; -const EXEC_APPROVALS_LOCK_QUEUE = resolveGlobalMap>( - Symbol.for("openclaw.execApprovalsLockQueue"), -); -let execApprovalsProcessStartTime: number | null | undefined; - -function getExecApprovalsProcessStartTime(): number | null { - if (execApprovalsProcessStartTime === undefined) { - execApprovalsProcessStartTime = getFileLockProcessStartTime(process.pid); - } - return execApprovalsProcessStartTime; -} -const EXEC_APPROVALS_SYNC_LOCK_RETRIES = 10; -const EXEC_APPROVALS_SYNC_LOCK_RETRY_MS = 20; - -type ExecApprovalsSyncLock = { - descriptor: number; - lockPath: string; - device: number; - inode: number; - raw: string; -}; - -function readLockPayload(raw: string): Record | null { - try { - const parsed = JSON.parse(raw) as unknown; - return parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as Record) - : null; - } catch { - return null; - } -} - -function readExecApprovalsLockState(lockPath: string): { - ownerPid: number | null; - definitelyStale: boolean; -} { - try { - const payload = readLockPayload(fs.readFileSync(lockPath, "utf8")); - const ownerPid = - typeof payload?.pid === "number" && Number.isInteger(payload.pid) && payload.pid > 0 - ? payload.pid - : null; - return { - ownerPid, - definitelyStale: isLockOwnerDefinitelyStale({ payload }), - }; - } catch { - return { ownerPid: null, definitelyStale: false }; - } -} - -function sleepExecApprovalsSyncLockRetry(): void { - try { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, EXEC_APPROVALS_SYNC_LOCK_RETRY_MS); - } catch { - const deadline = Date.now() + EXEC_APPROVALS_SYNC_LOCK_RETRY_MS; - while (Date.now() < deadline) { - // Best-effort fallback when Atomics.wait is unavailable. - } - } -} - -function removeOwnedExecApprovalsLock( - lock: ExecApprovalsSyncLock, - options: { requirePayloadMatch: boolean }, -): void { - try { - const current = fs.lstatSync(lock.lockPath); - if ( - current.dev === lock.device && - current.ino === lock.inode && - (!options.requirePayloadMatch || fs.readFileSync(lock.lockPath, "utf8") === lock.raw) - ) { - fs.rmSync(lock.lockPath, { force: true }); - } - } catch { - // Best-effort release; a changed path belongs to another lock owner. - } -} - -function acquireExecApprovalsLockSync(filePath: string): ExecApprovalsSyncLock { - const normalizedTarget = resolveCanonicalExecApprovalsTarget(filePath); - const lockPath = `${normalizedTarget}.lock`; - const payload: Record = { - pid: process.pid, - createdAt: new Date().toISOString(), - nonce: crypto.randomUUID(), - }; - const starttime = getExecApprovalsProcessStartTime(); - if (starttime !== null) { - payload.starttime = starttime; - } - const raw = `${JSON.stringify(payload, null, 2)}\n`; - for (let attempt = 0; attempt <= EXEC_APPROVALS_SYNC_LOCK_RETRIES; attempt += 1) { - let descriptor: number; - try { - descriptor = fs.openSync(lockPath, "wx", 0o600); - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== "EEXIST") { - throw err; - } - const state = readExecApprovalsLockState(lockPath); - if (state.definitelyStale) { - throw Object.assign(new Error(`Exec approvals lock has a stale owner: ${lockPath}`), { - code: "file_lock_stale", - lockPath, - }); - } - if ( - state.ownerPid !== null && - state.ownerPid !== process.pid && - attempt < EXEC_APPROVALS_SYNC_LOCK_RETRIES - ) { - sleepExecApprovalsSyncLockRetry(); - continue; - } - throw Object.assign(new Error(`Exec approvals are locked: ${lockPath}`), { - code: "file_lock_timeout", - lockPath, - // A same-process async holder can never release while sync code spins - // (single event loop), so callers may safely read the committed file - // instead of failing: the holder is suspended and cannot be mid-write. - heldByCurrentProcess: state.ownerPid === process.pid, - }); - } - let stat: fs.Stats; - try { - stat = fs.fstatSync(descriptor); - } catch (err) { - fs.closeSync(descriptor); - throw err; - } - const lock: ExecApprovalsSyncLock = { - descriptor, - lockPath, - device: stat.dev, - inode: stat.ino, - raw, - }; - try { - fs.writeFileSync(descriptor, raw, "utf8"); - return lock; - } catch (err) { - fs.closeSync(descriptor); - removeOwnedExecApprovalsLock(lock, { requirePayloadMatch: false }); - throw err; - } - } - throw new Error(`Failed to acquire exec approvals lock: ${lockPath}`); -} - -export function withExecApprovalsLockSync(fn: () => T): T { - const lock = acquireExecApprovalsLockSync(resolveExecApprovalsPath()); - try { - return fn(); - } finally { - fs.closeSync(lock.descriptor); - removeOwnedExecApprovalsLock(lock, { requirePayloadMatch: true }); - } -} - -export function withExecApprovalsReadLockSync(filePath: string, fn: () => T): T { - if (!isExecApprovalsTargetMissing(filePath) || !isExecApprovalsLockMissing(filePath)) { - return withExecApprovalsLockSync(fn); - } - // Avoid creating a missing state directory for an uncontended read. Recheck - // after reading: a writer can create the lock or target between the probes. - const result = fn(); - // Probe the lock first so the target probe is the final linearization check. - // A writer that finishes after the lock probe must make the target visible. - return isExecApprovalsLockMissing(filePath) && isExecApprovalsTargetMissing(filePath) - ? result - : withExecApprovalsLockSync(fn); -} - -function enqueueExecApprovalsLock(filePath: string, fn: () => Promise): Promise { - // Queue process-local holders before taking the re-entrant shared lock; - // otherwise concurrent callbacks could both mutate stale state. - const previous = EXEC_APPROVALS_LOCK_QUEUE.get(filePath) ?? Promise.resolve(); - const next = previous.then(fn, fn); - EXEC_APPROVALS_LOCK_QUEUE.set(filePath, next); - void next - .finally(() => { - if (EXEC_APPROVALS_LOCK_QUEUE.get(filePath) === next) { - EXEC_APPROVALS_LOCK_QUEUE.delete(filePath); - } - }) - .catch(() => {}); - return next; -} - -export async function withExecApprovalsLock(fn: () => Promise): Promise { - // Harden and canonicalize before entering either lock layer. This prevents a - // symlinked state component from redirecting the sidecar and secures the - // directory even when the guarded update becomes a no-op or loses its CAS. - const filePath = resolveCanonicalExecApprovalsTarget(resolveExecApprovalsPath()); - return await enqueueExecApprovalsLock(filePath, async () => - withFileLock(filePath, EXEC_APPROVALS_LOCK_OPTIONS, fn), - ); -} - -export async function withExecApprovalsReadLock( - filePath: string, - fn: () => Promise, -): Promise { - if (!isExecApprovalsTargetMissing(filePath) || !isExecApprovalsLockMissing(filePath)) { - return await withExecApprovalsLock(fn); - } - const result = await fn(); - // Keep the target probe last for the same missing-file race as the sync path. - return isExecApprovalsLockMissing(filePath) && isExecApprovalsTargetMissing(filePath) - ? result - : await withExecApprovalsLock(fn); -} diff --git a/src/infra/exec-approvals-migration-gate.ts b/src/infra/exec-approvals-migration-gate.ts new file mode 100644 index 000000000000..862f1364fb5c --- /dev/null +++ b/src/infra/exec-approvals-migration-gate.ts @@ -0,0 +1,53 @@ +// Blocks runtime use while retired exec approval state still awaits Doctor import. +import fs from "node:fs"; +import { resolveExecApprovalsPath } from "./exec-approvals-config.js"; + +const DOCTOR_CLAIM_SUFFIX = ".doctor-importing"; +const legacyPresenceCache = new Map(); + +export class ExecApprovalsMigrationRequiredError extends Error { + constructor(filePath: string) { + super( + `Legacy exec approvals exist at ${filePath}. Run \`openclaw doctor --fix\` before using exec approvals.`, + ); + this.name = "ExecApprovalsMigrationRequiredError"; + } +} + +function pathMayExist(filePath: string): boolean { + try { + fs.lstatSync(filePath); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code !== "ENOENT"; + } +} + +/** Refuse runtime access until Doctor owns the one-time legacy import. */ +export function assertNoPendingLegacyExecApprovals( + options: { pathMayExist?: (filePath: string) => boolean } = {}, +): void { + const sourcePath = resolveExecApprovalsPath(); + let hasLegacy = legacyPresenceCache.get(sourcePath); + if (hasLegacy === undefined) { + const probe = options.pathMayExist ?? pathMayExist; + // Bound both Doctor rename directions: source -> claim and claim -> source. + const sourceBefore = probe(sourcePath); + const claim = probe(`${sourcePath}${DOCTOR_CLAIM_SUFFIX}`); + const sourceAfter = probe(sourcePath); + hasLegacy = sourceBefore || claim || sourceAfter; + legacyPresenceCache.set(sourcePath, hasLegacy); + } + if (hasLegacy) { + throw new ExecApprovalsMigrationRequiredError(sourcePath); + } +} + +/** Forget one process-local legacy probe after Doctor resolves the source. */ +export function resetLegacyExecApprovalsPresenceCache(env: NodeJS.ProcessEnv = process.env): void { + legacyPresenceCache.delete(resolveExecApprovalsPath(env)); +} + +export function resetExecApprovalsMigrationGateForTest(): void { + legacyPresenceCache.clear(); +} diff --git a/src/infra/exec-approvals-policy.test.ts b/src/infra/exec-approvals-policy.test.ts index d01610a31e2b..08da8dac32fa 100644 --- a/src/infra/exec-approvals-policy.test.ts +++ b/src/infra/exec-approvals-policy.test.ts @@ -106,7 +106,7 @@ function expectMalformedAgentAskUsesDefaults(agentAsk: unknown): void { expectFields(summary.ask, { requested: "off", host: "always", - hostSource: "~/.openclaw/exec-approvals.json defaults.ask", + hostSource: "~/.openclaw/state/openclaw.sqlite#exec_approvals_config defaults.ask", effective: "always", note: "more aggressive ask wins", }); @@ -447,19 +447,19 @@ describe("exec approvals policy helpers", () => { requested: "full", host: "allowlist", effective: "allowlist", - hostSource: "~/.openclaw/exec-approvals.json defaults.security", + hostSource: "~/.openclaw/state/openclaw.sqlite#exec_approvals_config defaults.security", note: "stricter host security wins", }); expectFields(summary.ask, { requested: "off", host: "always", effective: "always", - hostSource: "~/.openclaw/exec-approvals.json defaults.ask", + hostSource: "~/.openclaw/state/openclaw.sqlite#exec_approvals_config defaults.ask", note: "more aggressive ask wins", }); expect(summary.askFallback).toEqual({ effective: "deny", - source: "~/.openclaw/exec-approvals.json defaults.askFallback", + source: "~/.openclaw/state/openclaw.sqlite#exec_approvals_config defaults.askFallback", }); }); @@ -630,7 +630,7 @@ describe("exec approvals policy helpers", () => { }); expect(summary.security.hostSource).toBe( - `${path.join(stateDir, "exec-approvals.json")} defaults.security`, + `${path.join(stateDir, "state", "openclaw.sqlite#exec_approvals_config")} defaults.security`, ); } finally { if (originalOpenClawStateDir === undefined) { @@ -684,7 +684,7 @@ describe("exec approvals policy helpers", () => { expect(summary.askFallback).toEqual({ effective: "allowlist", - source: "~/.openclaw/exec-approvals.json defaults.askFallback", + source: "~/.openclaw/state/openclaw.sqlite#exec_approvals_config defaults.askFallback", }); }); @@ -728,15 +728,15 @@ describe("exec approvals policy helpers", () => { expectFields(summary.security, { host: "allowlist", - hostSource: "~/.openclaw/exec-approvals.json agents.*.security", + hostSource: "~/.openclaw/state/openclaw.sqlite#exec_approvals_config agents.*.security", }); expectFields(summary.ask, { host: "always", - hostSource: "~/.openclaw/exec-approvals.json agents.*.ask", + hostSource: "~/.openclaw/state/openclaw.sqlite#exec_approvals_config agents.*.ask", }); expect(summary.askFallback).toEqual({ effective: "deny", - source: "~/.openclaw/exec-approvals.json agents.*.askFallback", + source: "~/.openclaw/state/openclaw.sqlite#exec_approvals_config agents.*.askFallback", }); }); @@ -885,11 +885,11 @@ describe("exec approvals policy helpers", () => { expect(snapshots.map((snapshot) => snapshot.scopeLabel)).toEqual(["tools.exec"]); expectFields(snapshots[0]?.security, { host: "allowlist", - hostSource: "~/.openclaw/exec-approvals.json agents.main.security", + hostSource: "~/.openclaw/state/openclaw.sqlite#exec_approvals_config agents.main.security", }); expectFields(snapshots[0]?.ask, { host: "always", - hostSource: "~/.openclaw/exec-approvals.json agents.main.ask", + hostSource: "~/.openclaw/state/openclaw.sqlite#exec_approvals_config agents.main.ask", }); }); diff --git a/src/infra/exec-approvals-resolver.ts b/src/infra/exec-approvals-resolver.ts index 847be0bc7cb5..15b9f4006c6e 100644 --- a/src/infra/exec-approvals-resolver.ts +++ b/src/infra/exec-approvals-resolver.ts @@ -5,7 +5,7 @@ import { DEFAULT_EXEC_APPROVAL_ASK_FALLBACK, DEFAULT_SECURITY, normalizeExecApprovalsInternal, - resolveExecApprovalsPath, + resolveExecApprovalsDisplayPath, resolveExecApprovalsSocketPath, } from "./exec-approvals-config.js"; import type { ExecApprovalsDefaultOverrides } from "./exec-approvals-contracts.js"; @@ -217,7 +217,7 @@ export function resolveExecApprovalsFromFilePrepared(params: { ...(Array.isArray(agent.allowlist) ? agent.allowlist : []), ]; return { - path: params.path ?? resolveExecApprovalsPath(), + path: params.path ?? resolveExecApprovalsDisplayPath(), socketPath: expandHomePrefix( params.socketPath ?? file.socket?.path ?? resolveExecApprovalsSocketPath(), ), diff --git a/src/infra/exec-approvals-sqlite.ts b/src/infra/exec-approvals-sqlite.ts new file mode 100644 index 000000000000..468e6901ecc1 --- /dev/null +++ b/src/infra/exec-approvals-sqlite.ts @@ -0,0 +1,190 @@ +// Canonical SQLite row helpers for exec approval policy state. +import type { DatabaseSync } from "node:sqlite"; +import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; +import type { OpenClawStateLeaseContext } from "../state/openclaw-state-lease.js"; +import { sha256Hex } from "./crypto-digest.js"; +import { + normalizeExecApprovalsInternal, + tryParsePersistedExecApprovals, +} from "./exec-approvals-config.js"; +import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./exec-approvals-core.js"; +import { + executeSqliteQuerySync, + executeSqliteQueryTakeFirstSync, + getNodeSqliteKysely, +} from "./kysely-sync.js"; + +const EXEC_APPROVALS_CONFIG_KEY = "current"; +export const EXEC_APPROVALS_MUTATION_LEASE_SCOPE = "exec-approvals"; +export const EXEC_APPROVALS_MUTATION_LEASE_KEY = "mutation"; + +type ExecApprovalsDatabase = Pick< + OpenClawStateKyselyDatabase, + "exec_approvals_config" | "state_leases" +>; + +export type ExecApprovalsMutationLeaseOwner = Pick< + OpenClawStateLeaseContext, + "assertOwnedInTransaction" +>; + +class ExecApprovalsMutationFencedError extends Error { + constructor() { + super("Exec approvals cannot be changed while agent deletion is in progress; retry."); + this.name = "ExecApprovalsMutationFencedError"; + } +} + +function assertExecApprovalsMutationAllowed(params: { + db: DatabaseSync; + leaseOwner?: ExecApprovalsMutationLeaseOwner; + now?: number; +}): void { + if (params.leaseOwner) { + params.leaseOwner.assertOwnedInTransaction(params.db); + return; + } + const activeLease = executeSqliteQueryTakeFirstSync( + params.db, + getNodeSqliteKysely(params.db) + .selectFrom("state_leases") + .select("owner") + .where("scope", "=", EXEC_APPROVALS_MUTATION_LEASE_SCOPE) + .where("lease_key", "=", EXEC_APPROVALS_MUTATION_LEASE_KEY) + .where("expires_at", ">", params.now ?? Date.now()), + ); + if (activeLease) { + throw new ExecApprovalsMutationFencedError(); + } +} + +type ExecApprovalsConfigRow = { + raw_json: string; +}; + +function hashExecApprovalsRaw(raw: string | null): string { + return raw === null ? `missing:${sha256Hex("")}` : sha256Hex(raw); +} + +export function serializeExecApprovals(file: ExecApprovalsFile): string { + return `${JSON.stringify(file, null, 2)}\n`; +} + +export function readExecApprovalsConfigRow(db: DatabaseSync): ExecApprovalsConfigRow | undefined { + return executeSqliteQueryTakeFirstSync( + db, + getNodeSqliteKysely(db) + .selectFrom("exec_approvals_config") + .select("raw_json") + .where("config_key", "=", EXEC_APPROVALS_CONFIG_KEY), + ); +} + +export function snapshotFromExecApprovalsRow(params: { + path: string; + row?: ExecApprovalsConfigRow; + onMalformed?: () => void; +}): ExecApprovalsSnapshot { + const raw = params.row?.raw_json ?? null; + if (raw === null) { + return { + path: params.path, + exists: false, + raw: null, + file: normalizeExecApprovalsInternal({ version: 1, agents: {} }), + hash: hashExecApprovalsRaw(null), + }; + } + const parsed = tryParsePersistedExecApprovals(raw); + if (!parsed) { + params.onMalformed?.(); + } + return { + path: params.path, + exists: true, + raw, + file: + parsed ?? + normalizeExecApprovalsInternal({ + version: 1, + defaults: { + security: "deny", + ask: "off", + askFallback: "deny", + autoAllowSkills: false, + }, + agents: {}, + }), + hash: hashExecApprovalsRaw(raw), + }; +} + +export function projectionValues(file: ExecApprovalsFile) { + const normalized = normalizeExecApprovalsInternal(file); + const agents = Object.values(normalized.agents ?? {}); + return { + socket_path: normalized.socket?.path ?? null, + has_socket_token: normalized.socket?.token ? 1 : 0, + default_security: normalized.defaults?.security ?? null, + default_ask: normalized.defaults?.ask ?? null, + default_ask_fallback: normalized.defaults?.askFallback ?? null, + auto_allow_skills: + normalized.defaults?.autoAllowSkills === undefined + ? null + : normalized.defaults.autoAllowSkills + ? 1 + : 0, + agent_count: agents.length, + allowlist_count: agents.reduce((total, agent) => total + (agent.allowlist?.length ?? 0), 0), + }; +} + +export function writeExecApprovalsConfigRow(params: { + db: DatabaseSync; + file: ExecApprovalsFile; + raw?: string; + now?: number; + leaseOwner?: ExecApprovalsMutationLeaseOwner; +}): void { + assertExecApprovalsMutationAllowed({ db: params.db, leaseOwner: params.leaseOwner }); + const raw = params.raw ?? serializeExecApprovals(params.file); + const values = { + config_key: EXEC_APPROVALS_CONFIG_KEY, + raw_json: raw, + ...projectionValues(params.file), + updated_at_ms: params.now ?? Date.now(), + }; + executeSqliteQuerySync( + params.db, + getNodeSqliteKysely(params.db) + .insertInto("exec_approvals_config") + .values(values) + .onConflict((conflict) => + conflict.column("config_key").doUpdateSet({ + raw_json: values.raw_json, + socket_path: values.socket_path, + has_socket_token: values.has_socket_token, + default_security: values.default_security, + default_ask: values.default_ask, + default_ask_fallback: values.default_ask_fallback, + auto_allow_skills: values.auto_allow_skills, + agent_count: values.agent_count, + allowlist_count: values.allowlist_count, + updated_at_ms: values.updated_at_ms, + }), + ), + ); +} + +export function deleteExecApprovalsConfigRow( + db: DatabaseSync, + leaseOwner?: ExecApprovalsMutationLeaseOwner, +): void { + assertExecApprovalsMutationAllowed({ db, leaseOwner }); + executeSqliteQuerySync( + db, + getNodeSqliteKysely(db) + .deleteFrom("exec_approvals_config") + .where("config_key", "=", EXEC_APPROVALS_CONFIG_KEY), + ); +} diff --git a/src/infra/exec-approvals-store.test-support.ts b/src/infra/exec-approvals-store.test-support.ts new file mode 100644 index 000000000000..6b2e318ed11e --- /dev/null +++ b/src/infra/exec-approvals-store.test-support.ts @@ -0,0 +1,15 @@ +import "./exec-approvals-store.js"; + +type ExecApprovalsStoreTestApi = { + reset(): void; +}; + +function getTesting(): ExecApprovalsStoreTestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.execApprovalsStoreTestApi") + ] as ExecApprovalsStoreTestApi; +} + +export const testing: ExecApprovalsStoreTestApi = { + reset: () => getTesting().reset(), +}; diff --git a/src/infra/exec-approvals-store.test.ts b/src/infra/exec-approvals-store.test.ts index 461285bdc587..e2dffea317c4 100644 --- a/src/infra/exec-approvals-store.test.ts +++ b/src/infra/exec-approvals-store.test.ts @@ -1,2268 +1,434 @@ -import { spawn } from "node:child_process"; -import { once } from "node:events"; -// Covers exec approvals store socket interactions. import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { DatabaseSync } from "node:sqlite"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; import { - AgentDeletionCommitUncertainError, - beginAgentDeletion, - claimCompletedAgentDeletion, -} from "../agents/agent-lifecycle-registry.js"; -import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; -import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js"; -import { makeTempDir } from "./exec-approvals-test-helpers.js"; + closeOpenClawStateDatabaseForTest, + openOpenClawStateDatabase, +} from "../state/openclaw-state-db.js"; +import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; +import { captureEnv, setTestEnvValue } from "../test-utils/env.js"; +import { sha256Hex } from "./crypto-digest.js"; +import { + assertNoPendingLegacyExecApprovals, + ExecApprovalsMigrationRequiredError, +} from "./exec-approvals-migration-gate.js"; +import { + readExecApprovalsConfigRow, + serializeExecApprovals, + snapshotFromExecApprovalsRow, + writeExecApprovalsConfigRow, +} from "./exec-approvals-sqlite.js"; +import { + ensureExecApprovals, + loadExecApprovals, + readExecApprovalsSnapshot, + restoreExecApprovalsSnapshot, + restoreExecApprovalsSnapshotLocked, + saveExecApprovals, + updateExecApprovals, + withAgentExecApprovalsRemoved, +} from "./exec-approvals-store.js"; +import { testing as execApprovalsStoreTesting } from "./exec-approvals-store.test-support.js"; +import { executeSqliteQueryTakeFirstSync, getNodeSqliteKysely } from "./kysely-sync.js"; +import { runSqliteImmediateTransactionSync } from "./sqlite-transaction.js"; -const requestJsonlSocketMock = vi.hoisted(() => vi.fn()); - -vi.mock("./jsonl-socket.js", () => ({ - requestJsonlSocket: (...args: unknown[]) => requestJsonlSocketMock(...args), +const loggerWarn = vi.hoisted(() => vi.fn()); +vi.mock("../logging/subsystem.js", () => ({ + createSubsystemLogger: (name: string) => ({ + debug: vi.fn(), + info: vi.fn(), + warn: name === "infra/exec-approvals" ? loggerWarn : vi.fn(), + error: vi.fn(), + }), })); -import type { ExecApprovalsFile } from "./exec-approvals.js"; -import { buildHashedArgPatternFromArgv } from "./exec-command-resolution.js"; - -type ExecApprovalsModule = typeof import("./exec-approvals.js"); - -let ensureExecApprovals: ExecApprovalsModule["ensureExecApprovals"]; -let ensureExecApprovalsSnapshot: ExecApprovalsModule["ensureExecApprovalsSnapshot"]; -let commitExecAuthorization: ExecApprovalsModule["commitExecAuthorizationLocked"]; -let createExecApprovalPolicySnapshot: ExecApprovalsModule["createExecApprovalPolicySnapshot"]; -let loadExecApprovals: ExecApprovalsModule["loadExecApprovals"]; -let mergeExecApprovalsSocketDefaults: ExecApprovalsModule["mergeExecApprovalsSocketDefaults"]; -let normalizeExecApprovals: ExecApprovalsModule["normalizeExecApprovals"]; -let persistAllowAlwaysDecisionSync: ExecApprovalsModule["persistAllowAlwaysDecision"]; -let persistAllowAlwaysPatterns: ExecApprovalsModule["persistAllowAlwaysPatterns"]; -let readExecApprovalsSnapshot: ExecApprovalsModule["readExecApprovalsSnapshot"]; -let withAgentExecApprovalsRemoved: ExecApprovalsModule["withAgentExecApprovalsRemoved"]; -let recordAllowlistMatchesUseSync: ExecApprovalsModule["recordAllowlistMatchesUse"]; -let requestExecApprovalViaSocket: ExecApprovalsModule["requestExecApprovalViaSocket"]; -let restoreExecApprovalsSnapshotLocked: ExecApprovalsModule["restoreExecApprovalsSnapshotLocked"]; -let resolveExecApprovals: ExecApprovalsModule["resolveExecApprovalsLocked"]; -let resolveExecApprovalsSync: ExecApprovalsModule["resolveExecApprovals"]; -let resolveExecApprovalsDisplayPath: ExecApprovalsModule["resolveExecApprovalsDisplayPath"]; -let resolveExecApprovalsPath: ExecApprovalsModule["resolveExecApprovalsPath"]; -let resolveExecApprovalsSocketPath: ExecApprovalsModule["resolveExecApprovalsSocketPath"]; -let resolveExecApprovalsTranscriptPath: ExecApprovalsModule["resolveExecApprovalsTranscriptPath"]; -let saveExecApprovals: ExecApprovalsModule["saveExecApprovals"]; -let updateExecApprovals: ExecApprovalsModule["updateExecApprovals"]; +type ExecApprovalsDatabase = Pick; const tempDirs: string[] = []; -const testEnvSnapshot = captureEnv(["OPENCLAW_HOME", "OPENCLAW_PROFILE", "OPENCLAW_STATE_DIR"]); +const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); -beforeAll(async () => { - const module = await import("./exec-approvals.js"); - ensureExecApprovals = module.ensureExecApprovals; - ensureExecApprovalsSnapshot = module.ensureExecApprovalsSnapshot; - commitExecAuthorization = module.commitExecAuthorizationLocked; - createExecApprovalPolicySnapshot = module.createExecApprovalPolicySnapshot; - loadExecApprovals = module.loadExecApprovals; - mergeExecApprovalsSocketDefaults = module.mergeExecApprovalsSocketDefaults; - normalizeExecApprovals = module.normalizeExecApprovals; - persistAllowAlwaysDecisionSync = module.persistAllowAlwaysDecision; - persistAllowAlwaysPatterns = module.persistAllowAlwaysPatterns; - readExecApprovalsSnapshot = module.readExecApprovalsSnapshot; - withAgentExecApprovalsRemoved = module.withAgentExecApprovalsRemoved; - recordAllowlistMatchesUseSync = module.recordAllowlistMatchesUse; - requestExecApprovalViaSocket = module.requestExecApprovalViaSocket; - restoreExecApprovalsSnapshotLocked = module.restoreExecApprovalsSnapshotLocked; - resolveExecApprovals = module.resolveExecApprovalsLocked; - resolveExecApprovalsSync = module.resolveExecApprovals; - resolveExecApprovalsDisplayPath = module.resolveExecApprovalsDisplayPath; - resolveExecApprovalsPath = module.resolveExecApprovalsPath; - resolveExecApprovalsSocketPath = module.resolveExecApprovalsSocketPath; - resolveExecApprovalsTranscriptPath = module.resolveExecApprovalsTranscriptPath; - saveExecApprovals = module.saveExecApprovals; - updateExecApprovals = module.updateExecApprovals; -}); +function createStateDir(): string { + const stateDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "exec-approvals-db-"))); + tempDirs.push(stateDir); + setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); + return stateDir; +} + +function row() { + return executeSqliteQueryTakeFirstSync( + openOpenClawStateDatabase().db, + getNodeSqliteKysely(openOpenClawStateDatabase().db) + .selectFrom("exec_approvals_config") + .selectAll() + .where("config_key", "=", "current"), + ); +} + +function makeStateDatabaseUnavailable(): void { + closeOpenClawStateDatabaseForTest(); + const stateDir = process.env.OPENCLAW_STATE_DIR; + if (!stateDir) { + throw new Error("missing test state dir"); + } + fs.writeFileSync(path.join(stateDir, "state"), "not a directory"); +} beforeEach(() => { - requestJsonlSocketMock.mockReset(); + createStateDir(); + loggerWarn.mockReset(); + execApprovalsStoreTesting.reset(); }); afterEach(() => { - vi.restoreAllMocks(); - testEnvSnapshot.restore(); closeOpenClawStateDatabaseForTest(); - for (const dir of tempDirs.splice(0)) { - fs.rmSync(dir, { recursive: true, force: true }); + execApprovalsStoreTesting.reset(); + envSnapshot.restore(); + for (const directory of tempDirs.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); } }); -function createHomeDir(): string { - const dir = makeTempDir(); - tempDirs.push(dir); - setTestEnvValue("OPENCLAW_HOME", dir); - deleteTestEnvValue("OPENCLAW_PROFILE"); - deleteTestEnvValue("OPENCLAW_STATE_DIR"); - return dir; -} - -function approvalsFilePath(homeDir: string): string { - return path.join(homeDir, ".openclaw", "exec-approvals.json"); -} - -function stateApprovalsFilePath(stateDir: string): string { - return path.join(stateDir, "exec-approvals.json"); -} - -function readApprovalsFile(homeDir: string): ExecApprovalsFile { - return JSON.parse(fs.readFileSync(approvalsFilePath(homeDir), "utf8")) as ExecApprovalsFile; -} - -function listExecApprovalTempFiles(homeDir: string): string[] { - const dir = path.dirname(approvalsFilePath(homeDir)); - if (!fs.existsSync(dir)) { - return []; - } - return fs.readdirSync(dir).filter((name) => name.endsWith(".tmp")); -} - -function requireRecord(value: unknown): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("Expected a non-array record"); - } - return value as Record; -} - -function allowlistEntries(homeDir: string, agentId: string): Record[] { - const file = readApprovalsFile(homeDir); - return (file.agents?.[agentId]?.allowlist ?? []).map((entry) => requireRecord(entry)); -} - -function expectAllowlistEntryFields( - entry: Record, - fields: Record, -): void { - for (const [key, value] of Object.entries(fields)) { - expect(entry[key]).toEqual(value); - } -} - -describe("exec approvals store helpers", () => { - it("expands home-prefixed default file and socket paths", () => { - const dir = createHomeDir(); - - expect(path.normalize(resolveExecApprovalsPath())).toBe( - path.normalize(path.join(dir, ".openclaw", "exec-approvals.json")), - ); - expect(path.normalize(resolveExecApprovalsSocketPath())).toBe( - path.normalize(path.join(dir, ".openclaw", "exec-approvals.sock")), - ); - expect(resolveExecApprovalsDisplayPath()).toBe("~/.openclaw/exec-approvals.json"); - }); - - it("uses OPENCLAW_STATE_DIR for default file and socket paths", () => { - const dir = createHomeDir(); - const stateDir = path.join(dir, "custom-state"); - setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); - - expect(path.normalize(resolveExecApprovalsPath())).toBe( - path.normalize(stateApprovalsFilePath(stateDir)), - ); - expect(path.normalize(resolveExecApprovalsSocketPath())).toBe( - path.normalize(path.join(stateDir, "exec-approvals.sock")), - ); - expect(resolveExecApprovalsDisplayPath()).toBe(stateApprovalsFilePath(stateDir)); - expect(resolveExecApprovalsTranscriptPath()).toBe("$OPENCLAW_STATE_DIR/exec-approvals.json"); - - const ensured = ensureExecApprovals(); - - expect(ensured.socket?.path).toBe(resolveExecApprovalsSocketPath()); - expect(fs.existsSync(stateApprovalsFilePath(stateDir))).toBe(true); - expect(fs.existsSync(approvalsFilePath(dir))).toBe(false); - }); - - it("keeps the deprecated plugin compatibility APIs synchronous", () => { - createHomeDir(); - const approvals = ensureExecApprovals(); - - expect(approvals).not.toBeInstanceOf(Promise); - expect(resolveExecApprovalsSync("main")).not.toBeInstanceOf(Promise); - expect( - persistAllowAlwaysDecisionSync({ - approvals, - agentId: "main", - decision: { kind: "one-shot", reasons: ["unplanned"] }, - }), - ).toBeUndefined(); - expect( - recordAllowlistMatchesUseSync({ - approvals, - agentId: "main", - matches: [], - command: "true", - }), - ).toBeUndefined(); - }); - - it("persists synchronous compatibility writes without restoring revoked policy", async () => { - const dir = createHomeDir(); - ensureExecApprovals(); - await updateExecApprovals({ - update: (file) => ({ - ...file, - defaults: { ...file.defaults, security: "allowlist" }, - agents: { - ...file.agents, - main: { - ...file.agents?.main, - allowlist: [{ id: "revoked", pattern: "/bin/echo" }], - }, - }, - }), +describe("exec approvals SQLite store", () => { + it("uses a permissive missing-row default without creating the row", () => { + expect(loadExecApprovals()).toEqual({ + version: 1, + socket: { path: undefined, token: undefined }, + defaults: { + security: undefined, + ask: undefined, + askFallback: undefined, + autoAllowSkills: undefined, + }, + agents: {}, }); - const stale = readExecApprovalsSnapshot().file; - - await updateExecApprovals({ - update: (file) => ({ - ...file, - defaults: { ...file.defaults, security: "deny" }, - agents: { ...file.agents, main: { ...file.agents?.main, allowlist: [] } }, - }), + expect(readExecApprovalsSnapshot()).toMatchObject({ + exists: false, + raw: null, + hash: expect.stringMatching(/^missing:/u), }); - persistAllowAlwaysDecisionSync({ - approvals: stale, - agentId: "main", - decision: { kind: "exact-command", commandText: "echo stale" }, - }); - recordAllowlistMatchesUseSync({ - approvals: stale, - agentId: "main", - matches: [{ id: "revoked", pattern: "/bin/echo" }], - command: "echo stale", - }); - - expect(stale.defaults?.security).toBe("deny"); - expect(stale.agents?.main?.allowlist).toEqual([ - expect.objectContaining({ pattern: expect.stringMatching(/^=command:/) }), - ]); - expect(readApprovalsFile(dir).defaults?.security).toBe("deny"); - expect(allowlistEntries(dir, "main")).toEqual([ - expect.objectContaining({ pattern: expect.stringMatching(/^=command:/) }), - ]); + expect(row()).toBeUndefined(); }); - it("fails closed when a synchronous writer finds an ownerless live lock", () => { - const dir = createHomeDir(); - ensureExecApprovals(); - const lockPath = `${approvalsFilePath(dir)}.lock`; - const descriptor = fs.openSync(lockPath, "wx", 0o600); - try { - const staleAt = new Date(Date.now() - 60_000); - fs.futimesSync(descriptor, staleAt, staleAt); - const before = fs.fstatSync(descriptor); - - expect(() => - saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} }), - ).toThrow(expect.objectContaining({ code: "file_lock_timeout" })); - - const after = fs.statSync(lockPath); - expect({ dev: after.dev, ino: after.ino }).toEqual({ dev: before.dev, ino: before.ino }); - fs.writeFileSync(descriptor, `${JSON.stringify({ pid: process.pid })}\n`, "utf8"); - expect(fs.readFileSync(lockPath, "utf8")).toContain(`"pid":${process.pid}`); - } finally { - fs.closeSync(descriptor); - fs.rmSync(lockPath, { force: true }); - } - }); - - it("retries brief synchronous contention from another process", async () => { - const dir = createHomeDir(); - ensureExecApprovals(); - const lockPath = `${approvalsFilePath(dir)}.lock`; - const child = spawn( - process.execPath, - [ - "-e", - [ - 'const fs = require("node:fs");', - "const lockPath = process.argv[1];", - 'const descriptor = fs.openSync(lockPath, "wx", 0o600);', - "fs.writeFileSync(descriptor, JSON.stringify({ pid: process.pid }));", - 'process.stdout.write("ready\\n");', - "setTimeout(() => {", - " fs.closeSync(descriptor);", - " fs.rmSync(lockPath, { force: true });", - "}, 100);", - ].join("\n"), - lockPath, - ], - { stdio: ["ignore", "pipe", "inherit"] }, - ); - await once(child.stdout, "data"); - - try { - saveExecApprovals({ version: 1, defaults: { security: "allowlist" }, agents: {} }); - expect(readApprovalsFile(dir).defaults?.security).toBe("allowlist"); - } finally { - if (child.exitCode === null) { - await once(child, "exit"); - } - } - }); - - it("keeps custom-state approvals independent from the default state", async () => { - const dir = createHomeDir(); - const stateDir = path.join(dir, "custom-state"); - fs.mkdirSync(path.dirname(approvalsFilePath(dir)), { recursive: true }); - fs.writeFileSync( - approvalsFilePath(dir), - `${JSON.stringify({ + it("persists CRUD state and all denormalized projections", async () => { + const written = await updateExecApprovals({ + update: () => ({ version: 1, - socket: { - path: path.join(dir, ".openclaw", "exec-approvals.sock"), - token: "legacy-token", - }, + socket: { path: "/tmp/openclaw-approvals.sock", token: "secret" }, defaults: { - security: "deny", - ask: "always", + security: "allowlist", + ask: "on-miss", + askFallback: "deny", + autoAllowSkills: true, }, - agents: {}, - })}\n`, - "utf8", - ); - const defaultBefore = fs.readFileSync(approvalsFilePath(dir), "utf8"); - setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); - - const resolved = await resolveExecApprovals("main", { - security: "full", - ask: "off", + agents: { + main: { allowlist: [{ pattern: "/usr/bin/rg" }, { pattern: "/usr/bin/git" }] }, + worker: { allowlist: [{ pattern: "/usr/bin/jq" }] }, + }, + }), }); - expect(resolved.agent.security).toBe("full"); - expect(resolved.agent.ask).toBe("off"); - expect(resolved.token).toBe(""); - expect(fs.existsSync(stateApprovalsFilePath(stateDir))).toBe(false); - - const ensured = ensureExecApprovals(); - - expect(ensured.socket?.token).not.toBe("legacy-token"); - expect(fs.existsSync(stateApprovalsFilePath(stateDir))).toBe(true); - - await updateExecApprovals({ - update: (current) => ({ ...current, defaults: { security: "allowlist" } }), + expect(written?.exists).toBe(true); + expect(loadExecApprovals().defaults?.security).toBe("allowlist"); + expect(row()).toMatchObject({ + config_key: "current", + socket_path: "/tmp/openclaw-approvals.sock", + has_socket_token: 1, + default_security: "allowlist", + default_ask: "on-miss", + default_ask_fallback: "deny", + auto_allow_skills: 1, + agent_count: 2, + allowlist_count: 3, }); - const custom = JSON.parse( - fs.readFileSync(stateApprovalsFilePath(stateDir), "utf8"), - ) as ExecApprovalsFile; - expect(custom.defaults?.security).toBe("allowlist"); - expect(fs.readFileSync(approvalsFilePath(dir), "utf8")).toBe(defaultBefore); - expect(fs.existsSync(`${approvalsFilePath(dir)}.migrated`)).toBe(false); }); - it("keeps named-profile approvals isolated from the default profile", () => { - const dir = createHomeDir(); - const stateDir = path.join(dir, ".openclaw-work"); - const defaultPath = approvalsFilePath(dir); - fs.mkdirSync(path.dirname(defaultPath), { recursive: true }); - fs.writeFileSync( - defaultPath, - `${JSON.stringify({ - version: 1, - socket: { token: "default-profile-token" }, - defaults: { security: "full", ask: "off" }, - agents: {}, - })}\n`, - "utf8", - ); - const defaultBefore = fs.readFileSync(defaultPath, "utf8"); - setTestEnvValue("OPENCLAW_PROFILE", "work"); - setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); - - const ensured = ensureExecApprovals(); - - expect(ensured.socket?.token).not.toBe("default-profile-token"); - expect(fs.existsSync(stateApprovalsFilePath(stateDir))).toBe(true); - expect(fs.readFileSync(defaultPath, "utf8")).toBe(defaultBefore); - }); - - it("keeps the default approvals path when only legacy state exists", () => { - const dir = createHomeDir(); - fs.mkdirSync(path.join(dir, ".clawdbot"), { recursive: true }); - - expect(path.normalize(resolveExecApprovalsPath())).toBe(path.normalize(approvalsFilePath(dir))); - - ensureExecApprovals(); - - expect(fs.existsSync(approvalsFilePath(dir))).toBe(true); - expect(fs.existsSync(path.join(dir, ".clawdbot", "exec-approvals.json"))).toBe(false); - }); - - it("merges socket defaults from normalized, current, and built-in fallback", () => { - const normalized = normalizeExecApprovals({ - version: 1, - agents: {}, - socket: { path: "/tmp/a.sock", token: "a" }, - }); - const current = normalizeExecApprovals({ - version: 1, - agents: {}, - socket: { path: "/tmp/b.sock", token: "b" }, - }); - - expect(mergeExecApprovalsSocketDefaults({ normalized, current }).socket).toEqual({ - path: "/tmp/a.sock", - token: "a", - }); - - const merged = mergeExecApprovalsSocketDefaults({ - normalized: normalizeExecApprovals({ version: 1, agents: {} }), - current, - }); - expect(merged.socket).toEqual({ - path: "/tmp/b.sock", - token: "b", - }); - - createHomeDir(); - const initialized = mergeExecApprovalsSocketDefaults({ - normalized: normalizeExecApprovals({ version: 1, agents: {} }), - }); - expect(initialized.socket?.path).toBe(resolveExecApprovalsSocketPath()); - expect(initialized.socket?.token).toMatch(/^[A-Za-z0-9_-]{32}$/); - }); - - it("distinguishes a missing approvals file from malformed persisted policy", () => { - const dir = createHomeDir(); - + it("preserves raw-byte CAS hashes and returns null on a stale base", async () => { const missing = readExecApprovalsSnapshot(); - expect(missing.exists).toBe(false); - expect(missing.raw).toBeNull(); - expect(missing.file).toEqual(normalizeExecApprovals({ version: 1, agents: {} })); - expect(path.normalize(missing.path)).toBe(path.normalize(approvalsFilePath(dir))); - - fs.mkdirSync(path.dirname(approvalsFilePath(dir)), { recursive: true }); - fs.writeFileSync(approvalsFilePath(dir), "{invalid", "utf8"); - - const invalid = readExecApprovalsSnapshot(); - expect(invalid.exists).toBe(true); - expect(invalid.raw).toBe("{invalid"); - expect(invalid.file.defaults).toMatchObject({ - security: "deny", - ask: "off", - askFallback: "deny", - autoAllowSkills: false, + const first = await updateExecApprovals({ + baseHash: missing.hash, + update: () => ({ version: 1, defaults: { security: "deny" }, agents: {} }), }); - }); + expect(first?.raw).not.toBeNull(); + expect(first?.hash).toBe(sha256Hex(first?.raw ?? "")); - it.runIf(process.platform !== "win32")( - "fails closed on load and rejects snapshots for symlinked approvals", - async () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - const linkedPath = path.join(dir, "linked-approvals.json"); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync( - linkedPath, - '{"version":1,"defaults":{"security":"full","ask":"off"},"agents":{}}\n', - "utf8", - ); - fs.symlinkSync(linkedPath, approvalsPath); - - expect(loadExecApprovals().defaults).toMatchObject({ security: "deny", ask: "off" }); - expect(() => readExecApprovalsSnapshot()).toThrow(/symlink/); - await expect( - updateExecApprovals({ - update: () => ({ version: 1, defaults: { security: "deny" }, agents: {} }), - }), - ).rejects.toThrow(/symlink/); - expect(fs.readFileSync(linkedPath, "utf8")).toContain('"security":"full"'); - }, - ); - - it("fails closed on load and rejects snapshots for non-file approvals paths", () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - fs.mkdirSync(approvalsPath, { recursive: true }); - - expect(loadExecApprovals().defaults).toMatchObject({ security: "deny", ask: "off" }); - expect(() => readExecApprovalsSnapshot()).toThrow(/non-file exec approvals path/); - }); - - it("does not let a stale missing-file snapshot overwrite a present empty policy", async () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - const missing = readExecApprovalsSnapshot(); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync(approvalsPath, "", "utf8"); - - const empty = readExecApprovalsSnapshot(); - expect(empty.hash).not.toBe(missing.hash); await expect( updateExecApprovals({ baseHash: missing.hash, update: () => ({ version: 1, defaults: { security: "full" }, agents: {} }), }), ).resolves.toBeNull(); - expect(fs.readFileSync(approvalsPath, "utf8")).toBe(""); - }); - it("removes only the deleted agent policy so recreating the id inherits nothing", async () => { - const dir = createHomeDir(); - saveExecApprovals({ - version: 1, - defaults: { security: "deny", ask: "off" }, - agents: { - "deleted-agent": { - security: "allowlist", - allowlist: [{ pattern: "/usr/bin/old-tool" }], - }, - "other-agent": { - security: "allowlist", - allowlist: [{ pattern: "/usr/bin/keep-tool" }], - }, - }, - }); - - await expect( - withAgentExecApprovalsRemoved("Deleted-Agent", async () => "committed"), - ).resolves.toBe("committed"); - - const persisted = readApprovalsFile(dir); - expect(persisted.agents?.["deleted-agent"]).toBeUndefined(); - expect(persisted.agents?.["other-agent"]?.allowlist).toEqual([ - expect.objectContaining({ pattern: "/usr/bin/keep-tool" }), - ]); - const recreatedPolicy = resolveExecApprovalsSync("deleted-agent"); - expect(recreatedPolicy.agent.security).toBe("deny"); - expect(recreatedPolicy.allowlist).toEqual([]); - }); - - it("does not create an approvals file when the deleted agent has no policy", async () => { - const dir = createHomeDir(); - - await expect( - withAgentExecApprovalsRemoved("missing-agent", async () => "committed"), - ).resolves.toBe("committed"); - - expect(fs.existsSync(approvalsFilePath(dir))).toBe(false); - }); - - it("restores the deleted policy when the roster commit fails", async () => { - const dir = createHomeDir(); - saveExecApprovals({ - version: 1, - agents: { - "deleted-agent": { - security: "allowlist", - allowlist: [{ pattern: "/usr/bin/old-tool" }], - }, - "other-agent": { - security: "allowlist", - allowlist: [{ pattern: "/usr/bin/keep-tool" }], - }, - }, - }); - - await expect( - withAgentExecApprovalsRemoved("deleted-agent", async () => { - expect(readApprovalsFile(dir).agents?.["deleted-agent"]).toBeUndefined(); - throw new Error("config commit failed"); - }), - ).rejects.toThrow("config commit failed"); - - const restored = readApprovalsFile(dir); - expect(restored.agents?.["deleted-agent"]?.allowlist).toEqual([ - expect.objectContaining({ pattern: "/usr/bin/old-tool" }), - ]); - expect(restored.agents?.["other-agent"]?.allowlist).toEqual([ - expect.objectContaining({ pattern: "/usr/bin/keep-tool" }), - ]); - }); - - it("rejects a late policy write until the deleted id is recreated", async () => { - createHomeDir(); - const deletion = beginAgentDeletion({ - agentId: "deleted-agent", - agentDir: "/agents/deleted-agent", - workspaceDir: "/workspaces/deleted-agent", - sessionsDir: "/sessions/deleted-agent", - }); - try { - await withAgentExecApprovalsRemoved("deleted-agent", async () => { - deletion.commit(); - }); - - await expect( - updateExecApprovals({ - update: (file) => ({ - ...file, - agents: { - ...file.agents, - "deleted-agent": { security: "allowlist" }, - }, - }), - }), - ).rejects.toThrow("agent deleted-agent is deleted"); - - deletion.finish(); - await expect( - updateExecApprovals({ - update: (file) => ({ - ...file, - agents: { - ...file.agents, - "deleted-agent": { security: "allowlist" }, - }, - }), - }), - ).rejects.toThrow("agent deleted-agent is deleted"); - expect(claimCompletedAgentDeletion("deleted-agent", deletion.entry.operationId)).toBe(true); - await expect( - updateExecApprovals({ - update: (file) => ({ - ...file, - agents: { - ...file.agents, - "deleted-agent": { security: "allowlist" }, - }, - }), - }), - ).resolves.not.toBeNull(); - } finally { - deletion.finish(); - } - }); - - it("rejects generic removal of a fenced agent policy", async () => { - const dir = createHomeDir(); - saveExecApprovals({ - version: 1, - agents: { "deleted-agent": { security: "deny" } }, - }); - const deletion = beginAgentDeletion({ - agentId: "deleted-agent", - agentDir: "/agents/deleted-agent", - workspaceDir: "/workspaces/deleted-agent", - sessionsDir: "/sessions/deleted-agent", - }); - try { - await expect( - updateExecApprovals({ - update: (file) => ({ ...file, agents: {} }), - }), - ).rejects.toThrow("agent deleted-agent is deleted"); - expect(readApprovalsFile(dir).agents?.["deleted-agent"]?.security).toBe("deny"); - } finally { - deletion.rollback(); - } - }); - - it("rejects an in-place update to a fenced agent policy", async () => { - const dir = createHomeDir(); - saveExecApprovals({ - version: 1, - agents: { "deleted-agent": { security: "deny" } }, - }); - const deletion = beginAgentDeletion({ - agentId: "deleted-agent", - agentDir: "/agents/deleted-agent", - workspaceDir: "/workspaces/deleted-agent", - sessionsDir: "/sessions/deleted-agent", - }); - try { - await expect( - updateExecApprovals({ - update: (file) => { - file.agents = { "deleted-agent": { security: "full" } }; - return file; - }, - }), - ).rejects.toThrow("agent deleted-agent is deleted"); - expect(readApprovalsFile(dir).agents?.["deleted-agent"]?.security).toBe("deny"); - } finally { - deletion.rollback(); - } - }); - - it("does not restore removed policy when roster commit outcome is uncertain", async () => { - const dir = createHomeDir(); - saveExecApprovals({ - version: 1, - agents: { "deleted-agent": { security: "allowlist" } }, - }); - const deletion = beginAgentDeletion({ - agentId: "deleted-agent", - agentDir: "/agents/deleted-agent", - workspaceDir: "/workspaces/deleted-agent", - sessionsDir: "/sessions/deleted-agent", - }); - try { - await expect( - withAgentExecApprovalsRemoved("deleted-agent", async () => { - throw new AgentDeletionCommitUncertainError(new Error("config outcome unknown")); - }), - ).rejects.toThrow("config outcome unknown"); - expect(readApprovalsFile(dir).agents?.["deleted-agent"]).toBeUndefined(); - } finally { - deletion.rollback(); - } - }); - - it("restores a matching real-store snapshot", async () => { - const dir = createHomeDir(); - saveExecApprovals({ version: 1, defaults: { security: "deny" }, agents: {} }); - const snapshot = readExecApprovalsSnapshot(); - const current = await updateExecApprovals({ + const updated = await updateExecApprovals({ + baseHash: first?.hash, update: (file) => ({ ...file, defaults: { security: "full" } }), }); - - expect(current).not.toBeNull(); - if (!current) { - throw new Error("Expected the current approvals snapshot"); - } - await expect(restoreExecApprovalsSnapshotLocked(snapshot, current.hash)).resolves.toBe(true); - expect(fs.readFileSync(approvalsFilePath(dir), "utf8")).toBe(snapshot.raw); + expect(updated?.file.defaults?.security).toBe("full"); }); - it("removes a newly created approvals file when the current hash still matches", async () => { - const dir = createHomeDir(); + it("mints one socket token and reuses it on later initialization", () => { + const first = ensureExecApprovals(); + const second = ensureExecApprovals(); + expect(first.socket?.token).toMatch(/^[A-Za-z0-9_-]+$/u); + expect(first.socket?.token).toBe(second.socket?.token); + expect(first.socket?.path).toBe(second.socket?.path); + expect(row()).toMatchObject({ has_socket_token: 1, socket_path: first.socket?.path }); + }); + + it("fails closed and warns once for malformed raw_json", () => { + const { db } = openOpenClawStateDatabase(); + db.prepare( + "INSERT INTO exec_approvals_config (config_key, raw_json, socket_path, has_socket_token, default_security, default_ask, default_ask_fallback, auto_allow_skills, agent_count, allowlist_count, updated_at_ms) VALUES (?, ?, NULL, 0, NULL, NULL, NULL, NULL, 0, 0, 1)", + ).run("current", "{not-json"); + + expect(loadExecApprovals().defaults).toMatchObject({ security: "deny", ask: "off" }); + expect(loadExecApprovals().defaults?.security).toBe("deny"); + expect(loggerWarn).toHaveBeenCalledTimes(1); + expect(loggerWarn.mock.calls[0]?.[0]).toContain("malformed"); + }); + + it("throws typed snapshot failures while enforcement reads fail closed", () => { + makeStateDatabaseUnavailable(); + + expect(() => readExecApprovalsSnapshot()).toThrow("Exec approvals SQLite state is unavailable"); + expect(loadExecApprovals().defaults?.security).toBe("deny"); + expect(loadExecApprovals().defaults?.security).toBe("deny"); + expect(loggerWarn).toHaveBeenCalledTimes(1); + expect(loggerWarn.mock.calls[0]?.[0]).toContain("unavailable"); + }); + + it("aborts agent deletion before commit when the approvals store is unavailable", async () => { + makeStateDatabaseUnavailable(); + const commit = vi.fn(async () => "committed"); + + await expect(withAgentExecApprovalsRemoved("removed", commit)).rejects.toThrow( + "Exec approvals SQLite state is unavailable", + ); + expect(commit).not.toHaveBeenCalled(); + }); + + it("removes one agent and preserves unrelated policy", async () => { + saveExecApprovals({ + version: 1, + agents: { + removed: { security: "allowlist", allowlist: [{ pattern: "/usr/bin/old" }] }, + kept: { security: "allowlist", allowlist: [{ pattern: "/usr/bin/keep" }] }, + }, + }); + + await expect(withAgentExecApprovalsRemoved("removed", async () => "ok")).resolves.toBe("ok"); + expect(loadExecApprovals().agents).toEqual({ + kept: expect.objectContaining({ + allowlist: [expect.objectContaining({ pattern: "/usr/bin/keep" })], + }), + }); + }); + + it("fences a concurrent writer across a slow deletion commit", async () => { + saveExecApprovals({ + version: 1, + agents: { + removed: { security: "allowlist" }, + kept: { security: "deny" }, + }, + }); + let notifyCommitStarted!: () => void; + const commitStarted = new Promise((resolve) => { + notifyCommitStarted = resolve; + }); + let finishCommit!: () => void; + const commitGate = new Promise((resolve) => { + finishCommit = resolve; + }); + const deletion = withAgentExecApprovalsRemoved("removed", async () => { + notifyCommitStarted(); + await commitGate; + return "committed"; + }); + + await commitStarted; + try { + expect(loadExecApprovals().agents).toEqual({ kept: { security: "deny" } }); + await expect( + updateExecApprovals({ + update: (file) => ({ + ...file, + agents: { ...file.agents, removed: { security: "full" } }, + }), + }), + ).rejects.toMatchObject({ name: "ExecApprovalsMutationFencedError" }); + } finally { + finishCommit(); + } + + await expect(deletion).resolves.toBe("committed"); + }); + + it("fences writers during deletion even when the agent has no approval policy", async () => { + saveExecApprovals({ version: 1, agents: { kept: { security: "deny" } } }); + let notifyCommitStarted!: () => void; + const commitStarted = new Promise((resolve) => { + notifyCommitStarted = resolve; + }); + let finishCommit!: () => void; + const commitGate = new Promise((resolve) => { + finishCommit = resolve; + }); + const deletion = withAgentExecApprovalsRemoved("missing", async () => { + notifyCommitStarted(); + await commitGate; + }); + + await commitStarted; + try { + expect(() => + saveExecApprovals({ + version: 1, + agents: { kept: { security: "full" } }, + }), + ).toThrow("Exec approvals cannot be changed while agent deletion is in progress; retry."); + } finally { + finishCommit(); + } + await deletion; + }); + + it("restores only the removed agent when the surrounding commit fails", async () => { + saveExecApprovals({ + version: 1, + agents: { removed: { security: "allowlist" }, kept: { security: "deny" } }, + }); + + await expect( + withAgentExecApprovalsRemoved("removed", async () => { + throw new Error("roster commit failed"); + }), + ).rejects.toThrow("roster commit failed"); + + expect(loadExecApprovals().agents).toMatchObject({ + removed: { security: "allowlist" }, + kept: { security: "deny" }, + }); + }); + + it("allows writers after an abandoned mutation lease expires", async () => { + const { db } = openOpenClawStateDatabase(); + const now = Date.now(); + db.prepare( + "INSERT INTO state_leases (scope, lease_key, owner, expires_at, heartbeat_at, payload_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, NULL, ?, ?)", + ).run("exec-approvals", "mutation", "crashed-deletion", now - 1, now - 10, now - 10, now - 10); + + await expect( + updateExecApprovals({ + update: () => ({ version: 1, agents: { current: { security: "full" } } }), + }), + ).resolves.toMatchObject({ file: { agents: { current: { security: "full" } } } }); + }); + + it("restores snapshots and honors rollback CAS", async () => { const missing = readExecApprovalsSnapshot(); - const created = await updateExecApprovals({ - baseHash: missing.hash, + const first = await updateExecApprovals({ update: () => ({ version: 1, defaults: { security: "deny" }, agents: {} }), }); - - expect(created).not.toBeNull(); - if (!created) { - throw new Error("Expected the created approvals snapshot"); + if (!first) { + throw new Error("missing first snapshot"); } - await expect(restoreExecApprovalsSnapshotLocked(missing, created.hash)).resolves.toBe(true); - expect(fs.existsSync(approvalsFilePath(dir))).toBe(false); - }); + expect(await restoreExecApprovalsSnapshotLocked(missing, first.hash)).toBe(true); + expect(readExecApprovalsSnapshot().exists).toBe(false); - it("preserves a newer approvals file when snapshot restoration loses its CAS", async () => { - const dir = createHomeDir(); - saveExecApprovals({ version: 1, defaults: { security: "deny" }, agents: {} }); - const snapshot = readExecApprovalsSnapshot(); - const base = await updateExecApprovals({ - update: (file) => ({ ...file, defaults: { security: "allowlist" } }), - }); + saveExecApprovals({ version: 1, defaults: { security: "allowlist" }, agents: {} }); + const original = readExecApprovalsSnapshot(); const newer = await updateExecApprovals({ update: (file) => ({ ...file, defaults: { security: "full" } }), }); - - expect(base).not.toBeNull(); - expect(newer).not.toBeNull(); - if (!base || !newer) { - throw new Error("Expected both approvals snapshots"); + if (!newer) { + throw new Error("missing newer snapshot"); } - await expect(restoreExecApprovalsSnapshotLocked(snapshot, base.hash)).resolves.toBe(false); - expect(fs.readFileSync(approvalsFilePath(dir), "utf8")).toBe(newer.raw); + expect(await restoreExecApprovalsSnapshotLocked(original, original.hash)).toBe(false); + restoreExecApprovalsSnapshot(original); + expect(loadExecApprovals().defaults?.security).toBe("allowlist"); }); - it.runIf(process.platform !== "win32")( - "normalizes the approvals directory before an async no-op update", - async () => { - const dir = createHomeDir(); - saveExecApprovals({ version: 1, defaults: { security: "deny" }, agents: {} }); - const approvalsDir = path.dirname(approvalsFilePath(dir)); - fs.chmodSync(approvalsDir, 0o777); - - await expect(updateExecApprovals({ update: () => null })).resolves.not.toBeNull(); - - expect(fs.statSync(approvalsDir).mode & 0o777).toBe(0o700); - }, - ); - - it.runIf(process.platform !== "win32")( - "normalizes the approvals directory before an async CAS miss", - async () => { - const dir = createHomeDir(); - saveExecApprovals({ version: 1, defaults: { security: "deny" }, agents: {} }); - const approvalsDir = path.dirname(approvalsFilePath(dir)); - fs.chmodSync(approvalsDir, 0o777); - - await expect( - updateExecApprovals({ - baseHash: "stale", - update: (file) => ({ ...file, defaults: { security: "full" } }), - }), - ).resolves.toBeNull(); - - expect(fs.statSync(approvalsDir).mode & 0o777).toBe(0o700); - }, - ); - - it("fails closed when loading malformed or unreadable persisted approvals", () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync(approvalsPath, "{invalid", "utf8"); - - expect(loadExecApprovals().defaults).toMatchObject({ security: "deny", ask: "off" }); - - fs.writeFileSync( - approvalsPath, - '{"version":1,"defaults":{"security":"invalid"},"agents":{}}\n', - "utf8", - ); - expect(loadExecApprovals().defaults).toMatchObject({ security: "deny", ask: "off" }); - - fs.writeFileSync( - approvalsPath, - '{"version":1,"agents":{"main":{"allowlist":[{"pattern":"/usr/bin/tool","argPattern":null}]}}}\n', - "utf8", - ); - expect(loadExecApprovals().defaults).toMatchObject({ security: "deny", ask: "off" }); - - const approvalsStat = fs.statSync(approvalsPath); - const actualReadFileSync = fs.readFileSync.bind(fs); - vi.spyOn(fs, "readFileSync").mockImplementation((target, options) => { - const targetStat = typeof target === "number" ? fs.fstatSync(target) : null; - if ( - String(target) === approvalsPath || - (targetStat?.dev === approvalsStat.dev && targetStat.ino === approvalsStat.ino) - ) { - throw Object.assign(new Error("approval path blocked"), { code: "EACCES" }); - } - return actualReadFileSync(target, options as never); - }); - - expect(loadExecApprovals().defaults).toMatchObject({ security: "deny", ask: "off" }); - }); - - it("returns the persisted policy when the sync lock is held by this process", () => { - // A same-process async lock holder makes the sync lock fail instantly; - // that contention must not degrade a valid yolo policy to deny. - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync( - approvalsPath, - '{"version":1,"defaults":{"security":"full","ask":"off"},"agents":{}}\n', - "utf8", - ); - const lockPath = `${approvalsPath}.lock`; - fs.writeFileSync(lockPath, `${JSON.stringify({ pid: process.pid })}\n`, { mode: 0o600 }); + it("serializes two SQLite handles and rejects a stale cross-handle CAS", () => { + saveExecApprovals({ version: 1, defaults: { security: "deny" }, agents: {} }); + const databasePath = resolveOpenClawStateSqlitePath(process.env); + closeOpenClawStateDatabaseForTest(); + const first = new DatabaseSync(databasePath); + const second = new DatabaseSync(databasePath); try { - expect(loadExecApprovals().defaults).toMatchObject({ security: "full", ask: "off" }); + first.exec("PRAGMA busy_timeout = 5000"); + second.exec("PRAGMA busy_timeout = 5000"); + const stale = snapshotFromExecApprovalsRow({ + path: "state/openclaw.sqlite#exec_approvals_config", + row: readExecApprovalsConfigRow(first), + }); + runSqliteImmediateTransactionSync(second, () => { + writeExecApprovalsConfigRow({ + db: second, + file: { version: 1, defaults: { security: "full" }, agents: {} }, + }); + }); + const current = snapshotFromExecApprovalsRow({ + path: stale.path, + row: readExecApprovalsConfigRow(first), + }); + expect(current.hash).not.toBe(stale.hash); + expect(current.file.defaults?.security).toBe("full"); } finally { - fs.rmSync(lockPath, { force: true }); + first.close(); + second.close(); } }); - it("stays fail-closed when the sync lock is held by another process", async () => { - // A foreign holder may be mid-revocation; deny is the only assertable state. - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); + it("blocks runtime reads while the retired JSON or claim exists", () => { + closeOpenClawStateDatabaseForTest(); + const stateDir = process.env.OPENCLAW_STATE_DIR; + if (!stateDir) { + throw new Error("missing test state dir"); + } fs.writeFileSync( - approvalsPath, - '{"version":1,"defaults":{"security":"full","ask":"off"},"agents":{}}\n', - "utf8", + path.join(stateDir, "exec-approvals.json"), + serializeExecApprovals({ version: 1, agents: {} }), ); - const lockPath = `${approvalsPath}.lock`; - // A live child guarantees a foreign pid even when the runner is PID 1. - const child = spawn(process.execPath, ["-e", "setTimeout(() => {}, 30_000)"], { - stdio: "ignore", - }); - fs.writeFileSync(lockPath, `${JSON.stringify({ pid: child.pid })}\n`, { mode: 0o600 }); - try { - expect(loadExecApprovals().defaults).toMatchObject({ security: "deny", ask: "off" }); - } finally { - fs.rmSync(lockPath, { force: true }); - child.kill("SIGKILL"); - await once(child, "exit"); - } - }); - - it("stays fail-closed for malformed persisted policy while the sync lock is held", () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync(approvalsPath, "{invalid", "utf8"); - const lockPath = `${approvalsPath}.lock`; - fs.writeFileSync(lockPath, `${JSON.stringify({ pid: process.pid })}\n`, { mode: 0o600 }); - try { - expect(loadExecApprovals().defaults).toMatchObject({ security: "deny", ask: "off" }); - } finally { - fs.rmSync(lockPath, { force: true }); - } - }); - - it("keeps synchronous and locked resolution fail-closed for malformed persisted policy", async () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync(approvalsPath, "", "utf8"); - - const syncResolved = resolveExecApprovalsSync("main", { - security: "full", - ask: "off", - }); - const lockedResolved = await resolveExecApprovals("main", { - security: "full", - ask: "off", - }); - - expect(syncResolved.agent).toMatchObject({ security: "deny", ask: "off" }); - expect(lockedResolved.agent).toMatchObject({ security: "deny", ask: "off" }); - expect(syncResolved.token).toBe(""); - expect(lockedResolved.token).toBe(""); - expect(fs.readFileSync(approvalsPath, "utf8")).toBe(""); - }); - - it("ensures approvals file with default socket path and generated token", () => { - const dir = createHomeDir(); - - const ensured = ensureExecApprovals(); - const raw = fs.readFileSync(approvalsFilePath(dir), "utf8"); - - expect(ensured.socket?.path).toBe(resolveExecApprovalsSocketPath()); - expect(ensured.socket?.token).toMatch(/^[A-Za-z0-9_-]{32}$/); - expect(raw.endsWith("\n")).toBe(true); - expect(readApprovalsFile(dir).socket).toEqual(ensured.socket); - }); - - it("does not rewrite already-initialized approvals", async () => { - createHomeDir(); - await ensureExecApprovalsSnapshot(); - const renameSpy = vi.spyOn(fs, "renameSync"); - - await ensureExecApprovalsSnapshot(); - - expect(renameSpy).not.toHaveBeenCalled(); - }); - - it("does not create an approvals file when resolving the missing default no-prompt policy", async () => { - const dir = createHomeDir(); - const stateDir = path.dirname(approvalsFilePath(dir)); - - const resolved = await resolveExecApprovals("main", { - security: "full", - ask: "off", - }); - - expect(resolved.agent.security).toBe("full"); - expect(resolved.agent.ask).toBe("off"); - expect(resolved.socketPath).toBe(resolveExecApprovalsSocketPath()); - expect(resolved.token).toBe(""); - expect(fs.existsSync(approvalsFilePath(dir))).toBe(false); - expect(fs.existsSync(stateDir)).toBe(false); + execApprovalsStoreTesting.reset(); + expect(() => loadExecApprovals()).toThrow(ExecApprovalsMigrationRequiredError); }); it.each([ - { - mode: "sync", - resolve: async () => - resolveExecApprovalsSync("main", { - security: "full", - ask: "off", - }), - }, - { - mode: "async", - resolve: async () => - await resolveExecApprovals("main", { - security: "full", - ask: "off", - }), - }, - ])("re-reads policy created after the final lock probe ($mode)", async ({ resolve }) => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - const stateDir = path.dirname(approvalsPath); - fs.mkdirSync(stateDir, { recursive: true }); - const restrictivePolicy = `${JSON.stringify( - { - version: 1, - defaults: { security: "deny", ask: "off" }, - agents: {}, - }, - null, - 2, - )}\n`; - const realpathSync = fs.realpathSync.bind(fs); - let stateDirProbeCount = 0; - vi.spyOn(fs, "realpathSync").mockImplementation((target) => { - const resolved = realpathSync(target); - if (path.normalize(String(target)) === path.normalize(stateDir)) { - stateDirProbeCount += 1; - if (stateDirProbeCount === 2) { - // Simulate a writer that observed the old lock as absent, then fully - // committed a restrictive file before the reader's target probe. - fs.writeFileSync(approvalsPath, restrictivePolicy, "utf8"); - } - } - return resolved; - }); + [true, false, false], + [false, true, false], + [false, false, true], + ])("detects legacy state at every source-claim-source probe", (first, claim, second) => { + const stateDir = process.env.OPENCLAW_STATE_DIR; + if (!stateDir) { + throw new Error("missing test state dir"); + } + const sourcePath = path.join(stateDir, "exec-approvals.json"); + const probe = vi + .fn<(filePath: string) => boolean>() + .mockReturnValueOnce(first) + .mockReturnValueOnce(claim) + .mockReturnValueOnce(second); - const resolved = await resolve(); - - expect(stateDirProbeCount).toBeGreaterThanOrEqual(2); - expect(resolved.agent).toMatchObject({ security: "deny", ask: "off" }); - }); - - it("fails closed when a writer locks the store before creating the policy file", () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync( - `${approvalsPath}.lock`, - `${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`, - "utf8", + expect(() => assertNoPendingLegacyExecApprovals({ pathMayExist: probe })).toThrow( + ExecApprovalsMigrationRequiredError, ); - - expect(loadExecApprovals().defaults).toMatchObject({ security: "deny", ask: "off" }); - expect(fs.existsSync(approvalsPath)).toBe(false); - }); - - it("does not rewrite an empty approvals file while failing closed", async () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync(approvalsPath, "", "utf8"); - - const resolved = await resolveExecApprovals("main", { - security: "full", - ask: "off", - }); - - expect(resolved.agent.security).toBe("deny"); - expect(resolved.agent.ask).toBe("off"); - expect(resolved.token).toBe(""); - expect(fs.statSync(approvalsPath).size).toBe(0); - }); - - it.runIf(process.platform !== "win32")( - "hardens existing token-bearing approvals files before resolving default no-prompt policy", - async () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync( - approvalsPath, - JSON.stringify({ - version: 1, - socket: { path: resolveExecApprovalsSocketPath(), token: "existing-token" }, - defaults: { security: "full", ask: "off" }, - agents: {}, - }), - { mode: 0o644 }, - ); - fs.chmodSync(approvalsPath, 0o644); - - const resolved = await resolveExecApprovals("main", { - security: "full", - ask: "off", - }); - - expect(resolved.agent.security).toBe("full"); - expect(resolved.agent.ask).toBe("off"); - expect(resolved.token).toBe("existing-token"); - expect(fs.statSync(approvalsPath).mode & 0o777).toBe(0o600); - }, - ); - - it.runIf(process.platform !== "win32")( - "rejects symlinked approvals files before resolving the default no-prompt policy", - async () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - const linkedPath = path.join(dir, "linked-approvals.json"); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync( - linkedPath, - JSON.stringify({ - version: 1, - defaults: { security: "full", ask: "off" }, - agents: {}, - }), - "utf8", - ); - fs.symlinkSync(linkedPath, approvalsPath); - - await expect( - resolveExecApprovals("main", { - security: "deny", - ask: "always", - }), - ).rejects.toThrow("Refusing to write exec approvals via symlink"); - }, - ); - - it("rejects non-file approvals paths before resolving the default no-prompt policy", async () => { - const dir = createHomeDir(); - fs.mkdirSync(approvalsFilePath(dir), { recursive: true }); - - await expect( - resolveExecApprovals("main", { - security: "deny", - ask: "always", - }), - ).rejects.toThrow("Refusing to use non-file exec approvals path"); - }); - - it("does not treat approvals path access errors as a missing default policy", async () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync( - approvalsPath, - '{"version":1,"defaults":{"security":"full","ask":"off"},"agents":{}}\n', - "utf8", - ); - const approvalsStat = fs.statSync(approvalsPath); - const actualReadFileSync = fs.readFileSync.bind(fs); - vi.spyOn(fs, "readFileSync").mockImplementation((target, options) => { - const targetStat = typeof target === "number" ? fs.fstatSync(target) : null; - if ( - String(target) === approvalsPath || - (targetStat?.dev === approvalsStat.dev && targetStat.ino === approvalsStat.ino) - ) { - throw Object.assign(new Error("approval path blocked"), { code: "EACCES" }); - } - return actualReadFileSync(target, options as never); - }); - - const resolved = await resolveExecApprovals("main", { - security: "full", - ask: "off", - }); - - expect(resolved.agent).toMatchObject({ security: "deny", ask: "off" }); - }); - - it("creates an approvals file when resolving a missing policy that may prompt", async () => { - const dir = createHomeDir(); - - const resolved = await resolveExecApprovals("main", { - security: "allowlist", - ask: "on-miss", - }); - - expect(resolved.agent.security).toBe("allowlist"); - expect(resolved.agent.ask).toBe("on-miss"); - expect(resolved.token).toMatch(/^[A-Za-z0-9_-]{32}$/); - expect(readApprovalsFile(dir).socket).toEqual(resolved.file.socket); - }); - - it("creates an approvals file for default no-prompt policy when a socket is required", async () => { - const dir = createHomeDir(); - - const resolved = await resolveExecApprovals("main", { - security: "full", - ask: "off", - requireSocket: true, - }); - - expect(resolved.agent.security).toBe("full"); - expect(resolved.agent.ask).toBe("off"); - expect(resolved.token).toMatch(/^[A-Za-z0-9_-]{32}$/); - expect(readApprovalsFile(dir).socket).toEqual(resolved.file.socket); - }); - - it("atomically replaces existing approvals files instead of mutating linked inodes", () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - const linkedPath = path.join(dir, "linked.json"); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync(linkedPath, '{"sentinel":true}\n', "utf8"); - fs.linkSync(linkedPath, approvalsPath); - - saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} }); - - expect(fs.readFileSync(approvalsPath, "utf8")).toContain('"security": "full"'); - expect(fs.readFileSync(linkedPath, "utf8")).toBe('{"sentinel":true}\n'); - expect(fs.statSync(approvalsPath).ino).not.toBe(fs.statSync(linkedPath).ino); - }); - - it("normalizes successful rename writes to owner-only permissions", () => { - const dir = createHomeDir(); - const actualWriteFileSync = fs.writeFileSync.bind(fs); - vi.spyOn(fs, "writeFileSync").mockImplementation((file, data, options) => { - const result = actualWriteFileSync(file, data, options as never); - const filePath = String(file); - if ( - typeof file !== "number" && - filePath.includes(".exec-approvals.") && - filePath.endsWith(".tmp") - ) { - fs.chmodSync(file, 0o000); - } - return result; - }); - - saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} }); - - expect(fs.readFileSync(approvalsFilePath(dir), "utf8")).toContain('"security": "full"'); - expect(fs.statSync(approvalsFilePath(dir)).mode & 0o777).toBe(0o600); - }); - - it("normalizes the approvals directory to owner-only permissions", () => { - const dir = createHomeDir(); - const approvalsDir = path.dirname(approvalsFilePath(dir)); - fs.mkdirSync(approvalsDir, { recursive: true }); - fs.chmodSync(approvalsDir, 0o777); - - saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} }); - - expect(fs.readFileSync(approvalsFilePath(dir), "utf8")).toContain('"security": "full"'); - expect(fs.statSync(approvalsDir).mode & 0o777).toBe(0o700); - }); - - it.runIf(process.platform !== "win32")( - "keeps exec approvals strict when directory chmod fails", - async () => { - const dir = createHomeDir(); - const approvalsDir = path.dirname(approvalsFilePath(dir)); - const actualChmodSync = fs.chmodSync.bind(fs); - vi.spyOn(fs, "chmodSync").mockImplementation((target, mode) => { - if (String(target) === approvalsDir) { - throw Object.assign(new Error("chmod denied"), { code: "EPERM" }); - } - return actualChmodSync(target, mode); - }); - - expect(() => ensureExecApprovals()).toThrow("chmod denied"); - expect(fs.existsSync(approvalsFilePath(dir))).toBe(false); - }, - ); - - it("breaks a hard link when an otherwise unchanged file is ensured", async () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - const linkedPath = path.join(dir, "linked-approvals.json"); - ensureExecApprovals(); - fs.linkSync(approvalsPath, linkedPath); - - await ensureExecApprovalsSnapshot(); - - expect(fs.statSync(approvalsPath).ino).not.toBe(fs.statSync(linkedPath).ino); - expect(JSON.parse(fs.readFileSync(approvalsPath, "utf8"))).toEqual( - JSON.parse(fs.readFileSync(linkedPath, "utf8")), - ); - }); - - it("falls back to copying when rename cannot overwrite the approvals file", () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync(approvalsPath, '{"version":1,"agents":{}}\n', "utf8"); - const actualRenameSync = fs.renameSync.bind(fs); - const rename = vi.spyOn(fs, "renameSync").mockImplementation((from, to) => { - if (String(to) === approvalsPath) { - const error = Object.assign(new Error("locked target"), { code: "EPERM" }); - throw error; - } - return actualRenameSync(from, to); - }); - - saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} }); - - expect(rename).toHaveBeenCalled(); - expect(fs.readFileSync(approvalsPath, "utf8")).toContain('"security": "full"'); - expect(fs.statSync(approvalsPath).mode & 0o777).toBe(0o600); - expect(listExecApprovalTempFiles(dir)).toStrictEqual([]); - }); - - it("fails closed while the Windows copy fallback has truncated the live policy", () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync( - approvalsPath, - '{"version":1,"defaults":{"security":"deny","ask":"off"},"agents":{}}\n', - "utf8", - ); - const actualRenameSync = fs.renameSync.bind(fs); - vi.spyOn(fs, "renameSync").mockImplementation((from, to) => { - if (String(to) === approvalsPath) { - throw Object.assign(new Error("locked target"), { code: "EPERM" }); - } - return actualRenameSync(from, to); - }); - const actualReadFileSync = fs.readFileSync.bind(fs); - let fallbackWriteInProgress = false; - let approvalReadsDuringTruncate = 0; - vi.spyOn(fs, "readFileSync").mockImplementation((target, options) => { - if (fallbackWriteInProgress && String(target) === approvalsPath) { - approvalReadsDuringTruncate += 1; - } - return actualReadFileSync(target, options as never); - }); - const actualFtruncateSync = fs.ftruncateSync.bind(fs); - let policyDuringTruncate: ExecApprovalsFile | undefined; - vi.spyOn(fs, "ftruncateSync").mockImplementation((fd, length) => { - const result = actualFtruncateSync(fd, length); - if (length === 0 && !policyDuringTruncate) { - fallbackWriteInProgress = true; - try { - policyDuringTruncate = loadExecApprovals(); - } finally { - fallbackWriteInProgress = false; - } - } - return result; - }); - - saveExecApprovals({ - version: 1, - defaults: { security: "full", ask: "off" }, - agents: {}, - }); - - expect(policyDuringTruncate?.defaults).toMatchObject({ security: "deny", ask: "off" }); - expect(approvalReadsDuringTruncate).toBe(0); - expect(loadExecApprovals().defaults).toMatchObject({ security: "full", ask: "off" }); - }); - - it("normalizes fallback temp files before copying", () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync(approvalsPath, '{"version":1,"agents":{}}\n', "utf8"); - const actualWriteFileSync = fs.writeFileSync.bind(fs); - vi.spyOn(fs, "writeFileSync").mockImplementation((file, data, options) => { - const result = actualWriteFileSync(file, data, options as never); - const filePath = String(file); - if ( - typeof file !== "number" && - filePath.includes(".exec-approvals.") && - filePath.endsWith(".tmp") - ) { - fs.chmodSync(file, 0o000); - } - return result; - }); - const actualRenameSync = fs.renameSync.bind(fs); - vi.spyOn(fs, "renameSync").mockImplementation((from, to) => { - if (String(to) === approvalsPath) { - const error = Object.assign(new Error("locked target"), { code: "EPERM" }); - throw error; - } - return actualRenameSync(from, to); - }); - - saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} }); - - expect(fs.readFileSync(approvalsPath, "utf8")).toContain('"security": "full"'); - expect(fs.statSync(approvalsPath).mode & 0o777).toBe(0o600); - expect(listExecApprovalTempFiles(dir)).toStrictEqual([]); - }); - - it("restores the previous approvals file when fallback copy fails", () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - const previousRaw = '{"version":1,"defaults":{"security":"deny"},"agents":{}}\n'; - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync(approvalsPath, previousRaw, { encoding: "utf8", mode: 0o600 }); - const actualRenameSync = fs.renameSync.bind(fs); - vi.spyOn(fs, "renameSync").mockImplementation((from, to) => { - if (String(to) === approvalsPath) { - const error = Object.assign(new Error("locked target"), { code: "EPERM" }); - throw error; - } - return actualRenameSync(from, to); - }); - const actualFtruncateSync = fs.ftruncateSync.bind(fs); - let forcedFallbackFailure = false; - vi.spyOn(fs, "ftruncateSync").mockImplementation((fd, len) => { - if (!forcedFallbackFailure && len === 0) { - forcedFallbackFailure = true; - actualFtruncateSync(fd, len); - const error = Object.assign(new Error("copy failed after opening destination"), { - code: "ENOSPC", - }); - throw error; - } - return actualFtruncateSync(fd, len); - }); - - expect(() => - saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} }), - ).toThrow(/copy failed after opening destination/); - expect(fs.readFileSync(approvalsPath, "utf8")).toBe(previousRaw); - expect(fs.statSync(approvalsPath).mode & 0o777).toBe(0o600); - expect(listExecApprovalTempFiles(dir)).toStrictEqual([]); - }); - - it("does not follow a symlink swapped in before fallback copy", () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - const targetPath = path.join(dir, "elsewhere.json"); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync(approvalsPath, '{"version":1,"agents":{}}\n', "utf8"); - fs.writeFileSync(targetPath, '{"sentinel":true}\n', "utf8"); - const actualRenameSync = fs.renameSync.bind(fs); - vi.spyOn(fs, "renameSync").mockImplementation((from, to) => { - if (String(to) === approvalsPath) { - const error = Object.assign(new Error("locked target"), { code: "EPERM" }); - throw error; - } - return actualRenameSync(from, to); - }); - const actualStatSync = fs.statSync.bind(fs); - let swappedDestination = false; - vi.spyOn(fs, "statSync").mockImplementation((file, options) => { - const result = actualStatSync(file, options as never); - if (!swappedDestination && String(file) === approvalsPath) { - swappedDestination = true; - fs.rmSync(approvalsPath); - fs.symlinkSync(targetPath, approvalsPath); - } - return result; - }); - - expect(() => - saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} }), - ).toThrow(/symlink|ELOOP/); - expect(fs.readFileSync(targetPath, "utf8")).toBe('{"sentinel":true}\n'); - expect(listExecApprovalTempFiles(dir)).toStrictEqual([]); - }); - - it("does not use the copy fallback for hard-linked approvals files", () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - const linkedPath = path.join(dir, "linked.json"); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync(linkedPath, '{"sentinel":true}\n', "utf8"); - fs.linkSync(linkedPath, approvalsPath); - const actualRenameSync = fs.renameSync.bind(fs); - vi.spyOn(fs, "renameSync").mockImplementation((from, to) => { - if (String(to) === approvalsPath) { - const error = Object.assign(new Error("locked target"), { code: "EPERM" }); - throw error; - } - return actualRenameSync(from, to); - }); - - expect(() => - saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} }), - ).toThrow(/hard-linked exec approvals file/); - expect(fs.readFileSync(linkedPath, "utf8")).toBe('{"sentinel":true}\n'); - expect(listExecApprovalTempFiles(dir)).toStrictEqual([]); - }); - - it("refuses to write approvals through a symlink destination", () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - const targetPath = path.join(dir, "elsewhere.json"); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync(targetPath, '{"sentinel":true}\n', "utf8"); - fs.symlinkSync(targetPath, approvalsPath); - - expect(() => - saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} }), - ).toThrow(/Refusing to write exec approvals via symlink/); - expect(fs.readFileSync(targetPath, "utf8")).toBe('{"sentinel":true}\n'); - }); - - it("accepts a symlinked OPENCLAW_HOME as the trusted approvals root", () => { - const realHome = makeTempDir(); - const linkedHome = `${realHome}-link`; - tempDirs.push(realHome, linkedHome); - fs.symlinkSync(realHome, linkedHome, "dir"); - setTestEnvValue("OPENCLAW_HOME", linkedHome); - - saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} }); - - expect( - fs.readFileSync(path.join(realHome, ".openclaw", "exec-approvals.json"), "utf8"), - ).toContain('"security": "full"'); - }); - - it("refuses to traverse symlinked approvals components below a symlinked home", () => { - const realHome = makeTempDir(); - const linkedHome = `${realHome}-link`; - const linkedStateTarget = path.join(realHome, "state-target"); - tempDirs.push(realHome, linkedHome); - fs.mkdirSync(linkedStateTarget, { recursive: true }); - fs.symlinkSync(realHome, linkedHome, "dir"); - fs.symlinkSync(linkedStateTarget, path.join(realHome, ".openclaw"), "dir"); - setTestEnvValue("OPENCLAW_HOME", linkedHome); - - expect(() => - saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} }), - ).toThrow(/Refusing to traverse symlink in exec approvals path/); - expect(fs.existsSync(path.join(linkedStateTarget, "exec-approvals.json"))).toBe(false); - }); - - it.runIf(process.platform !== "win32")( - "rejects a nested symlink before acquiring the async approvals lock", - async () => { - const realHome = makeTempDir(); - const linkedHome = `${realHome}-link`; - const linkedStateTarget = path.join(realHome, "state-target"); - const redirectedLockPath = path.join(linkedStateTarget, "exec-approvals.json.lock"); - tempDirs.push(realHome, linkedHome); - fs.mkdirSync(linkedStateTarget, { recursive: true }); - fs.symlinkSync(realHome, linkedHome, "dir"); - fs.symlinkSync(linkedStateTarget, path.join(realHome, ".openclaw"), "dir"); - setTestEnvValue("OPENCLAW_HOME", linkedHome); - - await expect( - updateExecApprovals({ - update: () => ({ version: 1, defaults: { security: "deny" }, agents: {} }), - }), - ).rejects.toThrow(/Refusing to traverse symlink in exec approvals path/); - expect(fs.existsSync(redirectedLockPath)).toBe(false); - expect(fs.readdirSync(linkedStateTarget)).toEqual([]); - }, - ); - - it("strips legacy plaintext command text during normalization", () => { - const normalized = normalizeExecApprovals({ - version: 1, - agents: { - main: { - allowlist: [ - { - pattern: "=command:test", - source: "allow-always", - commandText: "echo secret-token", - }, - ], - }, - }, - }); - const allowlist = normalized.agents?.main?.allowlist ?? []; - expect(allowlist).toHaveLength(1); - expect(allowlist[0]?.pattern).toBe("=command:test"); - expect(allowlist[0]?.source).toBe("allow-always"); - expect(allowlist[0]).not.toHaveProperty("commandText"); - }); - - it("rejects reusable execution when its matched argPattern bytes change", async () => { - const dir = createHomeDir(); - saveExecApprovals({ - version: 1, - defaults: { security: "allowlist", ask: "off" }, - agents: { - main: { - allowlist: [{ pattern: "/usr/bin/rg", argPattern: " ^safe$ ", id: "rg-id" }], - }, - }, - }); - - await expect( - commitExecAuthorization({ - agentId: "main", - matches: [{ pattern: "/usr/bin/rg", argPattern: "^safe$", id: "rg-id" }], - command: "rg safe", - authorization: { - source: "current-policy", - security: "allowlist", - ask: "off", - allowlistSatisfied: true, - }, - }), - ).rejects.toThrow("Exec approval changed before execution"); - expect(readApprovalsFile(dir).agents?.main?.allowlist).toEqual([ - { pattern: "/usr/bin/rg", argPattern: " ^safe$ ", id: "rg-id" }, + expect(probe.mock.calls.map(([filePath]) => filePath)).toEqual([ + sourcePath, + `${sourcePath}.doctor-importing`, + sourcePath, ]); }); - it("rejects reusable execution when matched entry fields collide under separator encoding", async () => { - const dir = createHomeDir(); - const currentEntry = { pattern: "/usr/bin/rg\x00a", argPattern: "b", id: "current-id" }; - saveExecApprovals({ - version: 1, - defaults: { security: "allowlist", ask: "off" }, - agents: { main: { allowlist: [currentEntry] } }, - }); + it("caches an absent legacy result only after all three probes are clear", () => { + const clearProbe = vi.fn<(filePath: string) => boolean>(() => false); + assertNoPendingLegacyExecApprovals({ pathMayExist: clearProbe }); + expect(clearProbe).toHaveBeenCalledTimes(3); - await expect( - commitExecAuthorization({ - agentId: "main", - matches: [{ pattern: "/usr/bin/rg", argPattern: "a\x00b", id: "stale-id" }], - command: "rg needle", - authorization: { - source: "current-policy", - security: "allowlist", - ask: "off", - allowlistSatisfied: true, - }, - }), - ).rejects.toThrow("Exec approval changed before execution"); - expect(readApprovalsFile(dir).agents?.main?.allowlist).toEqual([currentEntry]); - }); - - it("normalizes legacy allowlist sources in portable policy snapshots", () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync( - approvalsPath, - JSON.stringify({ - version: 1, - defaults: { security: "allowlist", ask: "always" }, - agents: { - main: { - allowlist: [ - { pattern: "/usr/bin/jq", source: "legacy" }, - { pattern: "/usr/bin/rg", source: "allow-always" }, - ], - }, - }, - }), - ); - - const policySnapshot = createExecApprovalPolicySnapshot({ - file: readExecApprovalsSnapshot().file, - agentId: "main", - }); - - expect(policySnapshot.allowlistRules).toEqual([ - { pattern: "/usr/bin/jq" }, - { pattern: "/usr/bin/rg", source: "allow-always" }, - ]); - }); - - it("rejects an explicit approval after policy changes to deny without persisting its grant", async () => { - const dir = createHomeDir(); - saveExecApprovals({ - version: 1, - defaults: { security: "allowlist", ask: "always" }, - agents: { main: { allowlist: [] } }, - }); - const policySnapshot = createExecApprovalPolicySnapshot({ - file: readExecApprovalsSnapshot().file, - agentId: "main", - }); - - await updateExecApprovals({ - update: (current) => ({ - ...current, - defaults: { ...current.defaults, security: "deny", ask: "off" }, - }), - }); - - await expect( - commitExecAuthorization({ - agentId: "main", - matches: [], - command: "printf approved", - authorization: { - source: "explicit-approval", - security: "allowlist", - ask: "always", - allowlistSatisfied: false, - policySnapshot, - }, - allowAlwaysDecision: { - kind: "exact-command", - commandText: "printf approved", - }, - }), - ).rejects.toThrow("Exec approval changed before execution"); - expect(allowlistEntries(dir, "main")).toEqual([]); - }); - - it("rejects an explicit allow-always grant after its matched policy entry is revoked", async () => { - const dir = createHomeDir(); - const matchedEntry = { pattern: "/usr/bin/rg", id: "rg-id" }; - saveExecApprovals({ - version: 1, - defaults: { security: "allowlist", ask: "always" }, - agents: { main: { allowlist: [matchedEntry] } }, - }); - const policySnapshot = createExecApprovalPolicySnapshot({ - file: readExecApprovalsSnapshot().file, - agentId: "main", - }); - - await updateExecApprovals({ - update: (current) => ({ - ...current, - agents: { ...current.agents, main: { allowlist: [] } }, - }), - }); - - await expect( - commitExecAuthorization({ - agentId: "main", - matches: [matchedEntry], - command: "rg needle", - authorization: { - source: "explicit-approval", - security: "allowlist", - ask: "always", - allowlistSatisfied: true, - policySnapshot, - }, - allowAlwaysDecision: { - kind: "patterns", - patterns: [{ pattern: "/usr/bin/rg" }], - }, - }), - ).rejects.toThrow("Exec approval changed before execution"); - expect(allowlistEntries(dir, "main")).toEqual([]); - }); - - it("rejects an explicit grant after an allow-always source downgrade", async () => { - const dir = createHomeDir(); - saveExecApprovals({ - version: 1, - defaults: { security: "allowlist", ask: "always" }, - agents: { - main: { allowlist: [{ pattern: "/usr/bin/rg", source: "allow-always" }] }, - }, - }); - const policySnapshot = createExecApprovalPolicySnapshot({ - file: readExecApprovalsSnapshot().file, - agentId: "main", - }); - - await updateExecApprovals({ - update: (current) => ({ - ...current, - agents: { ...current.agents, main: { allowlist: [{ pattern: "/usr/bin/rg" }] } }, - }), - }); - - await expect( - commitExecAuthorization({ - agentId: "main", - matches: [], - command: "rg needle", - authorization: { - source: "explicit-approval", - security: "allowlist", - ask: "always", - allowlistSatisfied: false, - policySnapshot, - }, - allowAlwaysDecision: { - kind: "patterns", - patterns: [{ pattern: "/usr/bin/rg" }], - }, - }), - ).rejects.toThrow("Exec approval changed before execution"); - expect(allowlistEntries(dir, "main")).toEqual([{ pattern: "/usr/bin/rg" }]); - }); - - it("commits an explicit allow-always grant after current-policy authorization", async () => { - const dir = createHomeDir(); - vi.spyOn(Date, "now").mockReturnValue(321_000); - saveExecApprovals({ - version: 1, - defaults: { security: "allowlist", ask: "always" }, - agents: { main: { allowlist: [] } }, - }); - const policySnapshot = createExecApprovalPolicySnapshot({ - file: readExecApprovalsSnapshot().file, - agentId: "main", - }); - - await commitExecAuthorization({ - agentId: "main", - matches: [], - command: "printf approved", - authorization: { - source: "explicit-approval", - security: "allowlist", - ask: "always", - allowlistSatisfied: false, - policySnapshot, - }, - allowAlwaysDecision: { - kind: "exact-command", - commandText: "printf approved", - }, - }); - - expect(allowlistEntries(dir, "main")).toEqual([ - expect.objectContaining({ - pattern: expect.stringMatching(/^=command:/), - source: "allow-always", - lastUsedAt: 321_000, - }), - ]); - expect(allowlistEntries(dir, "main")[0]).not.toHaveProperty("commandText"); - }); - - it("preserves concurrent explicit allow-always grants from the same policy snapshot", async () => { - const dir = createHomeDir(); - saveExecApprovals({ - version: 1, - defaults: { security: "allowlist", ask: "always" }, - agents: { researcher: { allowlist: [{ pattern: "/usr/bin/grep" }] } }, - }); - const policySnapshot = createExecApprovalPolicySnapshot({ - file: readExecApprovalsSnapshot().file, - agentId: "researcher", - }); - const commitGrant = (command: string, pattern: string) => - commitExecAuthorization({ - agentId: "researcher", - matches: [], - command, - authorization: { - source: "explicit-approval", - security: "allowlist", - ask: "always", - allowlistSatisfied: false, - policySnapshot, - }, - allowAlwaysDecision: { - kind: "patterns", - patterns: [{ pattern }], - }, - }); - - await Promise.all([ - commitGrant("grep --version", "/usr/bin/grep"), - commitGrant("cat --version", "/usr/bin/cat"), - ]); - - const allowlist = allowlistEntries(dir, "researcher"); - const patterns = allowlist.flatMap((entry) => - typeof entry.pattern === "string" ? [entry.pattern] : [], - ); - expect(patterns).toHaveLength(allowlist.length); - expect(patterns.toSorted((left, right) => left.localeCompare(right))).toEqual([ - "/usr/bin/cat", - "/usr/bin/grep", - ]); - expect(allowlist.every((entry) => entry.source === "allow-always")).toBe(true); - }); - - it("rejects explicit allow-once without a policy snapshot", async () => { - const dir = createHomeDir(); - saveExecApprovals({ - version: 1, - defaults: { security: "allowlist", ask: "always" }, - agents: { main: { allowlist: [] } }, - }); - - await expect( - commitExecAuthorization({ - agentId: "main", - matches: [], - command: "printf approved", - authorization: { - source: "explicit-approval", - security: "allowlist", - ask: "always", - allowlistSatisfied: false, - }, - }), - ).rejects.toThrow("Delayed exec authorization requires a policy snapshot"); - expect(allowlistEntries(dir, "main")).toEqual([]); - }); - - it("rejects auto-review without a policy snapshot", async () => { - createHomeDir(); - saveExecApprovals({ - version: 1, - defaults: { security: "full", ask: "on-miss" }, - agents: { main: {} }, - }); - - await expect( - commitExecAuthorization({ - agentId: "main", - matches: [], - command: "printf reviewed", - authorization: { - source: "auto-review", - security: "full", - ask: "on-miss", - allowlistSatisfied: false, - }, - }), - ).rejects.toThrow("Delayed exec authorization requires a policy snapshot"); - }); - - it("does not let current policy create an allow-always grant", async () => { - const dir = createHomeDir(); - saveExecApprovals({ - version: 1, - defaults: { security: "full", ask: "off" }, - agents: { main: { allowlist: [] } }, - }); - - await expect( - commitExecAuthorization({ - agentId: "main", - matches: [], - command: "printf full", - authorization: { - source: "current-policy", - security: "full", - ask: "off", - allowlistSatisfied: false, - }, - allowAlwaysDecision: { - kind: "exact-command", - commandText: "printf full", - }, - }), - ).rejects.toThrow("Allow-always persistence requires explicit approval"); - expect(allowlistEntries(dir, "main")).toEqual([]); - }); - - it("rejects unprompted full execution after policy changes to deny", async () => { - const dir = createHomeDir(); - saveExecApprovals({ - version: 1, - defaults: { security: "deny", ask: "off" }, - agents: { main: {} }, - }); - - await expect( - commitExecAuthorization({ - agentId: "main", - matches: [], - command: "printf full", - authorization: { - source: "current-policy", - security: "full", - ask: "off", - allowlistSatisfied: false, - }, - }), - ).rejects.toThrow("Exec approval changed before execution"); - expect(readApprovalsFile(dir).defaults?.security).toBe("deny"); - }); - - it("rejects unprompted full execution after ask tightens to on-miss", async () => { - createHomeDir(); - saveExecApprovals({ - version: 1, - defaults: { security: "full", ask: "on-miss" }, - agents: { main: {} }, - }); - - await expect( - commitExecAuthorization({ - agentId: "main", - matches: [], - command: "printf full", - authorization: { - source: "current-policy", - security: "full", - ask: "off", - allowlistSatisfied: false, - }, - }), - ).rejects.toThrow("Exec approval changed before execution"); - }); - - it("rejects auto-review when current policy changes to always ask", async () => { - createHomeDir(); - saveExecApprovals({ - version: 1, - defaults: { security: "full", ask: "on-miss" }, - agents: { main: {} }, - }); - const policySnapshot = createExecApprovalPolicySnapshot({ - file: readExecApprovalsSnapshot().file, - agentId: "main", - }); - await updateExecApprovals({ - update: (current) => ({ - ...current, - defaults: { ...current.defaults, ask: "always" }, - }), - }); - - await expect( - commitExecAuthorization({ - agentId: "main", - matches: [], - command: "printf reviewed", - authorization: { - source: "auto-review", - security: "full", - ask: "on-miss", - allowlistSatisfied: false, - policySnapshot, - }, - }), - ).rejects.toThrow("Exec approval changed before execution"); - }); - - it("rejects auto-review when current ask tightens from off to on-miss", async () => { - createHomeDir(); - saveExecApprovals({ - version: 1, - defaults: { security: "full", ask: "off" }, - agents: { main: {} }, - }); - const policySnapshot = createExecApprovalPolicySnapshot({ - file: readExecApprovalsSnapshot().file, - agentId: "main", - }); - await updateExecApprovals({ - update: (current) => ({ - ...current, - defaults: { ...current.defaults, ask: "on-miss" }, - }), - }); - - await expect( - commitExecAuthorization({ - agentId: "main", - matches: [], - command: "printf reviewed", - authorization: { - source: "auto-review", - security: "full", - ask: "off", - allowlistSatisfied: false, - policySnapshot, - }, - }), - ).rejects.toThrow("Exec approval changed before execution"); - }); - - it("rejects auto-review when current security tightens from full to allowlist", async () => { - createHomeDir(); - saveExecApprovals({ - version: 1, - defaults: { security: "full", ask: "off" }, - agents: { main: {} }, - }); - const policySnapshot = createExecApprovalPolicySnapshot({ - file: readExecApprovalsSnapshot().file, - agentId: "main", - }); - await updateExecApprovals({ - update: (current) => ({ - ...current, - defaults: { ...current.defaults, security: "allowlist" }, - }), - }); - - await expect( - commitExecAuthorization({ - agentId: "main", - matches: [], - command: "printf reviewed", - authorization: { - source: "auto-review", - security: "full", - ask: "off", - allowlistSatisfied: false, - policySnapshot, - }, - }), - ).rejects.toThrow("Exec approval changed before execution"); - }); - - it("rejects a durable grant after its source is downgraded", async () => { - const dir = createHomeDir(); - saveExecApprovals({ - version: 1, - defaults: { security: "allowlist", ask: "off" }, - agents: { - main: { allowlist: [{ pattern: "/usr/bin/rg" }] }, - }, - }); - - await expect( - commitExecAuthorization({ - agentId: "main", - matches: [{ pattern: "/usr/bin/rg", source: "allow-always" }], - command: "rg needle", - authorization: { - source: "current-policy", - security: "allowlist", - ask: "off", - allowlistSatisfied: true, - requireDurableAllowlistApproval: true, - }, - }), - ).rejects.toThrow("Exec approval changed before execution"); - expect(allowlistEntries(dir, "main")).toEqual([{ pattern: "/usr/bin/rg" }]); - }); - - it("persists allow-always patterns with shared helper", () => { - const dir = createHomeDir(); - vi.spyOn(Date, "now").mockReturnValue(654_321); - - const approvals = ensureExecApprovals(); - const patterns = persistAllowAlwaysPatterns({ - approvals, - agentId: "worker", - platform: "win32", - segments: [ - { - raw: "/usr/bin/custom-tool.exe a.py", - argv: ["/usr/bin/custom-tool.exe", "a.py"], - resolution: { - execution: { - rawExecutable: "/usr/bin/custom-tool.exe", - resolvedPath: "/usr/bin/custom-tool.exe", - executableName: "custom-tool", - }, - policy: { - rawExecutable: "/usr/bin/custom-tool.exe", - resolvedPath: "/usr/bin/custom-tool.exe", - executableName: "custom-tool", - }, - }, - }, - ], - }); - - expect(patterns).toEqual([ - { - pattern: "/usr/bin/custom-tool.exe", - argPattern: "^a\\.py\x00$", - }, - ]); - const allowlist = allowlistEntries(dir, "worker"); - expect(allowlist).toHaveLength(1); - expectAllowlistEntryFields(allowlist[0] ?? {}, { - pattern: "/usr/bin/custom-tool.exe", - argPattern: "^a\\.py\x00$", - source: "allow-always", - lastUsedAt: 654_321, - }); - }); - - it("persists node command markers only for fully represented allow-always patterns", () => { - const dir = createHomeDir(); - vi.spyOn(Date, "now").mockReturnValue(654_322); - - const approvals = ensureExecApprovals(); - const completePatterns = persistAllowAlwaysPatterns({ - approvals, - agentId: "worker", - commandText: "/usr/bin/tool ok", - platform: "linux", - segments: [ - { - raw: "/usr/bin/tool ok", - argv: ["/usr/bin/tool", "ok"], - resolution: { - execution: { - rawExecutable: "/usr/bin/tool", - resolvedPath: "/usr/bin/tool", - executableName: "tool", - }, - policy: { - rawExecutable: "/usr/bin/tool", - resolvedPath: "/usr/bin/tool", - executableName: "tool", - }, - }, - }, - ], - }); - - const expectedArgPattern = buildHashedArgPatternFromArgv(["/usr/bin/tool", "ok"]); - expect(completePatterns).toEqual([ - { pattern: "/usr/bin/tool", argPattern: expectedArgPattern }, - ]); - expect(expectedArgPattern).not.toContain("ok"); - let allowlist = allowlistEntries(dir, "worker"); - expect(allowlist.map((entry) => entry.pattern)).toEqual([ - "/usr/bin/tool", - expect.stringMatching(/^=node-command:[0-9a-f]{16}$/), - ]); - expect(allowlist.some((entry) => entry.lastUsedCommand === "/usr/bin/tool ok")).toBe(false); - - vi.spyOn(Date, "now").mockReturnValue(654_323); - recordAllowlistMatchesUseSync({ - approvals: readExecApprovalsSnapshot().file, - agentId: "worker", - matches: [ - { pattern: "/usr/bin/tool", source: "allow-always", argPattern: expectedArgPattern }, - ], - command: "/usr/bin/tool ok", - resolvedPath: "/usr/bin/tool", - }); - allowlist = allowlistEntries(dir, "worker"); - const hashedEntry = allowlist.find((entry) => entry.pattern === "/usr/bin/tool"); - expect(hashedEntry).toMatchObject({ - pattern: "/usr/bin/tool", - source: "allow-always", - argPattern: expectedArgPattern, - lastUsedAt: 654_323, - lastResolvedPath: "/usr/bin/tool", - }); - expect(hashedEntry).not.toHaveProperty("lastUsedCommand"); - - const partialPatterns = persistAllowAlwaysPatterns({ - approvals, - agentId: "worker", - commandText: "sh -c '/bin/echo ok && missingcmd'", - segments: [ - { - raw: "sh -c '/bin/echo ok && missingcmd'", - argv: ["sh", "-c", "/bin/echo ok && missingcmd"], - resolution: { - execution: { - rawExecutable: "sh", - resolvedPath: "/bin/sh", - executableName: "sh", - }, - policy: { - rawExecutable: "sh", - resolvedPath: "/bin/sh", - executableName: "sh", - }, - }, - }, - ], - }); - - expect(partialPatterns).toEqual([]); - allowlist = allowlistEntries(dir, "worker"); - expect( - allowlist.some( - (entry) => - typeof entry.pattern === "string" && - entry.pattern.startsWith("=node-command:") && - entry.lastUsedCommand === "sh -c '/bin/echo ok && missingcmd'", - ), - ).toBe(false); - expect( - allowlist.filter( - (entry) => typeof entry.pattern === "string" && entry.pattern.startsWith("=node-command:"), - ), - ).toHaveLength(1); - }); - - it("returns null when approval socket credentials are missing", async () => { - await expect( - requestExecApprovalViaSocket({ - socketPath: "", - token: "secret", - request: { command: "echo hi" }, - }), - ).resolves.toBeNull(); - await expect( - requestExecApprovalViaSocket({ - socketPath: "/tmp/socket", - token: "", - request: { command: "echo hi" }, - }), - ).resolves.toBeNull(); - expect(requestJsonlSocketMock).not.toHaveBeenCalled(); - }); - - it("builds approval socket payloads and accepts decision responses only", async () => { - requestJsonlSocketMock.mockImplementationOnce(async ({ requestLine, accept, timeoutMs }) => { - expect(timeoutMs).toBe(15_000); - const parsed = JSON.parse(requestLine) as { - type: string; - token: string; - id: string; - request: { command: string }; - }; - expect(parsed.type).toBe("request"); - expect(parsed.token).toBe("secret"); - expect(parsed.request).toEqual({ command: "echo hi" }); - expect(parsed.id).toMatch(/^[0-9a-f-]{36}$/i); - expect(accept({ type: "noop", decision: "allow-once" })).toBeUndefined(); - expect(accept({ type: "decision", decision: "allow-always" })).toBe("allow-always"); - return "deny"; - }); - - await expect( - requestExecApprovalViaSocket({ - socketPath: "/tmp/socket", - token: "secret", - request: { command: "echo hi" }, - }), - ).resolves.toBe("deny"); + const laterProbe = vi.fn<(filePath: string) => boolean>(() => true); + assertNoPendingLegacyExecApprovals({ pathMayExist: laterProbe }); + expect(laterProbe).not.toHaveBeenCalled(); }); }); -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/infra/exec-approvals-store.ts b/src/infra/exec-approvals-store.ts index 82bd5f890bdd..461f7830a159 100644 --- a/src/infra/exec-approvals-store.ts +++ b/src/infra/exec-approvals-store.ts @@ -1,107 +1,104 @@ // Loads, updates, restores, and initializes exec approval policy state. -import fs from "node:fs"; import { isDeepStrictEqual } from "node:util"; import { AgentDeletionAuthorityRollbackError, AgentDeletionCommitUncertainError, isAgentDeletionBlocked, } from "../agents/agent-lifecycle-registry.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; import { normalizeAgentId } from "../routing/session-key.js"; +import { + openOpenClawStateDatabase, + runOpenClawStateWriteTransaction, +} from "../state/openclaw-state-db.js"; +import { OpenClawStateLeaseError, withOpenClawStateLease } from "../state/openclaw-state-lease.js"; +import { formatErrorMessage } from "./errors.js"; import { createFailClosedExecApprovalsFallback, generateToken, normalizeExecApprovalsInternal, - resolveExecApprovalsPath, + resolveExecApprovalsDisplayPath, resolveExecApprovalsSocketPath, } from "./exec-approvals-config.js"; import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./exec-approvals-core.js"; import { - hardenUnchangedExecApprovals, - hashExecApprovalsFile, - readExecApprovalsSnapshotFromPath, - UnsafeExecApprovalsPathError, - writeExecApprovalsRaw, -} from "./exec-approvals-file-io.js"; + assertNoPendingLegacyExecApprovals, + ExecApprovalsMigrationRequiredError, + resetExecApprovalsMigrationGateForTest, +} from "./exec-approvals-migration-gate.js"; import { - withExecApprovalsLock, - withExecApprovalsLockSync, - withExecApprovalsReadLock, - withExecApprovalsReadLockSync, -} from "./exec-approvals-lock.js"; + EXEC_APPROVALS_MUTATION_LEASE_KEY, + EXEC_APPROVALS_MUTATION_LEASE_SCOPE, + deleteExecApprovalsConfigRow, + type ExecApprovalsMutationLeaseOwner, + readExecApprovalsConfigRow, + serializeExecApprovals, + snapshotFromExecApprovalsRow, + writeExecApprovalsConfigRow, +} from "./exec-approvals-sqlite.js"; -function readExecApprovalsSnapshotUnlocked(): ExecApprovalsSnapshot { - const filePath = resolveExecApprovalsPath(); - return readExecApprovalsSnapshotFromPath(filePath); +const log = createSubsystemLogger("infra/exec-approvals"); +const WARN_INTERVAL_MS = 60_000; +const EXEC_APPROVALS_DELETION_LEASE_MS = 120_000; +const EXEC_APPROVALS_DELETION_LEASE_WAIT_MS = 10_000; +let lastWarnAt: number | undefined; + +class ExecApprovalsStoreUnavailableError extends Error { + constructor(cause: unknown) { + super(`Exec approvals SQLite state is unavailable: ${String(cause)}`, { cause }); + this.name = "ExecApprovalsStoreUnavailableError"; + } +} + +function warnFailClosed(message: string, error?: unknown): void { + const now = Date.now(); + if (lastWarnAt !== undefined && now - lastWarnAt < WARN_INTERVAL_MS) { + return; + } + lastWarnAt = now; + if (error === undefined) { + log.warn(message); + } else { + log.warn(message, { error: formatErrorMessage(error) }); + } +} + +function readExecApprovalsSnapshotFromDatabase(): ExecApprovalsSnapshot { + assertNoPendingLegacyExecApprovals(); + const { db } = openOpenClawStateDatabase(); + return snapshotFromExecApprovalsRow({ + path: resolveExecApprovalsDisplayPath(), + row: readExecApprovalsConfigRow(db), + onMalformed: () => + warnFailClosed("exec approvals SQLite row is malformed; denying host execution"), + }); } export function readExecApprovalsSnapshot(): ExecApprovalsSnapshot { - // Windows' overwrite fallback updates the destination inode in place. Readers - // must share its lock so they observe either the old policy or the new one. - return withExecApprovalsReadLockSync( - resolveExecApprovalsPath(), - readExecApprovalsSnapshotUnlocked, - ); -} - -function loadExecApprovalsUnlocked(): ExecApprovalsFile { - const filePath = resolveExecApprovalsPath(); try { - return readExecApprovalsSnapshotFromPath(filePath).file; - } catch { - return createFailClosedExecApprovalsFallback(); - } -} - -function loadExecApprovalsHeldByCurrentProcess(): ExecApprovalsFile { - // The sync lock fails instantly when a same-process async holder owns it — - // spinning would deadlock the event loop. That failure must not degrade a - // valid persisted policy to the fail-closed deny fallback: all approvals - // mutations go through the fully synchronous writeExecApprovalsRaw / - // hardenUnchangedExecApprovals (no fs.promises anywhere in this store), so - // while this sync code runs no same-process write can be in flight — the - // file on disk IS the current committed policy. A missing file stays deny — - // the holder may be committing the first policy. - try { - const snapshot = readExecApprovalsSnapshotFromPath(resolveExecApprovalsPath()); - if (snapshot.exists) { - return snapshot.file; + return readExecApprovalsSnapshotFromDatabase(); + } catch (error) { + if (error instanceof ExecApprovalsMigrationRequiredError) { + throw error; } - } catch { - // Unreadable state keeps the fail-closed fallback below. + throw new ExecApprovalsStoreUnavailableError(error); } - return createFailClosedExecApprovalsFallback(); } export function loadExecApprovals(): ExecApprovalsFile { try { - return withExecApprovalsReadLockSync(resolveExecApprovalsPath(), loadExecApprovalsUnlocked); - } catch (err) { - // Cross-process contention stays fail-closed: a foreign writer may be - // mid-revocation, and deny is the only state we can assert. Only the - // provably race-free same-process case reads the committed file. - if ((err as { heldByCurrentProcess?: boolean }).heldByCurrentProcess === true) { - return loadExecApprovalsHeldByCurrentProcess(); + return readExecApprovalsSnapshot().file; + } catch (error) { + if (!(error instanceof ExecApprovalsStoreUnavailableError)) { + throw error; } + warnFailClosed("exec approvals SQLite state is unavailable; denying host execution", error); return createFailClosedExecApprovalsFallback(); } } export async function loadExecApprovalsAsync(): Promise { - try { - return await withExecApprovalsReadLock(resolveExecApprovalsPath(), async () => - loadExecApprovalsUnlocked(), - ); - } catch { - // Match the synchronous reader's fail-closed contract; the async lock - // already waits for same-process holders via the process-local queue. - return createFailClosedExecApprovalsFallback(); - } -} - -function saveExecApprovalsUnlocked(file: ExecApprovalsFile): void { - const filePath = resolveExecApprovalsPath(); - const raw = `${JSON.stringify(file, null, 2)}\n`; - writeExecApprovalsRaw(filePath, raw); + return loadExecApprovals(); } type ExecApprovalsUpdate = { @@ -133,12 +130,17 @@ export function replaceExecApprovalsSnapshot( type InternalExecApprovalsUpdate = ExecApprovalsUpdate & { allowDeletedAgentRemoval?: string; + allowDeletedAgentRestore?: string; + leaseOwner?: ExecApprovalsMutationLeaseOwner; }; function assertNoDeletedAgentApprovalChanged( current: ExecApprovalsFile, next: ExecApprovalsFile, - allowDeletedAgentRemoval?: string, + params: Pick< + InternalExecApprovalsUpdate, + "allowDeletedAgentRemoval" | "allowDeletedAgentRestore" + >, ): void { const agentIds = new Set([ ...Object.keys(current.agents ?? {}), @@ -148,12 +150,17 @@ function assertNoDeletedAgentApprovalChanged( const currentPolicy = current.agents?.[agentId]; const nextPolicy = next.agents?.[agentId]; const allowedRemoval = - agentId === allowDeletedAgentRemoval && + agentId === params.allowDeletedAgentRemoval && currentPolicy !== undefined && nextPolicy === undefined; + const allowedRestore = + agentId === params.allowDeletedAgentRestore && + currentPolicy === undefined && + nextPolicy !== undefined; if ( isAgentDeletionBlocked(agentId) && !allowedRemoval && + !allowedRestore && !isDeepStrictEqual(currentPolicy, nextPolicy) ) { throw new Error(`Exec approvals are unavailable while agent ${agentId} is deleted.`); @@ -161,32 +168,43 @@ function assertNoDeletedAgentApprovalChanged( } } -function updateExecApprovalsUnlocked( +function updateExecApprovalsInTransaction( params: InternalExecApprovalsUpdate, ): ExecApprovalsSnapshot | null { - // Both sync and async entry points hold the sidecar lock across this full CAS transaction. - const current = readExecApprovalsSnapshotUnlocked(); - if (params.baseHash !== undefined && current.hash !== params.baseHash) { - return null; - } - const next = params.update(structuredClone(current.file)); - if (next === null) { - return current; - } - assertNoDeletedAgentApprovalChanged(current.file, next, params.allowDeletedAgentRemoval); - if ( - current.exists && - current.hash === hashExecApprovalsFile(next) && - hardenUnchangedExecApprovals(current.path) - ) { - return current; - } - saveExecApprovalsUnlocked(next); - return readExecApprovalsSnapshotUnlocked(); + assertNoPendingLegacyExecApprovals(); + return runOpenClawStateWriteTransaction( + ({ db }) => { + const current = snapshotFromExecApprovalsRow({ + path: resolveExecApprovalsDisplayPath(), + row: readExecApprovalsConfigRow(db), + onMalformed: () => + warnFailClosed("exec approvals SQLite row is malformed; denying host execution"), + }); + if (params.baseHash !== undefined && current.hash !== params.baseHash) { + return null; + } + const next = params.update(structuredClone(current.file)); + if (next === null) { + return current; + } + assertNoDeletedAgentApprovalChanged(current.file, next, params); + const raw = serializeExecApprovals(next); + if (current.exists && current.raw === raw) { + return current; + } + writeExecApprovalsConfigRow({ db, file: next, raw, leaseOwner: params.leaseOwner }); + return snapshotFromExecApprovalsRow({ + path: current.path, + row: { raw_json: raw }, + }); + }, + {}, + { operationLabel: "exec-approvals.update" }, + ); } export function updateExecApprovalsSync(params: ExecApprovalsUpdate): ExecApprovalsSnapshot | null { - return withExecApprovalsLockSync(() => updateExecApprovalsUnlocked(params)); + return updateExecApprovalsInTransaction(params); } export function saveExecApprovals(file: ExecApprovalsFile): void { @@ -196,74 +214,133 @@ export function saveExecApprovals(file: ExecApprovalsFile): void { export async function updateExecApprovals( params: ExecApprovalsUpdate, ): Promise { - return await withExecApprovalsLock(async () => updateExecApprovalsUnlocked(params)); + return updateExecApprovalsInTransaction(params); } -/** Hold the approvals lock across an agent deletion and restore policy if commit fails. */ +/** Remove one deleted agent's policy, restoring only that entry if commit fails. */ export async function withAgentExecApprovalsRemoved( agentId: string, commit: () => Promise, ): Promise { const key = normalizeAgentId(agentId); - return await withExecApprovalsLock(async () => { - const snapshot = readExecApprovalsSnapshotUnlocked(); - try { - if (Object.hasOwn(snapshot.file.agents ?? {}, key)) { - const agents = { ...snapshot.file.agents }; - delete agents[key]; - const updated = updateExecApprovalsUnlocked({ - baseHash: snapshot.hash, - allowDeletedAgentRemoval: key, - update: (file) => ({ ...file, agents }), - }); - if (!updated) { - throw new Error("Exec approvals changed while deleting agent; retry deletion."); + try { + return await withOpenClawStateLease( + { + scope: EXEC_APPROVALS_MUTATION_LEASE_SCOPE, + key: EXEC_APPROVALS_MUTATION_LEASE_KEY, + database: { scope: "shared" }, + leaseMs: EXEC_APPROVALS_DELETION_LEASE_MS, + waitMs: EXEC_APPROVALS_DELETION_LEASE_WAIT_MS, + leaseLabel: "exec approvals agent deletion lease", + operationLabel: "exec-approvals.agent-deletion.lease", + }, + async (leaseOwner) => { + // Heartbeats retain the fence during a slow roster commit. If this process + // crashes, the persisted TTL expires and writers can proceed without cleanup. + const snapshot = readExecApprovalsSnapshot(); + const removedPolicy = snapshot.file.agents?.[key]; + if (removedPolicy !== undefined) { + const updated = updateExecApprovalsInTransaction({ + baseHash: snapshot.hash, + allowDeletedAgentRemoval: key, + leaseOwner, + update: (file) => { + const agents = { ...file.agents }; + delete agents[key]; + return { ...file, agents }; + }, + }); + if (!updated) { + throw new Error("Exec approvals changed while deleting agent; retry deletion."); + } } - } - return await commit(); - } catch (error) { - if (error instanceof AgentDeletionCommitUncertainError) { - throw error; - } - try { - restoreExecApprovalsSnapshotUnlocked(snapshot); - } catch (rollbackError) { - throw new AgentDeletionAuthorityRollbackError( - [error, rollbackError], - `Failed to roll back exec approvals deletion for agent ${key}.`, - { cause: error }, - ); - } - throw error; + try { + return await commit(); + } catch (error) { + if (error instanceof AgentDeletionCommitUncertainError) { + throw error; + } + if (removedPolicy !== undefined) { + try { + updateExecApprovalsInTransaction({ + allowDeletedAgentRestore: key, + leaseOwner, + update: (file) => ({ + ...file, + agents: { ...file.agents, [key]: removedPolicy }, + }), + }); + } catch (rollbackError) { + throw new AgentDeletionAuthorityRollbackError( + [error, rollbackError], + `Failed to roll back exec approvals deletion for agent ${key}.`, + { cause: error }, + ); + } + } + throw error; + } + }, + ); + } catch (error) { + if ( + error instanceof OpenClawStateLeaseError && + error.code === "OPENCLAW_STATE_LEASE_STORAGE_FAILED" + ) { + throw new ExecApprovalsStoreUnavailableError(error); } - }); -} - -function restoreExecApprovalsSnapshotUnlocked(snapshot: ExecApprovalsSnapshot): void { - if (!snapshot.exists) { - fs.rmSync(snapshot.path, { force: true }); - } else if (snapshot.raw !== null) { - writeExecApprovalsRaw(snapshot.path, snapshot.raw); - } else { - saveExecApprovalsUnlocked(snapshot.file); + throw error; } } +function restoreExecApprovalsSnapshotInTransaction( + snapshot: ExecApprovalsSnapshot, + leaseOwner?: ExecApprovalsMutationLeaseOwner, +): void { + runOpenClawStateWriteTransaction( + ({ db }) => { + if (!snapshot.exists) { + deleteExecApprovalsConfigRow(db, leaseOwner); + return; + } + const raw = snapshot.raw ?? serializeExecApprovals(snapshot.file); + writeExecApprovalsConfigRow({ db, file: snapshot.file, raw, leaseOwner }); + }, + {}, + { operationLabel: "exec-approvals.restore" }, + ); +} + export function restoreExecApprovalsSnapshot(snapshot: ExecApprovalsSnapshot): void { - withExecApprovalsLockSync(() => restoreExecApprovalsSnapshotUnlocked(snapshot)); + assertNoPendingLegacyExecApprovals(); + restoreExecApprovalsSnapshotInTransaction(snapshot); } export async function restoreExecApprovalsSnapshotLocked( snapshot: ExecApprovalsSnapshot, baseHash: string, ): Promise { - return await withExecApprovalsLock(async () => { - if (readExecApprovalsSnapshotUnlocked().hash !== baseHash) { - return false; - } - restoreExecApprovalsSnapshotUnlocked(snapshot); - return true; - }); + assertNoPendingLegacyExecApprovals(); + return runOpenClawStateWriteTransaction( + ({ db }) => { + const current = snapshotFromExecApprovalsRow({ + path: resolveExecApprovalsDisplayPath(), + row: readExecApprovalsConfigRow(db), + }); + if (current.hash !== baseHash) { + return false; + } + if (!snapshot.exists) { + deleteExecApprovalsConfigRow(db); + } else { + const raw = snapshot.raw ?? serializeExecApprovals(snapshot.file); + writeExecApprovalsConfigRow({ db, file: snapshot.file, raw }); + } + return true; + }, + {}, + { operationLabel: "exec-approvals.restore-cas" }, + ); } function ensureExecApprovalsSocket(file: ExecApprovalsFile): ExecApprovalsFile { @@ -290,23 +367,24 @@ function requireInitializedExecApprovals( export async function ensureExecApprovalsSnapshot(): Promise { return requireInitializedExecApprovals( - await updateExecApprovals({ update: ensureExecApprovalsSocket }), + updateExecApprovalsInTransaction({ update: ensureExecApprovalsSocket }), ); } export function ensureExecApprovals(): ExecApprovalsFile { return requireInitializedExecApprovals( - updateExecApprovalsSync({ update: ensureExecApprovalsSocket }), + updateExecApprovalsInTransaction({ update: ensureExecApprovalsSocket }), ).file; } -export function readExecApprovalsForNoPersistenceUnlocked(filePath: string): ExecApprovalsFile { - try { - return readExecApprovalsSnapshotFromPath(filePath).file; - } catch (err) { - if (err instanceof UnsafeExecApprovalsPathError) { - throw err; - } - return createFailClosedExecApprovalsFallback(); - } +const testing = { + reset(): void { + resetExecApprovalsMigrationGateForTest(); + lastWarnAt = undefined; + }, +}; + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.execApprovalsStoreTestApi")] = + testing; } diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index c74afa6d4d8e..ab4683cc975e 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -1,17 +1,16 @@ // Manages exec approval policy, allowlist entries, and host targeting. import { normalizeExecApprovalsInternal, - resolveExecApprovalsPath, + resolveExecApprovalsDisplayPath, resolveExecApprovalsSocketPath, } from "./exec-approvals-config.js"; import type { ExecApprovalsDefaultOverrides } from "./exec-approvals-contracts.js"; import type { ExecApprovalsFile, ExecApprovalsResolved } from "./exec-approvals-core.js"; -import { withExecApprovalsReadLock, withExecApprovalsReadLockSync } from "./exec-approvals-lock.js"; import { resolveExecApprovalsFromFilePrepared } from "./exec-approvals-resolver.js"; import { ensureExecApprovals, ensureExecApprovalsSnapshot, - readExecApprovalsForNoPersistenceUnlocked, + loadExecApprovals, } from "./exec-approvals-store.js"; import { expandHomePrefix } from "./home-dir.js"; @@ -89,11 +88,9 @@ export function resolveExecApprovals( agentId?: string, overrides?: ExecApprovalsDefaultOverrides, ): ExecApprovalsResolved { - const filePath = resolveExecApprovalsPath(); + const filePath = resolveExecApprovalsDisplayPath(); if (!overrides?.requireSocket) { - const file = withExecApprovalsReadLockSync(filePath, () => - readExecApprovalsForNoPersistenceUnlocked(filePath), - ); + const file = loadExecApprovals(); const resolved = resolveExecApprovalsWithoutSocket({ file, filePath, @@ -118,11 +115,9 @@ export async function resolveExecApprovalsLocked( agentId?: string, overrides?: ExecApprovalsDefaultOverrides, ): Promise { - const filePath = resolveExecApprovalsPath(); + const filePath = resolveExecApprovalsDisplayPath(); if (!overrides?.requireSocket) { - const file = await withExecApprovalsReadLock(filePath, async () => - readExecApprovalsForNoPersistenceUnlocked(filePath), - ); + const file = loadExecApprovals(); const resolved = resolveExecApprovalsWithoutSocket({ file, filePath, @@ -135,7 +130,7 @@ export async function resolveExecApprovalsLocked( } return shapeResolvedExecApprovals({ file: (await ensureExecApprovalsSnapshot()).file, - filePath: resolveExecApprovalsPath(), + filePath: resolveExecApprovalsDisplayPath(), agentId, overrides, socket: "persisted", diff --git a/src/infra/state-migrations.doctor.ts b/src/infra/state-migrations.doctor.ts index 42bc4dd467af..829752c6d2ca 100644 --- a/src/infra/state-migrations.doctor.ts +++ b/src/infra/state-migrations.doctor.ts @@ -62,6 +62,10 @@ import { detectLegacyDeviceIdentity, migrateLegacyDeviceIdentity, } from "./state-migrations.device-identity.js"; +import { + detectLegacyExecApprovals, + migrateLegacyExecApprovals, +} from "./state-migrations.exec-approvals.js"; import { existsDir, fileExists, @@ -476,6 +480,10 @@ export async function detectLegacyStateMigrations(params: { env, doctorOnlyStateMigrations: params.doctorOnlyStateMigrations, }); + const execApprovals = detectLegacyExecApprovals({ + stateDir, + doctorOnlyStateMigrations: params.doctorOnlyStateMigrations, + }); const mcpOauth = detectLegacyMcpOAuthStores({ stateDir, doctorOnlyStateMigrations: params.doctorOnlyStateMigrations, @@ -678,6 +686,9 @@ export async function detectLegacyStateMigrations(params: { if (deviceIdentity.hasInvalidCanonical && !deviceIdentity.hasLegacy) { preview.push("- Primary device identity: invalid SQLite row → new device identity"); } + if (execApprovals.hasLegacy) { + preview.push("- Exec approvals: legacy JSON → shared SQLite state"); + } if (mcpOauth.hasLegacy) { preview.push("- MCP OAuth credentials: legacy JSON → shared SQLite state"); } @@ -793,6 +804,7 @@ export async function detectLegacyStateMigrations(params: { apns, deviceAuth, deviceIdentity, + execApprovals, mcpOauth, meetingTranscripts, restartSentinel, @@ -1069,6 +1081,11 @@ export async function runLegacyStateMigrations(params: { stateDir: detected.stateDir, doctorOnlyStateMigrations: params.doctorOnlyStateMigrations, }); + const execApprovals = await migrateLegacyExecApprovals({ + detected: detected.execApprovals, + env, + stateDir: detected.stateDir, + }); const mcpOauth = await migrateLegacyMcpOAuthStores({ detected: detected.mcpOauth, env, @@ -1146,6 +1163,7 @@ export async function runLegacyStateMigrations(params: { apns, deviceAuth, deviceIdentity, + execApprovals, mcpOauth, meetingTranscripts, restartSentinel, @@ -1176,6 +1194,7 @@ export async function runLegacyStateMigrations(params: { ...apns.changes, ...deviceAuth.changes, ...deviceIdentity.changes, + ...execApprovals.changes, ...mcpOauth.changes, ...meetingTranscripts.changes, ...restartSentinel.changes, @@ -1213,6 +1232,7 @@ export async function runLegacyStateMigrations(params: { ...apns.warnings, ...deviceAuth.warnings, ...deviceIdentity.warnings, + ...execApprovals.warnings, ...mcpOauth.warnings, ...meetingTranscripts.warnings, ...restartSentinel.warnings, diff --git a/src/infra/state-migrations.exec-approvals.test.ts b/src/infra/state-migrations.exec-approvals.test.ts new file mode 100644 index 000000000000..b9b01dfba68e --- /dev/null +++ b/src/infra/state-migrations.exec-approvals.test.ts @@ -0,0 +1,305 @@ +// Covers Doctor-only import of the retired exec approvals JSON file. +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; +import { + closeOpenClawStateDatabaseForTest, + openOpenClawStateDatabase, +} from "../state/openclaw-state-db.js"; +import { captureEnv, setTestEnvValue } from "../test-utils/env.js"; +import { resolveExecApprovalsPath } from "./exec-approvals-config.js"; +import { ExecApprovalsMigrationRequiredError } from "./exec-approvals-migration-gate.js"; +import { + readExecApprovalsConfigRow, + serializeExecApprovals, + writeExecApprovalsConfigRow, +} from "./exec-approvals-sqlite.js"; +import { loadExecApprovals } from "./exec-approvals-store.js"; +import { testing as execApprovalsStoreTesting } from "./exec-approvals-store.test-support.js"; +import { acquireGatewayLock } from "./gateway-lock.js"; +import { executeSqliteQueryTakeFirstSync, getNodeSqliteKysely } from "./kysely-sync.js"; +import { + detectLegacyExecApprovals, + migrateLegacyExecApprovals, +} from "./state-migrations.exec-approvals.js"; + +type MigrationDatabase = Pick; + +describe("legacy exec approvals migration", () => { + const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); + const tempDirs = useAutoCleanupTempDirTracker((cleanup) => { + afterEach(() => { + closeOpenClawStateDatabaseForTest(); + execApprovalsStoreTesting.reset(); + envSnapshot.restore(); + cleanup(); + }); + }); + + function useStateDir(): { env: NodeJS.ProcessEnv; stateDir: string; sourcePath: string } { + const stateDir = tempDirs.make("openclaw-exec-approvals-migration-"); + const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + return { env, stateDir, sourcePath: resolveExecApprovalsPath(env) }; + } + + async function writeLegacy(sourcePath: string, value: unknown): Promise { + await fsp.writeFile(sourcePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + return sourcePath; + } + + async function migrate(params: { + env: NodeJS.ProcessEnv; + stateDir: string; + beforeClaim?: () => void; + beforeVerify?: () => void; + removeSource?: (sourcePath: string) => Promise | void; + }) { + return await migrateLegacyExecApprovals({ + detected: detectLegacyExecApprovals({ + stateDir: params.stateDir, + doctorOnlyStateMigrations: true, + }), + ...params, + }); + } + + function database(env: NodeJS.ProcessEnv) { + return openOpenClawStateDatabase({ env }).db; + } + + function receipt(env: NodeJS.ProcessEnv) { + const db = database(env); + return executeSqliteQueryTakeFirstSync( + db, + getNodeSqliteKysely(db) + .selectFrom("migration_sources") + .selectAll() + .where("migration_kind", "=", "legacy-exec-approvals-json"), + ); + } + + function runReceipt(env: NodeJS.ProcessEnv) { + const db = database(env); + return executeSqliteQueryTakeFirstSync( + db, + getNodeSqliteKysely(db) + .selectFrom("migration_runs") + .selectAll() + .where("id", "like", "exec-approvals-json:%"), + ); + } + + it("detects source and claim only for Doctor-owned migration", async () => { + const { stateDir, sourcePath } = useStateDir(); + await writeLegacy(sourcePath, { version: 1, agents: {} }); + expect(detectLegacyExecApprovals({ stateDir }).hasLegacy).toBe(false); + expect(detectLegacyExecApprovals({ stateDir, doctorOnlyStateMigrations: true }).hasLegacy).toBe( + true, + ); + + await fsp.rename(sourcePath, `${sourcePath}.doctor-importing`); + expect(detectLegacyExecApprovals({ stateDir, doctorOnlyStateMigrations: true }).hasLegacy).toBe( + true, + ); + }); + + it("imports, verifies, receipts, and removes valid legacy policy", async () => { + const { env, stateDir, sourcePath } = useStateDir(); + const expected = { + version: 1 as const, + socket: { path: "/tmp/approvals.sock", token: "secret" }, + defaults: { security: "allowlist" as const, ask: "on-miss" as const }, + agents: { main: { allowlist: [{ pattern: "/usr/bin/rg" }] } }, + }; + await writeLegacy(sourcePath, expected); + + const result = await migrate({ env, stateDir }); + + expect(result.warnings).toEqual([]); + expect(result.changes).toEqual(["Imported legacy exec approvals into shared SQLite state."]); + const importedRaw = readExecApprovalsConfigRow(database(env))?.raw_json; + expect(importedRaw).toContain('"pattern": "/usr/bin/rg"'); + expect(importedRaw).toContain('"id":'); + expect(fs.existsSync(sourcePath)).toBe(false); + expect(receipt(env)).toMatchObject({ + removed_source: 1, + source_record_count: 1, + status: "completed", + target_table: "exec_approvals_config", + }); + expect(runReceipt(env)).toMatchObject({ status: "completed" }); + }); + + it("is idempotent after successful source removal", async () => { + const { env, stateDir, sourcePath } = useStateDir(); + await writeLegacy(sourcePath, { version: 1, agents: {} }); + await migrate({ env, stateDir }); + + await expect(migrate({ env, stateDir })).resolves.toEqual({ changes: [], warnings: [] }); + expect(receipt(env)).toMatchObject({ removed_source: 1 }); + }); + + it("preserves malformed bytes and records a non-removal receipt", async () => { + const { env, stateDir, sourcePath } = useStateDir(); + await fsp.writeFile(sourcePath, "{malformed-secret-marker", "utf8"); + + const result = await migrate({ env, stateDir }); + + expect(result.changes).toEqual([]); + expect(result.warnings[0]).toContain("Preserved malformed legacy exec approvals"); + expect(JSON.stringify(result)).not.toContain("secret-marker"); + expect(fs.readFileSync(sourcePath, "utf8")).toContain("secret-marker"); + expect(receipt(env)).toMatchObject({ removed_source: 0, source_record_count: 0 }); + expect(readExecApprovalsConfigRow(database(env))).toBeUndefined(); + }); + + it("removes a byte-identical source when canonical state already exists", async () => { + const { env, stateDir, sourcePath } = useStateDir(); + const file = { version: 1 as const, defaults: { security: "deny" as const }, agents: {} }; + const raw = serializeExecApprovals(file); + writeExecApprovalsConfigRow({ db: database(env), file, raw }); + await fsp.writeFile(sourcePath, raw, "utf8"); + + const result = await migrate({ env, stateDir }); + + expect(result.warnings).toEqual([]); + expect(result.changes).toEqual(["Preserved byte-identical canonical SQLite exec approvals."]); + expect(fs.existsSync(sourcePath)).toBe(false); + expect(receipt(env)).toMatchObject({ removed_source: 1 }); + }); + + it("preserves conflicting legacy bytes while canonical SQLite wins", async () => { + const { env, stateDir, sourcePath } = useStateDir(); + const canonical = { + version: 1 as const, + defaults: { security: "deny" as const }, + agents: {}, + }; + writeExecApprovalsConfigRow({ db: database(env), file: canonical }); + await writeLegacy(sourcePath, { + version: 1, + defaults: { security: "full" }, + agents: {}, + }); + + const result = await migrate({ env, stateDir }); + + expect(result.changes).toEqual([]); + expect(result.warnings[0]).toContain("retained conflicting legacy JSON"); + expect(fs.existsSync(sourcePath)).toBe(true); + expect(readExecApprovalsConfigRow(database(env))?.raw_json).toBe( + serializeExecApprovals(canonical), + ); + expect(receipt(env)).toMatchObject({ removed_source: 0 }); + }); + + it("repairs an invalid canonical row from validated legacy policy", async () => { + const { env, stateDir, sourcePath } = useStateDir(); + const db = database(env); + db.prepare( + "INSERT INTO exec_approvals_config (config_key, raw_json, socket_path, has_socket_token, default_security, default_ask, default_ask_fallback, auto_allow_skills, agent_count, allowlist_count, updated_at_ms) VALUES ('current', '{invalid', NULL, 0, NULL, NULL, NULL, NULL, 0, 0, 1)", + ).run(); + await writeLegacy(sourcePath, { version: 1, defaults: { security: "deny" }, agents: {} }); + + const result = await migrate({ env, stateDir }); + + expect(result.warnings).toEqual([]); + expect(result.changes).toEqual([ + "Replaced an invalid SQLite exec approvals row with validated legacy state.", + ]); + expect(readExecApprovalsConfigRow(db)?.raw_json).toContain('"security": "deny"'); + }); + + it("recovers an interrupted claim and completes import", async () => { + const { env, stateDir, sourcePath } = useStateDir(); + await writeLegacy(sourcePath, { version: 1, agents: {} }); + await fsp.rename(sourcePath, `${sourcePath}.doctor-importing`); + + const result = await migrate({ env, stateDir }); + + expect(result.warnings).toEqual([]); + expect(fs.existsSync(sourcePath)).toBe(false); + expect(fs.existsSync(`${sourcePath}.doctor-importing`)).toBe(false); + expect(readExecApprovalsConfigRow(database(env))).toBeDefined(); + }); + + it("preserves changed source bytes before claim and writes no receipt", async () => { + const { env, stateDir, sourcePath } = useStateDir(); + await writeLegacy(sourcePath, { version: 1, agents: {} }); + + const result = await migrate({ + env, + stateDir, + beforeVerify: () => { + fs.writeFileSync( + sourcePath, + serializeExecApprovals({ version: 1, defaults: { security: "deny" }, agents: {} }), + ); + }, + }); + + expect(result.warnings[0]).toContain("changed after migration loaded"); + expect(fs.existsSync(sourcePath)).toBe(true); + expect(receipt(env)).toBeUndefined(); + }); + + it("retains the claim after cleanup failure and converges on retry", async () => { + const { env, stateDir, sourcePath } = useStateDir(); + await writeLegacy(sourcePath, { version: 1, agents: {} }); + const first = await migrate({ + env, + stateDir, + removeSource: () => { + throw new Error("forced cleanup failure"); + }, + }); + expect(first.warnings[0]).toContain("cleanup failed"); + expect(fs.existsSync(`${sourcePath}.doctor-importing`)).toBe(true); + expect(receipt(env)).toMatchObject({ removed_source: 0 }); + + const second = await migrate({ env, stateDir }); + expect(second.warnings).toEqual([]); + expect(fs.existsSync(sourcePath)).toBe(false); + expect(fs.existsSync(`${sourcePath}.doctor-importing`)).toBe(false); + expect(receipt(env)).toMatchObject({ removed_source: 1 }); + }); + + it("requires exclusive state ownership and prints the stop-Gateway warning", async () => { + const { env, stateDir, sourcePath } = useStateDir(); + await writeLegacy(sourcePath, { version: 1, agents: {} }); + const gatewayLock = await acquireGatewayLock({ + allowInTests: true, + env, + pollIntervalMs: 10, + port: 18_791, + timeoutMs: 100, + }); + if (!gatewayLock) { + throw new Error("expected test Gateway lock"); + } + let result: Awaited>; + try { + result = await migrate({ env, stateDir }); + } finally { + await gatewayLock.release(); + } + + expect(result.warnings[0]).toContain("Stop the Gateway"); + expect(fs.existsSync(sourcePath)).toBe(true); + expect(receipt(env)).toBeUndefined(); + }); + + it("keeps store APIs blocked until Doctor completes the import", async () => { + const { env, stateDir, sourcePath } = useStateDir(); + await writeLegacy(sourcePath, { version: 1, defaults: { security: "deny" }, agents: {} }); + setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); + execApprovalsStoreTesting.reset(); + expect(() => loadExecApprovals()).toThrow(ExecApprovalsMigrationRequiredError); + + const result = await migrate({ env, stateDir }); + expect(result.warnings).toEqual([]); + expect(loadExecApprovals().defaults?.security).toBe("deny"); + }); +}); diff --git a/src/infra/state-migrations.exec-approvals.ts b/src/infra/state-migrations.exec-approvals.ts new file mode 100644 index 000000000000..015139c207c5 --- /dev/null +++ b/src/infra/state-migrations.exec-approvals.ts @@ -0,0 +1,571 @@ +// Doctor-only import for the retired exec approvals JSON store. +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; +import { root, type Root } from "@openclaw/fs-safe"; +import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; +import { runOpenClawStateWriteTransaction } from "../state/openclaw-state-db.js"; +import { formatErrorMessage } from "./errors.js"; +import { + resolveExecApprovalsPath, + tryParsePersistedExecApprovals, +} from "./exec-approvals-config.js"; +import { resetLegacyExecApprovalsPresenceCache } from "./exec-approvals-migration-gate.js"; +import { + readExecApprovalsConfigRow, + serializeExecApprovals, + writeExecApprovalsConfigRow, +} from "./exec-approvals-sqlite.js"; +import { acquireGatewayLock, GatewayLockError } from "./gateway-lock.js"; +import { + executeSqliteQuerySync, + executeSqliteQueryTakeFirstSync, + getNodeSqliteKysely, +} from "./kysely-sync.js"; +import type { LegacyExecApprovalsDetection } from "./state-migrations.exec-approvals.types.js"; +import type { MigrationMessages } from "./state-migrations.types.js"; + +const DOCTOR_CLAIM_SUFFIX = ".doctor-importing"; +const MAX_LEGACY_EXEC_APPROVALS_BYTES = 4 * 1024 * 1024; +const MIGRATION_KIND = "legacy-exec-approvals-json"; +const TARGET_TABLE = "exec_approvals_config"; +const MIGRATION_LOCK_TIMEOUT_MS = 250; +const MIGRATION_LOCK_POLL_INTERVAL_MS = 25; +const utf8Decoder = new TextDecoder("utf-8", { fatal: true }); + +type ExecApprovalsMigrationDatabase = Pick< + OpenClawStateKyselyDatabase, + "exec_approvals_config" | "migration_runs" | "migration_sources" +>; + +type LegacySourceSnapshot = { + buffer: Buffer; + dev: number; + ino: number; + mtimeMs: number; + raw: string | null; + sha256: string; + size: number; +}; + +type MigrationDecision = + | "canonical-preserved" + | "invalid-canonical-repaired" + | "legacy-imported" + | "malformed-legacy-preserved" + | "receipt-authoritative"; + +function legacyPathMayExist(filePath: string): boolean { + try { + fs.lstatSync(filePath); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code !== "ENOENT"; + } +} + +/** Detect retired approvals only when an explicit Doctor flow opts in. */ +export function detectLegacyExecApprovals(params: { + stateDir: string; + doctorOnlyStateMigrations?: boolean; +}): LegacyExecApprovalsDetection { + const env = { ...process.env, OPENCLAW_STATE_DIR: params.stateDir }; + const sourcePath = resolveExecApprovalsPath(env); + const sourcePresent = + legacyPathMayExist(sourcePath) || legacyPathMayExist(`${sourcePath}${DOCTOR_CLAIM_SUFFIX}`); + return { + sourcePath, + hasLegacy: params.doctorOnlyStateMigrations === true && sourcePresent, + }; +} + +function relativeLegacyPath(stateDir: string, filePath: string): string { + const relativePath = path.relative(path.resolve(stateDir), path.resolve(filePath)); + if ( + !relativePath || + relativePath === ".." || + relativePath.startsWith(`..${path.sep}`) || + path.isAbsolute(relativePath) + ) { + throw new Error("legacy exec approvals path is outside the state directory"); + } + return relativePath; +} + +async function readLegacySourceSnapshot( + stateRoot: Root, + stateDir: string, + sourcePath: string, +): Promise { + const opened = await stateRoot.read(relativeLegacyPath(stateDir, sourcePath), { + hardlinks: "reject", + maxBytes: MAX_LEGACY_EXEC_APPROVALS_BYTES, + symlinks: "reject", + }); + if (!opened.stat.isFile() || opened.stat.size !== opened.buffer.byteLength) { + throw new Error("legacy exec approvals are not a stable regular file"); + } + let raw: string | null = null; + try { + raw = utf8Decoder.decode(opened.buffer); + } catch { + // Invalid UTF-8 is malformed input that must stay available for recovery. + } + return { + buffer: opened.buffer, + dev: opened.stat.dev, + ino: opened.stat.ino, + mtimeMs: opened.stat.mtimeMs, + raw, + sha256: createHash("sha256").update(opened.buffer).digest("hex"), + size: opened.stat.size, + }; +} + +function snapshotsMatch(left: LegacySourceSnapshot, right: LegacySourceSnapshot): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.mtimeMs === right.mtimeMs && + left.sha256 === right.sha256 && + left.size === right.size + ); +} + +function receiptSourceKey(sourcePath: string): string { + return `exec-approvals-json:${createHash("sha256").update(path.resolve(sourcePath)).digest("hex")}`; +} + +function decideAndRecordMigration(params: { + env: NodeJS.ProcessEnv; + sourcePath: string; + snapshot: LegacySourceSnapshot; +}): { decision: MigrationDecision; removeSource: boolean; sourceKey: string } { + const sourceKey = receiptSourceKey(params.sourcePath); + const runId = `${sourceKey}:${params.snapshot.sha256.slice(0, 16)}`; + const now = Date.now(); + const legacyFile = + params.snapshot.raw === null ? null : tryParsePersistedExecApprovals(params.snapshot.raw); + + return runOpenClawStateWriteTransaction( + ({ db }) => { + const stateDb = getNodeSqliteKysely(db); + const canonical = readExecApprovalsConfigRow(db); + const canonicalFile = canonical ? tryParsePersistedExecApprovals(canonical.raw_json) : null; + const importedRaw = legacyFile ? serializeExecApprovals(legacyFile) : null; + const receipt = executeSqliteQueryTakeFirstSync( + db, + stateDb + .selectFrom("migration_sources") + .select(["source_sha256", "report_json"]) + .where("source_key", "=", sourceKey), + ); + let receiptImportedSameSource = false; + if (receipt?.source_sha256 === params.snapshot.sha256) { + try { + const report = JSON.parse(receipt.report_json) as { decision?: unknown }; + receiptImportedSameSource = + report.decision === "legacy-imported" || + report.decision === "invalid-canonical-repaired" || + report.decision === "receipt-authoritative"; + } catch { + // A malformed receipt is not authority to discard security state. + } + } + let decision: MigrationDecision; + let removeSource = false; + if (!legacyFile || params.snapshot.raw === null) { + decision = "malformed-legacy-preserved"; + } else if (receiptImportedSameSource && canonicalFile) { + decision = "receipt-authoritative"; + removeSource = true; + } else if (!canonical) { + writeExecApprovalsConfigRow({ + db, + file: legacyFile, + raw: importedRaw ?? undefined, + now, + }); + decision = "legacy-imported"; + removeSource = true; + } else if (!canonicalFile) { + writeExecApprovalsConfigRow({ + db, + file: legacyFile, + raw: importedRaw ?? undefined, + now, + }); + decision = "invalid-canonical-repaired"; + removeSource = true; + } else { + decision = "canonical-preserved"; + removeSource = canonical.raw_json === params.snapshot.raw; + } + + if (decision === "legacy-imported" || decision === "invalid-canonical-repaired") { + if (!legacyFile) { + throw new Error("exec approvals import decisions require a parsed legacy file"); + } + const verified = readExecApprovalsConfigRow(db); + const verifiedFile = verified ? tryParsePersistedExecApprovals(verified.raw_json) : null; + const rawMatches = verified?.raw_json === importedRaw; + const fileMatches = + verifiedFile && + isDeepStrictEqual( + JSON.parse(serializeExecApprovals(verifiedFile)), + JSON.parse(serializeExecApprovals(legacyFile)), + ); + if (!rawMatches || !fileMatches) { + throw new Error( + `SQLite verification failed for the exec approvals migration (raw=${rawMatches}, parsed=${Boolean(fileMatches)})`, + ); + } + } + + const reportJson = JSON.stringify({ + source: MIGRATION_KIND, + target: TARGET_TABLE, + decision, + sourceSha256: params.snapshot.sha256, + sourceValid: legacyFile !== null, + importedRecordCount: + decision === "legacy-imported" || decision === "invalid-canonical-repaired" ? 1 : 0, + preservedSqliteRecordCount: + decision === "canonical-preserved" || decision === "receipt-authoritative" ? 1 : 0, + removesSource: removeSource, + }); + executeSqliteQuerySync( + db, + stateDb + .insertInto("migration_runs") + .values({ + id: runId, + started_at: now, + finished_at: now, + status: "completed", + report_json: reportJson, + }) + .onConflict((conflict) => + conflict.column("id").doUpdateSet({ + finished_at: now, + status: "completed", + report_json: reportJson, + }), + ), + ); + executeSqliteQuerySync( + db, + stateDb + .insertInto("migration_sources") + .values({ + source_key: sourceKey, + migration_kind: MIGRATION_KIND, + source_path: params.sourcePath, + target_table: TARGET_TABLE, + source_sha256: params.snapshot.sha256, + source_size_bytes: params.snapshot.size, + source_record_count: legacyFile ? 1 : 0, + last_run_id: runId, + status: "completed", + imported_at: now, + removed_source: 0, + report_json: reportJson, + }) + .onConflict((conflict) => + conflict.column("source_key").doUpdateSet({ + source_sha256: params.snapshot.sha256, + source_size_bytes: params.snapshot.size, + source_record_count: legacyFile ? 1 : 0, + last_run_id: runId, + status: "completed", + imported_at: now, + removed_source: 0, + report_json: reportJson, + }), + ), + ); + return { decision, removeSource, sourceKey }; + }, + { env: params.env }, + { operationLabel: "state-migration.exec-approvals" }, + ); +} + +function markSourceRemoved(sourceKey: string, env: NodeJS.ProcessEnv): void { + runOpenClawStateWriteTransaction( + ({ db }) => { + executeSqliteQuerySync( + db, + getNodeSqliteKysely(db) + .updateTable("migration_sources") + .set({ removed_source: 1 }) + .where("source_key", "=", sourceKey), + ); + }, + { env }, + { operationLabel: "state-migration.exec-approvals.receipt" }, + ); +} + +async function restoreClaim(params: { + stateRoot: Root; + stateDir: string; + sourcePath: string; +}): Promise { + const claimPath = `${params.sourcePath}${DOCTOR_CLAIM_SUFFIX}`; + try { + if (!(await params.stateRoot.exists(relativeLegacyPath(params.stateDir, claimPath)))) { + return null; + } + if (await params.stateRoot.exists(relativeLegacyPath(params.stateDir, params.sourcePath))) { + return `source path already exists: ${params.sourcePath}`; + } + await params.stateRoot.move( + relativeLegacyPath(params.stateDir, claimPath), + relativeLegacyPath(params.stateDir, params.sourcePath), + ); + return null; + } catch (error) { + return String(error); + } +} + +async function recoverInterruptedClaim(params: { + stateRoot: Root; + stateDir: string; + sourcePath: string; +}): Promise { + const claimPath = `${params.sourcePath}${DOCTOR_CLAIM_SUFFIX}`; + const claimRelative = relativeLegacyPath(params.stateDir, claimPath); + if (!(await params.stateRoot.exists(claimRelative))) { + return; + } + const sourceRelative = relativeLegacyPath(params.stateDir, params.sourcePath); + if (!(await params.stateRoot.exists(sourceRelative))) { + await params.stateRoot.move(claimRelative, sourceRelative); + return; + } + const [source, claim] = await Promise.all([ + readLegacySourceSnapshot(params.stateRoot, params.stateDir, params.sourcePath), + readLegacySourceSnapshot(params.stateRoot, params.stateDir, claimPath), + ]); + if (source.sha256 !== claim.sha256 || source.size !== claim.size) { + throw new Error("legacy exec approvals source and interrupted claim both exist"); + } + await params.stateRoot.remove(claimRelative); +} + +function decisionMessage(decision: MigrationDecision, removeSource: boolean): string { + switch (decision) { + case "legacy-imported": + return "Imported legacy exec approvals into shared SQLite state."; + case "invalid-canonical-repaired": + return "Replaced an invalid SQLite exec approvals row with validated legacy state."; + case "canonical-preserved": + return removeSource + ? "Preserved byte-identical canonical SQLite exec approvals." + : "Preserved canonical SQLite exec approvals and retained conflicting legacy JSON."; + case "malformed-legacy-preserved": + return "Preserved malformed legacy exec approvals for operator recovery."; + case "receipt-authoritative": + return "Completed cleanup for previously imported legacy exec approvals."; + } + const unreachable: never = decision; + return unreachable; +} + +async function migrateWithExclusiveStateOwnership(params: { + detected: LegacyExecApprovalsDetection; + stateRoot: Root; + stateDir: string; + env: NodeJS.ProcessEnv; + beforeClaim?: () => void; + beforeVerify?: () => void; + removeSource?: (sourcePath: string) => Promise | void; +}): Promise { + const sourcePath = params.detected.sourcePath; + try { + await recoverInterruptedClaim({ ...params, sourcePath }); + } catch (error) { + return { + changes: [], + warnings: [`Failed recovering a legacy exec approvals Doctor claim: ${String(error)}`], + }; + } + const sourceRelative = relativeLegacyPath(params.stateDir, sourcePath); + if (!(await params.stateRoot.exists(sourceRelative))) { + return { changes: [], warnings: [] }; + } + + let snapshot: LegacySourceSnapshot; + try { + snapshot = await readLegacySourceSnapshot(params.stateRoot, params.stateDir, sourcePath); + } catch (error) { + return { changes: [], warnings: [`Failed reading legacy exec approvals: ${String(error)}`] }; + } + + const claimPath = `${sourcePath}${DOCTOR_CLAIM_SUFFIX}`; + const claimRelative = relativeLegacyPath(params.stateDir, claimPath); + try { + params.beforeVerify?.(); + const current = await readLegacySourceSnapshot(params.stateRoot, params.stateDir, sourcePath); + if (!snapshotsMatch(current, snapshot)) { + throw new Error("legacy exec approvals changed after migration loaded them"); + } + params.beforeClaim?.(); + await params.stateRoot.move(sourceRelative, claimRelative); + const claimed = await readLegacySourceSnapshot(params.stateRoot, params.stateDir, claimPath); + if (!snapshotsMatch(claimed, snapshot)) { + throw new Error("legacy exec approvals changed before migration could claim them"); + } + } catch (error) { + const restoreError = await restoreClaim({ ...params, sourcePath }); + return { + changes: [], + warnings: [ + `Failed claiming legacy exec approvals: ${String(error)}${restoreError ? `; restore failure: ${restoreError}` : ""}`, + ], + }; + } + + let result: ReturnType; + try { + result = decideAndRecordMigration({ + env: params.env, + sourcePath, + snapshot, + }); + } catch (error) { + const restoreError = await restoreClaim({ ...params, sourcePath }); + return { + changes: [], + warnings: [ + `Failed migrating legacy exec approvals: ${String(error)}${restoreError ? `; restore failure: ${restoreError}` : ""}`, + ], + }; + } + + const preserveSource = !result.removeSource; + if (preserveSource) { + const restoreError = await restoreClaim({ ...params, sourcePath }); + return { + changes: [], + warnings: [ + `${decisionMessage(result.decision, result.removeSource)}${restoreError ? ` Claim restore failed: ${restoreError}` : ""}`, + ], + }; + } + + try { + if (await params.stateRoot.exists(sourceRelative)) { + throw new Error("legacy exec approvals reappeared during migration cleanup"); + } + if (params.removeSource) { + await params.removeSource(claimPath); + } else { + await params.stateRoot.remove(claimRelative); + } + if ( + (await params.stateRoot.exists(sourceRelative)) || + (await params.stateRoot.exists(claimRelative)) + ) { + throw new Error("legacy exec approvals remain after migration cleanup"); + } + } catch (error) { + return { + changes: [], + warnings: [`Legacy exec approvals cleanup failed: ${String(error)}`], + }; + } + + const warnings: string[] = []; + try { + markSourceRemoved(result.sourceKey, params.env); + } catch (error) { + warnings.push( + `Legacy exec approvals were removed, but their receipt could not be finalized: ${String(error)}`, + ); + } + resetLegacyExecApprovalsPresenceCache(params.env); + return { + changes: [decisionMessage(result.decision, result.removeSource)], + warnings, + notices: ["Removed retired exec approvals JSON after recording its migration decision."], + }; +} + +/** Import or retire the old file under exclusive state ownership. */ +export async function migrateLegacyExecApprovals(params: { + detected?: LegacyExecApprovalsDetection; + stateDir: string; + env?: NodeJS.ProcessEnv; + beforeClaim?: () => void; + beforeVerify?: () => void; + removeSource?: (sourcePath: string) => Promise | void; +}): Promise { + const detected = params.detected; + if (!detected?.hasLegacy) { + return { changes: [], warnings: [] }; + } + const env = { ...(params.env ?? process.env), OPENCLAW_STATE_DIR: params.stateDir }; + let lock: Awaited>; + try { + lock = await acquireGatewayLock({ + allowInTests: true, + env, + pollIntervalMs: MIGRATION_LOCK_POLL_INTERVAL_MS, + role: "sqlite-maintenance", + timeoutMs: MIGRATION_LOCK_TIMEOUT_MS, + }); + } catch (error) { + const detail = + error instanceof GatewayLockError + ? "the Gateway or another SQLite maintenance command owns this state directory" + : String(error); + return { + changes: [], + warnings: [ + `Failed migrating legacy exec approvals: ${detail}. Stop the Gateway, then run \`openclaw doctor --fix\` again.`, + ], + }; + } + if (!lock) { + return { + changes: [], + warnings: ["Failed migrating legacy exec approvals: exclusive state ownership unavailable."], + }; + } + + let result: MigrationMessages = { changes: [], warnings: [] }; + let releaseError: unknown; + try { + try { + const stateRoot = await root(params.stateDir, { + hardlinks: "reject", + maxBytes: MAX_LEGACY_EXEC_APPROVALS_BYTES, + symlinks: "reject", + }); + result = await migrateWithExclusiveStateOwnership({ + ...params, + detected, + env, + stateRoot, + }); + } catch (error) { + result.warnings.push(`Failed reading legacy exec approvals: ${String(error)}`); + } + } finally { + try { + await lock.release(); + } catch (error) { + releaseError = error; + } + } + if (releaseError) { + result.warnings.push( + `Exec approvals migration lock release failed: ${formatErrorMessage(releaseError)}`, + ); + } + return result; +} diff --git a/src/infra/state-migrations.exec-approvals.types.ts b/src/infra/state-migrations.exec-approvals.types.ts new file mode 100644 index 000000000000..98290a08e3ea --- /dev/null +++ b/src/infra/state-migrations.exec-approvals.types.ts @@ -0,0 +1,4 @@ +export type LegacyExecApprovalsDetection = { + sourcePath: string; + hasLegacy: boolean; +}; diff --git a/src/infra/state-migrations.types.ts b/src/infra/state-migrations.types.ts index c3e7ed6e1427..17755a6f7007 100644 --- a/src/infra/state-migrations.types.ts +++ b/src/infra/state-migrations.types.ts @@ -4,6 +4,7 @@ import type { PluginDoctorStateMigration } from "../plugins/doctor-contract-regi import type { LegacyAuditLogsDetection } from "./state-migrations.audit-logs.types.js"; import type { LegacyChannelPairingStateDetection } from "./state-migrations.channel-pairing.js"; import type { LegacyDeviceIdentityDetection } from "./state-migrations.device-identity.types.js"; +import type { LegacyExecApprovalsDetection } from "./state-migrations.exec-approvals.types.js"; import type { LegacyMcpOAuthDetection } from "./state-migrations.mcp-oauth.types.js"; import type { LegacyMeetingTranscriptsDetection } from "./state-migrations.meeting-transcripts.types.js"; import type { LegacyRestartSentinelDetection } from "./state-migrations.restart-sentinel.types.js"; @@ -126,6 +127,7 @@ export type LegacyStateDetection = { hasLegacy: boolean; }; deviceIdentity: LegacyDeviceIdentityDetection; + execApprovals: LegacyExecApprovalsDetection; mcpOauth: LegacyMcpOAuthDetection; meetingTranscripts?: LegacyMeetingTranscriptsDetection; restartSentinel?: LegacyRestartSentinelDetection; diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index fbf27251a8bc..cc8d32d5ec20 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -19,16 +19,18 @@ import { getRuntimeConfigSnapshot, setRuntimeConfigSnapshot, } from "../config/runtime-snapshot.js"; +import { testing as execApprovalsStoreTesting } from "../infra/exec-approvals-store.test-support.js"; import type { SystemRunApprovalPlan } from "../infra/exec-approvals.js"; import { commitExecAuthorizationLocked, createExecApprovalPolicySnapshot, loadExecApprovals, - resolveExecApprovalsPath, saveExecApprovals, } from "../infra/exec-approvals.js"; import type { ExecAutoReviewer } from "../infra/exec-auto-review.js"; import type { ExecHostResponse } from "../infra/exec-host.js"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; +import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; import { withEnvAsync } from "../test-utils/env.js"; import { buildSystemRunApprovalPlan } from "./invoke-system-run-plan.js"; import { handleSystemRunInvoke } from "./invoke-system-run.js"; @@ -79,11 +81,15 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { beforeEach(() => { previousOpenClawHome = process.env.OPENCLAW_HOME; process.env.OPENCLAW_HOME = sharedOpenClawHome; - fs.rmSync(resolveExecApprovalsPath(), { force: true }); + closeOpenClawStateDatabaseForTest(); + fs.rmSync(resolveOpenClawStateSqlitePath(), { force: true }); + execApprovalsStoreTesting.reset(); clearRuntimeConfigSnapshot(); }); afterEach(() => { + closeOpenClawStateDatabaseForTest(); + execApprovalsStoreTesting.reset(); clearRuntimeConfigSnapshot(); if (previousOpenClawHome === undefined) { delete process.env.OPENCLAW_HOME; diff --git a/src/node-host/invoke.test.ts b/src/node-host/invoke.test.ts index 369f69788ebe..c38b3445e533 100644 --- a/src/node-host/invoke.test.ts +++ b/src/node-host/invoke.test.ts @@ -8,6 +8,7 @@ import type { GatewayClient } from "../gateway/client.js"; import { saveExecApprovals, type ExecApprovalsSnapshot } from "../infra/exec-approvals.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { withEnvAsync } from "../test-utils/env.js"; import type { SkillBinsProvider } from "./invoke-types.js"; import { handleInvoke } from "./invoke.js"; @@ -674,8 +675,9 @@ describe("node host invoke", () => { it("forwards suppressNotifyOnExit on completed system.run events", async () => { const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-node-event-suppress-")); + const stateDir = path.join(tempHome, ".openclaw"); try { - await withEnvAsync({ OPENCLAW_HOME: tempHome }, async () => { + await withEnvAsync({ OPENCLAW_HOME: tempHome, OPENCLAW_STATE_DIR: stateDir }, async () => { saveExecApprovals({ version: 1, defaults: { security: "allowlist", ask: "on-miss", askFallback: "deny" }, @@ -736,6 +738,7 @@ describe("node host invoke", () => { }); }); } finally { + closeOpenClawStateDatabaseForTest(); fs.rmSync(tempHome, { recursive: true, force: true }); } }); diff --git a/src/plugin-sdk/exec-approvals-runtime.ts b/src/plugin-sdk/exec-approvals-runtime.ts index 5fba81af5bc3..d87da2b1b567 100644 --- a/src/plugin-sdk/exec-approvals-runtime.ts +++ b/src/plugin-sdk/exec-approvals-runtime.ts @@ -2,6 +2,8 @@ export { loadExecApprovals, + readExecApprovalsSnapshot, + resolveExecApprovalsDisplayPath, resolveExecApprovalsFromFile, resolveExecModePolicy, type ExecApprovalsFile, diff --git a/src/state/openclaw-state-snapshot-sanitizer.test.ts b/src/state/openclaw-state-snapshot-sanitizer.test.ts new file mode 100644 index 000000000000..9643fda9987a --- /dev/null +++ b/src/state/openclaw-state-snapshot-sanitizer.test.ts @@ -0,0 +1,106 @@ +import { DatabaseSync } from "node:sqlite"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { sanitizeOpenClawGlobalStateSnapshot } from "./openclaw-state-snapshot-sanitizer.js"; + +describe("OpenClaw state snapshot sanitizer", () => { + let database: DatabaseSync; + + beforeEach(() => { + database = new DatabaseSync(":memory:"); + database.exec(` + CREATE TABLE exec_approvals_config ( + config_key TEXT PRIMARY KEY, + raw_json TEXT NOT NULL, + socket_path TEXT, + has_socket_token INTEGER NOT NULL, + default_security TEXT, + default_ask TEXT, + default_ask_fallback TEXT, + auto_allow_skills INTEGER, + agent_count INTEGER NOT NULL, + allowlist_count INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL + ) STRICT; + `); + }); + + afterEach(() => database.close()); + + it("redacts the exec approvals socket token without dropping policy", () => { + const raw = JSON.stringify({ + version: 1, + socket: { path: "/tmp/exec.sock", token: "secret-token" }, + defaults: { security: "allowlist" }, + agents: {}, + }); + database + .prepare( + `INSERT INTO exec_approvals_config ( + config_key, raw_json, socket_path, has_socket_token, + default_security, default_ask, default_ask_fallback, auto_allow_skills, + agent_count, allowlist_count, updated_at_ms + ) VALUES (?, ?, '/stale.sock', 1, 'full', 'always', 'full', 1, 9, 9, 1)`, + ) + .run("current", raw); + + sanitizeOpenClawGlobalStateSnapshot(database); + + const row = database + .prepare("SELECT * FROM exec_approvals_config WHERE config_key = 'current'") + .get() as Record & { raw_json: string }; + expect(row).toMatchObject({ + socket_path: "/tmp/exec.sock", + has_socket_token: 0, + default_security: "allowlist", + default_ask: null, + default_ask_fallback: null, + auto_allow_skills: null, + agent_count: 0, + allowlist_count: 0, + }); + expect(JSON.parse(row.raw_json)).toEqual({ + version: 1, + socket: { path: "/tmp/exec.sock" }, + defaults: { security: "allowlist" }, + agents: {}, + }); + expect(row.raw_json).not.toContain("secret-token"); + }); + + it.each([ + ["invalid JSON", "{malformed-secret-token", "secret-token"], + [ + "invalid structure", + JSON.stringify({ version: 1, socket: [{ token: "nested-secret-token" }], agents: {} }), + "nested-secret-token", + ], + ])("replaces %s policy with a fail-closed secret-free document", (_label, raw, secret) => { + database + .prepare( + `INSERT INTO exec_approvals_config ( + config_key, raw_json, socket_path, has_socket_token, + default_security, default_ask, default_ask_fallback, auto_allow_skills, + agent_count, allowlist_count, updated_at_ms + ) VALUES (?, ?, '/stale.sock', 1, 'full', 'always', 'full', 1, 9, 9, 1)`, + ) + .run("current", raw); + + sanitizeOpenClawGlobalStateSnapshot(database); + + const row = database + .prepare("SELECT * FROM exec_approvals_config WHERE config_key = 'current'") + .get() as Record & { raw_json: string }; + expect(row).toMatchObject({ + socket_path: null, + has_socket_token: 0, + default_security: "deny", + default_ask: "off", + default_ask_fallback: "deny", + auto_allow_skills: 0, + agent_count: 0, + allowlist_count: 0, + }); + expect(JSON.parse(row.raw_json).defaults).toMatchObject({ security: "deny", ask: "off" }); + expect(row.raw_json).not.toContain(secret); + }); +}); diff --git a/src/state/openclaw-state-snapshot-sanitizer.ts b/src/state/openclaw-state-snapshot-sanitizer.ts index 3667260f2da0..702bac9f4cad 100644 --- a/src/state/openclaw-state-snapshot-sanitizer.ts +++ b/src/state/openclaw-state-snapshot-sanitizer.ts @@ -1,5 +1,23 @@ // Removes transient runtime state from restorable OpenClaw database snapshots. import type { DatabaseSync } from "node:sqlite"; +import { tryParsePersistedExecApprovals } from "../infra/exec-approvals-config.js"; +import type { ExecApprovalsFile } from "../infra/exec-approvals-core.js"; +import { projectionValues } from "../infra/exec-approvals-sqlite.js"; +import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js"; +import type { DB as OpenClawStateKyselyDatabase } from "./openclaw-state-db.generated.js"; + +type SnapshotSanitizerDatabase = Pick; + +const FAIL_CLOSED_EXEC_APPROVALS: ExecApprovalsFile = { + version: 1, + defaults: { + security: "deny", + ask: "off", + askFallback: "deny", + autoAllowSkills: false, + }, + agents: {}, +}; function tableExists(database: DatabaseSync, tableName: string): boolean { const row = database // sqlite-allow-raw -- Offline snapshot maintenance boundary. @@ -28,4 +46,31 @@ export function sanitizeOpenClawGlobalStateSnapshot(database: DatabaseSync): voi // that is still live, so restore cannot prolong its original retention. database.prepare("DELETE FROM plugin_blob_entries WHERE expires_at IS NOT NULL").run(); // sqlite-allow-raw -- Offline snapshot maintenance boundary. } + if (tableExists(database, "exec_approvals_config")) { + const stateDb = getNodeSqliteKysely(database); + const rows = executeSqliteQuerySync( + database, + stateDb.selectFrom("exec_approvals_config").select(["config_key", "raw_json"]), + ).rows; + for (const row of rows) { + let sanitized: ExecApprovalsFile = FAIL_CLOSED_EXEC_APPROVALS; + const parsed = tryParsePersistedExecApprovals(row.raw_json); + if (parsed) { + sanitized = structuredClone(parsed); + if (sanitized.socket) { + delete sanitized.socket.token; + } + } + executeSqliteQuerySync( + database, + stateDb + .updateTable("exec_approvals_config") + .set({ + raw_json: `${JSON.stringify(sanitized, null, 2)}\n`, + ...projectionValues(sanitized), + }) + .where("config_key", "=", row.config_key), + ); + } + } } diff --git a/test/scripts/check-database-first-legacy-stores.test.ts b/test/scripts/check-database-first-legacy-stores.test.ts index 2d30dbc69a7b..9ac9fbaec5ae 100644 --- a/test/scripts/check-database-first-legacy-stores.test.ts +++ b/test/scripts/check-database-first-legacy-stores.test.ts @@ -168,6 +168,44 @@ describe("check-database-first-legacy-stores", () => { expect(migrationViolations).toEqual([]); }); + it("keeps exec approvals legacy paths and stable URI identity in their exact owners", () => { + const runtimeViolations = collectDatabaseFirstLegacyStoreViolations( + ` + import fs from "node:fs"; + const legacyFilename = "exec-approvals.json"; + `, + "src/infra/exec-approvals-store.ts", + ); + const migrationViolations = collectDatabaseFirstLegacyStoreViolations( + ` + import fs from "node:fs"; + const legacyFilename = "exec-approvals.json"; + `, + "src/infra/state-migrations.exec-approvals.ts", + ); + const configViolations = collectDatabaseFirstLegacyStoreViolations( + 'const EXEC_APPROVALS_FILE = "exec-approvals.json";', + "src/infra/exec-approvals-config.ts", + ); + const stableUriViolations = collectDatabaseFirstLegacyStoreViolations( + 'export const EXEC_APPROVALS_POLICY_URI = "oc://exec-approvals.json";', + "extensions/policy/src/exec-approvals-uri.ts", + ); + const copiedUriViolations = collectDatabaseFirstLegacyStoreViolations( + 'const copied = "oc://exec-approvals.json";', + "extensions/policy/src/doctor/copied-uri.ts", + ); + + expect(runtimeViolations).toEqual([ + { kind: "legacy exec approvals filesystem import", line: 2 }, + { kind: "legacy exec approvals reference", line: 3 }, + ]); + expect(migrationViolations).toEqual([]); + expect(configViolations).toEqual([]); + expect(stableUriViolations).toEqual([]); + expect(copiedUriViolations).toEqual([{ kind: "legacy exec approvals reference", line: 1 }]); + }); + // Legacy paths and literal propagation. it.each( namedCases({