diff --git a/apps/macos/Sources/OpenClaw/ExecApprovals.swift b/apps/macos/Sources/OpenClaw/ExecApprovals.swift index 20a2e0644d00..0880e5feaf17 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovals.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovals.swift @@ -1,4 +1,5 @@ import CryptoKit +import Darwin import Foundation import OSLog import Security @@ -229,6 +230,12 @@ enum ExecApprovalsStore { private static let secureStateDirPermissions = 0o700 private static let fileLock = NSRecursiveLock() + private enum LegacyMigrationResult { + case notNeeded + case migrated + case blocked + } + private static func withFileLock(_ body: () throws -> T) rethrows -> T { self.fileLock.lock() defer { self.fileLock.unlock() } @@ -243,6 +250,195 @@ enum ExecApprovalsStore { OpenClawPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path } + private static func legacyStateDirURLs() -> [URL] { + if let home = OpenClawEnv.path("OPENCLAW_HOME") { + var urls = [ + URL(fileURLWithPath: home, isDirectory: true) + .appendingPathComponent(".openclaw", isDirectory: true), + ] + let osHomeURL = FileManager().homeDirectoryForCurrentUser + .appendingPathComponent(".openclaw", isDirectory: true) + if !urls.contains(where: { + $0.standardizedFileURL.path == osHomeURL.standardizedFileURL.path + }) { + urls.append(osHomeURL) + } + return urls + } + return [ + FileManager().homeDirectoryForCurrentUser + .appendingPathComponent(".openclaw", isDirectory: true), + ] + } + + private static func legacyFileURLIfPending() -> URL? { + guard OpenClawEnv.path("OPENCLAW_STATE_DIR") != nil else { return nil } + let targetURL = self.fileURL() + for stateDirURL in self.legacyStateDirURLs() { + let legacyURL = stateDirURL + .appendingPathComponent("exec-approvals.json", isDirectory: false) + guard legacyURL.standardizedFileURL.path != targetURL.standardizedFileURL.path else { + continue + } + guard FileManager().fileExists(atPath: legacyURL.path) else { continue } + guard !FileManager().fileExists(atPath: targetURL.path) else { return nil } + return legacyURL + } + return nil + } + + private static func unmigratedLegacyFallbackFile() -> ExecApprovalsFile { + ExecApprovalsFile( + version: 1, + socket: nil, + defaults: ExecApprovalsDefaults( + security: .deny, + ask: .always, + askFallback: .deny, + autoAllowSkills: nil), + agents: [:]) + } + + private static func isLegacyDefaultSocketPath(_ raw: String, legacyFileURL: URL) -> Bool { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return true } + let expanded = self.expandPath(trimmed) + let legacySocket = legacyFileURL.deletingLastPathComponent() + .appendingPathComponent("exec-approvals.sock", isDirectory: false) + .path + return URL(fileURLWithPath: expanded).standardizedFileURL.path + == URL(fileURLWithPath: legacySocket).standardizedFileURL.path + } + + private static func hasSymlinkParent(_ url: URL) -> Bool { + var cursor = url.deletingLastPathComponent() + let manager = FileManager() + while true { + var isDirectory = ObjCBool(false) + if manager.fileExists(atPath: cursor.path, isDirectory: &isDirectory) { + if (try? manager.destinationOfSymbolicLink(atPath: cursor.path)) != nil { + return true + } + } + let parent = cursor.deletingLastPathComponent() + if parent.path == cursor.path { return false } + cursor = parent + } + } + + private static func archiveMigratedLegacyFile(_ legacyURL: URL) throws -> URL { + let manager = FileManager() + var archiveURL = URL(fileURLWithPath: "\(legacyURL.path).migrated") + if manager.fileExists(atPath: archiveURL.path) { + archiveURL = URL(fileURLWithPath: "\(archiveURL.path)-\(UUID().uuidString)") + } + try manager.moveItem(at: legacyURL, to: archiveURL) + return archiveURL + } + + private static func writeMigratedFileExclusively(_ data: Data, to targetURL: URL) throws -> Bool { + let tempURL = targetURL.deletingLastPathComponent() + .appendingPathComponent(".exec-approvals.migration.\(UUID().uuidString)") + let fd = open(tempURL.path, O_WRONLY | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR) + if fd == -1 { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + var closed = false + defer { + if !closed { close(fd) } + } + do { + try data.withUnsafeBytes { rawBuffer in + guard let base = rawBuffer.baseAddress else { return } + var offset = 0 + while offset < rawBuffer.count { + let written = Darwin.write( + fd, + base.advanced(by: offset), + rawBuffer.count - offset) + if written < 0 { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + offset += written + } + } + close(fd) + closed = true + let copied = copyfile( + tempURL.path, + targetURL.path, + nil, + copyfile_flags_t(COPYFILE_EXCL)) + if copied == -1 { + if errno == EEXIST { + try? FileManager().removeItem(at: tempURL) + return false + } + try? FileManager().removeItem(at: targetURL) + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + try? FileManager().removeItem(at: tempURL) + return true + } catch { + try? FileManager().removeItem(at: tempURL) + throw error + } + } + + private static func migrateLegacyFileIfNeeded() -> LegacyMigrationResult { + guard let legacyURL = self.legacyFileURLIfPending() else { return .notNeeded } + let targetURL = self.fileURL() + do { + if self.hasSymlinkParent(targetURL) { + throw NSError(domain: "ExecApprovals", code: 10, userInfo: [ + NSLocalizedDescriptionKey: "target path has a symlink parent", + ]) + } + let data = try Data(contentsOf: legacyURL) + var file = try JSONDecoder().decode(ExecApprovalsFile.self, from: data) + guard file.version == 1 else { + throw NSError(domain: "ExecApprovals", code: 11, userInfo: [ + NSLocalizedDescriptionKey: "unsupported legacy approvals version", + ]) + } + file = self.normalizeIncoming(file) + let rawSocketPath = file.socket?.path? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if self.isLegacyDefaultSocketPath(rawSocketPath, legacyFileURL: legacyURL) { + if file.socket == nil { + file.socket = ExecApprovalsSocketConfig(path: nil, token: nil) + } + file.socket?.path = self.socketPath() + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let migrated = try encoder.encode(file) + self.ensureSecureStateDirectory() + try FileManager().createDirectory( + at: targetURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + if FileManager().fileExists(atPath: targetURL.path) { return .notNeeded } + let created = try self.writeMigratedFileExclusively(migrated, to: targetURL) + if !created { return .notNeeded } + try? FileManager().setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: targetURL.path) + do { + _ = try self.archiveMigratedLegacyFile(legacyURL) + } catch { + self.logger + .warning( + "exec approvals legacy archive failed: \(error.localizedDescription, privacy: .public)") + } + return .migrated + } catch { + self.logger + .error( + "exec approvals legacy migration failed: \(error.localizedDescription, privacy: .public)") + return .blocked + } + } + static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile { let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" @@ -278,6 +474,14 @@ enum ExecApprovalsStore { static func readSnapshot() -> ExecApprovalsSnapshot { self.withFileLock { + if self.legacyFileURLIfPending() != nil { + let file = self.unmigratedLegacyFallbackFile() + return ExecApprovalsSnapshot( + path: self.fileURL().path, + exists: false, + hash: self.hashRaw(nil), + file: file) + } let url = self.fileURL() guard FileManager().fileExists(atPath: url.path) else { return ExecApprovalsSnapshot( @@ -322,6 +526,14 @@ enum ExecApprovalsStore { static func loadFile() -> ExecApprovalsFile { self.withFileLock { + if self.legacyFileURLIfPending() != nil { + switch self.migrateLegacyFileIfNeeded() { + case .migrated, .notNeeded: + break + case .blocked: + return self.unmigratedLegacyFallbackFile() + } + } let url = self.fileURL() guard FileManager().fileExists(atPath: url.path) else { return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) @@ -361,6 +573,14 @@ enum ExecApprovalsStore { static func ensureFile() -> ExecApprovalsFile { self.withFileLock { + if self.legacyFileURLIfPending() != nil { + switch self.migrateLegacyFileIfNeeded() { + case .migrated, .notNeeded: + break + case .blocked: + return self.unmigratedLegacyFallbackFile() + } + } self.ensureSecureStateDirectory() let url = self.fileURL() let existed = FileManager().fileExists(atPath: url.path) diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift index eaaa452cfa5b..4ef941237795 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift @@ -16,6 +16,23 @@ struct ExecApprovalsStoreRefactorTests { } } + private func withTempHomeAndStateDir( + _ body: @escaping @Sendable (URL, URL) async throws -> Void) async throws + { + let root = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-home-state-\(UUID().uuidString)", isDirectory: true) + let home = root.appendingPathComponent("home", isDirectory: true) + let stateDir = root.appendingPathComponent("state", isDirectory: true) + defer { try? FileManager().removeItem(at: root) } + + try await TestIsolation.withEnvValues([ + "OPENCLAW_HOME": home.path, + "OPENCLAW_STATE_DIR": stateDir.path, + ]) { + try await body(home, stateDir) + } + } + @Test func `ensure file skips rewrite when unchanged`() async throws { try await self.withTempStateDir { _ in @@ -30,6 +47,50 @@ struct ExecApprovalsStoreRefactorTests { } } + @Test + func `ensure file migrates default approvals into custom state dir`() 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 file = ExecApprovalsStore.ensureFile() + let targetURL = ExecApprovalsStore.fileURL() + + #expect(targetURL.path == stateDir.appendingPathComponent("exec-approvals.json").path) + #expect(FileManager().fileExists(atPath: targetURL.path)) + #expect(file.socket?.path == stateDir.appendingPathComponent("exec-approvals.sock").path) + #expect(file.socket?.token == "legacy-token") + #expect(file.defaults?.security == .deny) + #expect(file.defaults?.ask == .always) + #expect(file.agents?["main"]?.allowlist?.map(\.pattern) == ["git status"]) + #expect(!FileManager().fileExists(atPath: legacyFile.path)) + #expect(FileManager().fileExists(atPath: "\(legacyFile.path).migrated")) + } + } + @Test func `update allowlist accepts basename pattern`() async throws { try await self.withTempStateDir { _ in diff --git a/docs/cli/approvals.md b/docs/cli/approvals.md index 2a59d974b9fc..c807b1215d3e 100644 --- a/docs/cli/approvals.md +++ b/docs/cli/approvals.md @@ -27,7 +27,7 @@ Use it when you want to: - inspect the local requested policy, host approvals file, and effective merge - apply a local preset such as YOLO or deny-all -- synchronize local `tools.exec.*` and local `~/.openclaw/exec-approvals.json` +- synchronize local `tools.exec.*` and the local host approvals file Examples: @@ -183,7 +183,9 @@ Targeting notes: - `--node` uses the same resolver as `openclaw nodes` (id, name, ip, or id prefix). - `--agent` defaults to `"*"`, which applies to all agents. - The node host must advertise `system.execApprovals.get/set` (macOS app or headless node host). -- Approvals files are stored per host at `~/.openclaw/exec-approvals.json`. +- 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). ## Related diff --git a/docs/cli/node.md b/docs/cli/node.md index 1f846d276b23..1a906473c11e 100644 --- a/docs/cli/node.md +++ b/docs/cli/node.md @@ -162,7 +162,8 @@ The node host stores its node id, token, display name, and gateway connection in `system.run` is gated by local exec approvals: -- `~/.openclaw/exec-approvals.json` +- `$OPENCLAW_STATE_DIR/exec-approvals.json`, or + `~/.openclaw/exec-approvals.json` when the variable is unset - [Exec approvals](/tools/exec-approvals) - `openclaw approvals --node ` (edit from the Gateway) diff --git a/docs/gateway/security/audit-checks.md b/docs/gateway/security/audit-checks.md index 394f1271edd3..945b309f9f01 100644 --- a/docs/gateway/security/audit-checks.md +++ b/docs/gateway/security/audit-checks.md @@ -93,7 +93,7 @@ exhaustive): | `tools.exec.host_sandbox_no_sandbox_agents` | warn | Per-agent `exec host=sandbox` fails closed when sandbox is off | `agents.list[].tools.exec.host`, `agents.list[].sandbox.mode` | no | | `tools.exec.security_full_configured` | warn/critical | Host exec is running with `security="full"` | `tools.exec.security`, `agents.list[].tools.exec.security` | no | | `tools.exec.fs_tools_disabled_but_exec_enabled` | warn | Filesystem tool policy does not make shell execution read-only | `tools.deny`, `agents.list[].tools.deny`, `agents.*.sandbox.workspaceAccess` | no | -| `tools.exec.auto_allow_skills_enabled` | warn | Exec approvals trust skill bins implicitly | `~/.openclaw/exec-approvals.json` | no | +| `tools.exec.auto_allow_skills_enabled` | warn | Exec approvals trust skill bins implicitly | host approvals file | no | | `tools.exec.allowlist_interpreter_without_strict_inline_eval` | warn | Interpreter allowlists permit inline eval without forced reapproval | `tools.exec.strictInlineEval`, `agents.list[].tools.exec.strictInlineEval`, exec approvals allowlist | no | | `tools.exec.safe_bins_interpreter_unprofiled` | warn | Interpreter/runtime bins in `safeBins` without explicit profiles broaden exec risk | `tools.exec.safeBins`, `tools.exec.safeBinProfiles`, `agents.list[].tools.exec.*` | no | | `tools.exec.safe_bins_broad_behavior` | warn | Broad-behavior tools in `safeBins` weaken the low-risk stdin-filter trust model | `tools.exec.safeBins`, `agents.list[].tools.exec.safeBins` | no | diff --git a/docs/tools/exec-approvals-advanced.md b/docs/tools/exec-approvals-advanced.md index b3c22e3d4873..5b6911c2e0ee 100644 --- a/docs/tools/exec-approvals-advanced.md +++ b/docs/tools/exec-approvals-advanced.md @@ -115,7 +115,7 @@ Configuration location: - `safeBins` comes from config (`tools.exec.safeBins` or per-agent `agents.list[].tools.exec.safeBins`). - `safeBinTrustedDirs` comes from config (`tools.exec.safeBinTrustedDirs` or per-agent `agents.list[].tools.exec.safeBinTrustedDirs`). - `safeBinProfiles` comes from config (`tools.exec.safeBinProfiles` or per-agent `agents.list[].tools.exec.safeBinProfiles`). Per-agent profile keys override global keys. -- allowlist entries live in host-local `~/.openclaw/exec-approvals.json` under `agents..allowlist` (or via Control UI / `openclaw approvals allowlist ...`). +- allowlist entries live in the host-local approvals file 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. diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index baae33ad5ac1..08e7c50533a9 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -23,7 +23,7 @@ Codex Guardian mapping, and ACPX harness permissions, see Effective policy is the **stricter** of `tools.exec.*` and approvals defaults; 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 `~/.openclaw/exec-approvals.json` keeps +host-local `ask: "always"` in the execution host approvals file keeps prompting even if session or config defaults request `ask: "on-miss"`. @@ -73,12 +73,20 @@ Exec approvals are enforced locally on the execution host: ## Settings and storage -Approvals live in a local JSON file on the execution host: +Approvals live in a local JSON file on the execution host. When +`OPENCLAW_STATE_DIR` is set, the file follows that state directory; +otherwise it uses the default OpenClaw state directory: ```text +$OPENCLAW_STATE_DIR/exec-approvals.json +# otherwise ~/.openclaw/exec-approvals.json ``` +The default approval socket follows the same root: +`$OPENCLAW_STATE_DIR/exec-approvals.sock`, or +`~/.openclaw/exec-approvals.sock` when the variable is unset. + Example schema: ```json @@ -210,7 +218,7 @@ agent under `agents.list[].tools.exec.commandHighlighting`. If you want host exec to run without approval prompts, you must open **both** policy layers - requested exec policy in OpenClaw config (`tools.exec.*`) **and** host-local approvals policy in -`~/.openclaw/exec-approvals.json`. +the execution host approvals file. OpenClaw defaults omitted `askFallback` to `deny`. Set host `askFallback` to `full` explicitly when a no-UI approval prompt should @@ -281,8 +289,7 @@ openclaw exec-policy preset yolo That local shortcut updates both: - Local `tools.exec.host/security/ask`. -- Local `~/.openclaw/exec-approvals.json` defaults, including - `askFallback: "full"`. +- Local approvals file defaults, including `askFallback: "full"`. It is intentionally local-only. To change gateway-host or node-host approvals remotely, use `openclaw approvals set --gateway` or @@ -425,7 +432,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 `~/.openclaw/exec-approvals.json` directly. +edit its local approvals file directly. CLI: `openclaw approvals` supports gateway or node editing - see [Approvals CLI](/cli/approvals). diff --git a/docs/tools/exec.md b/docs/tools/exec.md index 72e14f1def6a..1a37b9fb3f76 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -47,7 +47,7 @@ Where to execute. `auto` resolves to `sandbox` when a sandbox runtime is active Ignored for normal tool calls. `gateway` / `node` security is controlled by -`tools.exec.security` and `~/.openclaw/exec-approvals.json`; elevated mode can +`tools.exec.security` and the host approvals file; elevated mode can force `security=full` only when the operator explicitly grants elevated access. @@ -75,7 +75,7 @@ Notes: - `tools.exec.mode` is the normalized policy knob. Values are `deny`, `allowlist`, `ask`, `auto`, and `full`. `auto` runs deterministic allowlist/safe-bin matches directly and routes every remaining exec approval case through OpenClaw's native auto reviewer before asking a human. `ask` / `ask=always` still asks a human every time. - With no extra config, `host=auto` still "just works": no sandbox means it resolves to `gateway`; a live sandbox means it stays in the sandbox. - `elevated` escapes the sandbox onto the configured host path: `gateway` by default, or `node` when `tools.exec.host=node` (or the session default is `host=node`). It is only available when elevated access is enabled for the current session/provider. -- `gateway`/`node` approvals are controlled by `~/.openclaw/exec-approvals.json`. +- `gateway`/`node` approvals are controlled by the host approvals file. - `node` requires a paired node (companion app or headless node host). - If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one. - `exec host=node` is the only shell-execution path for nodes; the legacy `nodes.run` wrapper has been removed. @@ -114,7 +114,7 @@ Notes: - `tools.exec.host` (default: `auto`; resolves to `sandbox` when sandbox runtime is active, `gateway` otherwise) - `tools.exec.security` (default: `deny` for sandbox, `full` for gateway + node when unset) - `tools.exec.ask` (default: `off`) -- No-approval host exec is the default for gateway + node. If you want approvals/allowlist behavior, tighten both `tools.exec.*` and the host `~/.openclaw/exec-approvals.json`; see [Exec approvals](/tools/exec-approvals#yolo-mode-no-approval). +- No-approval host exec is the default for gateway + node. If you want approvals/allowlist behavior, tighten both `tools.exec.*` and the host approvals file; see [Exec approvals](/tools/exec-approvals#yolo-mode-no-approval). - YOLO comes from the host-policy defaults (`security=full`, `ask=off`), not from `host=auto`. If you want to force gateway or node routing, set `tools.exec.host` or use `/exec host=...`. - In `security=full` plus `ask=off` mode, host exec follows the configured policy directly; there is no extra heuristic command-obfuscation prefilter or script-preflight rejection layer. - `tools.exec.node` (default: unset) diff --git a/src/agents/bash-tools.exec-host-shared.ts b/src/agents/bash-tools.exec-host-shared.ts index 32ba67d30cb5..b777ce89a580 100644 --- a/src/agents/bash-tools.exec-host-shared.ts +++ b/src/agents/bash-tools.exec-host-shared.ts @@ -16,6 +16,7 @@ import { maxAsk, resolveExecApprovalAllowedDecisions, resolveExecApprovals, + resolveExecApprovalsTranscriptPath, type ExecAsk, type ExecApprovalDecision, type ExecSecurity, @@ -437,7 +438,7 @@ export function buildHeadlessExecApprovalDeniedMessage(params: { return [ `exec denied: ${runLabel} cannot wait for interactive exec approval.`, `Effective host exec policy: security=${params.security} ask=${params.ask} askFallback=${params.askFallback}`, - "Stricter values from tools.exec and ~/.openclaw/exec-approvals.json both apply.", + `Stricter values from tools.exec and ${resolveExecApprovalsTranscriptPath()} both apply.`, "Fix one of these:", '- align both files to security="full" and ask="off" for trusted local automation', "- keep allowlist mode and add an explicit allowlist entry for this command", diff --git a/src/agents/bash-tools.exec.security-floor.test.ts b/src/agents/bash-tools.exec.security-floor.test.ts index 530934e61ff7..14b3e189c633 100644 --- a/src/agents/bash-tools.exec.security-floor.test.ts +++ b/src/agents/bash-tools.exec.security-floor.test.ts @@ -20,22 +20,39 @@ vi.mock("./tools/gateway.js", () => ({ function installAllowlistedGogFixture(root: string): string { const binDir = path.join(root, "bin"); - const openclawDir = path.join(root, ".openclaw"); fs.mkdirSync(binDir, { recursive: true }); - fs.mkdirSync(openclawDir, { recursive: true }); const gogPath = path.join(binDir, "gog"); fs.writeFileSync(gogPath, "#!/bin/sh\nprintf 'gog-ok %s\\n' \"$*\"\n", { mode: 0o755 }); - fs.writeFileSync( - path.join(openclawDir, "exec-approvals.json"), - `${JSON.stringify({ - version: 1, - defaults: { security: "allowlist", ask: "off", askFallback: "allowlist" }, - agents: { "*": { allowlist: [{ pattern: gogPath }] } }, - })}\n`, - ); + writeExecApprovalsFixture(root, { + version: 1, + defaults: { security: "allowlist", ask: "off", askFallback: "allowlist" }, + agents: { "*": { allowlist: [{ pattern: gogPath }] } }, + }); 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 writeDenyExecApprovalsFixture(root: string): void { + writeExecApprovalsFixture(root, { + version: 1, + defaults: { security: "deny", ask: "off" }, + agents: {}, + }); +} + +function writeFullAskExecApprovalsFixture(root: string): void { + writeExecApprovalsFixture(root, { + version: 1, + defaults: { security: "full", ask: "always" }, + agents: {}, + }); +} + describe("exec security floor", () => { let envSnapshot: ReturnType; let tempRoot: string | undefined; @@ -189,12 +206,7 @@ describe("exec security floor", () => { }); it("does not let host approval defaults deny implicit sandbox execution", async () => { - const openclawDir = path.join(tempRoot ?? os.tmpdir(), ".openclaw"); - fs.mkdirSync(openclawDir, { recursive: true }); - fs.writeFileSync( - path.join(openclawDir, "exec-approvals.json"), - `${JSON.stringify({ version: 1, defaults: { security: "deny", ask: "off" }, agents: {} })}\n`, - ); + writeDenyExecApprovalsFixture(tempRoot ?? os.tmpdir()); const buildExecSpec = vi.fn(async () => ({ argv: ["/bin/sh", "-lc", "printf sandbox-ok"], env: process.env, @@ -273,12 +285,7 @@ describe("exec security floor", () => { }); it("intersects normalized gateway auto mode with host approval deny defaults", async () => { - const openclawDir = path.join(tempRoot ?? os.tmpdir(), ".openclaw"); - fs.mkdirSync(openclawDir, { recursive: true }); - fs.writeFileSync( - path.join(openclawDir, "exec-approvals.json"), - `${JSON.stringify({ version: 1, defaults: { security: "deny", ask: "off" }, agents: {} })}\n`, - ); + writeDenyExecApprovalsFixture(tempRoot ?? os.tmpdir()); const autoReviewer = vi.fn(async () => ({ decision: "allow-once", risk: "low", @@ -300,16 +307,11 @@ describe("exec security floor", () => { }); it("uses agent-scoped host policy when clamping normalized modes", async () => { - const openclawDir = path.join(tempRoot ?? os.tmpdir(), ".openclaw"); - fs.mkdirSync(openclawDir, { recursive: true }); - fs.writeFileSync( - path.join(openclawDir, "exec-approvals.json"), - `${JSON.stringify({ - version: 1, - defaults: { security: "deny", ask: "off" }, - agents: { main: { security: "full", ask: "off" } }, - })}\n`, - ); + writeExecApprovalsFixture(tempRoot ?? os.tmpdir(), { + version: 1, + defaults: { security: "deny", ask: "off" }, + agents: { main: { security: "full", ask: "off" } }, + }); const tool = createExecTool({ host: "gateway", mode: "full", @@ -326,12 +328,7 @@ describe("exec security floor", () => { }); it("preserves host ask floors for elevated full gateway exec", async () => { - const openclawDir = path.join(tempRoot ?? os.tmpdir(), ".openclaw"); - fs.mkdirSync(openclawDir, { recursive: true }); - fs.writeFileSync( - path.join(openclawDir, "exec-approvals.json"), - `${JSON.stringify({ version: 1, defaults: { security: "full", ask: "always" }, agents: {} })}\n`, - ); + writeFullAskExecApprovalsFixture(tempRoot ?? os.tmpdir()); const calls: string[] = []; vi.mocked(callGatewayTool).mockImplementation(async (method) => { calls.push(method); diff --git a/src/cli/program/config-guard.test.ts b/src/cli/program/config-guard.test.ts index 95c4a2b0a17c..2e71e4343e6b 100644 --- a/src/cli/program/config-guard.test.ts +++ b/src/cli/program/config-guard.test.ts @@ -227,6 +227,22 @@ describe("ensureConfigReady", () => { }); }); + it("runs doctor flow before agent commands when default exec approvals must move to a custom state dir", async () => { + const root = useTempOpenClawHome(); + const stateDir = path.join(root, "custom-state"); + process.env.OPENCLAW_STATE_DIR = stateDir; + writeStateMarker(root, "exec-approvals.json"); + + await runEnsureConfigReady(["agent"]); + + expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledOnce(); + expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledWith({ + migrateState: true, + migrateLegacyConfig: false, + invalidConfigNote: false, + }); + }); + it.each([ ["Discord model picker preferences", "discord/model-picker-preferences.json"], ["Discord thread bindings", "discord/thread-bindings.json"], diff --git a/src/cli/program/config-guard.ts b/src/cli/program/config-guard.ts index 356118b0a22f..97df3193986e 100644 --- a/src/cli/program/config-guard.ts +++ b/src/cli/program/config-guard.ts @@ -97,6 +97,20 @@ function hasBundledChannelLegacyStateMigrationInputs(stateDir: string, oauthDir: return dirHasFile(oauthDir, isLegacyWhatsAppAuthFile); } +function hasLegacyExecApprovalsMigrationInput(stateDir: string): boolean { + if (!process.env.OPENCLAW_STATE_DIR?.trim()) { + return false; + } + const homeDir = resolveRequiredHomeDir(process.env, os.homedir); + const sourcePath = path.join(homeDir, ".openclaw", "exec-approvals.json"); + const targetPath = path.join(stateDir, "exec-approvals.json"); + return ( + path.resolve(sourcePath) !== path.resolve(targetPath) && + fileOrDirExists(sourcePath) && + !fileOrDirExists(targetPath) + ); +} + function hasLegacyStateMigrationInputs(): boolean { // Only run migration prompts when old state actually exists in known legacy locations. const stateDir = resolveStateDir(process.env, os.homedir); @@ -118,7 +132,9 @@ function hasLegacyStateMigrationInputs(): boolean { path.join(stateDir, "plugins", "installs.json"), path.join(stateDir, "sessions"), path.join(stateDir, "tasks", "runs.sqlite"), - ].some(fileOrDirExists) || hasBundledChannelLegacyStateMigrationInputs(stateDir, oauthDir) + ].some(fileOrDirExists) || + hasBundledChannelLegacyStateMigrationInputs(stateDir, oauthDir) || + hasLegacyExecApprovalsMigrationInput(stateDir) ); } diff --git a/src/commands/doctor-config-preflight.state-migration.test.ts b/src/commands/doctor-config-preflight.state-migration.test.ts index c662a7bf0926..aee93bdd8953 100644 --- a/src/commands/doctor-config-preflight.state-migration.test.ts +++ b/src/commands/doctor-config-preflight.state-migration.test.ts @@ -83,7 +83,7 @@ describe("runDoctorConfigPreflight state migration", () => { }); }); - it("limits invalid-config preflight to task sidecar migration", async () => { + it("limits invalid-config preflight to config-independent state migration", async () => { vi.clearAllMocks(); readConfigFileSnapshot.mockResolvedValueOnce({ exists: true, diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts index 9e8a2781bad6..d1cf5117ebab 100644 --- a/src/commands/doctor-security.ts +++ b/src/commands/doctor-security.ts @@ -14,6 +14,7 @@ import { isLoopbackHost, resolveGatewayBindHost } from "../gateway/net.js"; import { resolveExecPolicyScopeSnapshot } from "../infra/exec-approvals-effective.js"; import { loadExecApprovals, + resolveExecApprovalsDisplayPath, type ExecAsk, type ExecMode, type ExecSecurity, @@ -246,7 +247,7 @@ export async function collectSecurityWarnings( if (cfg.approvals?.exec?.enabled === false) { warnings.push( "- Note: approvals.exec.enabled=false disables approval forwarding only.", - " Host exec gating still comes from ~/.openclaw/exec-approvals.json.", + ` Host exec gating still comes from ${resolveExecApprovalsDisplayPath()}.`, ` Check local policy with: ${formatCliCommand("openclaw approvals get --gateway")}`, ); } diff --git a/src/commands/doctor-state-migrations.test.ts b/src/commands/doctor-state-migrations.test.ts index 045a5faca9b0..c2ae3163008a 100644 --- a/src/commands/doctor-state-migrations.test.ts +++ b/src/commands/doctor-state-migrations.test.ts @@ -28,6 +28,7 @@ import { detectLegacyStateMigrations, resetAutoMigrateLegacyStateDirForTest, resetAutoMigrateLegacyStateForTest, + resetAutoMigrateLegacyTaskStateSidecarsForTest, runLegacyStateMigrations, } from "./doctor-state-migrations.js"; @@ -245,6 +246,7 @@ async function runTelegramAllowFromMigration(params: { root: string; cfg: OpenCl afterEach(async () => { resetAutoMigrateLegacyStateForTest(); resetAutoMigrateLegacyStateDirForTest(); + resetAutoMigrateLegacyTaskStateSidecarsForTest(); closeOpenClawStateDatabaseForTest(); setMaxPluginStateEntriesPerPluginForTests(); resetPluginStateStoreForTests(); @@ -1857,6 +1859,141 @@ describe("doctor legacy state migrations", () => { }); }); + it("migrates default exec approvals into an explicit state dir", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "custom-state"); + const sourcePath = path.join(root, ".openclaw", "exec-approvals.json"); + const legacySocketPath = path.join(root, ".openclaw", "exec-approvals.sock"); + const targetPath = path.join(stateDir, "exec-approvals.json"); + const targetSocketPath = path.join(stateDir, "exec-approvals.sock"); + writeJson5(sourcePath, { + version: 1, + socket: { + path: legacySocketPath, + token: "legacy-token", + }, + defaults: { + security: "deny", + ask: "always", + }, + agents: { + main: { + allowlist: [{ pattern: "git status" }], + }, + }, + }); + + const detected = await detectLegacyStateMigrations({ + cfg: {}, + env: { OPENCLAW_STATE_DIR: stateDir } as NodeJS.ProcessEnv, + homedir: () => root, + }); + expect(detected.preview).toContain(`- Exec approvals: ${sourcePath} → ${targetPath}`); + + const result = await runLegacyStateMigrations({ detected }); + + expect(result.warnings).toStrictEqual([]); + expect(result.changes).toContain(`Migrated exec approvals → ${targetPath}`); + expect(result.changes).toContain(`Archived legacy exec approvals → ${sourcePath}.migrated`); + expect(fs.existsSync(sourcePath)).toBe(false); + expect(fs.existsSync(`${sourcePath}.migrated`)).toBe(true); + const migrated = JSON.parse(fs.readFileSync(targetPath, "utf8")) as { + socket?: { path?: string; token?: string }; + defaults?: Record; + agents?: Record> }>; + }; + expect(migrated.socket?.path).toBe(targetSocketPath); + expect(migrated.socket?.token).toBe("legacy-token"); + expect(migrated.defaults).toEqual({ + security: "deny", + ask: "always", + }); + expect(migrated.agents?.main?.allowlist?.[0]?.pattern).toBe("git status"); + }); + + it("skips exec approvals migration when the target appears after detection", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "custom-state"); + const sourcePath = path.join(root, ".openclaw", "exec-approvals.json"); + const targetPath = path.join(stateDir, "exec-approvals.json"); + writeJson5(sourcePath, { + version: 1, + socket: { + token: "legacy-token", + }, + defaults: { + security: "deny", + }, + }); + const detected = await detectLegacyStateMigrations({ + cfg: {}, + env: { OPENCLAW_STATE_DIR: stateDir } as NodeJS.ProcessEnv, + homedir: () => root, + }); + writeJson5(targetPath, { + version: 1, + socket: { + path: path.join(stateDir, "exec-approvals.sock"), + token: "current-token", + }, + defaults: { + security: "full", + ask: "off", + }, + }); + + const result = await runLegacyStateMigrations({ detected }); + + expect(result.warnings).toStrictEqual([]); + expect(result.changes).not.toContain(`Migrated exec approvals → ${targetPath}`); + const current = JSON.parse(fs.readFileSync(targetPath, "utf8")) as { + socket?: { token?: string }; + defaults?: Record; + }; + expect(current.socket?.token).toBe("current-token"); + expect(current.defaults).toEqual({ + security: "full", + ask: "off", + }); + }); + + it("auto-migrates exec approvals without a valid config snapshot", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "custom-state"); + const sourcePath = path.join(root, ".openclaw", "exec-approvals.json"); + const targetPath = path.join(stateDir, "exec-approvals.json"); + writeJson5(sourcePath, { + version: 1, + socket: { + token: "legacy-token", + }, + defaults: { + security: "deny", + ask: "always", + }, + }); + + const result = await autoMigrateLegacyTaskStateSidecars({ + env: { OPENCLAW_STATE_DIR: stateDir } as NodeJS.ProcessEnv, + homedir: () => root, + }); + + expect(result.warnings).toStrictEqual([]); + expect(result.changes).toContain(`Migrated exec approvals → ${targetPath}`); + expect(result.changes).toContain(`Archived legacy exec approvals → ${sourcePath}.migrated`); + expect(fs.existsSync(sourcePath)).toBe(false); + expect(fs.existsSync(`${sourcePath}.migrated`)).toBe(true); + const migrated = JSON.parse(fs.readFileSync(targetPath, "utf8")) as { + socket?: { token?: string }; + defaults?: Record; + }; + expect(migrated.socket?.token).toBe("legacy-token"); + expect(migrated.defaults).toEqual({ + security: "deny", + ask: "always", + }); + }); + it("keeps the plugin-state sidecar when shared state already has a conflicting row", async () => { const root = await makeTempRoot(); const sourcePath = writeLegacyPluginStateSidecar(root); diff --git a/src/commands/doctor.e2e-harness.ts b/src/commands/doctor.e2e-harness.ts index 772dc1b66160..80b76d7474c1 100644 --- a/src/commands/doctor.e2e-harness.ts +++ b/src/commands/doctor.e2e-harness.ts @@ -241,6 +241,11 @@ function createLegacyStateMigrationDetectionResult(params?: { sessionPath: "/tmp/state/session-delivery-queue", hasLegacy: false, }, + execApprovals: { + sourcePath: "/tmp/state/exec-approvals.legacy.json", + targetPath: "/tmp/state/exec-approvals.json", + hasLegacy: false, + }, channelPlans: { hasLegacy: false, plans: [], diff --git a/src/infra/exec-approvals-effective.ts b/src/infra/exec-approvals-effective.ts index b8e13a230cb4..7fb55b08f8ee 100644 --- a/src/infra/exec-approvals-effective.ts +++ b/src/infra/exec-approvals-effective.ts @@ -5,6 +5,7 @@ import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; import { DEFAULT_EXEC_APPROVAL_ASK_FALLBACK, resolveExecApprovalAllowedDecisions, + resolveExecApprovalsDisplayPath, type ExecApprovalDecision, maxAsk, minSecurity, @@ -20,7 +21,6 @@ import { const DEFAULT_REQUESTED_SECURITY: ExecSecurity = "full"; const DEFAULT_REQUESTED_ASK: ExecAsk = "off"; -const DEFAULT_HOST_PATH = "~/.openclaw/exec-approvals.json"; const REQUESTED_DEFAULT_LABEL = { security: DEFAULT_REQUESTED_SECURITY, ask: DEFAULT_REQUESTED_ASK, @@ -367,7 +367,7 @@ export function resolveExecPolicyScopeSnapshot(params: { ask: requestedPolicy.ask, }, }); - const hostPath = params.hostPath ?? DEFAULT_HOST_PATH; + const hostPath = params.hostPath ?? resolveExecApprovalsDisplayPath(); const effectiveSecurity = minSecurity(requestedPolicy.security, resolved.agent.security); const effectiveAsk = maxAsk(requestedPolicy.ask, resolved.agent.ask); const effectiveAskFallback = minSecurity(effectiveSecurity, resolved.agent.askFallback); diff --git a/src/infra/exec-approvals-policy.test.ts b/src/infra/exec-approvals-policy.test.ts index ea6a903f3a98..dba94fa8979f 100644 --- a/src/infra/exec-approvals-policy.test.ts +++ b/src/infra/exec-approvals-policy.test.ts @@ -1,4 +1,5 @@ // Tests execution approval policy matching and persistence. +import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; @@ -550,6 +551,37 @@ describe("exec approvals policy helpers", () => { }); }); + it("uses OPENCLAW_STATE_DIR when reporting default host sources", () => { + const originalOpenClawStateDir = process.env.OPENCLAW_STATE_DIR; + const stateDir = path.join(process.cwd(), ".tmp-openclaw-state"); + process.env.OPENCLAW_STATE_DIR = stateDir; + try { + const summary = resolveExecPolicyScopeSummary({ + approvals: { + version: 1, + defaults: { + security: "allowlist", + }, + }, + scopeExecConfig: { + security: "full", + }, + configPath: "tools.exec", + scopeLabel: "tools.exec", + }); + + expect(summary.security.hostSource).toBe( + `${path.join(stateDir, "exec-approvals.json")} defaults.security`, + ); + } finally { + if (originalOpenClawStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = originalOpenClawStateDir; + } + } + }); + it("does not let host ask=off suppress a stricter requested ask", () => { const summary = resolveExecPolicyScopeSummary({ approvals: { diff --git a/src/infra/exec-approvals-store.test.ts b/src/infra/exec-approvals-store.test.ts index c06f4fb44f0b..2f94a0c32176 100644 --- a/src/infra/exec-approvals-store.test.ts +++ b/src/infra/exec-approvals-store.test.ts @@ -25,12 +25,15 @@ let recordAllowlistMatchesUse: ExecApprovalsModule["recordAllowlistMatchesUse"]; let recordAllowlistUse: ExecApprovalsModule["recordAllowlistUse"]; let requestExecApprovalViaSocket: ExecApprovalsModule["requestExecApprovalViaSocket"]; let resolveExecApprovals: ExecApprovalsModule["resolveExecApprovals"]; +let resolveExecApprovalsDisplayPath: ExecApprovalsModule["resolveExecApprovalsDisplayPath"]; let resolveExecApprovalsPath: ExecApprovalsModule["resolveExecApprovalsPath"]; let resolveExecApprovalsSocketPath: ExecApprovalsModule["resolveExecApprovalsSocketPath"]; +let resolveExecApprovalsTranscriptPath: ExecApprovalsModule["resolveExecApprovalsTranscriptPath"]; let saveExecApprovals: ExecApprovalsModule["saveExecApprovals"]; const tempDirs: string[] = []; const originalOpenClawHome = process.env.OPENCLAW_HOME; +const originalOpenClawStateDir = process.env.OPENCLAW_STATE_DIR; beforeAll(async () => { ({ @@ -45,8 +48,10 @@ beforeAll(async () => { recordAllowlistUse, requestExecApprovalViaSocket, resolveExecApprovals, + resolveExecApprovalsDisplayPath, resolveExecApprovalsPath, resolveExecApprovalsSocketPath, + resolveExecApprovalsTranscriptPath, saveExecApprovals, } = await import("./exec-approvals.js")); }); @@ -62,6 +67,11 @@ afterEach(() => { } else { process.env.OPENCLAW_HOME = originalOpenClawHome; } + if (originalOpenClawStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = originalOpenClawStateDir; + } for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } @@ -71,6 +81,7 @@ function createHomeDir(): string { const dir = makeTempDir(); tempDirs.push(dir); process.env.OPENCLAW_HOME = dir; + delete process.env.OPENCLAW_STATE_DIR; return dir; } @@ -78,6 +89,10 @@ 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; } @@ -121,6 +136,84 @@ describe("exec approvals store helpers", () => { 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"); + process.env.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("fails closed without writing target approvals before state migration runs", () => { + const dir = createHomeDir(); + const stateDir = path.join(dir, "custom-state"); + fs.mkdirSync(path.dirname(approvalsFilePath(dir)), { recursive: true }); + fs.writeFileSync( + approvalsFilePath(dir), + `${JSON.stringify({ + version: 1, + socket: { + path: path.join(dir, ".openclaw", "exec-approvals.sock"), + token: "legacy-token", + }, + defaults: { + security: "deny", + ask: "always", + }, + agents: {}, + })}\n`, + "utf8", + ); + process.env.OPENCLAW_STATE_DIR = stateDir; + + const resolved = resolveExecApprovals("main", { + security: "full", + ask: "off", + }); + + expect(resolved.agent.security).toBe("deny"); + expect(resolved.agent.ask).toBe("always"); + expect(resolved.token).toBe(""); + expect(fs.existsSync(stateApprovalsFilePath(stateDir))).toBe(false); + expect(fs.existsSync(approvalsFilePath(dir))).toBe(true); + + const ensured = ensureExecApprovals(); + + expect(ensured.defaults).toEqual({ + security: "deny", + ask: "always", + askFallback: "deny", + autoAllowSkills: undefined, + }); + expect(fs.existsSync(stateApprovalsFilePath(stateDir))).toBe(false); + }); + + 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", () => { diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index f0fbc972d7a6..d2aec50a87e0 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -15,7 +15,7 @@ import { analyzeShellCommand, type ExecCommandSegment } from "./exec-approvals-a import type { ExecAllowlistEntry } from "./exec-approvals.types.js"; import { isShellWrapperInvocation } from "./exec-wrapper-resolution.js"; import { assertNoSymlinkParentsSync } from "./fs-safe-advanced.js"; -import { expandHomePrefix, resolveRequiredHomeDir } from "./home-dir.js"; +import { expandHomePrefix, resolveHomeRelativePath, resolveRequiredHomeDir } from "./home-dir.js"; import { requestJsonlSocket } from "./jsonl-socket.js"; export * from "./exec-approvals-analysis.js"; export * from "./exec-approvals-allowlist.js"; @@ -284,8 +284,9 @@ const DEFAULT_SECURITY: ExecSecurity = "full"; const DEFAULT_ASK: ExecAsk = "off"; export const DEFAULT_EXEC_APPROVAL_ASK_FALLBACK: ExecSecurity = "deny"; const DEFAULT_AUTO_ALLOW_SKILLS = false; -const DEFAULT_SOCKET = "~/.openclaw/exec-approvals.sock"; -const DEFAULT_FILE = "~/.openclaw/exec-approvals.json"; +const DEFAULT_EXEC_APPROVALS_STATE_DIR = "~/.openclaw"; +const EXEC_APPROVALS_FILE = "exec-approvals.json"; +const EXEC_APPROVALS_SOCKET = "exec-approvals.sock"; function hashExecApprovalsRaw(raw: string | null): string { return crypto @@ -294,12 +295,71 @@ function hashExecApprovalsRaw(raw: string | null): string { .digest("hex"); } +function resolveExecApprovalsStateDir(env: NodeJS.ProcessEnv = process.env): { + path: string; + displayPath: string; +} { + const override = env.OPENCLAW_STATE_DIR?.trim(); + if (override) { + const resolved = resolveHomeRelativePath(override, { env }); + return { + path: resolved, + displayPath: resolved, + }; + } + return { + path: expandHomePrefix(DEFAULT_EXEC_APPROVALS_STATE_DIR, { env }), + displayPath: DEFAULT_EXEC_APPROVALS_STATE_DIR, + }; +} + export function resolveExecApprovalsPath(): string { - return expandHomePrefix(DEFAULT_FILE); + return path.join(resolveExecApprovalsStateDir().path, EXEC_APPROVALS_FILE); } export function resolveExecApprovalsSocketPath(): string { - return expandHomePrefix(DEFAULT_SOCKET); + return path.join(resolveExecApprovalsStateDir().path, EXEC_APPROVALS_SOCKET); +} + +export function resolveExecApprovalsDisplayPath(): string { + const stateDir = resolveExecApprovalsStateDir().displayPath; + return stateDir === DEFAULT_EXEC_APPROVALS_STATE_DIR + ? `${stateDir}/${EXEC_APPROVALS_FILE}` + : path.join(stateDir, EXEC_APPROVALS_FILE); +} + +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}`; +} + +function resolveLegacyExecApprovalsPath(): string { + return path.join(expandHomePrefix(DEFAULT_EXEC_APPROVALS_STATE_DIR), EXEC_APPROVALS_FILE); +} + +function hasUnmigratedLegacyExecApprovals(filePath: string): boolean { + if (!process.env.OPENCLAW_STATE_DIR?.trim()) { + return false; + } + const legacyPath = resolveLegacyExecApprovalsPath(); + return ( + path.resolve(legacyPath) !== path.resolve(filePath) && + !fs.existsSync(filePath) && + fs.existsSync(legacyPath) + ); +} + +function createUnmigratedLegacyExecApprovalsFallback(): ExecApprovalsFile { + return normalizeExecApprovals({ + version: 1, + defaults: { + security: "deny", + ask: "always", + askFallback: "deny", + }, + agents: {}, + }); } function normalizeAllowlistPattern(value: string | undefined): string | null { @@ -728,6 +788,16 @@ function generateToken(): string { export function readExecApprovalsSnapshot(): ExecApprovalsSnapshot { const filePath = resolveExecApprovalsPath(); + if (hasUnmigratedLegacyExecApprovals(filePath)) { + const file = createUnmigratedLegacyExecApprovalsFallback(); + return { + path: filePath, + exists: false, + raw: null, + file, + hash: hashExecApprovalsRaw(null), + }; + } if (!fs.existsSync(filePath)) { const file = normalizeExecApprovals({ version: 1, agents: {} }); return { @@ -760,6 +830,9 @@ export function readExecApprovalsSnapshot(): ExecApprovalsSnapshot { export function loadExecApprovals(): ExecApprovalsFile { const filePath = resolveExecApprovalsPath(); + if (hasUnmigratedLegacyExecApprovals(filePath)) { + return createUnmigratedLegacyExecApprovalsFallback(); + } try { if (!fs.existsSync(filePath)) { return normalizeExecApprovals({ version: 1, agents: {} }); @@ -820,6 +893,9 @@ export function restoreExecApprovalsSnapshot(snapshot: ExecApprovalsSnapshot): v } export function ensureExecApprovals(): ExecApprovalsFile { + if (hasUnmigratedLegacyExecApprovals(resolveExecApprovalsPath())) { + return createUnmigratedLegacyExecApprovalsFallback(); + } const loaded = loadExecApprovals(); const next = normalizeExecApprovals(loaded); const socketPath = next.socket?.path?.trim(); @@ -836,6 +912,9 @@ export function ensureExecApprovals(): ExecApprovalsFile { } function readExecApprovalsForNoPersistence(filePath: string): ExecApprovalsFile { + if (hasUnmigratedLegacyExecApprovals(filePath)) { + return createUnmigratedLegacyExecApprovalsFallback(); + } const dir = path.dirname(filePath); assertNoExecApprovalsSymlinkParents(dir, resolveRequiredHomeDir()); assertSafeExecApprovalsDestination(filePath); @@ -1000,6 +1079,16 @@ export function resolveExecApprovals( overrides?: ExecApprovalsDefaultOverrides, ): ExecApprovalsResolved { const filePath = resolveExecApprovalsPath(); + if (hasUnmigratedLegacyExecApprovals(filePath)) { + return resolveExecApprovalsFromFile({ + file: createUnmigratedLegacyExecApprovalsFallback(), + agentId, + overrides, + path: filePath, + socketPath: resolveExecApprovalsSocketPath(), + token: "", + }); + } if (!overrides?.requireSocket) { const file = readExecApprovalsForNoPersistence(filePath); const resolved = resolveExecApprovalsFromFile({ diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index b2afc776a0af..d0896c9c3208 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -61,7 +61,8 @@ import { repairOpenClawStateDatabaseSchema, runOpenClawStateWriteTransaction, } from "../state/openclaw-state-db.js"; -import { expandHomePrefix } from "./home-dir.js"; +import { assertNoSymlinkParentsSync } from "./fs-safe-advanced.js"; +import { expandHomePrefix, resolveRequiredHomeDir } from "./home-dir.js"; import { executeSqliteQuerySync, executeSqliteQueryTakeFirstSync, @@ -128,9 +129,16 @@ export type LegacyStateDetection = { sessionPath: string; hasLegacy: boolean; }; + execApprovals: { + sourcePath: string; + targetPath: string; + hasLegacy: boolean; + }; preview: string[]; }; +type LegacyExecApprovalsMigrationDetection = LegacyStateDetection["execApprovals"]; + type MigrationLogger = { info: (message: string) => void; warn: (message: string) => void; @@ -173,6 +181,8 @@ const LEGACY_DELIVERY_QUEUE_DIRS = [ { label: "outbound delivery queue", queueName: "outbound", dirName: "delivery-queue" }, { label: "session delivery queue", queueName: "session", dirName: "session-delivery-queue" }, ] as const; +const EXEC_APPROVALS_FILENAME = "exec-approvals.json"; +const EXEC_APPROVALS_SOCKET_FILENAME = "exec-approvals.sock"; type LegacyDeliveryQueueFile = { sourcePath: string; status: "pending" | "failed"; @@ -232,6 +242,43 @@ function resolveLegacyFlowRunsSidecarPath(stateDir: string): string { return path.join(stateDir, "flows", "registry.sqlite"); } +function resolveDefaultExecApprovalsStateDir( + env: NodeJS.ProcessEnv, + homedir: () => string, +): string { + return path.join(resolveRequiredHomeDir(env, homedir), ".openclaw"); +} + +function resolveDefaultExecApprovalsPath(env: NodeJS.ProcessEnv, homedir: () => string): string { + return path.join(resolveDefaultExecApprovalsStateDir(env, homedir), EXEC_APPROVALS_FILENAME); +} + +function resolveExecApprovalsPathForStateDir(stateDir: string): string { + return path.join(stateDir, EXEC_APPROVALS_FILENAME); +} + +function resolveExecApprovalsSocketPathForStateDir(stateDir: string): string { + return path.join(stateDir, EXEC_APPROVALS_SOCKET_FILENAME); +} + +function detectLegacyExecApprovalsMigration(params: { + env: NodeJS.ProcessEnv; + homedir: () => string; + stateDir: string; +}): LegacyExecApprovalsMigrationDetection { + const sourcePath = resolveDefaultExecApprovalsPath(params.env, params.homedir); + const targetPath = resolveExecApprovalsPathForStateDir(params.stateDir); + return { + sourcePath, + targetPath, + hasLegacy: + Boolean(params.env.OPENCLAW_STATE_DIR?.trim()) && + path.resolve(sourcePath) !== path.resolve(targetPath) && + fileExists(sourcePath) && + !fileExists(targetPath), + }; +} + function readLegacyPluginStateSidecarRows(sourcePath: string): LegacyPluginStateSidecarRow[] { const sqlite = requireNodeSqlite(); const db = new sqlite.DatabaseSync(sourcePath, { readOnly: true }); @@ -2583,22 +2630,29 @@ export async function autoMigrateLegacyTaskStateSidecars(params: { const stateDir = resolveStateDir(params.env ?? process.env, params.homedir); const result = await migrateLegacyTaskStateSidecars({ stateDir }); + const execApprovals = migrateLegacyExecApprovals( + detectLegacyExecApprovalsMigration({ + env: params.env ?? process.env, + homedir: params.homedir ?? os.homedir, + stateDir, + }), + ); + const changes = [...result.changes, ...execApprovals.changes]; + const warnings = [...result.warnings, ...execApprovals.warnings]; const logger = params.log ?? createSubsystemLogger("state-migrations"); - if (result.changes.length > 0) { - logger.info( - `Auto-migrated legacy task state:\n${result.changes.map((entry) => `- ${entry}`).join("\n")}`, - ); + if (changes.length > 0) { + logger.info(`Auto-migrated legacy state:\n${changes.map((entry) => `- ${entry}`).join("\n")}`); } - if (result.warnings.length > 0) { + if (warnings.length > 0) { logger.warn( - `Legacy task state migration warnings:\n${result.warnings.map((entry) => `- ${entry}`).join("\n")}`, + `Legacy state migration warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`, ); } return { - migrated: result.changes.length > 0, + migrated: changes.length > 0, skipped: false, - changes: result.changes, - warnings: result.warnings, + changes, + warnings, }; } @@ -2684,6 +2738,7 @@ export async function detectLegacyStateMigrations(params: { const homedir = params.homedir ?? os.homedir; const stateDir = resolveStateDir(env, homedir); const oauthDir = resolveOAuthDir(env, stateDir); + const execApprovals = detectLegacyExecApprovalsMigration({ env, homedir, stateDir }); const targetAgentId = normalizeAgentId(resolveDefaultAgentId(params.cfg)); const rawMainKey = params.cfg.session?.mainKey; @@ -2797,6 +2852,9 @@ export async function detectLegacyStateMigrations(params: { if (hasDeliveryQueues) { preview.push("- Delivery queues: legacy JSON queue files → shared SQLite state"); } + if (execApprovals.hasLegacy) { + preview.push(`- Exec approvals: ${execApprovals.sourcePath} → ${execApprovals.targetPath}`); + } if (channelPlans.length > 0) { preview.push(...channelPlans.map(buildLegacyMigrationPreview)); } @@ -2852,6 +2910,7 @@ export async function detectLegacyStateMigrations(params: { ...deliveryQueuePaths, hasLegacy: hasDeliveryQueues, }, + execApprovals, preview, }; } @@ -3146,6 +3205,192 @@ async function runPluginDoctorStateMigrationPlans(params: { return { changes, warnings }; } +function isPlainJsonObject(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function isDefaultLegacyExecApprovalsSocketPath(params: { + socketPath: string; + sourcePath: string; +}): boolean { + const expanded = expandHomePrefix(params.socketPath); + return ( + path.resolve(expanded) === + path.join(path.dirname(params.sourcePath), EXEC_APPROVALS_SOCKET_FILENAME) + ); +} + +function prepareMigratedExecApprovalsFile(params: { + raw: string; + sourcePath: string; + targetPath: string; +}): { raw: string; warning?: string } { + let parsed: unknown; + try { + parsed = JSON.parse(params.raw) as unknown; + } catch { + return { + raw: "", + warning: `Legacy exec approvals file unreadable; left in place at ${params.sourcePath}`, + }; + } + if (!isPlainJsonObject(parsed) || parsed.version !== 1) { + return { + raw: "", + warning: `Legacy exec approvals file has unsupported shape; left in place at ${params.sourcePath}`, + }; + } + + const next: Record = { ...parsed }; + const socket = isPlainJsonObject(next.socket) ? { ...next.socket } : {}; + const rawSocketPath = typeof socket.path === "string" ? socket.path.trim() : ""; + if ( + !rawSocketPath || + isDefaultLegacyExecApprovalsSocketPath({ + socketPath: rawSocketPath, + sourcePath: params.sourcePath, + }) + ) { + socket.path = resolveExecApprovalsSocketPathForStateDir(path.dirname(params.targetPath)); + } + next.socket = socket; + return { raw: `${JSON.stringify(next, null, 2)}\n` }; +} + +function assertSafeExecApprovalsMigrationTarget(targetPath: string): void { + const targetDir = path.dirname(targetPath); + assertNoSymlinkParentsSync({ + rootDir: resolveRequiredHomeDir(), + targetPath: targetDir, + allowOutsideRoot: true, + messagePrefix: "Refusing to traverse symlink in exec approvals migration path", + }); + try { + const targetStat = fs.lstatSync(targetPath); + if (targetStat.isSymbolicLink()) { + throw new Error(`Refusing to migrate exec approvals via symlink: ${targetPath}`); + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + throw err; + } + } +} + +function writeMigratedExecApprovalsFile(targetPath: string, raw: string): boolean { + const targetDir = path.dirname(targetPath); + assertSafeExecApprovalsMigrationTarget(targetPath); + fs.mkdirSync(targetDir, { recursive: true, mode: 0o700 }); + assertSafeExecApprovalsMigrationTarget(targetPath); + const dirStat = fs.lstatSync(targetDir); + if (!dirStat.isDirectory() || dirStat.isSymbolicLink()) { + throw new Error(`Refusing to migrate exec approvals into unsafe directory: ${targetDir}`); + } + try { + fs.chmodSync(targetDir, 0o700); + } catch { + // best-effort on platforms without chmod + } + const tempPath = path.join(targetDir, `.exec-approvals.migration.${process.pid}.tmp`); + fs.writeFileSync(tempPath, raw, { encoding: "utf8", mode: 0o600, flag: "wx" }); + try { + try { + fs.copyFileSync(tempPath, targetPath, fs.constants.COPYFILE_EXCL); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EEXIST") { + return false; + } + try { + fs.rmSync(targetPath, { force: true }); + } catch { + // best-effort cleanup for an incomplete exclusive copy target + } + throw err; + } + try { + fs.chmodSync(targetPath, 0o600); + } catch { + // best-effort on platforms without chmod + } + return true; + } finally { + fs.rmSync(tempPath, { force: true }); + } +} + +function archiveMigratedExecApprovalsSource(sourcePath: string): string { + let archivePath = `${sourcePath}.migrated`; + if (fileExists(archivePath)) { + archivePath = `${archivePath}-${Date.now()}`; + } + fs.renameSync(sourcePath, archivePath); + return archivePath; +} + +function migrateLegacyExecApprovals(detected: LegacyExecApprovalsMigrationDetection): { + changes: string[]; + warnings: string[]; +} { + const changes: string[] = []; + const warnings: string[] = []; + if (!detected.hasLegacy) { + return { changes, warnings }; + } + if (fileExists(detected.targetPath)) { + return { changes, warnings }; + } + try { + const sourceStat = fs.lstatSync(detected.sourcePath); + if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) { + warnings.push( + `Legacy exec approvals file is not a regular file; left in place at ${detected.sourcePath}`, + ); + return { changes, warnings }; + } + try { + const targetStat = fs.lstatSync(detected.targetPath); + if (targetStat.isSymbolicLink()) { + warnings.push( + `Target exec approvals path is a symlink; skipped migration at ${detected.targetPath}`, + ); + return { changes, warnings }; + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + throw err; + } + } + const prepared = prepareMigratedExecApprovalsFile({ + raw: fs.readFileSync(detected.sourcePath, "utf8"), + sourcePath: detected.sourcePath, + targetPath: detected.targetPath, + }); + if (prepared.warning) { + warnings.push(prepared.warning); + return { changes, warnings }; + } + if (!writeMigratedExecApprovalsFile(detected.targetPath, prepared.raw)) { + return { changes, warnings }; + } + changes.push(`Migrated exec approvals → ${detected.targetPath}`); + try { + const archivePath = archiveMigratedExecApprovalsSource(detected.sourcePath); + changes.push(`Archived legacy exec approvals → ${archivePath}`); + } catch (err) { + warnings.push( + `Failed archiving legacy exec approvals at ${detected.sourcePath}: ${String(err)}`, + ); + } + } catch (err) { + warnings.push( + `Failed migrating exec approvals (${detected.sourcePath} → ${detected.targetPath}): ${String( + err, + )}`, + ); + } + return { changes, warnings }; +} + function migrateLegacyStateSchema(detected: LegacyStateDetection): { changes: string[]; warnings: string[]; @@ -3179,6 +3424,7 @@ export async function runLegacyStateMigrations(params: { const deliveryQueues = await migrateLegacyDeliveryQueues({ stateDir: detected.stateDir, }); + const execApprovals = migrateLegacyExecApprovals(detected.execApprovals); const preSessionChannelPlans = await runLegacyMigrationPlans( detected.channelPlans.plans.filter((plan) => plan.kind === "plugin-state-import"), ); @@ -3207,6 +3453,7 @@ export async function runLegacyStateMigrations(params: { ...pluginInstallIndex.changes, ...taskStateSidecars.changes, ...deliveryQueues.changes, + ...execApprovals.changes, ...preSessionChannelPlans.changes, ...pluginPlans.changes, ...sessions.changes, @@ -3220,6 +3467,7 @@ export async function runLegacyStateMigrations(params: { ...pluginInstallIndex.warnings, ...taskStateSidecars.warnings, ...deliveryQueues.warnings, + ...execApprovals.warnings, ...preSessionChannelPlans.warnings, ...pluginPlans.warnings, ...sessions.warnings, @@ -3551,6 +3799,7 @@ export async function autoMigrateLegacyState(params: { const deliveryQueues = await migrateLegacyDeliveryQueues({ stateDir: detected.stateDir, }); + const execApprovals = migrateLegacyExecApprovals(detected.execApprovals); const preSessionChannelPlans = await runLegacyMigrationPlans( detected.channelPlans.plans.filter((plan) => plan.kind === "plugin-state-import"), ); @@ -3567,6 +3816,7 @@ export async function autoMigrateLegacyState(params: { ...pluginInstallIndex.changes, ...taskStateSidecars.changes, ...deliveryQueues.changes, + ...execApprovals.changes, ...preSessionChannelPlans.changes, ...pluginPlans.changes, ]; @@ -3579,6 +3829,7 @@ export async function autoMigrateLegacyState(params: { ...pluginInstallIndex.warnings, ...taskStateSidecars.warnings, ...deliveryQueues.warnings, + ...execApprovals.warnings, ...preSessionChannelPlans.warnings, ...pluginPlans.warnings, ]; @@ -3593,6 +3844,7 @@ export async function autoMigrateLegacyState(params: { pluginInstallIndex.changes.length > 0 || taskStateSidecars.changes.length > 0 || deliveryQueues.changes.length > 0 || + execApprovals.changes.length > 0 || preSessionChannelPlans.changes.length > 0 || pluginPlans.changes.length > 0, skipped: true, @@ -3609,7 +3861,8 @@ export async function autoMigrateLegacyState(params: { !detected.pluginInstallIndex.hasLegacy && !detected.stateSchema.hasLegacy && !detected.taskStateSidecars.hasLegacy && - !detected.deliveryQueues.hasLegacy + !detected.deliveryQueues.hasLegacy && + !detected.execApprovals.hasLegacy ) { const changes = [ ...stateDirResult.changes, @@ -3649,6 +3902,7 @@ export async function autoMigrateLegacyState(params: { const deliveryQueues = await migrateLegacyDeliveryQueues({ stateDir: detected.stateDir, }); + const execApprovals = migrateLegacyExecApprovals(detected.execApprovals); const preSessionChannelPlans = await runLegacyMigrationPlans( detected.channelPlans.plans.filter((plan) => plan.kind === "plugin-state-import"), ); @@ -3677,6 +3931,7 @@ export async function autoMigrateLegacyState(params: { ...pluginInstallIndex.changes, ...taskStateSidecars.changes, ...deliveryQueues.changes, + ...execApprovals.changes, ...preSessionChannelPlans.changes, ...pluginPlans.changes, ...sessions.changes, @@ -3693,6 +3948,7 @@ export async function autoMigrateLegacyState(params: { ...pluginInstallIndex.warnings, ...taskStateSidecars.warnings, ...deliveryQueues.warnings, + ...execApprovals.warnings, ...preSessionChannelPlans.warnings, ...pluginPlans.warnings, ...sessions.warnings,