mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 04:07:28 +00:00
fix(macos): harden exec allowlist shell-chain checks
This commit is contained in:
@@ -357,8 +357,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
|
||||
func photoOutput(
|
||||
_ output: AVCapturePhotoOutput,
|
||||
didFinishProcessingPhoto photo: AVCapturePhoto,
|
||||
error: Error?
|
||||
) {
|
||||
error: Error?)
|
||||
{
|
||||
guard !self.didResume, let cont else { return }
|
||||
self.didResume = true
|
||||
self.cont = nil
|
||||
@@ -380,8 +380,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
|
||||
func photoOutput(
|
||||
_ output: AVCapturePhotoOutput,
|
||||
didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings,
|
||||
error: Error?
|
||||
) {
|
||||
error: Error?)
|
||||
{
|
||||
guard let error else { return }
|
||||
guard !self.didResume, let cont else { return }
|
||||
self.didResume = true
|
||||
|
||||
@@ -16,8 +16,8 @@ final class CoalescingFSEventsWatcher: @unchecked Sendable {
|
||||
queueLabel: String,
|
||||
coalesceDelay: TimeInterval = 0.12,
|
||||
shouldNotify: @escaping (Int, UnsafeMutableRawPointer?) -> Bool = { _, _ in true },
|
||||
onChange: @escaping () -> Void
|
||||
) {
|
||||
onChange: @escaping () -> Void)
|
||||
{
|
||||
self.paths = paths
|
||||
self.queue = DispatchQueue(label: queueLabel)
|
||||
self.coalesceDelay = coalesceDelay
|
||||
@@ -92,8 +92,8 @@ extension CoalescingFSEventsWatcher {
|
||||
private func handleEvents(
|
||||
numEvents: Int,
|
||||
eventPaths: UnsafeMutableRawPointer?,
|
||||
eventFlags: UnsafePointer<FSEventStreamEventFlags>?
|
||||
) {
|
||||
eventFlags: UnsafePointer<FSEventStreamEventFlags>?)
|
||||
{
|
||||
guard numEvents > 0 else { return }
|
||||
guard eventFlags != nil else { return }
|
||||
guard self.shouldNotify(numEvents, eventPaths) else { return }
|
||||
@@ -108,4 +108,3 @@ extension CoalescingFSEventsWatcher {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -571,6 +571,40 @@ struct ExecCommandResolution: Sendable {
|
||||
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
|
||||
@@ -619,6 +653,156 @@ struct ExecCommandResolution: Sendable {
|
||||
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 {
|
||||
@@ -692,6 +876,22 @@ enum ExecAllowlistMatcher {
|
||||
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 }
|
||||
|
||||
@@ -360,7 +360,9 @@ private enum ExecHostExecutor {
|
||||
let autoAllowSkills: Bool
|
||||
let env: [String: String]?
|
||||
let resolution: ExecCommandResolution?
|
||||
let allowlistMatch: ExecAllowlistEntry?
|
||||
let allowlistResolutions: [ExecCommandResolution]
|
||||
let allowlistMatches: [ExecAllowlistEntry]
|
||||
let allowlistSatisfied: Bool
|
||||
let skillAllow: Bool
|
||||
}
|
||||
|
||||
@@ -393,7 +395,7 @@ private enum ExecHostExecutor {
|
||||
if ExecApprovalHelpers.requiresAsk(
|
||||
ask: context.ask,
|
||||
security: context.security,
|
||||
allowlistMatch: context.allowlistMatch,
|
||||
allowlistMatch: context.allowlistSatisfied ? context.allowlistMatches.first : nil,
|
||||
skillAllow: context.skillAllow),
|
||||
approvalDecision == nil
|
||||
{
|
||||
@@ -425,7 +427,7 @@ private enum ExecHostExecutor {
|
||||
self.persistAllowlistEntry(decision: approvalDecision, context: context)
|
||||
|
||||
if context.security == .allowlist,
|
||||
context.allowlistMatch == nil,
|
||||
!context.allowlistSatisfied,
|
||||
!context.skillAllow,
|
||||
!approvedByAsk
|
||||
{
|
||||
@@ -435,12 +437,21 @@ private enum ExecHostExecutor {
|
||||
reason: "allowlist-miss")
|
||||
}
|
||||
|
||||
if let match = context.allowlistMatch {
|
||||
ExecApprovalsStore.recordAllowlistUse(
|
||||
agentId: context.trimmedAgent,
|
||||
pattern: match.pattern,
|
||||
command: context.displayCommand,
|
||||
resolvedPath: context.resolution?.resolvedPath)
|
||||
if context.allowlistSatisfied {
|
||||
var seenPatterns = Set<String>()
|
||||
for (idx, match) in context.allowlistMatches.enumerated() {
|
||||
if !seenPatterns.insert(match.pattern).inserted {
|
||||
continue
|
||||
}
|
||||
let resolvedPath = idx < context.allowlistResolutions.count
|
||||
? context.allowlistResolutions[idx].resolvedPath
|
||||
: nil
|
||||
ExecApprovalsStore.recordAllowlistUse(
|
||||
agentId: context.trimmedAgent,
|
||||
pattern: match.pattern,
|
||||
command: context.displayCommand,
|
||||
resolvedPath: resolvedPath)
|
||||
}
|
||||
}
|
||||
|
||||
if let errorResponse = await self.ensureScreenRecordingAccess(request.needsScreenRecording) {
|
||||
@@ -465,18 +476,22 @@ private enum ExecHostExecutor {
|
||||
let ask = approvals.agent.ask
|
||||
let autoAllowSkills = approvals.agent.autoAllowSkills
|
||||
let env = self.sanitizedEnv(request.env)
|
||||
let resolution = ExecCommandResolution.resolve(
|
||||
let allowlistResolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: request.rawCommand,
|
||||
cwd: request.cwd,
|
||||
env: env)
|
||||
let allowlistMatch = security == .allowlist
|
||||
? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution)
|
||||
: nil
|
||||
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, let name = resolution?.executableName {
|
||||
if autoAllowSkills, !allowlistResolutions.isEmpty {
|
||||
let bins = await SkillBinsCache.shared.currentBins()
|
||||
skillAllow = bins.contains(name)
|
||||
skillAllow = allowlistResolutions.allSatisfy { bins.contains($0.executableName) }
|
||||
} else {
|
||||
skillAllow = false
|
||||
}
|
||||
@@ -490,7 +505,9 @@ private enum ExecHostExecutor {
|
||||
autoAllowSkills: autoAllowSkills,
|
||||
env: env,
|
||||
resolution: resolution,
|
||||
allowlistMatch: allowlistMatch,
|
||||
allowlistResolutions: allowlistResolutions,
|
||||
allowlistMatches: allowlistMatches,
|
||||
allowlistSatisfied: allowlistSatisfied,
|
||||
skillAllow: skillAllow)
|
||||
}
|
||||
|
||||
@@ -499,13 +516,18 @@ private enum ExecHostExecutor {
|
||||
context: ExecApprovalContext)
|
||||
{
|
||||
guard decision == .allowAlways, context.security == .allowlist else { return }
|
||||
guard let pattern = ExecApprovalHelpers.allowlistPattern(
|
||||
command: context.command,
|
||||
resolution: context.resolution)
|
||||
else {
|
||||
return
|
||||
var seenPatterns = Set<String>()
|
||||
for candidate in context.allowlistResolutions {
|
||||
guard let pattern = ExecApprovalHelpers.allowlistPattern(
|
||||
command: context.command,
|
||||
resolution: candidate)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
if seenPatterns.insert(pattern).inserted {
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: context.trimmedAgent, pattern: pattern)
|
||||
}
|
||||
}
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: context.trimmedAgent, pattern: pattern)
|
||||
}
|
||||
|
||||
private static func ensureScreenRecordingAccess(_ needsScreenRecording: Bool?) async -> ExecHostResponse? {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import Foundation
|
||||
|
||||
enum HostEnvSanitizer {
|
||||
// Keep in sync with src/infra/host-env-security-policy.json.
|
||||
// Parity is validated by src/infra/host-env-security.policy-parity.test.ts.
|
||||
/// Keep in sync with src/infra/host-env-security-policy.json.
|
||||
/// Parity is validated by src/infra/host-env-security.policy-parity.test.ts.
|
||||
private static let blockedKeys: Set<String> = [
|
||||
"NODE_OPTIONS",
|
||||
"NODE_PATH",
|
||||
|
||||
@@ -454,18 +454,23 @@ actor MacNodeRuntime {
|
||||
: self.mainSessionKey
|
||||
let runId = UUID().uuidString
|
||||
let env = Self.sanitizedEnv(params.env)
|
||||
let resolution = ExecCommandResolution.resolve(
|
||||
let allowlistResolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: params.rawCommand,
|
||||
cwd: params.cwd,
|
||||
env: env)
|
||||
let allowlistMatch = security == .allowlist
|
||||
? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution)
|
||||
: nil
|
||||
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, let name = resolution?.executableName {
|
||||
if autoAllowSkills, !allowlistResolutions.isEmpty {
|
||||
let bins = await SkillBinsCache.shared.currentBins()
|
||||
skillAllow = bins.contains(name)
|
||||
skillAllow = allowlistResolutions.allSatisfy { bins.contains($0.executableName) }
|
||||
} else {
|
||||
skillAllow = false
|
||||
}
|
||||
@@ -501,13 +506,14 @@ actor MacNodeRuntime {
|
||||
if let response = approval.response { return response }
|
||||
let approvedByAsk = approval.approvedByAsk
|
||||
let persistAllowlist = approval.persistAllowlist
|
||||
if persistAllowlist, security == .allowlist,
|
||||
let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: resolution)
|
||||
{
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
|
||||
}
|
||||
self.persistAllowlistPatterns(
|
||||
persistAllowlist: persistAllowlist,
|
||||
security: security,
|
||||
agentId: agentId,
|
||||
command: command,
|
||||
allowlistResolutions: allowlistResolutions)
|
||||
|
||||
if security == .allowlist, allowlistMatch == nil, !skillAllow, !approvedByAsk {
|
||||
if security == .allowlist, !allowlistSatisfied, !skillAllow, !approvedByAsk {
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
@@ -522,79 +528,32 @@ actor MacNodeRuntime {
|
||||
message: "SYSTEM_RUN_DENIED: allowlist miss")
|
||||
}
|
||||
|
||||
if let match = allowlistMatch {
|
||||
ExecApprovalsStore.recordAllowlistUse(
|
||||
agentId: agentId,
|
||||
pattern: match.pattern,
|
||||
command: displayCommand,
|
||||
resolvedPath: resolution?.resolvedPath)
|
||||
self.recordAllowlistMatches(
|
||||
security: security,
|
||||
allowlistSatisfied: allowlistSatisfied,
|
||||
agentId: agentId,
|
||||
allowlistMatches: allowlistMatches,
|
||||
allowlistResolutions: allowlistResolutions,
|
||||
displayCommand: displayCommand)
|
||||
|
||||
if let permissionResponse = await self.validateScreenRecordingIfNeeded(
|
||||
req: req,
|
||||
needsScreenRecording: params.needsScreenRecording,
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
displayCommand: displayCommand)
|
||||
{
|
||||
return permissionResponse
|
||||
}
|
||||
|
||||
if params.needsScreenRecording == true {
|
||||
let authorized = await PermissionManager
|
||||
.status([.screenRecording])[.screenRecording] ?? false
|
||||
if !authorized {
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand,
|
||||
reason: "permission:screenRecording"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "PERMISSION_MISSING: screenRecording")
|
||||
}
|
||||
}
|
||||
|
||||
let timeoutSec = params.timeoutMs.flatMap { Double($0) / 1000.0 }
|
||||
await self.emitExecEvent(
|
||||
"exec.started",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand))
|
||||
let result = await ShellExecutor.runDetailed(
|
||||
return try await self.executeSystemRun(
|
||||
req: req,
|
||||
params: params,
|
||||
command: command,
|
||||
cwd: params.cwd,
|
||||
env: env,
|
||||
timeout: timeoutSec)
|
||||
let combined = [result.stdout, result.stderr, result.errorMessage]
|
||||
.compactMap(\.self)
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: "\n")
|
||||
await self.emitExecEvent(
|
||||
"exec.finished",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand,
|
||||
exitCode: result.exitCode,
|
||||
timedOut: result.timedOut,
|
||||
success: result.success,
|
||||
output: ExecEventPayload.truncateOutput(combined)))
|
||||
|
||||
struct RunPayload: Encodable {
|
||||
var exitCode: Int?
|
||||
var timedOut: Bool
|
||||
var success: Bool
|
||||
var stdout: String
|
||||
var stderr: String
|
||||
var error: String?
|
||||
}
|
||||
|
||||
let payload = try Self.encodePayload(RunPayload(
|
||||
exitCode: result.exitCode,
|
||||
timedOut: result.timedOut,
|
||||
success: result.success,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
error: result.errorMessage))
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
displayCommand: displayCommand)
|
||||
}
|
||||
|
||||
private func handleSystemWhich(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
@@ -835,6 +794,132 @@ actor MacNodeRuntime {
|
||||
}
|
||||
|
||||
extension MacNodeRuntime {
|
||||
private func persistAllowlistPatterns(
|
||||
persistAllowlist: Bool,
|
||||
security: ExecSecurity,
|
||||
agentId: String?,
|
||||
command: [String],
|
||||
allowlistResolutions: [ExecCommandResolution])
|
||||
{
|
||||
guard persistAllowlist, security == .allowlist else { return }
|
||||
var seenPatterns = Set<String>()
|
||||
for candidate in allowlistResolutions {
|
||||
guard let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: candidate) else {
|
||||
continue
|
||||
}
|
||||
if seenPatterns.insert(pattern).inserted {
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func recordAllowlistMatches(
|
||||
security: ExecSecurity,
|
||||
allowlistSatisfied: Bool,
|
||||
agentId: String?,
|
||||
allowlistMatches: [ExecAllowlistEntry],
|
||||
allowlistResolutions: [ExecCommandResolution],
|
||||
displayCommand: String)
|
||||
{
|
||||
guard security == .allowlist, allowlistSatisfied else { return }
|
||||
var seenPatterns = Set<String>()
|
||||
for (idx, match) in allowlistMatches.enumerated() {
|
||||
if !seenPatterns.insert(match.pattern).inserted {
|
||||
continue
|
||||
}
|
||||
let resolvedPath = idx < allowlistResolutions.count ? allowlistResolutions[idx].resolvedPath : nil
|
||||
ExecApprovalsStore.recordAllowlistUse(
|
||||
agentId: agentId,
|
||||
pattern: match.pattern,
|
||||
command: displayCommand,
|
||||
resolvedPath: resolvedPath)
|
||||
}
|
||||
}
|
||||
|
||||
private func validateScreenRecordingIfNeeded(
|
||||
req: BridgeInvokeRequest,
|
||||
needsScreenRecording: Bool?,
|
||||
sessionKey: String,
|
||||
runId: String,
|
||||
displayCommand: String) async -> BridgeInvokeResponse?
|
||||
{
|
||||
guard needsScreenRecording == true else { return nil }
|
||||
let authorized = await PermissionManager
|
||||
.status([.screenRecording])[.screenRecording] ?? false
|
||||
if authorized {
|
||||
return nil
|
||||
}
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand,
|
||||
reason: "permission:screenRecording"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "PERMISSION_MISSING: screenRecording")
|
||||
}
|
||||
|
||||
private func executeSystemRun(
|
||||
req: BridgeInvokeRequest,
|
||||
params: OpenClawSystemRunParams,
|
||||
command: [String],
|
||||
env: [String: String],
|
||||
sessionKey: String,
|
||||
runId: String,
|
||||
displayCommand: String) async throws -> BridgeInvokeResponse
|
||||
{
|
||||
let timeoutSec = params.timeoutMs.flatMap { Double($0) / 1000.0 }
|
||||
await self.emitExecEvent(
|
||||
"exec.started",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand))
|
||||
let result = await ShellExecutor.runDetailed(
|
||||
command: command,
|
||||
cwd: params.cwd,
|
||||
env: env,
|
||||
timeout: timeoutSec)
|
||||
let combined = [result.stdout, result.stderr, result.errorMessage]
|
||||
.compactMap(\.self)
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: "\n")
|
||||
await self.emitExecEvent(
|
||||
"exec.finished",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand,
|
||||
exitCode: result.exitCode,
|
||||
timedOut: result.timedOut,
|
||||
success: result.success,
|
||||
output: ExecEventPayload.truncateOutput(combined)))
|
||||
|
||||
struct RunPayload: Encodable {
|
||||
var exitCode: Int?
|
||||
var timedOut: Bool
|
||||
var success: Bool
|
||||
var stdout: String
|
||||
var stderr: String
|
||||
var error: String?
|
||||
}
|
||||
let runPayload = RunPayload(
|
||||
exitCode: result.exitCode,
|
||||
timedOut: result.timedOut,
|
||||
success: result.success,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
error: result.errorMessage)
|
||||
let payload = try Self.encodePayload(runPayload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
}
|
||||
|
||||
private static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
|
||||
guard let json, let data = json.data(using: .utf8) else {
|
||||
throw NSError(domain: "Gateway", code: 20, userInfo: [
|
||||
|
||||
@@ -44,4 +44,3 @@ public enum TailscaleNetwork {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,4 +46,72 @@ struct ExecAllowlistTests {
|
||||
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
|
||||
#expect(match?.pattern == entry.pattern)
|
||||
}
|
||||
|
||||
@Test func resolveForAllowlistSplitsShellChains() {
|
||||
let command = ["/bin/sh", "-lc", "echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test"]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: "echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test",
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.count == 2)
|
||||
#expect(resolutions[0].executableName == "echo")
|
||||
#expect(resolutions[1].executableName == "touch")
|
||||
}
|
||||
|
||||
@Test func resolveForAllowlistKeepsQuotedOperatorsInSingleSegment() {
|
||||
let command = ["/bin/sh", "-lc", "echo \"a && b\""]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: "echo \"a && b\"",
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.count == 1)
|
||||
#expect(resolutions[0].executableName == "echo")
|
||||
}
|
||||
|
||||
@Test func resolveForAllowlistFailsClosedOnCommandSubstitution() {
|
||||
let command = ["/bin/sh", "-lc", "echo $(/usr/bin/touch /tmp/openclaw-allowlist-test-subst)"]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: "echo $(/usr/bin/touch /tmp/openclaw-allowlist-test-subst)",
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.isEmpty)
|
||||
}
|
||||
|
||||
@Test func resolveForAllowlistTreatsPlainShInvocationAsDirectExec() {
|
||||
let command = ["/bin/sh", "./script.sh"]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: nil,
|
||||
cwd: "/tmp",
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.count == 1)
|
||||
#expect(resolutions[0].executableName == "sh")
|
||||
}
|
||||
|
||||
@Test func matchAllRequiresEverySegmentToMatch() {
|
||||
let first = ExecCommandResolution(
|
||||
rawExecutable: "echo",
|
||||
resolvedPath: "/usr/bin/echo",
|
||||
executableName: "echo",
|
||||
cwd: nil)
|
||||
let second = ExecCommandResolution(
|
||||
rawExecutable: "/usr/bin/touch",
|
||||
resolvedPath: "/usr/bin/touch",
|
||||
executableName: "touch",
|
||||
cwd: nil)
|
||||
let resolutions = [first, second]
|
||||
|
||||
let partial = ExecAllowlistMatcher.matchAll(
|
||||
entries: [ExecAllowlistEntry(pattern: "echo")],
|
||||
resolutions: resolutions)
|
||||
#expect(partial.isEmpty)
|
||||
|
||||
let full = ExecAllowlistMatcher.matchAll(
|
||||
entries: [ExecAllowlistEntry(pattern: "echo"), ExecAllowlistEntry(pattern: "touch")],
|
||||
resolutions: resolutions)
|
||||
#expect(full.count == 2)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user