fix(macos): consolidate exec approval evaluation

This commit is contained in:
Peter Steinberger
2026-02-21 19:30:26 +01:00
parent 9fc6c8b713
commit e371da38aa
7 changed files with 464 additions and 478 deletions

View File

@@ -38,7 +38,7 @@ Docs: https://docs.openclaw.ai
- Agents/Sanitization: stop rewriting billing-shaped assistant text outside explicit error context so normal replies about billing/credits/payment are preserved across messaging channels. (#17834, fixes #11359)
- Security/Agents: cap embedded Pi runner outer retry loop with a higher profile-aware dynamic limit (32-160 attempts) and return an explicit `retry_limit` error payload when retries never converge, preventing unbounded internal retry cycles (`GHSA-76m6-pj3w-v7mf`).
- Telegram: detect duplicate bot-token ownership across Telegram accounts at startup/status time, mark secondary accounts as not configured with an explicit fix message, and block duplicate account startup before polling to avoid endless `getUpdates` conflict loops.
- Security/macOS Exec approvals: treat raw shell text containing shell control or expansion syntax (`&&`, `||`, `;`, `|`, `` ` ``, `$`, `<`, `>`, `(`, `)`) as allowlist misses so first-token resolution can no longer approve chained payloads in `system.run`. This ships in the next npm release. Thanks @tdjackey for reporting.
- Security/macOS app beta: harden `system.run` allowlist handling by evaluating shell chains per segment, treating control/expansion syntax as approval-required misses, and failing closed on unsafe parse cases. Default installs are unaffected unless `tools.exec.host` is explicitly enabled. This ships in the next npm release. Thanks @tdjackey for reporting.
- Agents/Tool images: include source filenames in `agents/tool-images` resize logs so compression events can be traced back to specific files.
- Providers/OAuth: harden Qwen and Chutes refresh handling by validating refresh response expiry values and preserving prior refresh tokens when providers return empty refresh token fields, with regression coverage for empty-token responses.
- Models/Kimi-Coding: add missing implicit provider template for `kimi-coding` with correct `anthropic-messages` API type and base URL, fixing 403 errors when using Kimi for Coding. (#22409)
@@ -116,7 +116,6 @@ Docs: https://docs.openclaw.ai
- Signal/Outbound: preserve case for Base64 group IDs during outbound target normalization so cross-context routing and policy checks no longer break when group IDs include uppercase characters. (#5578) Thanks @heyhudson.
- Anthropic/Agents: preserve required pi-ai default OAuth beta headers when `context1m` injects `anthropic-beta`, preventing 401 auth failures for `sk-ant-oat-*` tokens. (#19789, fixes #19769) Thanks @minupla.
- Security/Exec: block unquoted heredoc body expansion tokens in shell allowlist analysis, reject unterminated heredocs, and require explicit approval for allowlisted heredoc execution on gateway hosts to prevent heredoc substitution allowlist bypass. Thanks @torturado for reporting.
- macOS/Security: evaluate `system.run` allowlists per shell segment in macOS node runtime and companion exec host (including chained shell operators), fail closed on shell/process substitution parsing, and require explicit approval on unsafe parse cases to prevent allowlist bypass via `rawCommand` chaining. Thanks @tdjackey for reporting.
- WhatsApp/Security: enforce allowlist JID authorization for reaction actions so authenticated callers cannot target non-allowlisted chats by forging `chatJid` + valid `messageId` pairs. Thanks @aether-ai-agent for reporting.
- ACP/Security: escape control and delimiter characters in ACP `resource_link` title/URI metadata before prompt interpolation to prevent metadata-driven prompt injection through resource links. Thanks @aether-ai-agent for reporting.
- TTS/Security: make model-driven provider switching opt-in by default (`messages.tts.modelOverrides.allowProvider=false` unless explicitly enabled), while keeping voice/style overrides available, to reduce prompt-injection-driven provider hops and unexpected TTS cost escalation. Thanks @aether-ai-agent for reporting.

View File

@@ -0,0 +1,82 @@
import Foundation
enum ExecAllowlistMatcher {
static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? {
guard let resolution, !entries.isEmpty else { return nil }
let rawExecutable = resolution.rawExecutable
let resolvedPath = resolution.resolvedPath
let executableName = resolution.executableName
for entry in entries {
let pattern = entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines)
if pattern.isEmpty { continue }
let hasPath = pattern.contains("/") || pattern.contains("~") || pattern.contains("\\")
if hasPath {
let target = resolvedPath ?? rawExecutable
if self.matches(pattern: pattern, target: target) { return entry }
} else if self.matches(pattern: pattern, target: executableName) {
return entry
}
}
return nil
}
static func matchAll(
entries: [ExecAllowlistEntry],
resolutions: [ExecCommandResolution]) -> [ExecAllowlistEntry]
{
guard !entries.isEmpty, !resolutions.isEmpty else { return [] }
var matches: [ExecAllowlistEntry] = []
matches.reserveCapacity(resolutions.count)
for resolution in resolutions {
guard let match = self.match(entries: entries, resolution: resolution) else {
return []
}
matches.append(match)
}
return matches
}
private static func matches(pattern: String, target: String) -> Bool {
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
let expanded = trimmed.hasPrefix("~") ? (trimmed as NSString).expandingTildeInPath : trimmed
let normalizedPattern = self.normalizeMatchTarget(expanded)
let normalizedTarget = self.normalizeMatchTarget(target)
guard let regex = self.regex(for: normalizedPattern) else { return false }
let range = NSRange(location: 0, length: normalizedTarget.utf16.count)
return regex.firstMatch(in: normalizedTarget, options: [], range: range) != nil
}
private static func normalizeMatchTarget(_ value: String) -> String {
value.replacingOccurrences(of: "\\\\", with: "/").lowercased()
}
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, options: [.caseInsensitive])
}
}

View File

@@ -0,0 +1,67 @@
import Foundation
struct ExecApprovalEvaluation {
let command: [String]
let displayCommand: String
let agentId: String?
let security: ExecSecurity
let ask: ExecAsk
let env: [String: String]
let resolution: ExecCommandResolution?
let allowlistResolutions: [ExecCommandResolution]
let allowlistMatches: [ExecAllowlistEntry]
let allowlistSatisfied: Bool
let allowlistMatch: ExecAllowlistEntry?
let skillAllow: Bool
}
enum ExecApprovalEvaluator {
static func evaluate(
command: [String],
rawCommand: String?,
cwd: String?,
envOverrides: [String: String]?,
agentId: String?) async -> ExecApprovalEvaluation
{
let trimmedAgent = agentId?.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedAgentId = (trimmedAgent?.isEmpty == false) ? trimmedAgent : nil
let approvals = ExecApprovalsStore.resolve(agentId: normalizedAgentId)
let security = approvals.agent.security
let ask = approvals.agent.ask
let env = HostEnvSanitizer.sanitize(overrides: envOverrides)
let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: rawCommand)
let allowlistResolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: rawCommand,
cwd: cwd,
env: env)
let allowlistMatches = security == .allowlist
? ExecAllowlistMatcher.matchAll(entries: approvals.allowlist, resolutions: allowlistResolutions)
: []
let allowlistSatisfied = security == .allowlist &&
!allowlistResolutions.isEmpty &&
allowlistMatches.count == allowlistResolutions.count
let skillAllow: Bool
if approvals.agent.autoAllowSkills, !allowlistResolutions.isEmpty {
let bins = await SkillBinsCache.shared.currentBins()
skillAllow = allowlistResolutions.allSatisfy { bins.contains($0.executableName) }
} else {
skillAllow = false
}
return ExecApprovalEvaluation(
command: command,
displayCommand: displayCommand,
agentId: normalizedAgentId,
security: security,
ask: ask,
env: env,
resolution: allowlistResolutions.first,
allowlistResolutions: allowlistResolutions,
allowlistMatches: allowlistMatches,
allowlistSatisfied: allowlistSatisfied,
allowlistMatch: allowlistSatisfied ? allowlistMatches.first : nil,
skillAllow: skillAllow)
}
}

View File

@@ -552,285 +552,6 @@ enum ExecApprovalsStore {
}
}
struct ExecCommandResolution: Sendable {
let rawExecutable: String
let resolvedPath: String?
let executableName: String
let cwd: String?
static func resolve(
command: [String],
rawCommand: String?,
cwd: String?,
env: [String: String]?) -> ExecCommandResolution?
{
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedRaw.isEmpty, let token = self.parseFirstToken(trimmedRaw) {
return self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env)
}
return self.resolve(command: command, cwd: cwd, env: env)
}
static func resolveForAllowlist(
command: [String],
rawCommand: String?,
cwd: String?,
env: [String: String]?) -> [ExecCommandResolution]
{
let shell = self.extractShellCommandFromArgv(command: command, rawCommand: rawCommand)
if shell.isWrapper {
guard let shellCommand = shell.command,
let segments = self.splitShellCommandChain(shellCommand)
else {
// Fail closed: if we cannot safely parse a shell wrapper payload,
// treat this as an allowlist miss and require approval.
return []
}
var resolutions: [ExecCommandResolution] = []
resolutions.reserveCapacity(segments.count)
for segment in segments {
guard let token = self.parseFirstToken(segment),
let resolution = self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env)
else {
return []
}
resolutions.append(resolution)
}
return resolutions
}
guard let resolution = self.resolve(command: command, rawCommand: rawCommand, cwd: cwd, env: env) else {
return []
}
return [resolution]
}
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
return nil
}
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
}
private static func resolveExecutable(
rawExecutable: String,
cwd: String?,
env: [String: String]?) -> ExecCommandResolution?
{
let expanded = rawExecutable.hasPrefix("~") ? (rawExecutable as NSString).expandingTildeInPath : rawExecutable
let hasPathSeparator = expanded.contains("/") || 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().currentDirectoryPath
return URL(fileURLWithPath: root).appendingPathComponent(expanded).path
}
let searchPaths = self.searchPaths(from: env)
return CommandResolver.findExecutable(named: expanded, searchPaths: searchPaths)
}()
let name = resolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded
return ExecCommandResolution(
rawExecutable: expanded,
resolvedPath: resolvedPath,
executableName: name,
cwd: cwd)
}
private static func parseFirstToken(_ command: String) -> String? {
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
guard let first = trimmed.first else { return nil }
if first == "\"" || first == "'" {
let rest = trimmed.dropFirst()
if let end = rest.firstIndex(of: first) {
return String(rest[..<end])
}
return String(rest)
}
return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
}
private static func basenameLower(_ token: String) -> String {
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "" }
let normalized = trimmed.replacingOccurrences(of: "\\", with: "/")
return normalized.split(separator: "/").last.map { String($0).lowercased() } ?? normalized.lowercased()
}
private static func extractShellCommandFromArgv(
command: [String],
rawCommand: String?) -> (isWrapper: Bool, command: String?)
{
guard let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else {
return (false, nil)
}
let base0 = self.basenameLower(token0)
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let preferredRaw = trimmedRaw.isEmpty ? nil : trimmedRaw
if ["sh", "bash", "zsh", "dash", "ksh"].contains(base0) {
let flag = command.count > 1 ? command[1].trimmingCharacters(in: .whitespacesAndNewlines) : ""
guard flag == "-lc" || flag == "-c" else { return (false, nil) }
let payload = command.count > 2 ? command[2].trimmingCharacters(in: .whitespacesAndNewlines) : ""
let normalized = preferredRaw ?? (payload.isEmpty ? nil : payload)
return (true, normalized)
}
if base0 == "cmd.exe" || base0 == "cmd" {
guard let idx = command
.firstIndex(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "/c" })
else {
return (false, nil)
}
let tail = command.suffix(from: command.index(after: idx)).joined(separator: " ")
let payload = tail.trimmingCharacters(in: .whitespacesAndNewlines)
let normalized = preferredRaw ?? (payload.isEmpty ? nil : payload)
return (true, normalized)
}
return (false, nil)
}
private static func splitShellCommandChain(_ command: String) -> [String]? {
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
var segments: [String] = []
var current = ""
var inSingle = false
var inDouble = false
var escaped = false
let chars = Array(trimmed)
var idx = 0
func appendCurrent() -> Bool {
let segment = current.trimmingCharacters(in: .whitespacesAndNewlines)
guard !segment.isEmpty else { return false }
segments.append(segment)
current.removeAll(keepingCapacity: true)
return true
}
while idx < chars.count {
let ch = chars[idx]
let next: Character? = idx + 1 < chars.count ? chars[idx + 1] : nil
if escaped {
current.append(ch)
escaped = false
idx += 1
continue
}
if ch == "\\", !inSingle {
current.append(ch)
escaped = true
idx += 1
continue
}
if ch == "'", !inDouble {
inSingle.toggle()
current.append(ch)
idx += 1
continue
}
if ch == "\"", !inSingle {
inDouble.toggle()
current.append(ch)
idx += 1
continue
}
if !inSingle, !inDouble {
if self.shouldFailClosedForUnquotedShell(ch: ch, next: next) {
// Fail closed on command/process substitution in allowlist mode.
return nil
}
let prev: Character? = idx > 0 ? chars[idx - 1] : nil
if let delimiterStep = self.chainDelimiterStep(ch: ch, prev: prev, next: next) {
guard appendCurrent() else { return nil }
idx += delimiterStep
continue
}
}
current.append(ch)
idx += 1
}
if escaped || inSingle || inDouble { return nil }
guard appendCurrent() else { return nil }
return segments
}
private static func shouldFailClosedForUnquotedShell(ch: Character, next: Character?) -> Bool {
if ch == "`" {
return true
}
if ch == "$", next == "(" {
return true
}
if ch == "<" || ch == ">", next == "(" {
return true
}
return false
}
private static func chainDelimiterStep(ch: Character, prev: Character?, next: Character?) -> Int? {
if ch == ";" || ch == "\n" {
return 1
}
if ch == "&" {
if next == "&" {
return 2
}
// Keep fd redirections like 2>&1 or &>file intact.
let prevIsRedirect = prev == ">"
let nextIsRedirect = next == ">"
return (!prevIsRedirect && !nextIsRedirect) ? 1 : nil
}
if ch == "|" {
if next == "|" || next == "&" {
return 2
}
return 1
}
return nil
}
private static func searchPaths(from env: [String: String]?) -> [String] {
let raw = env?["PATH"]
if let raw, !raw.isEmpty {
return raw.split(separator: ":").map(String.init)
}
return CommandResolver.preferredPaths()
}
}
enum ExecCommandFormatter {
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 displayString(for argv: [String], rawCommand: String?) -> String {
let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmed.isEmpty { return trimmed }
return self.displayString(for: argv)
}
}
enum ExecApprovalHelpers {
static func parseDecision(_ raw: String?) -> ExecApprovalDecision? {
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@@ -855,87 +576,6 @@ enum ExecApprovalHelpers {
}
}
enum ExecAllowlistMatcher {
static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? {
guard let resolution, !entries.isEmpty else { return nil }
let rawExecutable = resolution.rawExecutable
let resolvedPath = resolution.resolvedPath
let executableName = resolution.executableName
for entry in entries {
let pattern = entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines)
if pattern.isEmpty { continue }
let hasPath = pattern.contains("/") || pattern.contains("~") || pattern.contains("\\")
if hasPath {
let target = resolvedPath ?? rawExecutable
if self.matches(pattern: pattern, target: target) { return entry }
} else if self.matches(pattern: pattern, target: executableName) {
return entry
}
}
return nil
}
static func matchAll(
entries: [ExecAllowlistEntry],
resolutions: [ExecCommandResolution]) -> [ExecAllowlistEntry]
{
guard !entries.isEmpty, !resolutions.isEmpty else { return [] }
var matches: [ExecAllowlistEntry] = []
matches.reserveCapacity(resolutions.count)
for resolution in resolutions {
guard let match = self.match(entries: entries, resolution: resolution) else {
return []
}
matches.append(match)
}
return matches
}
private static func matches(pattern: String, target: String) -> Bool {
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
let expanded = trimmed.hasPrefix("~") ? (trimmed as NSString).expandingTildeInPath : trimmed
let normalizedPattern = self.normalizeMatchTarget(expanded)
let normalizedTarget = self.normalizeMatchTarget(target)
guard let regex = self.regex(for: normalizedPattern) else { return false }
let range = NSRange(location: 0, length: normalizedTarget.utf16.count)
return regex.firstMatch(in: normalizedTarget, options: [], range: range) != nil
}
private static func normalizeMatchTarget(_ value: String) -> String {
value.replacingOccurrences(of: "\\\\", with: "/").lowercased()
}
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, options: [.caseInsensitive])
}
}
struct ExecEventPayload: Codable, Sendable {
var sessionKey: String
var runId: String

View File

@@ -350,21 +350,7 @@ enum ExecApprovalsPromptPresenter {
@MainActor
private enum ExecHostExecutor {
private struct ExecApprovalContext {
let command: [String]
let displayCommand: String
let trimmedAgent: String?
let approvals: ExecApprovalsResolved
let security: ExecSecurity
let ask: ExecAsk
let autoAllowSkills: Bool
let env: [String: String]?
let resolution: ExecCommandResolution?
let allowlistResolutions: [ExecCommandResolution]
let allowlistMatches: [ExecAllowlistEntry]
let allowlistSatisfied: Bool
let skillAllow: Bool
}
private typealias ExecApprovalContext = ExecApprovalEvaluation
static func handle(_ request: ExecHostRequest) async -> ExecHostResponse {
let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
@@ -395,7 +381,7 @@ private enum ExecHostExecutor {
if ExecApprovalHelpers.requiresAsk(
ask: context.ask,
security: context.security,
allowlistMatch: context.allowlistSatisfied ? context.allowlistMatches.first : nil,
allowlistMatch: context.allowlistMatch,
skillAllow: context.skillAllow),
approvalDecision == nil
{
@@ -406,7 +392,7 @@ private enum ExecHostExecutor {
host: "node",
security: context.security.rawValue,
ask: context.ask.rawValue,
agentId: context.trimmedAgent,
agentId: context.agentId,
resolvedPath: context.resolution?.resolvedPath,
sessionKey: request.sessionKey))
@@ -447,7 +433,7 @@ private enum ExecHostExecutor {
? context.allowlistResolutions[idx].resolvedPath
: nil
ExecApprovalsStore.recordAllowlistUse(
agentId: context.trimmedAgent,
agentId: context.agentId,
pattern: match.pattern,
command: context.displayCommand,
resolvedPath: resolvedPath)
@@ -466,49 +452,12 @@ private enum ExecHostExecutor {
}
private static func buildContext(request: ExecHostRequest, command: [String]) async -> ExecApprovalContext {
let displayCommand = ExecCommandFormatter.displayString(
for: command,
rawCommand: request.rawCommand)
let agentId = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedAgent = (agentId?.isEmpty == false) ? agentId : nil
let approvals = ExecApprovalsStore.resolve(agentId: trimmedAgent)
let security = approvals.agent.security
let ask = approvals.agent.ask
let autoAllowSkills = approvals.agent.autoAllowSkills
let env = self.sanitizedEnv(request.env)
let allowlistResolutions = ExecCommandResolution.resolveForAllowlist(
await ExecApprovalEvaluator.evaluate(
command: command,
rawCommand: request.rawCommand,
cwd: request.cwd,
env: env)
let resolution = allowlistResolutions.first
let allowlistMatches = security == .allowlist
? ExecAllowlistMatcher.matchAll(entries: approvals.allowlist, resolutions: allowlistResolutions)
: []
let allowlistSatisfied = security == .allowlist &&
!allowlistResolutions.isEmpty &&
allowlistMatches.count == allowlistResolutions.count
let skillAllow: Bool
if autoAllowSkills, !allowlistResolutions.isEmpty {
let bins = await SkillBinsCache.shared.currentBins()
skillAllow = allowlistResolutions.allSatisfy { bins.contains($0.executableName) }
} else {
skillAllow = false
}
return ExecApprovalContext(
command: command,
displayCommand: displayCommand,
trimmedAgent: trimmedAgent,
approvals: approvals,
security: security,
ask: ask,
autoAllowSkills: autoAllowSkills,
env: env,
resolution: resolution,
allowlistResolutions: allowlistResolutions,
allowlistMatches: allowlistMatches,
allowlistSatisfied: allowlistSatisfied,
skillAllow: skillAllow)
envOverrides: request.env,
agentId: request.agentId)
}
private static func persistAllowlistEntry(
@@ -525,7 +474,7 @@ private enum ExecHostExecutor {
continue
}
if seenPatterns.insert(pattern).inserted {
ExecApprovalsStore.addAllowlistEntry(agentId: context.trimmedAgent, pattern: pattern)
ExecApprovalsStore.addAllowlistEntry(agentId: context.agentId, pattern: pattern)
}
}
}
@@ -586,10 +535,6 @@ private enum ExecHostExecutor {
payload: payload,
error: nil)
}
private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String] {
HostEnvSanitizer.sanitize(overrides: overrides)
}
}
private final class ExecApprovalsSocketServer: @unchecked Sendable {

View File

@@ -0,0 +1,280 @@
import Foundation
struct ExecCommandResolution: Sendable {
let rawExecutable: String
let resolvedPath: String?
let executableName: String
let cwd: String?
static func resolve(
command: [String],
rawCommand: String?,
cwd: String?,
env: [String: String]?) -> ExecCommandResolution?
{
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedRaw.isEmpty, let token = self.parseFirstToken(trimmedRaw) {
return self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env)
}
return self.resolve(command: command, cwd: cwd, env: env)
}
static func resolveForAllowlist(
command: [String],
rawCommand: String?,
cwd: String?,
env: [String: String]?) -> [ExecCommandResolution]
{
let shell = self.extractShellCommandFromArgv(command: command, rawCommand: rawCommand)
if shell.isWrapper {
guard let shellCommand = shell.command,
let segments = self.splitShellCommandChain(shellCommand)
else {
// Fail closed: if we cannot safely parse a shell wrapper payload,
// treat this as an allowlist miss and require approval.
return []
}
var resolutions: [ExecCommandResolution] = []
resolutions.reserveCapacity(segments.count)
for segment in segments {
guard let token = self.parseFirstToken(segment),
let resolution = self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env)
else {
return []
}
resolutions.append(resolution)
}
return resolutions
}
guard let resolution = self.resolve(command: command, rawCommand: rawCommand, cwd: cwd, env: env) else {
return []
}
return [resolution]
}
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
return nil
}
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
}
private static func resolveExecutable(
rawExecutable: String,
cwd: String?,
env: [String: String]?) -> ExecCommandResolution?
{
let expanded = rawExecutable.hasPrefix("~") ? (rawExecutable as NSString).expandingTildeInPath : rawExecutable
let hasPathSeparator = expanded.contains("/") || 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().currentDirectoryPath
return URL(fileURLWithPath: root).appendingPathComponent(expanded).path
}
let searchPaths = self.searchPaths(from: env)
return CommandResolver.findExecutable(named: expanded, searchPaths: searchPaths)
}()
let name = resolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded
return ExecCommandResolution(
rawExecutable: expanded,
resolvedPath: resolvedPath,
executableName: name,
cwd: cwd)
}
private static func parseFirstToken(_ command: String) -> String? {
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
guard let first = trimmed.first else { return nil }
if first == "\"" || first == "'" {
let rest = trimmed.dropFirst()
if let end = rest.firstIndex(of: first) {
return String(rest[..<end])
}
return String(rest)
}
return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
}
private static func basenameLower(_ token: String) -> String {
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "" }
let normalized = trimmed.replacingOccurrences(of: "\\", with: "/")
return normalized.split(separator: "/").last.map { String($0).lowercased() } ?? normalized.lowercased()
}
private static func extractShellCommandFromArgv(
command: [String],
rawCommand: String?) -> (isWrapper: Bool, command: String?)
{
guard let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else {
return (false, nil)
}
let base0 = self.basenameLower(token0)
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let preferredRaw = trimmedRaw.isEmpty ? nil : trimmedRaw
if ["sh", "bash", "zsh", "dash", "ksh"].contains(base0) {
let flag = command.count > 1 ? command[1].trimmingCharacters(in: .whitespacesAndNewlines) : ""
guard flag == "-lc" || flag == "-c" else { return (false, nil) }
let payload = command.count > 2 ? command[2].trimmingCharacters(in: .whitespacesAndNewlines) : ""
let normalized = preferredRaw ?? (payload.isEmpty ? nil : payload)
return (true, normalized)
}
if base0 == "cmd.exe" || base0 == "cmd" {
guard let idx = command
.firstIndex(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "/c" })
else {
return (false, nil)
}
let tail = command.suffix(from: command.index(after: idx)).joined(separator: " ")
let payload = tail.trimmingCharacters(in: .whitespacesAndNewlines)
let normalized = preferredRaw ?? (payload.isEmpty ? nil : payload)
return (true, normalized)
}
return (false, nil)
}
private static func splitShellCommandChain(_ command: String) -> [String]? {
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
var segments: [String] = []
var current = ""
var inSingle = false
var inDouble = false
var escaped = false
let chars = Array(trimmed)
var idx = 0
func appendCurrent() -> Bool {
let segment = current.trimmingCharacters(in: .whitespacesAndNewlines)
guard !segment.isEmpty else { return false }
segments.append(segment)
current.removeAll(keepingCapacity: true)
return true
}
while idx < chars.count {
let ch = chars[idx]
let next: Character? = idx + 1 < chars.count ? chars[idx + 1] : nil
if escaped {
current.append(ch)
escaped = false
idx += 1
continue
}
if ch == "\\", !inSingle {
current.append(ch)
escaped = true
idx += 1
continue
}
if ch == "'", !inDouble {
inSingle.toggle()
current.append(ch)
idx += 1
continue
}
if ch == "\"", !inSingle {
inDouble.toggle()
current.append(ch)
idx += 1
continue
}
if !inSingle, !inDouble {
if self.shouldFailClosedForUnquotedShell(ch: ch, next: next) {
// Fail closed on command/process substitution in allowlist mode.
return nil
}
let prev: Character? = idx > 0 ? chars[idx - 1] : nil
if let delimiterStep = self.chainDelimiterStep(ch: ch, prev: prev, next: next) {
guard appendCurrent() else { return nil }
idx += delimiterStep
continue
}
}
current.append(ch)
idx += 1
}
if escaped || inSingle || inDouble { return nil }
guard appendCurrent() else { return nil }
return segments
}
private static func shouldFailClosedForUnquotedShell(ch: Character, next: Character?) -> Bool {
if ch == "`" {
return true
}
if ch == "$", next == "(" {
return true
}
if ch == "<" || ch == ">", next == "(" {
return true
}
return false
}
private static func chainDelimiterStep(ch: Character, prev: Character?, next: Character?) -> Int? {
if ch == ";" || ch == "\n" {
return 1
}
if ch == "&" {
if next == "&" {
return 2
}
// Keep fd redirections like 2>&1 or &>file intact.
let prevIsRedirect = prev == ">"
let nextIsRedirect = next == ">"
return (!prevIsRedirect && !nextIsRedirect) ? 1 : nil
}
if ch == "|" {
if next == "|" || next == "&" {
return 2
}
return 1
}
return nil
}
private static func searchPaths(from env: [String: String]?) -> [String] {
let raw = env?["PATH"]
if let raw, !raw.isEmpty {
return raw.split(separator: ":").map(String.init)
}
return CommandResolver.preferredPaths()
}
}
enum ExecCommandFormatter {
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 displayString(for argv: [String], rawCommand: String?) -> String {
let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmed.isEmpty { return trimmed }
return self.displayString(for: argv)
}
}

