refactor: unify exec shell parser parity and gateway websocket test helpers

This commit is contained in:
Peter Steinberger
2026-02-21 23:16:53 +01:00
parent ffa63173e0
commit 1bc5c2a7e9
11 changed files with 278 additions and 225 deletions

View File

@@ -142,6 +142,29 @@ struct ExecCommandResolution: Sendable {
return (false, nil)
}
private enum ShellTokenContext {
case unquoted
case doubleQuoted
}
private struct ShellFailClosedRule {
let token: Character
let next: Character?
}
private static let shellFailClosedRules: [ShellTokenContext: [ShellFailClosedRule]] = [
.unquoted: [
ShellFailClosedRule(token: "`", next: nil),
ShellFailClosedRule(token: "$", next: "("),
ShellFailClosedRule(token: "<", next: "("),
ShellFailClosedRule(token: ">", next: "("),
],
.doubleQuoted: [
ShellFailClosedRule(token: "`", next: nil),
ShellFailClosedRule(token: "$", next: "("),
],
]
private static func splitShellCommandChain(_ command: String) -> [String]? {
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
@@ -194,9 +217,9 @@ struct ExecCommandResolution: Sendable {
continue
}
if !inSingle, self.shouldFailClosedForShell(ch: ch, next: next) {
if !inSingle, self.shouldFailClosedForShell(ch: ch, next: next, inDouble: inDouble) {
// Fail closed on command/process substitution in allowlist mode,
// including inside double-quoted shell strings.
// including command substitution inside double-quoted shell strings.
return nil
}
@@ -218,15 +241,15 @@ struct ExecCommandResolution: Sendable {
return segments
}
private static func shouldFailClosedForShell(ch: Character, next: Character?) -> Bool {
if ch == "`" {
return true
private static func shouldFailClosedForShell(ch: Character, next: Character?, inDouble: Bool) -> Bool {
let context: ShellTokenContext = inDouble ? .doubleQuoted : .unquoted
guard let rules = self.shellFailClosedRules[context] else {
return false
}
if ch == "$", next == "(" {
return true
}
if ch == "<" || ch == ">", next == "(" {
return true
for rule in rules {
if ch == rule.token, rule.next == nil || next == rule.next {
return true
}
}
return false
}

View File

@@ -5,6 +5,35 @@ import Testing
/// These cases cover optional `security=allowlist` behavior.
/// Default install posture remains deny-by-default for exec on macOS node-host.
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 static func loadShellParserParityCases() throws -> [ShellParserParityFixture.Case] {
let fixtureURL = self.shellParserParityFixtureURL()
let data = try Data(contentsOf: fixtureURL)
let fixture = try JSONDecoder().decode(ShellParserParityFixture.self, from: data)
return fixture.cases
}
private static func shellParserParityFixtureURL() -> URL {
var repoRoot = URL(fileURLWithPath: #filePath)
for _ in 0..<5 {
repoRoot.deleteLastPathComponent()
}
return repoRoot
.appendingPathComponent("test")
.appendingPathComponent("fixtures")
.appendingPathComponent("exec-allowlist-shell-parser-parity.json")
}
@Test func matchUsesResolvedPath() {
let entry = ExecAllowlistEntry(pattern: "/opt/homebrew/bin/rg")
let resolution = ExecCommandResolution(
@@ -113,6 +142,24 @@ struct ExecAllowlistTests {
#expect(resolutions.isEmpty)
}
@Test func resolveForAllowlistMatchesSharedShellParserFixture() throws {
let fixtures = try Self.loadShellParserParityCases()
for fixture in fixtures {
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: ["/bin/sh", "-lc", 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 resolveForAllowlistTreatsPlainShInvocationAsDirectExec() {
let command = ["/bin/sh", "./script.sh"]
let resolutions = ExecCommandResolution.resolveForAllowlist(

View File

@@ -45,12 +45,7 @@ import Testing
// First send is the connect handshake request. Subsequent sends are request frames.
if currentSendCount == 0 {
guard case let .data(data) = message else { return }
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
(obj["type"] as? String) == "req",
(obj["method"] as? String) == "connect",
let id = obj["id"] as? String
{
if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) {
self.connectRequestID.withLock { $0 = id }
}
return
@@ -65,21 +60,17 @@ import Testing
return
}
let response = Self.responseData(id: id)
let response = GatewayWebSocketTestSupport.okResponseData(id: id)
let handler = self.pendingReceiveHandler.withLock { $0 }
handler?(Result<URLSessionWebSocketTask.Message, Error>.success(.data(response)))
}
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
pongReceiveHandler(nil)
}
func receive() async throws -> URLSessionWebSocketTask.Message {
if self.helloDelayMs > 0 {
try await Task.sleep(nanoseconds: UInt64(self.helloDelayMs) * 1_000_000)
}
let id = self.connectRequestID.withLock { $0 } ?? "connect"
return .data(Self.connectOkData(id: id))
return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
}
func receive(
@@ -93,41 +84,6 @@ import Testing
handler?(Result<URLSessionWebSocketTask.Message, Error>.success(.data(data)))
}
private static func connectOkData(id: String) -> Data {
let json = """
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": {
"type": "hello-ok",
"protocol": 2,
"server": { "version": "test", "connId": "test" },
"features": { "methods": [], "events": [] },
"snapshot": {
"presence": [ { "ts": 1 } ],
"health": {},
"stateVersion": { "presence": 0, "health": 0 },
"uptimeMs": 0
},
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
}
}
"""
return Data(json.utf8)
}
private static func responseData(id: String) -> Data {
let json = """
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": { "ok": true }
}
"""
return Data(json.utf8)
}
}
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {

View File

@@ -38,25 +38,11 @@ import Testing
}
func send(_ message: URLSessionWebSocketTask.Message) async throws {
let data: Data? = switch message {
case let .data(d): d
case let .string(s): s.data(using: .utf8)
@unknown default: nil
}
guard let data else { return }
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
obj["type"] as? String == "req",
obj["method"] as? String == "connect",
let id = obj["id"] as? String
{
if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) {
self.connectRequestID.withLock { $0 = id }
}
}
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
pongReceiveHandler(nil)
}
func receive() async throws -> URLSessionWebSocketTask.Message {
let delayMs: Int
let msg: URLSessionWebSocketTask.Message
@@ -64,7 +50,7 @@ import Testing
case let .helloOk(ms):
delayMs = ms
let id = self.connectRequestID.withLock { $0 } ?? "connect"
msg = .data(Self.connectOkData(id: id))
msg = .data(GatewayWebSocketTestSupport.connectOkData(id: id))
case let .invalid(ms):
delayMs = ms
msg = .string("not json")
@@ -81,29 +67,6 @@ import Testing
self.pendingReceiveHandler.withLock { $0 = completionHandler }
}
private static func connectOkData(id: String) -> Data {
let json = """
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": {
"type": "hello-ok",
"protocol": 2,
"server": { "version": "test", "connId": "test" },
"features": { "methods": [], "events": [] },
"snapshot": {
"presence": [ { "ts": 1 } ],
"health": {},
"stateVersion": { "presence": 0, "health": 0 },
"uptimeMs": 0
},
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
}
}
"""
return Data(json.utf8)
}
}
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {

View File

@@ -42,17 +42,7 @@ import Testing
// First send is the connect handshake. Second send is the request frame.
if currentSendCount == 0 {
let data: Data? = switch message {
case let .data(d): d
case let .string(s): s.data(using: .utf8)
@unknown default: nil
}
guard let data else { return }
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
obj["type"] as? String == "req",
obj["method"] as? String == "connect",
let id = obj["id"] as? String
{
if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) {
self.connectRequestID.withLock { $0 = id }
}
}
@@ -62,13 +52,9 @@ import Testing
}
}
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
pongReceiveHandler(nil)
}
func receive() async throws -> URLSessionWebSocketTask.Message {
let id = self.connectRequestID.withLock { $0 } ?? "connect"
return .data(Self.connectOkData(id: id))
return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
}
func receive(
@@ -77,29 +63,6 @@ import Testing
self.pendingReceiveHandler.withLock { $0 = completionHandler }
}
private static func connectOkData(id: String) -> Data {
let json = """
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": {
"type": "hello-ok",
"protocol": 2,
"server": { "version": "test", "connId": "test" },
"features": { "methods": [], "events": [] },
"snapshot": {
"presence": [ { "ts": 1 } ],
"health": {},
"stateVersion": { "presence": 0, "health": 0 },
"uptimeMs": 0
},
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
}
}
"""
return Data(json.utf8)
}
}
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {

View File

@@ -32,28 +32,14 @@ import Testing
}
func send(_ message: URLSessionWebSocketTask.Message) async throws {
let data: Data? = switch message {
case let .data(d): d
case let .string(s): s.data(using: .utf8)
@unknown default: nil
}
guard let data else { return }
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
obj["type"] as? String == "req",
obj["method"] as? String == "connect",
let id = obj["id"] as? String
{
if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) {
self.connectRequestID.withLock { $0 = id }
}
}
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
pongReceiveHandler(nil)
}
func receive() async throws -> URLSessionWebSocketTask.Message {
let id = self.connectRequestID.withLock { $0 } ?? "connect"
return .data(Self.connectOkData(id: id))
return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
}
func receive(
@@ -67,29 +53,6 @@ import Testing
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(URLError(.networkConnectionLost)))
}
private static func connectOkData(id: String) -> Data {
let json = """
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": {
"type": "hello-ok",
"protocol": 2,
"server": { "version": "test", "connId": "test" },
"features": { "methods": [], "events": [] },
"snapshot": {
"presence": [ { "ts": 1 } ],
"health": {},
"stateVersion": { "presence": 0, "health": 0 },
"uptimeMs": 0
},
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
}
}
"""
return Data(json.utf8)
}
}
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {

View File

@@ -15,10 +15,6 @@ private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
func send(_: URLSessionWebSocketTask.Message) async throws {}
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
pongReceiveHandler(nil)
}
func receive() async throws -> URLSessionWebSocketTask.Message {
throw URLError(.cannotConnectToHost)
}

View File

@@ -39,12 +39,7 @@ struct GatewayProcessManagerTests {
}
if currentSendCount == 0 {
guard case let .data(data) = message else { return }
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
(obj["type"] as? String) == "req",
(obj["method"] as? String) == "connect",
let id = obj["id"] as? String
{
if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) {
self.connectRequestID.withLock { $0 = id }
}
return
@@ -59,18 +54,14 @@ struct GatewayProcessManagerTests {
return
}
let response = Self.responseData(id: id)
let response = GatewayWebSocketTestSupport.okResponseData(id: id)
let handler = self.pendingReceiveHandler.withLock { $0 }
handler?(Result<URLSessionWebSocketTask.Message, Error>.success(.data(response)))
}
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
pongReceiveHandler(nil)
}
func receive() async throws -> URLSessionWebSocketTask.Message {
let id = self.connectRequestID.withLock { $0 } ?? "connect"
return .data(Self.connectOkData(id: id))
return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
}
func receive(
@@ -79,41 +70,6 @@ struct GatewayProcessManagerTests {
self.pendingReceiveHandler.withLock { $0 = completionHandler }
}
private static func connectOkData(id: String) -> Data {
let json = """
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": {
"type": "hello-ok",
"protocol": 2,
"server": { "version": "test", "connId": "test" },
"features": { "methods": [], "events": [] },
"snapshot": {
"presence": [ { "ts": 1 } ],
"health": {},
"stateVersion": { "presence": 0, "health": 0 },
"uptimeMs": 0
},
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
}
}
"""
return Data(json.utf8)
}
private static func responseData(id: String) -> Data {
let json = """
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": { "ok": true }
}
"""
return Data(json.utf8)
}
}
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {

View File

@@ -0,0 +1,63 @@
import OpenClawKit
import Foundation
extension WebSocketTasking {
// Keep unit-test doubles resilient to protocol additions.
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
pongReceiveHandler(nil)
}
}
enum GatewayWebSocketTestSupport {
static func connectRequestID(from message: URLSessionWebSocketTask.Message) -> String? {
let data: Data? = switch message {
case let .data(d): d
case let .string(s): s.data(using: .utf8)
@unknown default: nil
}
guard let data else { return nil }
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil
}
guard (obj["type"] as? String) == "req", (obj["method"] as? String) == "connect" else {
return nil
}
return obj["id"] as? String
}
static func connectOkData(id: String) -> Data {
let json = """
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": {
"type": "hello-ok",
"protocol": 2,
"server": { "version": "test", "connId": "test" },
"features": { "methods": [], "events": [] },
"snapshot": {
"presence": [ { "ts": 1 } ],
"health": {},
"stateVersion": { "presence": 0, "health": 0 },
"uptimeMs": 0
},
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
}
}
"""
return Data(json.utf8)
}
static func okResponseData(id: String) -> Data {
let json = """
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": { "ok": true }
}
"""
return Data(json.utf8)
}
}

