fix(macos): harden exec allowlist shell-chain checks

This commit is contained in:
Peter Steinberger
2026-02-21 16:27:05 +01:00
parent 8178ea472d
commit 5da03e6221
9 changed files with 507 additions and 133 deletions

View File

@@ -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

View File

@@ -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 {
}
}
}

View File

@@ -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 }

View File

@@ -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? {

View File

@@ -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",

View File

@@ -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: [

View File

@@ -44,4 +44,3 @@ public enum TailscaleNetwork {
return nil
}
}

View File

@@ -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)
}
}