View File

@@ -441,48 +441,25 @@ actor MacNodeRuntime {
guard !command.isEmpty else {
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
}
let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: params.rawCommand)
let trimmedAgent = params.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let agentId = trimmedAgent.isEmpty ? nil : trimmedAgent
let approvals = ExecApprovalsStore.resolve(agentId: agentId)
let security = approvals.agent.security
let ask = approvals.agent.ask
let autoAllowSkills = approvals.agent.autoAllowSkills
let sessionKey = (params.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
: self.mainSessionKey
let runId = UUID().uuidString
let env = Self.sanitizedEnv(params.env)
let allowlistResolutions = ExecCommandResolution.resolveForAllowlist(
let evaluation = await ExecApprovalEvaluator.evaluate(
command: command,
rawCommand: params.rawCommand,
cwd: params.cwd,
env: env)
let resolution = allowlistResolutions.first
let allowlistMatches = security == .allowlist
? ExecAllowlistMatcher.matchAll(entries: approvals.allowlist, resolutions: allowlistResolutions)
: []
let allowlistSatisfied = security == .allowlist &&
!allowlistResolutions.isEmpty &&
allowlistMatches.count == allowlistResolutions.count
let allowlistMatch = allowlistSatisfied ? allowlistMatches.first : nil
let skillAllow: Bool
if autoAllowSkills, !allowlistResolutions.isEmpty {
let bins = await SkillBinsCache.shared.currentBins()
skillAllow = allowlistResolutions.allSatisfy { bins.contains($0.executableName) }
} else {
skillAllow = false
}
envOverrides: params.env,
agentId: params.agentId)
if security == .deny {
if evaluation.security == .deny {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand,
command: evaluation.displayCommand,
reason: "security=deny"))
return Self.errorResponse(
req,
@@ -494,13 +471,13 @@ actor MacNodeRuntime {
req: req,
params: params,
context: ExecRunContext(
displayCommand: displayCommand,
security: security,
ask: ask,
agentId: agentId,
resolution: resolution,
allowlistMatch: allowlistMatch,
skillAllow: skillAllow,
displayCommand: evaluation.displayCommand,
security: evaluation.security,
ask: evaluation.ask,
agentId: evaluation.agentId,
resolution: evaluation.resolution,
allowlistMatch: evaluation.allowlistMatch,
skillAllow: evaluation.skillAllow,
sessionKey: sessionKey,
runId: runId))
if let response = approval.response { return response }
@@ -508,19 +485,19 @@ actor MacNodeRuntime {
let persistAllowlist = approval.persistAllowlist
self.persistAllowlistPatterns(
persistAllowlist: persistAllowlist,
security: security,
agentId: agentId,
security: evaluation.security,
agentId: evaluation.agentId,
command: command,
allowlistResolutions: allowlistResolutions)
allowlistResolutions: evaluation.allowlistResolutions)
if security == .allowlist, !allowlistSatisfied, !skillAllow, !approvedByAsk {
if evaluation.security == .allowlist, !evaluation.allowlistSatisfied, !evaluation.skillAllow, !approvedByAsk {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand,
command: evaluation.displayCommand,
reason: "allowlist-miss"))
return Self.errorResponse(
req,
@@ -529,19 +506,19 @@ actor MacNodeRuntime {
}
self.recordAllowlistMatches(
security: security,
allowlistSatisfied: allowlistSatisfied,
agentId: agentId,
allowlistMatches: allowlistMatches,
allowlistResolutions: allowlistResolutions,
displayCommand: displayCommand)
security: evaluation.security,
allowlistSatisfied: evaluation.allowlistSatisfied,
agentId: evaluation.agentId,
allowlistMatches: evaluation.allowlistMatches,
allowlistResolutions: evaluation.allowlistResolutions,
displayCommand: evaluation.displayCommand)
if let permissionResponse = await self.validateScreenRecordingIfNeeded(
req: req,
needsScreenRecording: params.needsScreenRecording,
sessionKey: sessionKey,
runId: runId,
displayCommand: displayCommand)
displayCommand: evaluation.displayCommand)
{
return permissionResponse
}
@@ -550,10 +527,10 @@ actor MacNodeRuntime {
req: req,
params: params,
command: command,
env: env,
env: evaluation.env,
sessionKey: sessionKey,
runId: runId,
displayCommand: displayCommand)
displayCommand: evaluation.displayCommand)
}
private func handleSystemWhich(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
@@ -947,10 +924,6 @@ extension MacNodeRuntime {
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
}
private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String] {
HostEnvSanitizer.sanitize(overrides: overrides)
}
private nonisolated static func locationMode() -> OpenClawLocationMode {
let raw = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
return OpenClawLocationMode(rawValue: raw) ?? .off