Files
openclaw/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift
Peter Steinberger 9b1c36d23c fix: prevent exec approval revocation races (#103515)
* fix(security): serialize exec approval mutations

* fix(security): preserve additive approval writes

* test(cli): expect normalized approval shape

* fix(security): preserve exec approval compatibility

* test(security): exercise locked approval initialization

* test(security): mock serialized approval helpers

* test(exec): derive enforced command path from plan

* fix(gateway): always return approval CAS conflicts

* fix(macos): serialize exec approvals writes

* fix(security): repair approval build errors

* fix(security): serialize exec approval mutations

* fix(security): fail closed on approval persistence errors

* test(security): cover detached approval persistence failures

* fix(security): harden exec approval state

* style(macos): format exec approval sources

* fix(security): complete exec approval hardening

Co-authored-by: Coy Geek <65363919+coygeek@users.noreply.github.com>

* fix(macos): preserve approved login-shell semantics

* fix(macos): keep login shell approvals one-shot

* fix(security): linearize exec authorization

Co-authored-by: Coy Geek <65363919+coygeek@users.noreply.github.com>

* fix(security): preserve durable approval basis

Co-authored-by: Coy Geek <65363919+coygeek@users.noreply.github.com>

* fix(security): bind exec grants to current policy

Co-authored-by: Coy Geek <65363919+coygeek@users.noreply.github.com>

* test(security): fix exec revocation fixtures

* test(security): align gateway approval fixtures

* fix(macos): return approval decisions

* chore(i18n): sync native approval strings

* test(security): align approval hardening fixtures

* test(node): authorize completed event fixture

* test(security): fix approval decision fixtures

* test(security): await durable approval visibility

* fix(exec): preserve concurrent approval grants

* fix(exec): address exact-head CI failures

* fix(exec): preserve concurrent approval promotions

* fix(exec): make Swift shutdown state explicit

* test(macos): handle approval read failures

* fix(macos): harden approval socket paths

* fix(macos): preserve exact shell payload bytes

* test(macos): make approval fixtures explicit

* test(macos): fix approval suite compilation

* fix(macos): bound approval socket JSONL reads

* chore: move exec approval note to release process

* chore: move exec approval note to release process

---------

Co-authored-by: Coy Geek <65363919+coygeek@users.noreply.github.com>
2026-07-10 21:35:05 +01:00

1016 lines
44 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import Testing
@testable import OpenClaw
/// These cases cover optional `security=allowlist` behavior.
struct ExecAllowlistTests {
private struct ShellParserParityFixture: Decodable {
struct Case: Decodable {
let id: String
let command: String
let ok: Bool
let executables: [String]
}
let cases: [Case]
}
private struct WrapperResolutionParityFixture: Decodable {
struct Case: Decodable {
let id: String
let argv: [String]
let expectedRawExecutable: String?
}
let cases: [Case]
}
private static func loadShellParserParityCases() throws -> [ShellParserParityFixture.Case] {
let fixtureURL = self.fixtureURL(filename: "exec-allowlist-shell-parser-parity.json")
let data = try Data(contentsOf: fixtureURL)
let fixture = try JSONDecoder().decode(ShellParserParityFixture.self, from: data)
return fixture.cases
}
private static func loadWrapperResolutionParityCases() throws -> [WrapperResolutionParityFixture.Case] {
let fixtureURL = self.fixtureURL(filename: "exec-wrapper-resolution-parity.json")
let data = try Data(contentsOf: fixtureURL)
let fixture = try JSONDecoder().decode(WrapperResolutionParityFixture.self, from: data)
return fixture.cases
}
private static func fixtureURL(filename: String) -> URL {
var repoRoot = URL(fileURLWithPath: #filePath)
for _ in 0..<5 {
repoRoot.deleteLastPathComponent()
}
return repoRoot
.appendingPathComponent("test")
.appendingPathComponent("fixtures")
.appendingPathComponent(filename)
}
private static func homebrewRGResolution() -> ExecCommandResolution {
ExecCommandResolution(
rawExecutable: "rg",
resolvedPath: "/opt/homebrew/bin/rg",
executableName: "rg",
cwd: nil)
}
private static func makeExecutable(at url: URL, body: String = "#!/bin/sh\nexit 0\n") throws {
try Data(body.utf8).write(to: url)
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path)
}
@Test func `match uses resolved path`() {
let entry = ExecAllowlistEntry(pattern: "/opt/homebrew/bin/rg")
let resolution = Self.homebrewRGResolution()
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
#expect(match?.pattern == entry.pattern)
}
@Test func `match accepts basename pattern for PATH resolved executable`() {
let entry = ExecAllowlistEntry(pattern: "rg")
let resolution = Self.homebrewRGResolution()
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
#expect(match?.pattern == entry.pattern)
}
@Test func `match accepts basename glob for PATH resolved executable`() {
let entry = ExecAllowlistEntry(pattern: "r?")
let resolution = Self.homebrewRGResolution()
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
#expect(match?.pattern == entry.pattern)
}
@Test func `match never treats durable command markers as executable patterns`() {
for marker in ["=command:0123456789abcdef", "=node-command:0123456789abcdef"] {
let entry = ExecAllowlistEntry(pattern: marker, source: "allow-always")
let resolution = ExecCommandResolution(
rawExecutable: marker,
resolvedPath: "/Users/test/.local/bin/\(marker)",
executableName: marker,
cwd: nil)
#expect(ExecAllowlistMatcher.match(entries: [entry], resolution: resolution) == nil)
}
}
@Test func `match ignores basename for path selected executable`() {
let entry = ExecAllowlistEntry(pattern: "echo")
let relativeResolution = ExecCommandResolution(
rawExecutable: "./echo",
resolvedPath: "/tmp/oc-basename/echo",
executableName: "echo",
cwd: "/tmp/oc-basename")
let absoluteResolution = ExecCommandResolution(
rawExecutable: "/tmp/oc-basename/echo",
resolvedPath: "/tmp/oc-basename/echo",
executableName: "echo",
cwd: "/tmp/oc-basename")
#expect(ExecAllowlistMatcher.match(entries: [entry], resolution: relativeResolution) == nil)
#expect(ExecAllowlistMatcher.match(entries: [entry], resolution: absoluteResolution) == nil)
}
@Test func `match is case sensitive on macOS`() {
let entry = ExecAllowlistEntry(pattern: "/OPT/HOMEBREW/BIN/RG")
let resolution = Self.homebrewRGResolution()
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
#expect(match == nil)
}
@Test func `match supports glob star`() {
let entry = ExecAllowlistEntry(pattern: "/opt/**/rg")
let resolution = Self.homebrewRGResolution()
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
#expect(match?.pattern == entry.pattern)
}
@Test func `single character glob does not cross path separator`() {
let entry = ExecAllowlistEntry(pattern: "/tmp/a?b")
let resolution = ExecCommandResolution(
rawExecutable: "/tmp/a/b",
resolvedPath: "/tmp/a/b",
resolvedRealPath: "/tmp/a/b",
executableName: "b",
cwd: nil)
#expect(ExecAllowlistMatcher.match(entries: [entry], resolution: resolution) == nil)
}
@Test func `match normalizes macOS private var alias`() {
let entry = ExecAllowlistEntry(pattern: "/var/tmp/openclaw-tool")
let resolution = ExecCommandResolution(
rawExecutable: "/var/tmp/openclaw-tool",
resolvedPath: "/var/tmp/openclaw-tool",
resolvedRealPath: "/private/var/tmp/openclaw-tool",
executableName: "openclaw-tool",
cwd: nil)
#expect(ExecAllowlistMatcher.match(entries: [entry], resolution: resolution) != nil)
}
@Test func `match enforces hand authored arg pattern and prefers it over path only fallback`() {
let executable = "/usr/bin/python3"
let fallback = ExecAllowlistEntry(id: "fallback", pattern: executable)
let restricted = ExecAllowlistEntry(
id: "restricted",
pattern: executable,
argPattern: #"^safe\.py$"#)
let safe = ExecCommandResolution(
rawExecutable: executable,
resolvedPath: executable,
resolvedRealPath: executable,
executableName: "python3",
cwd: nil,
argv: [executable, "safe.py"])
let unsafe = ExecCommandResolution(
rawExecutable: executable,
resolvedPath: executable,
resolvedRealPath: executable,
executableName: "python3",
cwd: nil,
argv: [executable, "unsafe.py"])
#expect(ExecAllowlistMatcher.match(entries: [fallback, restricted], resolution: safe)?.id == "restricted")
#expect(ExecAllowlistMatcher.match(entries: [fallback, restricted], resolution: unsafe)?.id == "fallback")
}
@Test func `match enforces generated nul arg patterns including zero args`() {
let executable = "/usr/bin/printf"
let zeroArgs = ExecAllowlistEntry(pattern: executable, argPattern: "^\0\0$")
let oneArg = ExecAllowlistEntry(pattern: executable, argPattern: "^hello world\0$")
let base = ExecCommandResolution(
rawExecutable: executable,
resolvedPath: executable,
resolvedRealPath: executable,
executableName: "printf",
cwd: nil,
argv: [executable])
let withSpace = ExecCommandResolution(
rawExecutable: executable,
resolvedPath: executable,
resolvedRealPath: executable,
executableName: "printf",
cwd: nil,
argv: [executable, "hello world"])
#expect(ExecAllowlistMatcher.match(entries: [zeroArgs], resolution: base) != nil)
#expect(ExecAllowlistMatcher.match(entries: [oneArg], resolution: withSpace) != nil)
#expect(ExecAllowlistMatcher.match(entries: [zeroArgs], resolution: withSpace) == nil)
}
@Test func `arg pattern does not discard redirect shaped direct argv literal`() {
let executable = "/usr/bin/python3"
let restricted = ExecAllowlistEntry(pattern: executable, argPattern: #"^safe\.py$"#)
let explicit = ExecAllowlistEntry(
pattern: executable,
argPattern: #"^safe\.py 2>/dev/null$"#)
let resolution = ExecCommandResolution(
rawExecutable: executable,
resolvedPath: executable,
resolvedRealPath: executable,
executableName: "python3",
cwd: nil,
argv: [executable, "safe.py", "2>/dev/null"])
#expect(ExecAllowlistMatcher.match(entries: [restricted], resolution: resolution) == nil)
#expect(ExecAllowlistMatcher.match(entries: [explicit], resolution: resolution)?.pattern == executable)
}
@Test func `arg pattern uses JavaScript regular expression semantics`() {
let executable = "/usr/bin/printf"
func resolution(_ argument: String) -> ExecCommandResolution {
ExecCommandResolution(
rawExecutable: executable,
resolvedPath: executable,
resolvedRealPath: executable,
executableName: "printf",
cwd: nil,
argv: [executable, argument])
}
let digit = ExecAllowlistEntry(pattern: executable, argPattern: #"^\d$"#)
#expect(ExecAllowlistMatcher.match(entries: [digit], resolution: resolution("1")) != nil)
#expect(ExecAllowlistMatcher.match(entries: [digit], resolution: resolution("١")) == nil)
let word = ExecAllowlistEntry(pattern: executable, argPattern: #"^\w$"#)
#expect(ExecAllowlistMatcher.match(entries: [word], resolution: resolution("a")) != nil)
#expect(ExecAllowlistMatcher.match(entries: [word], resolution: resolution("é")) == nil)
let anchored = ExecAllowlistEntry(pattern: executable, argPattern: "^safe$")
#expect(ExecAllowlistMatcher.match(entries: [anchored], resolution: resolution("safe")) != nil)
#expect(ExecAllowlistMatcher.match(entries: [anchored], resolution: resolution("safe\n")) == nil)
for icuOnlyPattern in [#"^a++$"#, #"^(?>a)$"#] {
let entry = ExecAllowlistEntry(pattern: executable, argPattern: icuOnlyPattern)
#expect(ExecAllowlistMatcher.match(entries: [entry], resolution: resolution("aa")) == nil)
}
}
@Test func `arg pattern fails closed without argv or with invalid regex`() {
let executable = "/usr/bin/printf"
let noArgv = ExecCommandResolution(
rawExecutable: executable,
resolvedPath: executable,
resolvedRealPath: executable,
executableName: "printf",
cwd: nil)
let invalid = ExecAllowlistEntry(pattern: executable, argPattern: "[")
let restricted = ExecAllowlistEntry(pattern: executable, argPattern: "^ok$")
#expect(ExecAllowlistMatcher.match(entries: [invalid], resolution: noArgv) == nil)
#expect(ExecAllowlistMatcher.match(entries: [restricted], resolution: noArgv) == nil)
}
@Test func `direct PATH command binds to resolved executable`() throws {
let root = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-bind-path-\(UUID().uuidString)", isDirectory: true)
.resolvingSymlinksInPath()
defer { try? FileManager().removeItem(at: root) }
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
let executable = root.appendingPathComponent("oc-bind")
try Self.makeExecutable(at: executable)
let command = ["oc-bind", "literal"]
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: nil,
cwd: nil,
env: ["PATH": root.path])
#expect(resolutions.count == 1)
#expect(ExecCommandResolution.bindForAllowlistExecution(
command: command,
rawCommand: nil,
resolutions: resolutions) == [executable.path, "literal"])
}
@Test func `symlink allowlist match and execution stay bound to canonical target`() throws {
let root = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-bind-symlink-\(UUID().uuidString)", isDirectory: true)
.resolvingSymlinksInPath()
defer { try? FileManager().removeItem(at: root) }
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
let first = root.appendingPathComponent("first")
let second = root.appendingPathComponent("second")
let alias = root.appendingPathComponent("tool")
try Self.makeExecutable(at: first)
try Self.makeExecutable(at: second)
try FileManager().createSymbolicLink(at: alias, withDestinationURL: first)
let command = [alias.path, "value"]
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: nil,
cwd: nil,
env: nil)
let resolution = try #require(resolutions.first)
let targetEntry = ExecAllowlistEntry(pattern: first.path)
let aliasEntry = ExecAllowlistEntry(pattern: alias.path)
let bound = ExecCommandResolution.bindForAllowlistExecution(
command: command,
rawCommand: nil,
resolutions: resolutions)
#expect(ExecAllowlistMatcher.match(entries: [targetEntry], resolution: resolution) != nil)
#expect(ExecAllowlistMatcher.match(entries: [aliasEntry], resolution: resolution) == nil)
#expect(bound == [first.path, "value"])
try FileManager().removeItem(at: alias)
try FileManager().createSymbolicLink(at: alias, withDestinationURL: second)
#expect(bound == [first.path, "value"])
}
@Test func `standard non-login sh transport binds one static command directly`() {
let payload = "/usr/bin/printf 'literal value'"
let command = ["/bin/sh", "-c", payload]
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: payload,
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(ExecCommandResolution.bindForAllowlistExecution(
command: command,
rawCommand: payload,
resolutions: resolutions) == ["/usr/bin/printf", "literal value"])
}
@Test func `login sh transport remains one-shot because startup files are outside the binding`() {
let payload = "/usr/bin/printf 'literal value'"
let command = ["/bin/sh", "-lc", payload]
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: payload,
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(ExecCommandResolution.bindForAllowlistExecution(
command: command,
rawCommand: payload,
resolutions: resolutions) == nil)
}
@Test func `nonstandard shell wrappers never receive reusable execution binding`() {
let cases: [([String], String?)] = [
(["/bin/sh", "-n", "-c", "/bin/rm /tmp/x"], "/bin/rm /tmp/x"),
(["fish", "-c", "/usr/bin/printf literal"], nil),
(["cmd", "/c", "/usr/bin/printf literal"], nil),
(["pwsh", "-command", "/usr/bin/printf literal"], nil),
]
for (command, rawCommand) in cases {
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: rawCommand,
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(!resolutions.isEmpty)
#expect(ExecCommandResolution.bindForAllowlistExecution(
command: command,
rawCommand: rawCommand,
resolutions: resolutions) == nil)
}
}
@Test func `shell carriers and canonical shell aliases cannot bind`() {
let directPath = "/usr/bin/true"
for carrier in [
"ash", "bash", "chrt", "cmd", "cmd.exe", "dash", "fish", "ionice", "ksh", "powershell",
"powershell.exe", "pwsh", "pwsh.exe", "setsid", "sh", "taskset", "zsh",
] {
let command = [carrier, "/tmp/unsafe-script"]
let resolution = ExecCommandResolution(
rawExecutable: carrier,
resolvedPath: directPath,
resolvedRealPath: directPath,
executableName: carrier,
cwd: nil,
argv: command)
#expect(ExecCommandResolution.bindForAllowlistExecution(
command: command,
rawCommand: nil,
resolutions: [resolution]) == nil)
}
let aliasCommand = ["skill-shell", "-c", "/usr/bin/printf ok"]
let aliasResolution = ExecCommandResolution(
rawExecutable: "skill-shell",
resolvedPath: "/tmp/skill-shell",
resolvedRealPath: "/bin/sh",
executableName: "skill-shell",
cwd: nil,
argv: aliasCommand)
#expect(ExecCommandResolution.bindForAllowlistExecution(
command: aliasCommand,
rawCommand: nil,
resolutions: [aliasResolution]) == nil)
}
@Test func `shell tokenization uses shell lexical separators only`() throws {
for separator in ["\u{00A0}", "\u{2003}", "\r"] {
let argument = "left\(separator)right\(separator)"
let payload = "/usr/bin/printf \(argument)"
let command = ["/bin/sh", "-c", payload]
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: payload,
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
let resolution = try #require(resolutions.first)
#expect(resolution.argv == ["/usr/bin/printf", argument])
#expect(ExecCommandResolution.bindForAllowlistExecution(
command: command,
rawCommand: payload,
resolutions: resolutions) == ["/usr/bin/printf", argument])
}
let tabPayload = "/usr/bin/printf left\tright"
let tabResolution = try #require(ExecCommandResolution.resolveForAllowlist(
command: ["/bin/sh", "-c", tabPayload],
rawCommand: tabPayload,
cwd: nil,
env: ["PATH": "/usr/bin:/bin"]).first)
#expect(tabResolution.argv == ["/usr/bin/printf", "left", "right"])
#expect(ExecCommandResolution.bindForAllowlistExecution(
command: ["/bin/sh", "-c", tabPayload],
rawCommand: tabPayload,
resolutions: [tabResolution]) == ["/usr/bin/printf", "left", "right"])
}
@Test func `dynamic shell payloads cannot receive reusable binding`() {
for payload in [
"/usr/bin/printf $HOME",
"/usr/bin/printf $(/usr/bin/id)",
"/usr/bin/printf *",
"/usr/bin/printf ok > /tmp/out",
"/usr/bin/printf ok && /usr/bin/true",
"FOO=bar /usr/bin/printf ok",
"/usr/bin/printf ok # ignored",
] {
let command = ["/bin/sh", "-lc", payload]
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: payload,
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(ExecCommandResolution.bindForAllowlistExecution(
command: command,
rawCommand: payload,
resolutions: resolutions) == nil)
}
let assignmentPayload = "FOO=bar /usr/bin/printf ok"
let spoofedAssignmentResolution = ExecCommandResolution(
rawExecutable: "FOO=bar",
resolvedPath: "/usr/bin/true",
resolvedRealPath: "/usr/bin/true",
executableName: "true",
cwd: nil,
argv: ["FOO=bar", "/usr/bin/printf", "ok"])
#expect(ExecCommandResolution.bindForAllowlistExecution(
command: ["/bin/sh", "-lc", assignmentPayload],
rawCommand: assignmentPayload,
resolutions: [spoofedAssignmentResolution]) == nil)
}
@Test func `env empty utility operands cannot receive reusable binding`() {
for command in [
["/usr/bin/env", "", "/usr/bin/touch", "/tmp/x"],
["/usr/bin/env", "--", " ", "/usr/bin/touch", "/tmp/x"],
] {
#expect(ExecEnvInvocationUnwrapper.unwrapWithMetadata(command) == nil)
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: nil,
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.first?.executableName == "env")
#expect(ExecCommandResolution.bindForAllowlistExecution(
command: command,
rawCommand: nil,
resolutions: resolutions) == nil)
}
}
@Test func `env clear environment marker is not transparent`() throws {
let clearCommand = ["/usr/bin/env", "-", "/usr/bin/true"]
let clear = try #require(ExecEnvInvocationUnwrapper.unwrapWithMetadata(clearCommand))
#expect(clear.usesModifiers)
let clearResolutions = ExecCommandResolution.resolveForAllowlist(
command: clearCommand,
rawCommand: nil,
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(clearResolutions.first?.executableName == "env")
#expect(ExecCommandResolution.bindForAllowlistExecution(
command: clearCommand,
rawCommand: nil,
resolutions: clearResolutions) == nil)
let separatorCommand = ["/usr/bin/env", "--", "/usr/bin/true"]
let separator = try #require(ExecEnvInvocationUnwrapper.unwrapWithMetadata(separatorCommand))
#expect(!separator.usesModifiers)
#expect(separator.command == ["/usr/bin/true"])
}
@Test func `non shell allowlist resolution ignores display text`() {
let command = ["/usr/bin/env", "/usr/bin/printf", "ok"]
let withoutDisplay = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: nil,
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
let withDisplay = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: "/usr/bin/env /usr/bin/printf ok",
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(withDisplay.map(\.rawExecutable) == withoutDisplay.map(\.rawExecutable))
#expect(withDisplay.first?.resolvedRealPath == "/usr/bin/printf")
#expect(ExecCommandResolution.bindForAllowlistExecution(
command: command,
rawCommand: "/usr/bin/env /usr/bin/printf ok",
resolutions: withDisplay) == ["/usr/bin/printf", "ok"])
}
@Test func `shell comments and dispatch wrappers cannot receive reusable binding`() {
let commented = ["/bin/sh", "-c", "/bin/rm /tmp/safe # /tmp/protected"]
let commentedResolutions = ExecCommandResolution.resolveForAllowlist(
command: commented,
rawCommand: "/bin/rm /tmp/safe # /tmp/protected",
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(ExecCommandResolution.bindForAllowlistExecution(
command: commented,
rawCommand: "/bin/rm /tmp/safe # /tmp/protected",
resolutions: commentedResolutions) == nil)
for command in [
["/usr/bin/nice", "/usr/bin/printf", "ok"],
["/bin/sh", "./script.sh"],
["npx", "some-package"],
] {
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: nil,
cwd: "/tmp",
env: ["PATH": "/usr/bin:/bin"])
#expect(ExecCommandResolution.bindForAllowlistExecution(
command: command,
rawCommand: nil,
resolutions: resolutions) == nil)
}
}
@Test func `resolve for allowlist splits shell chains`() {
let command = ["/bin/sh", "-c", "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 `resolve for allowlist splits posix combined c flag payloads`() {
for command in [
["/bin/bash", "-xc", "/usr/bin/printf safe_marker"],
["/bin/bash", "-ec", "/usr/bin/printf safe_marker"],
["/bin/bash", "-euxc", "/usr/bin/printf safe_marker"],
["/bin/bash", "-cx", "/usr/bin/printf safe_marker"],
["/bin/bash", "-O", "extglob", "-xc", "/usr/bin/printf safe_marker"],
["/bin/bash", "-co", "vi", "/usr/bin/printf safe_marker"],
["/bin/bash", "-oc", "vi", "/usr/bin/printf safe_marker"],
["/bin/bash", "-cO", "extglob", "/usr/bin/printf safe_marker"],
["/bin/bash", "-xo", "vi", "-c", "/usr/bin/printf safe_marker"],
["/bin/bash", "-xO", "extglob", "-c", "/usr/bin/printf safe_marker"],
["/bin/bash", "+xo", "vi", "-c", "/usr/bin/printf safe_marker"],
["/bin/bash", "--rcfile", "/tmp/rc", "-c", "/usr/bin/printf safe_marker"],
["/bin/bash", "--init-file=/tmp/rc", "-c", "/usr/bin/printf safe_marker"],
] {
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: nil,
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.count == 1)
#expect(resolutions[0].resolvedPath == "/usr/bin/printf")
#expect(resolutions[0].executableName == "printf")
}
}
@Test func `resolve for allowlist treats c after posix shell operand as direct exec`() {
for command in [
["/bin/bash", "./script.sh", "-c", "/usr/bin/printf safe_marker"],
["/bin/bash", "-x", "-C", "echo ok", "-c", "/usr/bin/printf safe_marker"],
] {
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: nil,
cwd: "/tmp",
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.count == 1)
#expect(resolutions[0].resolvedPath == "/bin/bash")
#expect(resolutions[0].executableName == "bash")
}
}
@Test func `resolve for allowlist fails closed for interactive posix shell wrappers`() {
for command in [
["/bin/bash", "-i", "-c", "/usr/bin/printf safe_marker"],
["/bin/bash", "-ic", "/usr/bin/printf safe_marker"],
["/bin/bash", "--rcfile", "/tmp/payload.sh", "-i", "-c", "/usr/bin/printf safe_marker"],
["/usr/bin/fish", "--interactive", "-c", "/usr/bin/printf safe_marker"],
] {
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: nil,
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.isEmpty)
}
}
@Test func `resolve for allowlist fails closed for login shell wrappers`() {
for command in [
["/bin/bash", "-l", "-c", "/usr/bin/printf safe_marker"],
["/bin/bash", "--login", "-c", "/usr/bin/printf safe_marker"],
["/bin/bash", "-xlc", "/usr/bin/printf safe_marker"],
["/bin/dash", "-lc", "/usr/bin/printf safe_marker"],
["ash", "-lc", "/usr/bin/printf safe_marker"],
["/usr/bin/fish", "-l", "-c", "/usr/bin/printf safe_marker"],
["/usr/bin/fish", "--login", "-c", "/usr/bin/printf safe_marker"],
["/bin/sh", "-lc", "/usr/bin/printf safe_marker"],
["/bin/sh", "-x", "-lc", "/usr/bin/printf safe_marker"],
["/usr/bin/env", "/bin/sh", "-lc", "/usr/bin/printf safe_marker"],
] {
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: nil,
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.isEmpty)
}
}
@Test func `resolve for allowlist fails closed for fish init command wrappers`() {
for command in [
["/usr/bin/fish", "--init-command=/tmp/payload.fish", "-c", "/usr/bin/printf safe_marker"],
["/usr/bin/fish", "--init-command", "/tmp/payload.fish", "-c", "/usr/bin/printf safe_marker"],
["/usr/bin/fish", "-C", "/tmp/payload.fish", "-c", "/usr/bin/printf safe_marker"],
["/usr/bin/fish", "-C/tmp/payload.fish", "-c", "/usr/bin/printf safe_marker"],
["/usr/bin/fish", "--init-command", "-c; /tmp/payload.fish", "/usr/bin/printf safe_marker"],
["/usr/bin/fish", "-C", "-c", "/usr/bin/printf safe_marker"],
["/usr/bin/fish", "-c/tmp/payload.fish", "/usr/bin/printf safe_marker"],
] {
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: nil,
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.isEmpty)
}
}
@Test func `resolve for allowlist uses wrapper argv payload even with canonical raw command`() {
let command = ["/bin/sh", "-c", "echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test"]
let canonicalRaw = "/bin/sh -c \"echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test\""
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: canonicalRaw,
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.count == 2)
#expect(resolutions[0].executableName == "echo")
#expect(resolutions[1].executableName == "touch")
}
@Test func `resolve for allowlist preserves generated sh lc raw payload binding`() {
let command = ["/bin/sh", "-lc", "/usr/bin/printf safe_marker"]
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: "/usr/bin/printf safe_marker",
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.count == 1)
#expect(resolutions[0].resolvedPath == "/usr/bin/printf")
#expect(resolutions[0].executableName == "printf")
let rawlessResolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: nil,
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(rawlessResolutions.isEmpty)
}
@Test func `resolve for allowlist fails closed for env modified shell wrappers`() {
let command = ["/usr/bin/env", "BASH_ENV=/tmp/payload.sh", "bash", "-lc", "echo allowlisted"]
let canonicalRaw = "/usr/bin/env BASH_ENV=/tmp/payload.sh bash -lc \"echo allowlisted\""
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: canonicalRaw,
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.isEmpty)
}
@Test func `resolve for allowlist fails closed for env dash shell wrappers`() {
let command = ["/usr/bin/env", "-", "bash", "-lc", "echo allowlisted"]
let canonicalRaw = "/usr/bin/env - bash -lc \"echo allowlisted\""
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: canonicalRaw,
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.isEmpty)
}
@Test func `resolve for allowlist keeps quoted operators in single segment`() {
let command = ["/bin/sh", "-c", "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 `resolve for allowlist fails closed on command substitution`() {
let command = ["/bin/sh", "-c", "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 `resolve for allowlist fails closed on quoted command substitution`() {
let command = ["/bin/sh", "-c", "echo \"ok $(/usr/bin/touch /tmp/openclaw-allowlist-test-quoted-subst)\""]
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: "echo \"ok $(/usr/bin/touch /tmp/openclaw-allowlist-test-quoted-subst)\"",
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.isEmpty)
}
@Test func `resolve for allowlist fails closed on line-continued command substitution`() {
let command = ["/bin/sh", "-c", "echo $\\\n(/usr/bin/touch /tmp/openclaw-allowlist-test-line-cont-subst)"]
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: "echo $\\\n(/usr/bin/touch /tmp/openclaw-allowlist-test-line-cont-subst)",
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.isEmpty)
}
@Test func `resolve for allowlist fails closed on chained line-continued command substitution`() {
let command = [
"/bin/sh",
"-c",
"echo ok && $\\\n(/usr/bin/touch /tmp/openclaw-allowlist-test-chained-line-cont-subst)",
]
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: "echo ok && $\\\n(/usr/bin/touch /tmp/openclaw-allowlist-test-chained-line-cont-subst)",
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.isEmpty)
}
@Test func `resolve for allowlist fails closed on quoted backticks`() {
let command = ["/bin/sh", "-c", "echo \"ok `/usr/bin/id`\""]
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: "echo \"ok `/usr/bin/id`\"",
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.isEmpty)
}
@Test func `resolve for allowlist matches shared shell parser fixture`() throws {
let fixtures = try Self.loadShellParserParityCases()
for fixture in fixtures {
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: ["/bin/sh", "-c", fixture.command],
rawCommand: fixture.command,
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(!resolutions.isEmpty == fixture.ok)
if fixture.ok {
let executables = resolutions.map { $0.executableName.lowercased() }
let expected = fixture.executables.map { $0.lowercased() }
#expect(executables == expected)
}
}
}
@Test func `resolve matches shared wrapper resolution fixture`() throws {
let fixtures = try Self.loadWrapperResolutionParityCases()
for fixture in fixtures {
let resolution = ExecCommandResolution.resolve(
command: fixture.argv,
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolution?.rawExecutable == fixture.expectedRawExecutable)
}
}
@Test func `resolve keeps env dash wrapper as effective executable`() {
let resolution = ExecCommandResolution.resolve(
command: ["/usr/bin/env", "-", "/usr/bin/printf", "ok"],
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolution?.rawExecutable == "/usr/bin/env")
#expect(resolution?.resolvedPath == "/usr/bin/env")
#expect(resolution?.executableName == "env")
}
@Test func `resolve for allowlist treats plain sh invocation as direct exec`() {
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 `resolve for allowlist unwraps env shell wrapper chains`() {
let command = [
"/usr/bin/env",
"/bin/sh",
"-c",
"echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test",
]
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: nil,
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.count == 2)
#expect(resolutions[0].executableName == "echo")
#expect(resolutions[1].executableName == "touch")
}
@Test func `resolve for allowlist unwraps env dispatch wrappers inside shell segments`() {
let command = ["/bin/sh", "-c", "env /usr/bin/touch /tmp/openclaw-allowlist-test"]
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: "env /usr/bin/touch /tmp/openclaw-allowlist-test",
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.count == 1)
#expect(resolutions[0].resolvedPath == "/usr/bin/touch")
#expect(resolutions[0].executableName == "touch")
}
@Test func `resolve for allowlist preserves env assignments inside shell segments`() {
let command = ["/bin/sh", "-c", "env FOO=bar /usr/bin/touch /tmp/openclaw-allowlist-test"]
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: "env FOO=bar /usr/bin/touch /tmp/openclaw-allowlist-test",
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.count == 1)
#expect(resolutions[0].resolvedPath == "/usr/bin/env")
#expect(resolutions[0].executableName == "env")
}
@Test func `resolve for allowlist preserves env wrapper with modifiers`() {
let command = ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"]
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: nil,
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.count == 1)
#expect(resolutions[0].resolvedPath == "/usr/bin/env")
#expect(resolutions[0].executableName == "env")
}
@Test func `approval evaluator resolves shell payload from canonical wrapper text`() async {
let command = ["/bin/sh", "-c", "/usr/bin/printf ok"]
let rawCommand = "/bin/sh -c \"/usr/bin/printf ok\""
let evaluation = await ExecApprovalEvaluator.evaluate(
command: command,
rawCommand: rawCommand,
cwd: nil,
envOverrides: ["PATH": "/usr/bin:/bin"],
agentId: nil)
#expect(evaluation.displayCommand == rawCommand)
#expect(evaluation.allowlistResolutions.count == 1)
#expect(evaluation.allowlistResolutions[0].resolvedPath == "/usr/bin/printf")
#expect(evaluation.allowlistResolutions[0].executableName == "printf")
#expect(evaluation.boundCommand == ["/usr/bin/printf", "ok"])
}
@Test func `approval evaluator keeps login shell requests non-reusable`() async {
let rawCommand = "/usr/bin/printf safe"
let evaluation = await ExecApprovalEvaluator.evaluate(
command: ["/bin/sh", "-lc", rawCommand],
rawCommand: rawCommand,
cwd: nil,
envOverrides: ["PATH": "/usr/bin:/bin"],
agentId: nil)
#expect(evaluation.boundCommand == nil)
#expect(!evaluation.allowlistSatisfied)
#expect(!evaluation.canPersistAllowAlways)
}
@Test func `allow always patterns unwrap env wrapper modifiers to the inner executable`() {
let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
command: ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"],
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(patterns == ["/usr/bin/printf"])
}
@Test func `allow always patterns fail closed for env modified shell wrappers`() {
let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
command: [
"/usr/bin/env",
"BASH_ENV=/tmp/payload.sh",
"/bin/sh",
"-lc",
"/usr/bin/printf ok",
],
cwd: nil,
env: ["PATH": "/usr/bin:/bin"],
rawCommand: "/usr/bin/printf ok")
#expect(patterns.isEmpty)
}
@Test func `allow always patterns preserve generated sh lc raw payload binding`() {
let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
command: ["/bin/sh", "-lc", "/usr/bin/printf safe_marker"],
cwd: nil,
env: ["PATH": "/usr/bin:/bin"],
rawCommand: "/usr/bin/printf safe_marker")
#expect(patterns == ["/usr/bin/printf"])
}
@Test func `allow always never persists broad interpreter grants`() {
for executable in [
"awk", "find", "gawk", "gmake", "gsed", "node", "osascript", "perl", "php",
"python3.13", "Rscript", "ruby", "sed", "xargs",
] {
let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
command: [executable, "inline-program"],
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(patterns.isEmpty)
}
let r2 = ExecCommandResolution(
rawExecutable: "r2",
resolvedPath: "/usr/local/bin/r2",
resolvedRealPath: "/usr/local/bin/r2",
executableName: "r2",
cwd: nil,
argv: ["r2"])
#expect(!ExecCommandResolution.isInterpreterLikePersistentGrantTarget(r2))
}
@Test func `match all requires every segment to match`() {
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: "/usr/bin/echo")],
resolutions: resolutions)
#expect(partial.isEmpty)
let caseMismatch = ExecAllowlistMatcher.matchAll(
entries: [ExecAllowlistEntry(pattern: "/USR/BIN/ECHO"), ExecAllowlistEntry(pattern: "/usr/bin/touch")],
resolutions: resolutions)
#expect(caseMismatch.isEmpty)
let full = ExecAllowlistMatcher.matchAll(
entries: [ExecAllowlistEntry(pattern: "/usr/bin/echo"), ExecAllowlistEntry(pattern: "/usr/bin/touch")],
resolutions: resolutions)
#expect(full.count == 2)
}
}