feat: mac node exec policy + remote skills hot reload
This commit is contained in:
@@ -170,6 +170,10 @@ final class AppState {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
||||
}
|
||||
|
||||
var systemRunPolicy: SystemRunPolicy {
|
||||
didSet { self.ifNotPreview { MacNodeConfigFile.setSystemRunPolicy(self.systemRunPolicy) } }
|
||||
}
|
||||
|
||||
/// Tracks whether the Canvas panel is currently visible (not persisted).
|
||||
var canvasPanelVisible: Bool = false
|
||||
|
||||
@@ -292,6 +296,7 @@ final class AppState {
|
||||
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
||||
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
||||
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||
self.systemRunPolicy = SystemRunPolicy.load()
|
||||
self.peekabooBridgeEnabled = UserDefaults.standard
|
||||
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
|
||||
if !self.isPreview {
|
||||
|
||||
@@ -26,6 +26,9 @@ let remoteProjectRootKey = "clawdbot.remoteProjectRoot"
|
||||
let remoteCliPathKey = "clawdbot.remoteCliPath"
|
||||
let canvasEnabledKey = "clawdbot.canvasEnabled"
|
||||
let cameraEnabledKey = "clawdbot.cameraEnabled"
|
||||
let systemRunPolicyKey = "clawdbot.systemRunPolicy"
|
||||
let systemRunAllowlistKey = "clawdbot.systemRunAllowlist"
|
||||
let systemRunEnabledKey = "clawdbot.systemRunEnabled"
|
||||
let locationModeKey = "clawdbot.locationMode"
|
||||
let locationPreciseKey = "clawdbot.locationPreciseEnabled"
|
||||
let peekabooBridgeEnabledKey = "clawdbot.peekabooBridgeEnabled"
|
||||
|
||||
@@ -83,6 +83,25 @@ struct GeneralSettings: View {
|
||||
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
||||
binding: self.$cameraEnabled)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Node Run Commands")
|
||||
.font(.body)
|
||||
|
||||
Picker("", selection: self.$state.systemRunPolicy) {
|
||||
ForEach(SystemRunPolicy.allCases) { policy in
|
||||
Text(policy.title).tag(policy)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
Text("""
|
||||
Controls remote command execution on this Mac when it is paired as a node. "Always Ask" prompts on each command; "Always Allow" runs without prompts; "Never" disables `system.run`.
|
||||
""")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.tertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Location Access")
|
||||
.font(.body)
|
||||
|
||||
80
apps/macos/Sources/Clawdbot/MacNodeConfigFile.swift
Normal file
80
apps/macos/Sources/Clawdbot/MacNodeConfigFile.swift
Normal file
@@ -0,0 +1,80 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
enum MacNodeConfigFile {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "mac-node-config")
|
||||
|
||||
static func url() -> URL {
|
||||
ClawdbotPaths.stateDirURL.appendingPathComponent("macos-node.json")
|
||||
}
|
||||
|
||||
static func loadDict() -> [String: Any] {
|
||||
let url = self.url()
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return [:] }
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
self.logger.warning("mac node config JSON root invalid")
|
||||
return [:]
|
||||
}
|
||||
return root
|
||||
} catch {
|
||||
self.logger.warning("mac node config read failed: \(error.localizedDescription, privacy: .public)")
|
||||
return [:]
|
||||
}
|
||||
}
|
||||
|
||||
static func saveDict(_ dict: [String: Any]) {
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
|
||||
let url = self.url()
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
} catch {
|
||||
self.logger.error("mac node config save failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
static func systemRunPolicy() -> SystemRunPolicy? {
|
||||
let root = self.loadDict()
|
||||
let systemRun = root["systemRun"] as? [String: Any]
|
||||
let raw = systemRun?["policy"] as? String
|
||||
guard let raw, let policy = SystemRunPolicy(rawValue: raw) else { return nil }
|
||||
return policy
|
||||
}
|
||||
|
||||
static func setSystemRunPolicy(_ policy: SystemRunPolicy) {
|
||||
var root = self.loadDict()
|
||||
var systemRun = root["systemRun"] as? [String: Any] ?? [:]
|
||||
systemRun["policy"] = policy.rawValue
|
||||
root["systemRun"] = systemRun
|
||||
self.saveDict(root)
|
||||
}
|
||||
|
||||
static func systemRunAllowlist() -> [String]? {
|
||||
let root = self.loadDict()
|
||||
let systemRun = root["systemRun"] as? [String: Any]
|
||||
return systemRun?["allowlist"] as? [String]
|
||||
}
|
||||
|
||||
static func setSystemRunAllowlist(_ allowlist: [String]) {
|
||||
let cleaned = allowlist
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
var root = self.loadDict()
|
||||
var systemRun = root["systemRun"] as? [String: Any] ?? [:]
|
||||
if cleaned.isEmpty {
|
||||
systemRun.removeValue(forKey: "allowlist")
|
||||
} else {
|
||||
systemRun["allowlist"] = cleaned
|
||||
}
|
||||
if systemRun.isEmpty {
|
||||
root.removeValue(forKey: "systemRun")
|
||||
} else {
|
||||
root["systemRun"] = systemRun
|
||||
}
|
||||
self.saveDict(root)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,12 @@ struct MenuContent: View {
|
||||
self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled)
|
||||
}
|
||||
|
||||
private var systemRunPolicyBinding: Binding<SystemRunPolicy> {
|
||||
Binding(
|
||||
get: { self.state.systemRunPolicy },
|
||||
set: { self.state.systemRunPolicy = $0 })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Toggle(isOn: self.activeBinding) {
|
||||
@@ -68,6 +74,13 @@ struct MenuContent: View {
|
||||
Toggle(isOn: self.$cameraEnabled) {
|
||||
Label("Allow Camera", systemImage: "camera")
|
||||
}
|
||||
Picker(selection: self.systemRunPolicyBinding) {
|
||||
ForEach(SystemRunPolicy.allCases) { policy in
|
||||
Text(policy.title).tag(policy)
|
||||
}
|
||||
} label: {
|
||||
Label("Node Run Commands", systemImage: "terminal")
|
||||
}
|
||||
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
|
||||
Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis")
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ final class MacNodeModeCoordinator {
|
||||
private func run() async {
|
||||
var retryDelay: UInt64 = 1_000_000_000
|
||||
var lastCameraEnabled: Bool?
|
||||
var lastSystemRunPolicy: SystemRunPolicy?
|
||||
let defaults = UserDefaults.standard
|
||||
while !Task.isCancelled {
|
||||
if await MainActor.run(body: { AppStateStore.shared.isPaused }) {
|
||||
@@ -53,6 +54,15 @@ final class MacNodeModeCoordinator {
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
|
||||
let systemRunPolicy = SystemRunPolicy.load()
|
||||
if lastSystemRunPolicy == nil {
|
||||
lastSystemRunPolicy = systemRunPolicy
|
||||
} else if lastSystemRunPolicy != systemRunPolicy {
|
||||
lastSystemRunPolicy = systemRunPolicy
|
||||
await self.session.disconnect()
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
|
||||
guard let endpoint = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else {
|
||||
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
|
||||
retryDelay = min(retryDelay * 2, 10_000_000_000)
|
||||
@@ -143,10 +153,13 @@ final class MacNodeModeCoordinator {
|
||||
ClawdbotCanvasA2UICommand.pushJSONL.rawValue,
|
||||
ClawdbotCanvasA2UICommand.reset.rawValue,
|
||||
MacNodeScreenCommand.record.rawValue,
|
||||
ClawdbotSystemCommand.run.rawValue,
|
||||
ClawdbotSystemCommand.notify.rawValue,
|
||||
]
|
||||
|
||||
if SystemRunPolicy.load() != .never {
|
||||
commands.append(ClawdbotSystemCommand.run.rawValue)
|
||||
}
|
||||
|
||||
let capsSet = Set(caps)
|
||||
if capsSet.contains(ClawdbotCapability.camera.rawValue) {
|
||||
commands.append(ClawdbotCameraCommand.list.rawValue)
|
||||
|
||||
@@ -426,6 +426,44 @@ actor MacNodeRuntime {
|
||||
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
|
||||
}
|
||||
|
||||
let wasAllowlisted = SystemRunAllowlist.contains(command)
|
||||
switch Self.systemRunPolicy() {
|
||||
case .never:
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DISABLED: policy=never")
|
||||
case .always:
|
||||
break
|
||||
case .ask:
|
||||
if !wasAllowlisted {
|
||||
let services = await self.mainActorServices()
|
||||
let decision = await services.confirmSystemRun(
|
||||
command: SystemRunAllowlist.displayString(for: command),
|
||||
cwd: params.cwd)
|
||||
switch decision {
|
||||
case .allowOnce:
|
||||
break
|
||||
case .allowAlways:
|
||||
SystemRunAllowlist.add(command)
|
||||
case .deny:
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: user denied")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var env = params.env
|
||||
if wasAllowlisted, let overrides = env {
|
||||
var merged = ProcessInfo.processInfo.environment
|
||||
for (key, value) in overrides where key != "PATH" {
|
||||
merged[key] = value
|
||||
}
|
||||
env = merged
|
||||
}
|
||||
|
||||
if params.needsScreenRecording == true {
|
||||
let authorized = await PermissionManager
|
||||
.status([.screenRecording])[.screenRecording] ?? false
|
||||
@@ -441,7 +479,7 @@ actor MacNodeRuntime {
|
||||
let result = await ShellExecutor.runDetailed(
|
||||
command: command,
|
||||
cwd: params.cwd,
|
||||
env: params.env,
|
||||
env: env,
|
||||
timeout: timeoutSec)
|
||||
|
||||
struct RunPayload: Encodable {
|
||||
@@ -529,6 +567,10 @@ actor MacNodeRuntime {
|
||||
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
|
||||
}
|
||||
|
||||
private nonisolated static func systemRunPolicy() -> SystemRunPolicy {
|
||||
SystemRunPolicy.load()
|
||||
}
|
||||
|
||||
private nonisolated static func locationMode() -> ClawdbotLocationMode {
|
||||
let raw = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
|
||||
return ClawdbotLocationMode(rawValue: raw) ?? .off
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import AppKit
|
||||
import ClawdbotKit
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
|
||||
enum SystemRunDecision: Sendable {
|
||||
case allowOnce
|
||||
case allowAlways
|
||||
case deny
|
||||
}
|
||||
|
||||
@MainActor
|
||||
protocol MacNodeRuntimeMainActorServices: Sendable {
|
||||
func recordScreen(
|
||||
@@ -17,6 +24,8 @@ protocol MacNodeRuntimeMainActorServices: Sendable {
|
||||
desiredAccuracy: ClawdbotLocationAccuracy,
|
||||
maxAgeMs: Int?,
|
||||
timeoutMs: Int?) async throws -> CLLocation
|
||||
|
||||
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -57,4 +66,31 @@ final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices
|
||||
maxAgeMs: maxAgeMs,
|
||||
timeoutMs: timeoutMs)
|
||||
}
|
||||
|
||||
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision {
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .warning
|
||||
alert.messageText = "Allow this command?"
|
||||
|
||||
var details = "Clawdbot wants to run:\n\n\(command)"
|
||||
let trimmedCwd = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedCwd.isEmpty {
|
||||
details += "\n\nWorking directory:\n\(trimmedCwd)"
|
||||
}
|
||||
details += "\n\nThis runs on this Mac via node mode."
|
||||
alert.informativeText = details
|
||||
|
||||
alert.addButton(withTitle: "Allow Once")
|
||||
alert.addButton(withTitle: "Always Allow")
|
||||
alert.addButton(withTitle: "Don't Allow")
|
||||
|
||||
switch alert.runModal() {
|
||||
case .alertFirstButtonReturn:
|
||||
return .allowOnce
|
||||
case .alertSecondButtonReturn:
|
||||
return .allowAlways
|
||||
default:
|
||||
return .deny
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
89
apps/macos/Sources/Clawdbot/SystemRunPolicy.swift
Normal file
89
apps/macos/Sources/Clawdbot/SystemRunPolicy.swift
Normal file
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
|
||||
enum SystemRunPolicy: String, CaseIterable, Identifiable {
|
||||
case never
|
||||
case ask
|
||||
case always
|
||||
|
||||
var id: String { self.rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .never:
|
||||
return "Never"
|
||||
case .ask:
|
||||
return "Always Ask"
|
||||
case .always:
|
||||
return "Always Allow"
|
||||
}
|
||||
}
|
||||
|
||||
static func load(from defaults: UserDefaults = .standard) -> SystemRunPolicy {
|
||||
if let policy = MacNodeConfigFile.systemRunPolicy() {
|
||||
return policy
|
||||
}
|
||||
if let raw = defaults.string(forKey: systemRunPolicyKey),
|
||||
let policy = SystemRunPolicy(rawValue: raw)
|
||||
{
|
||||
MacNodeConfigFile.setSystemRunPolicy(policy)
|
||||
return policy
|
||||
}
|
||||
if let legacy = defaults.object(forKey: systemRunEnabledKey) as? Bool {
|
||||
let policy: SystemRunPolicy = legacy ? .ask : .never
|
||||
MacNodeConfigFile.setSystemRunPolicy(policy)
|
||||
return policy
|
||||
}
|
||||
let fallback: SystemRunPolicy = .ask
|
||||
MacNodeConfigFile.setSystemRunPolicy(fallback)
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
enum SystemRunAllowlist {
|
||||
static func key(for argv: [String]) -> String {
|
||||
let trimmed = argv.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
guard !trimmed.isEmpty else { return "" }
|
||||
if let data = try? JSONEncoder().encode(trimmed),
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
{
|
||||
return json
|
||||
}
|
||||
return trimmed.joined(separator: " ")
|
||||
}
|
||||
|
||||
static func displayString(for argv: [String]) -> String {
|
||||
argv.map { arg in
|
||||
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return "\"\"" }
|
||||
let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" }
|
||||
if !needsQuotes { return trimmed }
|
||||
let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
return "\"\(escaped)\""
|
||||
}.joined(separator: " ")
|
||||
}
|
||||
|
||||
static func load(from defaults: UserDefaults = .standard) -> Set<String> {
|
||||
if let allowlist = MacNodeConfigFile.systemRunAllowlist() {
|
||||
return Set(allowlist)
|
||||
}
|
||||
if let legacy = defaults.stringArray(forKey: systemRunAllowlistKey), !legacy.isEmpty {
|
||||
MacNodeConfigFile.setSystemRunAllowlist(legacy)
|
||||
return Set(legacy)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
static func contains(_ argv: [String], defaults: UserDefaults = .standard) -> Bool {
|
||||
let key = key(for: argv)
|
||||
return load(from: defaults).contains(key)
|
||||
}
|
||||
|
||||
static func add(_ argv: [String], defaults: UserDefaults = .standard) {
|
||||
let key = key(for: argv)
|
||||
guard !key.isEmpty else { return }
|
||||
var allowlist = load(from: defaults)
|
||||
if allowlist.insert(key).inserted {
|
||||
MacNodeConfigFile.setSystemRunAllowlist(Array(allowlist).sorted())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user