View File

@@ -39,6 +39,28 @@ function makeTempDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-exec-approvals-"));
}
type ShellParserParityFixtureCase = {
id: string;
command: string;
ok: boolean;
executables: string[];
};
type ShellParserParityFixture = {
cases: ShellParserParityFixtureCase[];
};
function loadShellParserParityFixtureCases(): ShellParserParityFixtureCase[] {
const fixturePath = path.join(
process.cwd(),
"test",
"fixtures",
"exec-allowlist-shell-parser-parity.json",
);
const fixture = JSON.parse(fs.readFileSync(fixturePath, "utf8")) as ShellParserParityFixture;
return fixture.cases;
}
describe("exec approvals allowlist matching", () => {
it("ignores basename-only patterns", () => {
const resolution = {
@@ -427,6 +449,25 @@ describe("exec approvals shell parsing", () => {
});
});
describe("exec approvals shell parser parity fixture", () => {
const fixtures = loadShellParserParityFixtureCases();
for (const fixture of fixtures) {
it(`matches fixture: ${fixture.id}`, () => {
const res = analyzeShellCommand({ command: fixture.command });
expect(res.ok).toBe(fixture.ok);
if (fixture.ok) {
const executables = res.segments.map((segment) =>
path.basename(segment.argv[0] ?? "").toLowerCase(),
);
expect(executables).toEqual(fixture.executables.map((entry) => entry.toLowerCase()));
} else {
expect(res.segments).toHaveLength(0);
}
});
}
});
describe("exec approvals shell allowlist (chained commands)", () => {
it("allows chained commands when all parts are allowlisted", () => {
const allowlist: ExecAllowlistEntry[] = [

View File

@@ -0,0 +1,82 @@
{
"cases": [
{
"id": "simple-pipeline",
"command": "echo ok | jq .foo",
"ok": true,
"executables": ["echo", "jq"]
},
{
"id": "chained-commands",
"command": "ls && rm -rf /tmp/openclaw-allowlist",
"ok": true,
"executables": ["ls", "rm"]
},
{
"id": "quoted-chain-operators-remain-literal",
"command": "echo \"a && b\"",
"ok": true,
"executables": ["echo"]
},
{
"id": "reject-command-substitution-unquoted",
"command": "echo $(whoami)",
"ok": false,
"executables": []
},
{
"id": "reject-command-substitution-double-quoted",
"command": "echo \"output: $(whoami)\"",
"ok": false,
"executables": []
},
{
"id": "allow-command-substitution-literal-in-single-quotes",
"command": "echo 'output: $(whoami)'",
"ok": true,
"executables": ["echo"]
},
{
"id": "allow-escaped-command-substitution-double-quoted",
"command": "echo \"output: \\$(whoami)\"",
"ok": true,
"executables": ["echo"]
},
{
"id": "reject-backticks-unquoted",
"command": "echo `id`",
"ok": false,
"executables": []
},
{
"id": "reject-backticks-double-quoted",
"command": "echo \"output: `id`\"",
"ok": false,
"executables": []
},
{
"id": "reject-process-substitution-unquoted-input",
"command": "cat <(echo ok)",
"ok": false,
"executables": []
},
{
"id": "reject-process-substitution-unquoted-output",
"command": "echo >(cat)",
"ok": false,
"executables": []
},
{
"id": "allow-process-substitution-literal-double-quoted-input",
"command": "echo \"<(echo ok)\"",
"ok": true,
"executables": ["echo"]
},
{
"id": "allow-process-substitution-literal-double-quoted-output",
"command": "echo \">(cat)\"",
"ok": true,
"executables": ["echo"]
}
]
}