feat: add exec approvals allowlists
This commit is contained in:
@@ -10,11 +10,13 @@ Docs: https://docs.clawd.bot
|
|||||||
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
|
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
|
||||||
- Memory: add `--verbose` logging for memory status + batch indexing details.
|
- Memory: add `--verbose` logging for memory status + batch indexing details.
|
||||||
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
|
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
|
||||||
|
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
|
||||||
|
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Memory: apply OpenAI batch defaults even without explicit remote config.
|
- Memory: apply OpenAI batch defaults even without explicit remote config.
|
||||||
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
|
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
|
||||||
|
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
|
||||||
## 2026.1.17-3
|
## 2026.1.17-3
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -214,9 +214,10 @@ enum CommandResolver {
|
|||||||
subcommand: String,
|
subcommand: String,
|
||||||
extraArgs: [String] = [],
|
extraArgs: [String] = [],
|
||||||
defaults: UserDefaults = .standard,
|
defaults: UserDefaults = .standard,
|
||||||
|
configRoot: [String: Any]? = nil,
|
||||||
searchPaths: [String]? = nil) -> [String]
|
searchPaths: [String]? = nil) -> [String]
|
||||||
{
|
{
|
||||||
let settings = self.connectionSettings(defaults: defaults)
|
let settings = self.connectionSettings(defaults: defaults, configRoot: configRoot)
|
||||||
if settings.mode == .remote, let ssh = self.sshNodeCommand(
|
if settings.mode == .remote, let ssh = self.sshNodeCommand(
|
||||||
subcommand: subcommand,
|
subcommand: subcommand,
|
||||||
extraArgs: extraArgs,
|
extraArgs: extraArgs,
|
||||||
@@ -264,12 +265,14 @@ enum CommandResolver {
|
|||||||
subcommand: String,
|
subcommand: String,
|
||||||
extraArgs: [String] = [],
|
extraArgs: [String] = [],
|
||||||
defaults: UserDefaults = .standard,
|
defaults: UserDefaults = .standard,
|
||||||
|
configRoot: [String: Any]? = nil,
|
||||||
searchPaths: [String]? = nil) -> [String]
|
searchPaths: [String]? = nil) -> [String]
|
||||||
{
|
{
|
||||||
self.clawdbotNodeCommand(
|
self.clawdbotNodeCommand(
|
||||||
subcommand: subcommand,
|
subcommand: subcommand,
|
||||||
extraArgs: extraArgs,
|
extraArgs: extraArgs,
|
||||||
defaults: defaults,
|
defaults: defaults,
|
||||||
|
configRoot: configRoot,
|
||||||
searchPaths: searchPaths)
|
searchPaths: searchPaths)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,8 +387,11 @@ enum CommandResolver {
|
|||||||
let cliPath: String
|
let cliPath: String
|
||||||
}
|
}
|
||||||
|
|
||||||
static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings {
|
static func connectionSettings(
|
||||||
let root = ClawdbotConfigFile.loadDict()
|
defaults: UserDefaults = .standard,
|
||||||
|
configRoot: [String: Any]? = nil) -> RemoteSettings
|
||||||
|
{
|
||||||
|
let root = configRoot ?? ClawdbotConfigFile.loadDict()
|
||||||
let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode
|
let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode
|
||||||
let target = defaults.string(forKey: remoteTargetKey) ?? ""
|
let target = defaults.string(forKey: remoteTargetKey) ?? ""
|
||||||
let identity = defaults.string(forKey: remoteIdentityKey) ?? ""
|
let identity = defaults.string(forKey: remoteIdentityKey) ?? ""
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ struct Semver: Comparable, CustomStringConvertible, Sendable {
|
|||||||
else { return nil }
|
else { return nil }
|
||||||
// Strip prerelease suffix (e.g., "11-4" → "11", "5-beta.1" → "5")
|
// Strip prerelease suffix (e.g., "11-4" → "11", "5-beta.1" → "5")
|
||||||
let patchRaw = String(parts[2])
|
let patchRaw = String(parts[2])
|
||||||
let patchNumeric = patchRaw.split { $0 == "-" || $0 == "+" }.first.flatMap { Int($0) } ?? 0
|
guard let patchToken = patchRaw.split(whereSeparator: { $0 == "-" || $0 == "+" }).first,
|
||||||
|
let patchNumeric = Int(patchToken)
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return Semver(major: major, minor: minor, patch: patchNumeric)
|
return Semver(major: major, minor: minor, patch: patchNumeric)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,27 +83,7 @@ struct GeneralSettings: View {
|
|||||||
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
||||||
binding: self.$cameraEnabled)
|
binding: self.$cameraEnabled)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
SystemRunSettingsView()
|
||||||
Text("Node Run Commands")
|
|
||||||
.font(.body)
|
|
||||||
|
|
||||||
Picker("", selection: self.$state.systemRunPolicy) {
|
|
||||||
ForEach(SystemRunPolicy.allCases) { policy in
|
|
||||||
Text(policy.title).tag(policy)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.labelsHidden()
|
|
||||||
.pickerStyle(.menu)
|
|
||||||
|
|
||||||
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) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("Location Access")
|
Text("Location Access")
|
||||||
|
|||||||
@@ -38,39 +38,14 @@ enum MacNodeConfigFile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func systemRunPolicy() -> SystemRunPolicy? {
|
private static func systemRunSection(from root: [String: Any]) -> [String: Any] {
|
||||||
let root = self.loadDict()
|
root["systemRun"] as? [String: Any] ?? [:]
|
||||||
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) {
|
private static func updateSystemRunSection(_ mutate: (inout [String: Any]) -> Void) {
|
||||||
var root = self.loadDict()
|
var root = self.loadDict()
|
||||||
var systemRun = root["systemRun"] as? [String: Any] ?? [:]
|
var systemRun = self.systemRunSection(from: root)
|
||||||
systemRun["policy"] = policy.rawValue
|
mutate(&systemRun)
|
||||||
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 {
|
if systemRun.isEmpty {
|
||||||
root.removeValue(forKey: "systemRun")
|
root.removeValue(forKey: "systemRun")
|
||||||
} else {
|
} else {
|
||||||
@@ -78,4 +53,147 @@ enum MacNodeConfigFile {
|
|||||||
}
|
}
|
||||||
self.saveDict(root)
|
self.saveDict(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func agentSection(_ systemRun: [String: Any], agentId: String) -> [String: Any]? {
|
||||||
|
let agents = systemRun["agents"] as? [String: Any]
|
||||||
|
return agents?[agentId] as? [String: Any]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func updateAgentSection(
|
||||||
|
_ systemRun: inout [String: Any],
|
||||||
|
agentId: String,
|
||||||
|
mutate: (inout [String: Any]) -> Void)
|
||||||
|
{
|
||||||
|
var agents = systemRun["agents"] as? [String: Any] ?? [:]
|
||||||
|
var entry = agents[agentId] as? [String: Any] ?? [:]
|
||||||
|
mutate(&entry)
|
||||||
|
if entry.isEmpty {
|
||||||
|
agents.removeValue(forKey: agentId)
|
||||||
|
} else {
|
||||||
|
agents[agentId] = entry
|
||||||
|
}
|
||||||
|
if agents.isEmpty {
|
||||||
|
systemRun.removeValue(forKey: "agents")
|
||||||
|
} else {
|
||||||
|
systemRun["agents"] = agents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func systemRunPolicy(agentId: String? = nil) -> SystemRunPolicy? {
|
||||||
|
let root = self.loadDict()
|
||||||
|
let systemRun = self.systemRunSection(from: root)
|
||||||
|
if let agentId, let agent = self.agentSection(systemRun, agentId: agentId) {
|
||||||
|
let raw = agent["policy"] as? String
|
||||||
|
if let raw, let policy = SystemRunPolicy(rawValue: raw) { return policy }
|
||||||
|
}
|
||||||
|
let raw = systemRun["policy"] as? String
|
||||||
|
guard let raw, let policy = SystemRunPolicy(rawValue: raw) else { return nil }
|
||||||
|
return policy
|
||||||
|
}
|
||||||
|
|
||||||
|
static func setSystemRunPolicy(_ policy: SystemRunPolicy, agentId: String? = nil) {
|
||||||
|
self.updateSystemRunSection { systemRun in
|
||||||
|
if let agentId {
|
||||||
|
self.updateAgentSection(&systemRun, agentId: agentId) { entry in
|
||||||
|
entry["policy"] = policy.rawValue
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
systemRun["policy"] = policy.rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func systemRunAutoAllowSkills(agentId: String?) -> Bool? {
|
||||||
|
let root = self.loadDict()
|
||||||
|
let systemRun = self.systemRunSection(from: root)
|
||||||
|
if let agentId, let agent = self.agentSection(systemRun, agentId: agentId) {
|
||||||
|
if let value = agent["autoAllowSkills"] as? Bool { return value }
|
||||||
|
}
|
||||||
|
return systemRun["autoAllowSkills"] as? Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
static func setSystemRunAutoAllowSkills(_ enabled: Bool, agentId: String?) {
|
||||||
|
self.updateSystemRunSection { systemRun in
|
||||||
|
if let agentId {
|
||||||
|
self.updateAgentSection(&systemRun, agentId: agentId) { entry in
|
||||||
|
entry["autoAllowSkills"] = enabled
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
systemRun["autoAllowSkills"] = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func systemRunAllowlist(agentId: String?) -> [SystemRunAllowlistEntry]? {
|
||||||
|
let root = self.loadDict()
|
||||||
|
let systemRun = self.systemRunSection(from: root)
|
||||||
|
let raw: [Any]? = {
|
||||||
|
if let agentId, let agent = self.agentSection(systemRun, agentId: agentId) {
|
||||||
|
return agent["allowlist"] as? [Any]
|
||||||
|
}
|
||||||
|
return systemRun["allowlist"] as? [Any]
|
||||||
|
}()
|
||||||
|
guard let raw else { return nil }
|
||||||
|
|
||||||
|
if raw.allSatisfy({ $0 is String }) {
|
||||||
|
let legacy = raw.compactMap { $0 as? String }
|
||||||
|
return legacy.compactMap { key in
|
||||||
|
let pattern = key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !pattern.isEmpty else { return nil }
|
||||||
|
return SystemRunAllowlistEntry(
|
||||||
|
pattern: pattern,
|
||||||
|
enabled: true,
|
||||||
|
matchKind: .argv,
|
||||||
|
source: .manual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw.compactMap { item in
|
||||||
|
guard let dict = item as? [String: Any] else { return nil }
|
||||||
|
return SystemRunAllowlistEntry(dict: dict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func setSystemRunAllowlist(_ allowlist: [SystemRunAllowlistEntry], agentId: String?) {
|
||||||
|
let cleaned = allowlist
|
||||||
|
.map { $0 }
|
||||||
|
.filter { !$0.pattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||||
|
let raw = cleaned.map { $0.asDict() }
|
||||||
|
self.updateSystemRunSection { systemRun in
|
||||||
|
if let agentId {
|
||||||
|
self.updateAgentSection(&systemRun, agentId: agentId) { entry in
|
||||||
|
if raw.isEmpty {
|
||||||
|
entry.removeValue(forKey: "allowlist")
|
||||||
|
} else {
|
||||||
|
entry["allowlist"] = raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if raw.isEmpty {
|
||||||
|
systemRun.removeValue(forKey: "allowlist")
|
||||||
|
} else {
|
||||||
|
systemRun["allowlist"] = raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func systemRunAllowlistStrings() -> [String]? {
|
||||||
|
let root = self.loadDict()
|
||||||
|
let systemRun = self.systemRunSection(from: root)
|
||||||
|
return systemRun["allowlist"] as? [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func setSystemRunAllowlistStrings(_ allowlist: [String]) {
|
||||||
|
let cleaned = allowlist
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
self.updateSystemRunSection { systemRun in
|
||||||
|
if cleaned.isEmpty {
|
||||||
|
systemRun.removeValue(forKey: "allowlist")
|
||||||
|
} else {
|
||||||
|
systemRun["allowlist"] = cleaned
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -428,8 +428,32 @@ actor MacNodeRuntime {
|
|||||||
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
|
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
|
||||||
}
|
}
|
||||||
|
|
||||||
let wasAllowlisted = SystemRunAllowlist.contains(command)
|
let trimmedAgent = params.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
switch Self.systemRunPolicy() {
|
let agentId = trimmedAgent.isEmpty ? nil : trimmedAgent
|
||||||
|
let policy = SystemRunPolicy.load(agentId: agentId)
|
||||||
|
let allowlistEntries = SystemRunAllowlistStore.load(agentId: agentId)
|
||||||
|
let resolution = SystemRunCommandResolution.resolve(command: command, cwd: params.cwd)
|
||||||
|
let allowlistMatch = SystemRunAllowlistStore.match(
|
||||||
|
command: command,
|
||||||
|
resolution: resolution,
|
||||||
|
entries: allowlistEntries)
|
||||||
|
let autoAllowSkills = MacNodeConfigFile.systemRunAutoAllowSkills(agentId: agentId) ?? false
|
||||||
|
let skillAllow: Bool
|
||||||
|
if autoAllowSkills, let name = resolution?.executableName {
|
||||||
|
let bins = await SkillBinsCache.shared.currentBins()
|
||||||
|
skillAllow = bins.contains(name)
|
||||||
|
} else {
|
||||||
|
skillAllow = false
|
||||||
|
}
|
||||||
|
|
||||||
|
let shouldPrompt: Bool = {
|
||||||
|
if policy == .never { return false }
|
||||||
|
if allowlistMatch != nil { return false }
|
||||||
|
if skillAllow { return false }
|
||||||
|
return policy == .ask
|
||||||
|
}()
|
||||||
|
|
||||||
|
switch policy {
|
||||||
case .never:
|
case .never:
|
||||||
return Self.errorResponse(
|
return Self.errorResponse(
|
||||||
req,
|
req,
|
||||||
@@ -438,16 +462,24 @@ actor MacNodeRuntime {
|
|||||||
case .always:
|
case .always:
|
||||||
break
|
break
|
||||||
case .ask:
|
case .ask:
|
||||||
if !wasAllowlisted {
|
if shouldPrompt {
|
||||||
let services = await self.mainActorServices()
|
let services = await self.mainActorServices()
|
||||||
let decision = await services.confirmSystemRun(
|
let decision = await services.confirmSystemRun(context: SystemRunPromptContext(
|
||||||
command: SystemRunAllowlist.displayString(for: command),
|
command: SystemRunAllowlist.displayString(for: command),
|
||||||
cwd: params.cwd)
|
cwd: params.cwd,
|
||||||
|
agentId: agentId,
|
||||||
|
executablePath: resolution?.resolvedPath))
|
||||||
switch decision {
|
switch decision {
|
||||||
case .allowOnce:
|
case .allowOnce:
|
||||||
break
|
break
|
||||||
case .allowAlways:
|
case .allowAlways:
|
||||||
SystemRunAllowlist.add(command)
|
if let resolvedPath = resolution?.resolvedPath, !resolvedPath.isEmpty {
|
||||||
|
_ = SystemRunAllowlistStore.add(pattern: resolvedPath, agentId: agentId)
|
||||||
|
} else if let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!raw.isEmpty
|
||||||
|
{
|
||||||
|
_ = SystemRunAllowlistStore.add(pattern: raw, agentId: agentId)
|
||||||
|
}
|
||||||
case .deny:
|
case .deny:
|
||||||
return Self.errorResponse(
|
return Self.errorResponse(
|
||||||
req,
|
req,
|
||||||
@@ -457,6 +489,14 @@ actor MacNodeRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let match = allowlistMatch {
|
||||||
|
SystemRunAllowlistStore.markUsed(
|
||||||
|
entryId: match.id,
|
||||||
|
command: command,
|
||||||
|
resolvedPath: resolution?.resolvedPath,
|
||||||
|
agentId: agentId)
|
||||||
|
}
|
||||||
|
|
||||||
let env = Self.sanitizedEnv(params.env)
|
let env = Self.sanitizedEnv(params.env)
|
||||||
|
|
||||||
if params.needsScreenRecording == true {
|
if params.needsScreenRecording == true {
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ enum SystemRunDecision: Sendable {
|
|||||||
case deny
|
case deny
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct SystemRunPromptContext: Sendable {
|
||||||
|
let command: String
|
||||||
|
let cwd: String?
|
||||||
|
let agentId: String?
|
||||||
|
let executablePath: String?
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol MacNodeRuntimeMainActorServices: Sendable {
|
protocol MacNodeRuntimeMainActorServices: Sendable {
|
||||||
func recordScreen(
|
func recordScreen(
|
||||||
@@ -25,7 +32,7 @@ protocol MacNodeRuntimeMainActorServices: Sendable {
|
|||||||
maxAgeMs: Int?,
|
maxAgeMs: Int?,
|
||||||
timeoutMs: Int?) async throws -> CLLocation
|
timeoutMs: Int?) async throws -> CLLocation
|
||||||
|
|
||||||
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision
|
func confirmSystemRun(context: SystemRunPromptContext) async -> SystemRunDecision
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -67,16 +74,24 @@ final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices
|
|||||||
timeoutMs: timeoutMs)
|
timeoutMs: timeoutMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision {
|
func confirmSystemRun(context: SystemRunPromptContext) async -> SystemRunDecision {
|
||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
alert.alertStyle = .warning
|
alert.alertStyle = .warning
|
||||||
alert.messageText = "Allow this command?"
|
alert.messageText = "Allow this command?"
|
||||||
|
|
||||||
var details = "Clawdbot wants to run:\n\n\(command)"
|
var details = "Clawdbot wants to run:\n\n\(context.command)"
|
||||||
let trimmedCwd = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let trimmedCwd = context.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
if !trimmedCwd.isEmpty {
|
if !trimmedCwd.isEmpty {
|
||||||
details += "\n\nWorking directory:\n\(trimmedCwd)"
|
details += "\n\nWorking directory:\n\(trimmedCwd)"
|
||||||
}
|
}
|
||||||
|
let trimmedAgent = context.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedAgent.isEmpty {
|
||||||
|
details += "\n\nAgent:\n\(trimmedAgent)"
|
||||||
|
}
|
||||||
|
let trimmedPath = context.executablePath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedPath.isEmpty {
|
||||||
|
details += "\n\nExecutable:\n\(trimmedPath)"
|
||||||
|
}
|
||||||
details += "\n\nThis runs on this Mac via node mode."
|
details += "\n\nThis runs on this Mac via node mode."
|
||||||
alert.informativeText = details
|
alert.informativeText = details
|
||||||
|
|
||||||
|
|||||||
267
apps/macos/Sources/Clawdbot/SystemRunApprovals.swift
Normal file
267
apps/macos/Sources/Clawdbot/SystemRunApprovals.swift
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum SystemRunAllowlistMatchKind: String {
|
||||||
|
case glob
|
||||||
|
case argv
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SystemRunAllowlistSource: String {
|
||||||
|
case manual
|
||||||
|
case skill
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SystemRunAllowlistEntry: Identifiable, Hashable {
|
||||||
|
let id: String
|
||||||
|
var pattern: String
|
||||||
|
var enabled: Bool
|
||||||
|
var matchKind: SystemRunAllowlistMatchKind
|
||||||
|
var source: SystemRunAllowlistSource?
|
||||||
|
var skillId: String?
|
||||||
|
var lastUsedAt: Date?
|
||||||
|
var lastUsedCommand: String?
|
||||||
|
var lastUsedPath: String?
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: String = UUID().uuidString,
|
||||||
|
pattern: String,
|
||||||
|
enabled: Bool = true,
|
||||||
|
matchKind: SystemRunAllowlistMatchKind = .glob,
|
||||||
|
source: SystemRunAllowlistSource? = .manual,
|
||||||
|
skillId: String? = nil,
|
||||||
|
lastUsedAt: Date? = nil,
|
||||||
|
lastUsedCommand: String? = nil,
|
||||||
|
lastUsedPath: String? = nil)
|
||||||
|
{
|
||||||
|
self.id = id
|
||||||
|
self.pattern = pattern
|
||||||
|
self.enabled = enabled
|
||||||
|
self.matchKind = matchKind
|
||||||
|
self.source = source
|
||||||
|
self.skillId = skillId
|
||||||
|
self.lastUsedAt = lastUsedAt
|
||||||
|
self.lastUsedCommand = lastUsedCommand
|
||||||
|
self.lastUsedPath = lastUsedPath
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(dict: [String: Any]) {
|
||||||
|
let id = dict["id"] as? String ?? UUID().uuidString
|
||||||
|
let pattern = (dict["pattern"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if pattern.isEmpty { return nil }
|
||||||
|
let enabled = dict["enabled"] as? Bool ?? true
|
||||||
|
let matchRaw = dict["matchKind"] as? String
|
||||||
|
let matchKind = SystemRunAllowlistMatchKind(rawValue: matchRaw ?? "") ?? .glob
|
||||||
|
let sourceRaw = dict["source"] as? String
|
||||||
|
let source = SystemRunAllowlistSource(rawValue: sourceRaw ?? "")
|
||||||
|
let skillId = (dict["skillId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let lastUsedAt = (dict["lastUsedAt"] as? Double).map { Date(timeIntervalSince1970: $0) }
|
||||||
|
let lastUsedCommand = dict["lastUsedCommand"] as? String
|
||||||
|
let lastUsedPath = dict["lastUsedPath"] as? String
|
||||||
|
|
||||||
|
self.init(
|
||||||
|
id: id,
|
||||||
|
pattern: pattern,
|
||||||
|
enabled: enabled,
|
||||||
|
matchKind: matchKind,
|
||||||
|
source: source,
|
||||||
|
skillId: skillId?.isEmpty == true ? nil : skillId,
|
||||||
|
lastUsedAt: lastUsedAt,
|
||||||
|
lastUsedCommand: lastUsedCommand,
|
||||||
|
lastUsedPath: lastUsedPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func asDict() -> [String: Any] {
|
||||||
|
var dict: [String: Any] = [
|
||||||
|
"id": self.id,
|
||||||
|
"pattern": self.pattern,
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"matchKind": self.matchKind.rawValue,
|
||||||
|
]
|
||||||
|
if let source = self.source { dict["source"] = source.rawValue }
|
||||||
|
if let skillId = self.skillId { dict["skillId"] = skillId }
|
||||||
|
if let lastUsedAt = self.lastUsedAt { dict["lastUsedAt"] = lastUsedAt.timeIntervalSince1970 }
|
||||||
|
if let lastUsedCommand = self.lastUsedCommand { dict["lastUsedCommand"] = lastUsedCommand }
|
||||||
|
if let lastUsedPath = self.lastUsedPath { dict["lastUsedPath"] = lastUsedPath }
|
||||||
|
return dict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SystemRunCommandResolution: Sendable {
|
||||||
|
let rawExecutable: String
|
||||||
|
let resolvedPath: String?
|
||||||
|
let executableName: String
|
||||||
|
let cwd: String?
|
||||||
|
|
||||||
|
static func resolve(command: [String], cwd: String?) -> SystemRunCommandResolution? {
|
||||||
|
guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let expanded = raw.hasPrefix("~") ? (raw as NSString).expandingTildeInPath : raw
|
||||||
|
let hasPathSeparator = expanded.contains("/")
|
||||||
|
let resolvedPath: String? = {
|
||||||
|
if hasPathSeparator {
|
||||||
|
if expanded.hasPrefix("/") {
|
||||||
|
return expanded
|
||||||
|
}
|
||||||
|
let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let root = (base?.isEmpty == false) ? base! : FileManager.default.currentDirectoryPath
|
||||||
|
return URL(fileURLWithPath: root).appendingPathComponent(expanded).path
|
||||||
|
}
|
||||||
|
return CommandResolver.findExecutable(named: expanded)
|
||||||
|
}()
|
||||||
|
let name = resolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded
|
||||||
|
return SystemRunCommandResolution(rawExecutable: expanded, resolvedPath: resolvedPath, executableName: name, cwd: cwd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SystemRunAllowlistStore {
|
||||||
|
static func load(agentId: String?) -> [SystemRunAllowlistEntry] {
|
||||||
|
if let entries = MacNodeConfigFile.systemRunAllowlist(agentId: agentId) {
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
static func save(_ entries: [SystemRunAllowlistEntry], agentId: String?) {
|
||||||
|
MacNodeConfigFile.setSystemRunAllowlist(entries, agentId: agentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func add(pattern: String, agentId: String?, source: SystemRunAllowlistSource = .manual) -> SystemRunAllowlistEntry {
|
||||||
|
var entries = self.load(agentId: agentId)
|
||||||
|
let entry = SystemRunAllowlistEntry(pattern: pattern, enabled: true, matchKind: .glob, source: source)
|
||||||
|
entries.append(entry)
|
||||||
|
self.save(entries, agentId: agentId)
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
static func update(_ entry: SystemRunAllowlistEntry, agentId: String?) {
|
||||||
|
var entries = self.load(agentId: agentId)
|
||||||
|
guard let index = entries.firstIndex(where: { $0.id == entry.id }) else { return }
|
||||||
|
entries[index] = entry
|
||||||
|
self.save(entries, agentId: agentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func remove(entryId: String, agentId: String?) {
|
||||||
|
let entries = self.load(agentId: agentId).filter { $0.id != entryId }
|
||||||
|
self.save(entries, agentId: agentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func markUsed(entryId: String, command: [String], resolvedPath: String?, agentId: String?) {
|
||||||
|
var entries = self.load(agentId: agentId)
|
||||||
|
guard let index = entries.firstIndex(where: { $0.id == entryId }) else { return }
|
||||||
|
entries[index].lastUsedAt = Date()
|
||||||
|
entries[index].lastUsedCommand = SystemRunAllowlist.displayString(for: command)
|
||||||
|
entries[index].lastUsedPath = resolvedPath
|
||||||
|
self.save(entries, agentId: agentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func match(
|
||||||
|
command: [String],
|
||||||
|
resolution: SystemRunCommandResolution?,
|
||||||
|
entries: [SystemRunAllowlistEntry]) -> SystemRunAllowlistEntry?
|
||||||
|
{
|
||||||
|
guard !entries.isEmpty else { return nil }
|
||||||
|
let argvKey = SystemRunAllowlist.legacyKey(for: command)
|
||||||
|
let resolvedPath = resolution?.resolvedPath
|
||||||
|
let executableName = resolution?.executableName
|
||||||
|
let rawExecutable = resolution?.rawExecutable
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
guard entry.enabled else { continue }
|
||||||
|
switch entry.matchKind {
|
||||||
|
case .argv:
|
||||||
|
if argvKey == entry.pattern { return entry }
|
||||||
|
case .glob:
|
||||||
|
let pattern = entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if pattern.isEmpty { continue }
|
||||||
|
let hasPath = pattern.contains("/") || pattern.contains("~")
|
||||||
|
if hasPath {
|
||||||
|
let target = resolvedPath ?? rawExecutable
|
||||||
|
if let target, SystemRunGlob.matches(pattern: pattern, target: target) {
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
} else if let name = executableName, SystemRunGlob.matches(pattern: pattern, target: name) {
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SystemRunGlob {
|
||||||
|
static func matches(pattern rawPattern: String, target: String) -> Bool {
|
||||||
|
let trimmed = rawPattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return false }
|
||||||
|
let expanded = trimmed.hasPrefix("~") ? (trimmed as NSString).expandingTildeInPath : trimmed
|
||||||
|
guard let regex = self.regex(for: expanded) else { return false }
|
||||||
|
let range = NSRange(location: 0, length: target.utf16.count)
|
||||||
|
return regex.firstMatch(in: target, options: [], range: range) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func regex(for pattern: String) -> NSRegularExpression? {
|
||||||
|
var regex = "^"
|
||||||
|
var idx = pattern.startIndex
|
||||||
|
while idx < pattern.endIndex {
|
||||||
|
let ch = pattern[idx]
|
||||||
|
if ch == "*" {
|
||||||
|
let next = pattern.index(after: idx)
|
||||||
|
if next < pattern.endIndex, pattern[next] == "*" {
|
||||||
|
regex += ".*"
|
||||||
|
idx = pattern.index(after: next)
|
||||||
|
} else {
|
||||||
|
regex += "[^/]*"
|
||||||
|
idx = next
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ch == "?" {
|
||||||
|
regex += "."
|
||||||
|
idx = pattern.index(after: idx)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
regex += NSRegularExpression.escapedPattern(for: String(ch))
|
||||||
|
idx = pattern.index(after: idx)
|
||||||
|
}
|
||||||
|
regex += "$"
|
||||||
|
return try? NSRegularExpression(pattern: regex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actor SkillBinsCache {
|
||||||
|
static let shared = SkillBinsCache()
|
||||||
|
|
||||||
|
private var bins: Set<String> = []
|
||||||
|
private var lastRefresh: Date?
|
||||||
|
private let refreshInterval: TimeInterval = 90
|
||||||
|
|
||||||
|
func currentBins(force: Bool = false) async -> Set<String> {
|
||||||
|
if force || self.isStale() {
|
||||||
|
await self.refresh()
|
||||||
|
}
|
||||||
|
return self.bins
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() async {
|
||||||
|
do {
|
||||||
|
let report = try await GatewayConnection.shared.skillsStatus()
|
||||||
|
var next = Set<String>()
|
||||||
|
for skill in report.skills {
|
||||||
|
for bin in skill.requirements.bins {
|
||||||
|
let trimmed = bin.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !trimmed.isEmpty { next.insert(trimmed) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.bins = next
|
||||||
|
self.lastRefresh = Date()
|
||||||
|
} catch {
|
||||||
|
if self.lastRefresh == nil {
|
||||||
|
self.bins = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isStale() -> Bool {
|
||||||
|
guard let lastRefresh else { return true }
|
||||||
|
return Date().timeIntervalSince(lastRefresh) > self.refreshInterval
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,10 @@ enum SystemRunPolicy: String, CaseIterable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func load(from defaults: UserDefaults = .standard) -> SystemRunPolicy {
|
static func load(agentId: String? = nil, from defaults: UserDefaults = .standard) -> SystemRunPolicy {
|
||||||
|
if let policy = MacNodeConfigFile.systemRunPolicy(agentId: agentId) {
|
||||||
|
return policy
|
||||||
|
}
|
||||||
if let policy = MacNodeConfigFile.systemRunPolicy() {
|
if let policy = MacNodeConfigFile.systemRunPolicy() {
|
||||||
return policy
|
return policy
|
||||||
}
|
}
|
||||||
@@ -40,7 +43,7 @@ enum SystemRunPolicy: String, CaseIterable, Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum SystemRunAllowlist {
|
enum SystemRunAllowlist {
|
||||||
static func key(for argv: [String]) -> String {
|
static func legacyKey(for argv: [String]) -> String {
|
||||||
let trimmed = argv.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
let trimmed = argv.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
guard !trimmed.isEmpty else { return "" }
|
guard !trimmed.isEmpty else { return "" }
|
||||||
if let data = try? JSONEncoder().encode(trimmed),
|
if let data = try? JSONEncoder().encode(trimmed),
|
||||||
@@ -62,28 +65,14 @@ enum SystemRunAllowlist {
|
|||||||
}.joined(separator: " ")
|
}.joined(separator: " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
static func load(from defaults: UserDefaults = .standard) -> Set<String> {
|
static func loadLegacy(from defaults: UserDefaults = .standard) -> Set<String> {
|
||||||
if let allowlist = MacNodeConfigFile.systemRunAllowlist() {
|
if let allowlist = MacNodeConfigFile.systemRunAllowlistStrings() {
|
||||||
return Set(allowlist)
|
return Set(allowlist)
|
||||||
}
|
}
|
||||||
if let legacy = defaults.stringArray(forKey: systemRunAllowlistKey), !legacy.isEmpty {
|
if let legacy = defaults.stringArray(forKey: systemRunAllowlistKey), !legacy.isEmpty {
|
||||||
MacNodeConfigFile.setSystemRunAllowlist(legacy)
|
MacNodeConfigFile.setSystemRunAllowlistStrings(legacy)
|
||||||
return Set(legacy)
|
return Set(legacy)
|
||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
static func contains(_ argv: [String], defaults: UserDefaults = .standard) -> Bool {
|
|
||||||
let key = key(for: argv)
|
|
||||||
return self.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 = self.load(from: defaults)
|
|
||||||
if allowlist.insert(key).inserted {
|
|
||||||
MacNodeConfigFile.setSystemRunAllowlist(Array(allowlist).sorted())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
291
apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift
Normal file
291
apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SystemRunSettingsView: View {
|
||||||
|
@State private var model = SystemRunSettingsModel()
|
||||||
|
@State private var tab: SystemRunSettingsTab = .policy
|
||||||
|
@State private var newPattern: String = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(alignment: .center, spacing: 12) {
|
||||||
|
Text("Node Run Commands")
|
||||||
|
.font(.body)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
if self.model.agentIds.count > 1 {
|
||||||
|
Picker("Agent", selection: Binding(
|
||||||
|
get: { self.model.selectedAgentId },
|
||||||
|
set: { self.model.selectAgent($0) }))
|
||||||
|
{
|
||||||
|
ForEach(self.model.agentIds, id: \.self) { id in
|
||||||
|
Text(id).tag(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
.frame(width: 160, alignment: .trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Picker("", selection: self.$tab) {
|
||||||
|
ForEach(SystemRunSettingsTab.allCases) { tab in
|
||||||
|
Text(tab.title).tag(tab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(width: 280)
|
||||||
|
|
||||||
|
if self.tab == .policy {
|
||||||
|
self.policyView
|
||||||
|
} else {
|
||||||
|
self.allowlistView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await self.model.refresh() }
|
||||||
|
.onChange(of: self.tab) { _, _ in
|
||||||
|
Task { await self.model.refreshSkillBins() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var policyView: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Picker("", selection: Binding(
|
||||||
|
get: { self.model.policy },
|
||||||
|
set: { self.model.setPolicy($0) }))
|
||||||
|
{
|
||||||
|
ForEach(SystemRunPolicy.allCases) { policy in
|
||||||
|
Text(policy.title).tag(policy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var allowlistView: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Toggle("Auto-allow skill CLIs", isOn: Binding(
|
||||||
|
get: { self.model.autoAllowSkills },
|
||||||
|
set: { self.model.setAutoAllowSkills($0) }))
|
||||||
|
|
||||||
|
if self.model.autoAllowSkills, !self.model.skillBins.isEmpty {
|
||||||
|
Text("Skill CLIs: \(self.model.skillBins.joined(separator: ", "))")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
TextField("Add allowlist pattern (supports globs)", text: self.$newPattern)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
Button("Add") {
|
||||||
|
let pattern = self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !pattern.isEmpty else { return }
|
||||||
|
self.model.addEntry(pattern)
|
||||||
|
self.newPattern = ""
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.model.entries.isEmpty {
|
||||||
|
Text("No allowlisted commands yet.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
ForEach(Array(self.model.entries.enumerated()), id: \.element.id) { index, _ in
|
||||||
|
SystemRunAllowlistRow(
|
||||||
|
entry: Binding(
|
||||||
|
get: { self.model.entries[index] },
|
||||||
|
set: { self.model.updateEntry($0) }),
|
||||||
|
onRemove: { self.model.removeEntry($0.id) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum SystemRunSettingsTab: String, CaseIterable, Identifiable {
|
||||||
|
case policy
|
||||||
|
case allowlist
|
||||||
|
|
||||||
|
var id: String { self.rawValue }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .policy: "Policy"
|
||||||
|
case .allowlist: "Allowlist"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SystemRunAllowlistRow: View {
|
||||||
|
@Binding var entry: SystemRunAllowlistEntry
|
||||||
|
let onRemove: (SystemRunAllowlistEntry) -> Void
|
||||||
|
@State private var draftPattern: String = ""
|
||||||
|
|
||||||
|
private static let relativeFormatter: RelativeDateTimeFormatter = {
|
||||||
|
let formatter = RelativeDateTimeFormatter()
|
||||||
|
formatter.unitsStyle = .short
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Toggle("", isOn: self.$entry.enabled)
|
||||||
|
.labelsHidden()
|
||||||
|
|
||||||
|
TextField("Pattern", text: self.patternBinding)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
|
||||||
|
if self.entry.matchKind == .argv {
|
||||||
|
Text("Legacy")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(role: .destructive) {
|
||||||
|
self.onRemove(self.entry)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let lastUsedAt = self.entry.lastUsedAt {
|
||||||
|
Text("Last used \(Self.relativeFormatter.localizedString(for: lastUsedAt, relativeTo: Date()))")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else if let lastUsedCommand = self.entry.lastUsedCommand, !lastUsedCommand.isEmpty {
|
||||||
|
Text("Last used: \(lastUsedCommand)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
self.draftPattern = self.entry.pattern
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var patternBinding: Binding<String> {
|
||||||
|
Binding(
|
||||||
|
get: { self.draftPattern.isEmpty ? self.entry.pattern : self.draftPattern },
|
||||||
|
set: { newValue in
|
||||||
|
self.draftPattern = newValue
|
||||||
|
self.entry.pattern = newValue
|
||||||
|
if self.entry.matchKind == .argv {
|
||||||
|
self.entry.matchKind = .glob
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class SystemRunSettingsModel {
|
||||||
|
var agentIds: [String] = []
|
||||||
|
var selectedAgentId: String = "main"
|
||||||
|
var defaultAgentId: String = "main"
|
||||||
|
var policy: SystemRunPolicy = .ask
|
||||||
|
var autoAllowSkills = false
|
||||||
|
var entries: [SystemRunAllowlistEntry] = []
|
||||||
|
var skillBins: [String] = []
|
||||||
|
|
||||||
|
func refresh() async {
|
||||||
|
await self.refreshAgents()
|
||||||
|
self.loadSettings(for: self.selectedAgentId)
|
||||||
|
await self.refreshSkillBins()
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshAgents() async {
|
||||||
|
let root = await ConfigStore.load()
|
||||||
|
let agents = root["agents"] as? [String: Any]
|
||||||
|
let list = agents?["list"] as? [[String: Any]] ?? []
|
||||||
|
var ids: [String] = []
|
||||||
|
var seen = Set<String>()
|
||||||
|
var defaultId: String?
|
||||||
|
for entry in list {
|
||||||
|
guard let raw = entry["id"] as? String else { continue }
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { continue }
|
||||||
|
if !seen.insert(trimmed).inserted { continue }
|
||||||
|
ids.append(trimmed)
|
||||||
|
if (entry["default"] as? Bool) == true, defaultId == nil {
|
||||||
|
defaultId = trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ids.isEmpty {
|
||||||
|
ids = ["main"]
|
||||||
|
defaultId = "main"
|
||||||
|
} else if defaultId == nil {
|
||||||
|
defaultId = ids.first
|
||||||
|
}
|
||||||
|
self.agentIds = ids
|
||||||
|
self.defaultAgentId = defaultId ?? "main"
|
||||||
|
if !self.agentIds.contains(self.selectedAgentId) {
|
||||||
|
self.selectedAgentId = self.defaultAgentId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectAgent(_ id: String) {
|
||||||
|
self.selectedAgentId = id
|
||||||
|
self.loadSettings(for: id)
|
||||||
|
Task { await self.refreshSkillBins() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSettings(for agentId: String) {
|
||||||
|
self.policy = SystemRunPolicy.load(agentId: agentId)
|
||||||
|
self.autoAllowSkills = MacNodeConfigFile.systemRunAutoAllowSkills(agentId: agentId) ?? false
|
||||||
|
self.entries = SystemRunAllowlistStore.load(agentId: agentId)
|
||||||
|
.sorted { $0.pattern.localizedCaseInsensitiveCompare($1.pattern) == .orderedAscending }
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPolicy(_ policy: SystemRunPolicy) {
|
||||||
|
self.policy = policy
|
||||||
|
MacNodeConfigFile.setSystemRunPolicy(policy, agentId: self.selectedAgentId)
|
||||||
|
if self.selectedAgentId == self.defaultAgentId || self.agentIds.count <= 1 {
|
||||||
|
AppStateStore.shared.systemRunPolicy = policy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setAutoAllowSkills(_ enabled: Bool) {
|
||||||
|
self.autoAllowSkills = enabled
|
||||||
|
MacNodeConfigFile.setSystemRunAutoAllowSkills(enabled, agentId: self.selectedAgentId)
|
||||||
|
Task { await self.refreshSkillBins(force: enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func addEntry(_ pattern: String) {
|
||||||
|
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return }
|
||||||
|
let entry = SystemRunAllowlistEntry(pattern: trimmed, enabled: true, matchKind: .glob, source: .manual)
|
||||||
|
self.entries.append(entry)
|
||||||
|
SystemRunAllowlistStore.save(self.entries, agentId: self.selectedAgentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateEntry(_ entry: SystemRunAllowlistEntry) {
|
||||||
|
guard let index = self.entries.firstIndex(where: { $0.id == entry.id }) else { return }
|
||||||
|
self.entries[index] = entry
|
||||||
|
SystemRunAllowlistStore.save(self.entries, agentId: self.selectedAgentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeEntry(_ id: String) {
|
||||||
|
self.entries.removeAll { $0.id == id }
|
||||||
|
SystemRunAllowlistStore.save(self.entries, agentId: self.selectedAgentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshSkillBins(force: Bool = false) async {
|
||||||
|
guard self.autoAllowSkills else {
|
||||||
|
self.skillBins = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let bins = await SkillBinsCache.shared.currentBins(force: force)
|
||||||
|
self.skillBins = bins.sorted()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ import Testing
|
|||||||
let clawdbotPath = tmp.appendingPathComponent("node_modules/.bin/clawdbot")
|
let clawdbotPath = tmp.appendingPathComponent("node_modules/.bin/clawdbot")
|
||||||
try self.makeExec(at: clawdbotPath)
|
try self.makeExec(at: clawdbotPath)
|
||||||
|
|
||||||
let cmd = CommandResolver.clawdbotCommand(subcommand: "gateway", defaults: defaults)
|
let cmd = CommandResolver.clawdbotCommand(subcommand: "gateway", defaults: defaults, configRoot: [:])
|
||||||
#expect(cmd.prefix(2).elementsEqual([clawdbotPath.path, "gateway"]))
|
#expect(cmd.prefix(2).elementsEqual([clawdbotPath.path, "gateway"]))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +55,7 @@ import Testing
|
|||||||
let cmd = CommandResolver.clawdbotCommand(
|
let cmd = CommandResolver.clawdbotCommand(
|
||||||
subcommand: "rpc",
|
subcommand: "rpc",
|
||||||
defaults: defaults,
|
defaults: defaults,
|
||||||
|
configRoot: [:],
|
||||||
searchPaths: [tmp.appendingPathComponent("node_modules/.bin").path])
|
searchPaths: [tmp.appendingPathComponent("node_modules/.bin").path])
|
||||||
|
|
||||||
#expect(cmd.count >= 3)
|
#expect(cmd.count >= 3)
|
||||||
@@ -75,7 +76,7 @@ import Testing
|
|||||||
let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm")
|
let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm")
|
||||||
try self.makeExec(at: pnpmPath)
|
try self.makeExec(at: pnpmPath)
|
||||||
|
|
||||||
let cmd = CommandResolver.clawdbotCommand(subcommand: "rpc", defaults: defaults)
|
let cmd = CommandResolver.clawdbotCommand(subcommand: "rpc", defaults: defaults, configRoot: [:])
|
||||||
|
|
||||||
#expect(cmd.prefix(4).elementsEqual([pnpmPath.path, "--silent", "clawdbot", "rpc"]))
|
#expect(cmd.prefix(4).elementsEqual([pnpmPath.path, "--silent", "clawdbot", "rpc"]))
|
||||||
}
|
}
|
||||||
@@ -93,7 +94,8 @@ import Testing
|
|||||||
let cmd = CommandResolver.clawdbotCommand(
|
let cmd = CommandResolver.clawdbotCommand(
|
||||||
subcommand: "health",
|
subcommand: "health",
|
||||||
extraArgs: ["--json", "--timeout", "5"],
|
extraArgs: ["--json", "--timeout", "5"],
|
||||||
defaults: defaults)
|
defaults: defaults,
|
||||||
|
configRoot: [:])
|
||||||
|
|
||||||
#expect(cmd.prefix(5).elementsEqual([pnpmPath.path, "--silent", "clawdbot", "health", "--json"]))
|
#expect(cmd.prefix(5).elementsEqual([pnpmPath.path, "--silent", "clawdbot", "health", "--json"]))
|
||||||
#expect(cmd.suffix(2).elementsEqual(["--timeout", "5"]))
|
#expect(cmd.suffix(2).elementsEqual(["--timeout", "5"]))
|
||||||
@@ -114,7 +116,11 @@ import Testing
|
|||||||
defaults.set("/tmp/id_ed25519", forKey: remoteIdentityKey)
|
defaults.set("/tmp/id_ed25519", forKey: remoteIdentityKey)
|
||||||
defaults.set("/srv/clawdbot", forKey: remoteProjectRootKey)
|
defaults.set("/srv/clawdbot", forKey: remoteProjectRootKey)
|
||||||
|
|
||||||
let cmd = CommandResolver.clawdbotCommand(subcommand: "status", extraArgs: ["--json"], defaults: defaults)
|
let cmd = CommandResolver.clawdbotCommand(
|
||||||
|
subcommand: "status",
|
||||||
|
extraArgs: ["--json"],
|
||||||
|
defaults: defaults,
|
||||||
|
configRoot: [:])
|
||||||
|
|
||||||
#expect(cmd.first == "/usr/bin/ssh")
|
#expect(cmd.first == "/usr/bin/ssh")
|
||||||
#expect(cmd.contains("clawd@example.com"))
|
#expect(cmd.contains("clawd@example.com"))
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ struct MacNodeRuntimeTests {
|
|||||||
CLLocation(latitude: 0, longitude: 0)
|
CLLocation(latitude: 0, longitude: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision {
|
func confirmSystemRun(context: SystemRunPromptContext) async -> SystemRunDecision {
|
||||||
.allowOnce
|
.allowOnce
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Clawdbot
|
||||||
|
|
||||||
|
struct SystemRunAllowlistTests {
|
||||||
|
@Test func matchUsesResolvedPath() {
|
||||||
|
let entry = SystemRunAllowlistEntry(pattern: "/opt/homebrew/bin/rg", enabled: true, matchKind: .glob)
|
||||||
|
let resolution = SystemRunCommandResolution(
|
||||||
|
rawExecutable: "rg",
|
||||||
|
resolvedPath: "/opt/homebrew/bin/rg",
|
||||||
|
executableName: "rg",
|
||||||
|
cwd: nil)
|
||||||
|
let match = SystemRunAllowlistStore.match(
|
||||||
|
command: ["rg"],
|
||||||
|
resolution: resolution,
|
||||||
|
entries: [entry])
|
||||||
|
#expect(match?.id == entry.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func matchUsesBasenameForSimplePattern() {
|
||||||
|
let entry = SystemRunAllowlistEntry(pattern: "rg", enabled: true, matchKind: .glob)
|
||||||
|
let resolution = SystemRunCommandResolution(
|
||||||
|
rawExecutable: "rg",
|
||||||
|
resolvedPath: "/opt/homebrew/bin/rg",
|
||||||
|
executableName: "rg",
|
||||||
|
cwd: nil)
|
||||||
|
let match = SystemRunAllowlistStore.match(
|
||||||
|
command: ["rg"],
|
||||||
|
resolution: resolution,
|
||||||
|
entries: [entry])
|
||||||
|
#expect(match?.id == entry.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func matchUsesLegacyArgvKey() {
|
||||||
|
let key = SystemRunAllowlist.legacyKey(for: ["echo", "hi"])
|
||||||
|
let entry = SystemRunAllowlistEntry(pattern: key, enabled: true, matchKind: .argv)
|
||||||
|
let match = SystemRunAllowlistStore.match(
|
||||||
|
command: ["echo", "hi"],
|
||||||
|
resolution: nil,
|
||||||
|
entries: [entry])
|
||||||
|
#expect(match?.id == entry.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ import Testing
|
|||||||
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
||||||
defaults.set("ssh alice@example.com", forKey: remoteTargetKey)
|
defaults.set("ssh alice@example.com", forKey: remoteTargetKey)
|
||||||
|
|
||||||
let settings = CommandResolver.connectionSettings(defaults: defaults)
|
let settings = CommandResolver.connectionSettings(defaults: defaults, configRoot: [:])
|
||||||
#expect(settings.mode == .remote)
|
#expect(settings.mode == .remote)
|
||||||
#expect(settings.target == "alice@example.com")
|
#expect(settings.target == "alice@example.com")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,19 +24,22 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
|||||||
public var env: [String: String]?
|
public var env: [String: String]?
|
||||||
public var timeoutMs: Int?
|
public var timeoutMs: Int?
|
||||||
public var needsScreenRecording: Bool?
|
public var needsScreenRecording: Bool?
|
||||||
|
public var agentId: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
command: [String],
|
command: [String],
|
||||||
cwd: String? = nil,
|
cwd: String? = nil,
|
||||||
env: [String: String]? = nil,
|
env: [String: String]? = nil,
|
||||||
timeoutMs: Int? = nil,
|
timeoutMs: Int? = nil,
|
||||||
needsScreenRecording: Bool? = nil)
|
needsScreenRecording: Bool? = nil,
|
||||||
|
agentId: String? = nil)
|
||||||
{
|
{
|
||||||
self.command = command
|
self.command = command
|
||||||
self.cwd = cwd
|
self.cwd = cwd
|
||||||
self.env = env
|
self.env = env
|
||||||
self.timeoutMs = timeoutMs
|
self.timeoutMs = timeoutMs
|
||||||
self.needsScreenRecording = needsScreenRecording
|
self.needsScreenRecording = needsScreenRecording
|
||||||
|
self.agentId = agentId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
108
docs/tools/exec-approvals.md
Normal file
108
docs/tools/exec-approvals.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
---
|
||||||
|
summary: "Exec approvals, allowlists, and sandbox escape prompts in the macOS app"
|
||||||
|
read_when:
|
||||||
|
- Configuring exec approvals or allowlists
|
||||||
|
- Implementing exec approval UX in the macOS app
|
||||||
|
- Reviewing sandbox escape prompts and implications
|
||||||
|
---
|
||||||
|
|
||||||
|
# Exec approvals (macOS app)
|
||||||
|
|
||||||
|
Exec approvals are the **macOS companion app** guardrail for running host
|
||||||
|
commands from sandboxed agents. Think of it as a per-agent “run this on my Mac”
|
||||||
|
approval layer: the agent asks, the app decides, and the command runs (or not).
|
||||||
|
This is **in addition** to tool policy and elevated gating; all of those checks
|
||||||
|
must pass before a command can run.
|
||||||
|
|
||||||
|
If you are **not** running the macOS companion app, exec approvals are
|
||||||
|
unavailable and `system.run` requests will be rejected with a message that a
|
||||||
|
companion app is required.
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
In the macOS app, each agent has an **Exec approvals** setting:
|
||||||
|
|
||||||
|
- **Deny**: block all host exec requests from the agent.
|
||||||
|
- **Always ask**: show a confirmation dialog for each host exec request.
|
||||||
|
- **Always allow**: run host exec requests without prompting.
|
||||||
|
|
||||||
|
Optional toggles:
|
||||||
|
- **Auto-allow skill CLIs**: when enabled, CLIs referenced by known skills are
|
||||||
|
treated as allowlisted (see below).
|
||||||
|
|
||||||
|
## Allowlist (per agent)
|
||||||
|
|
||||||
|
The allowlist is **per agent**. If multiple agents exist, you can switch which
|
||||||
|
agent’s allowlist you’re editing. Entries are path-based and support **globs**.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `~/Projects/**/bin/bird`
|
||||||
|
- `~/.local/bin/*`
|
||||||
|
- `/opt/homebrew/bin/rg`
|
||||||
|
|
||||||
|
Each allowlist entry tracks:
|
||||||
|
- **last used** (timestamp)
|
||||||
|
- **last used command**
|
||||||
|
- **last used path** (resolved absolute path)
|
||||||
|
- **last seen metadata** (hash/version/mtime when available)
|
||||||
|
|
||||||
|
## How matching works
|
||||||
|
|
||||||
|
1) Parse the command to determine the executable (first token).
|
||||||
|
2) Resolve the executable to an absolute path using `PATH`.
|
||||||
|
3) Match against denylist (if present) → **deny**.
|
||||||
|
4) Match against allowlist → **allow**.
|
||||||
|
5) Otherwise follow the Exec approvals policy (deny/ask/allow).
|
||||||
|
|
||||||
|
If **auto-allow skill CLIs** is enabled, each installed skill can contribute one
|
||||||
|
or more allowlist entries. A skill-based allowlist entry only auto-allows when:
|
||||||
|
- the resolved path matches, and
|
||||||
|
- the binary hash/version matches the last approved record (if tracked).
|
||||||
|
|
||||||
|
If the binary changes (new hash/version), the command falls back to **Ask** so
|
||||||
|
the user can re-approve.
|
||||||
|
|
||||||
|
## Approval flow
|
||||||
|
|
||||||
|
When the policy is **Always ask** (or when a binary has changed), the macOS app
|
||||||
|
shows a confirmation dialog. The dialog should include:
|
||||||
|
- command + args
|
||||||
|
- cwd
|
||||||
|
- environment overrides (diff)
|
||||||
|
- policy + rule that matched (if any)
|
||||||
|
|
||||||
|
Actions:
|
||||||
|
- **Allow once** → run now
|
||||||
|
- **Always allow** → add/update allowlist entry + run
|
||||||
|
- **Deny** → block
|
||||||
|
|
||||||
|
When approved, the command runs **in the background** and the agent receives
|
||||||
|
system events as it starts and completes.
|
||||||
|
|
||||||
|
## System events
|
||||||
|
|
||||||
|
The agent receives system messages for observability and recovery:
|
||||||
|
|
||||||
|
- `exec.started` — command accepted and launched
|
||||||
|
- `exec.finished` — command completed (exit code + output)
|
||||||
|
- `exec.denied` — command blocked (policy or denylist)
|
||||||
|
|
||||||
|
These are **system messages**; no extra agent tool call is required to resume.
|
||||||
|
|
||||||
|
## Implications
|
||||||
|
|
||||||
|
- **Always allow** is powerful: the agent can run any host command without a
|
||||||
|
prompt. Prefer allowlisting trusted CLIs instead.
|
||||||
|
- **Ask** keeps you in the loop while still allowing fast approvals.
|
||||||
|
- Per-agent allowlists prevent one agent’s approval set from leaking into others.
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
Allowlists and approval settings are stored **locally in the macOS app** (SQLite
|
||||||
|
is a good fit). The Markdown docs describe behavior; they are not the storage
|
||||||
|
mechanism.
|
||||||
|
|
||||||
|
Related:
|
||||||
|
- [Exec tool](/tools/exec)
|
||||||
|
- [Elevated mode](/tools/elevated)
|
||||||
|
- [Skills](/tools/skills)
|
||||||
@@ -26,6 +26,11 @@ Note: `elevated` is ignored when sandboxing is off (exec already runs on the hos
|
|||||||
|
|
||||||
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
|
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
|
||||||
|
|
||||||
|
## Exec approvals (macOS app)
|
||||||
|
|
||||||
|
Sandboxed agents can require per-request approval before `exec` runs on the host.
|
||||||
|
See [Exec approvals](/tools/exec-approvals) for the policy, allowlist, and UI flow.
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
Foreground:
|
Foreground:
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ Notes:
|
|||||||
- If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`.
|
- If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`.
|
||||||
- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and runs on the host.
|
- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and runs on the host.
|
||||||
- `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op).
|
- `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op).
|
||||||
|
- macOS app approvals/allowlists: [Exec approvals](/tools/exec-approvals).
|
||||||
|
|
||||||
### `process`
|
### `process`
|
||||||
Manage background exec sessions.
|
Manage background exec sessions.
|
||||||
|
|||||||
@@ -74,7 +74,10 @@ export function createClawdbotTools(options?: {
|
|||||||
allowedControlPorts: options?.allowedControlPorts,
|
allowedControlPorts: options?.allowedControlPorts,
|
||||||
}),
|
}),
|
||||||
createCanvasTool(),
|
createCanvasTool(),
|
||||||
createNodesTool(),
|
createNodesTool({
|
||||||
|
agentSessionKey: options?.agentSessionKey,
|
||||||
|
config: options?.config,
|
||||||
|
}),
|
||||||
createCronTool({
|
createCronTool({
|
||||||
agentSessionKey: options?.agentSessionKey,
|
agentSessionKey: options?.agentSessionKey,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ import {
|
|||||||
writeScreenRecordToFile,
|
writeScreenRecordToFile,
|
||||||
} from "../../cli/nodes-screen.js";
|
} from "../../cli/nodes-screen.js";
|
||||||
import { parseDurationMs } from "../../cli/parse-duration.js";
|
import { parseDurationMs } from "../../cli/parse-duration.js";
|
||||||
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { imageMimeFromFormat } from "../../media/mime.js";
|
import { imageMimeFromFormat } from "../../media/mime.js";
|
||||||
|
import { resolveSessionAgentId } from "../agent-scope.js";
|
||||||
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
|
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
|
||||||
import { sanitizeToolResultImages } from "../tool-images.js";
|
import { sanitizeToolResultImages } from "../tool-images.js";
|
||||||
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
|
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
|
||||||
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
|
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
|
||||||
import { resolveNodeId } from "./nodes-utils.js";
|
import { listNodes, resolveNodeIdFromList, resolveNodeId } from "./nodes-utils.js";
|
||||||
|
|
||||||
const NODES_TOOL_ACTIONS = [
|
const NODES_TOOL_ACTIONS = [
|
||||||
"status",
|
"status",
|
||||||
@@ -86,7 +88,14 @@ const NodesToolSchema = Type.Object({
|
|||||||
needsScreenRecording: Type.Optional(Type.Boolean()),
|
needsScreenRecording: Type.Optional(Type.Boolean()),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function createNodesTool(): AnyAgentTool {
|
export function createNodesTool(options?: {
|
||||||
|
agentSessionKey?: string;
|
||||||
|
config?: ClawdbotConfig;
|
||||||
|
}): AnyAgentTool {
|
||||||
|
const agentId = resolveSessionAgentId({
|
||||||
|
sessionKey: options?.agentSessionKey,
|
||||||
|
config: options?.config,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
label: "Nodes",
|
label: "Nodes",
|
||||||
name: "nodes",
|
name: "nodes",
|
||||||
@@ -375,7 +384,22 @@ export function createNodesTool(): AnyAgentTool {
|
|||||||
}
|
}
|
||||||
case "run": {
|
case "run": {
|
||||||
const node = readStringParam(params, "node", { required: true });
|
const node = readStringParam(params, "node", { required: true });
|
||||||
const nodeId = await resolveNodeId(gatewayOpts, node);
|
const nodes = await listNodes(gatewayOpts);
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
"system.run requires a paired macOS companion app (no nodes available).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const nodeId = resolveNodeIdFromList(nodes, node);
|
||||||
|
const nodeInfo = nodes.find((entry) => entry.nodeId === nodeId);
|
||||||
|
const supportsSystemRun = Array.isArray(nodeInfo?.commands)
|
||||||
|
? nodeInfo?.commands?.includes("system.run")
|
||||||
|
: false;
|
||||||
|
if (!supportsSystemRun) {
|
||||||
|
throw new Error(
|
||||||
|
"system.run requires the macOS companion app; the selected node does not support system.run.",
|
||||||
|
);
|
||||||
|
}
|
||||||
const commandRaw = params.command;
|
const commandRaw = params.command;
|
||||||
if (!commandRaw) {
|
if (!commandRaw) {
|
||||||
throw new Error("command required (argv array, e.g. ['echo', 'Hello'])");
|
throw new Error("command required (argv array, e.g. ['echo', 'Hello'])");
|
||||||
@@ -405,6 +429,7 @@ export function createNodesTool(): AnyAgentTool {
|
|||||||
env,
|
env,
|
||||||
timeoutMs: commandTimeoutMs,
|
timeoutMs: commandTimeoutMs,
|
||||||
needsScreenRecording,
|
needsScreenRecording,
|
||||||
|
agentId,
|
||||||
},
|
},
|
||||||
timeoutMs: invokeTimeoutMs,
|
timeoutMs: invokeTimeoutMs,
|
||||||
idempotencyKey: crypto.randomUUID(),
|
idempotencyKey: crypto.randomUUID(),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
|
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
|
||||||
|
|
||||||
type NodeListNode = {
|
export type NodeListNode = {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
@@ -99,12 +99,15 @@ function pickDefaultNode(nodes: NodeListNode[]): NodeListNode | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveNodeId(
|
export async function listNodes(opts: GatewayCallOptions): Promise<NodeListNode[]> {
|
||||||
opts: GatewayCallOptions,
|
return loadNodes(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveNodeIdFromList(
|
||||||
|
nodes: NodeListNode[],
|
||||||
query?: string,
|
query?: string,
|
||||||
allowDefault = false,
|
allowDefault = false,
|
||||||
) {
|
): string {
|
||||||
const nodes = await loadNodes(opts);
|
|
||||||
const q = String(query ?? "").trim();
|
const q = String(query ?? "").trim();
|
||||||
if (!q) {
|
if (!q) {
|
||||||
if (allowDefault) {
|
if (allowDefault) {
|
||||||
@@ -138,3 +141,12 @@ export async function resolveNodeId(
|
|||||||
.join(", ")})`,
|
.join(", ")})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolveNodeId(
|
||||||
|
opts: GatewayCallOptions,
|
||||||
|
query?: string,
|
||||||
|
allowDefault = false,
|
||||||
|
) {
|
||||||
|
const nodes = await loadNodes(opts);
|
||||||
|
return resolveNodeIdFromList(nodes, query, allowDefault);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user