From 1bc5c2a7e90f253126f113325f78ed3a35c2b893 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 21 Feb 2026 23:16:53 +0100 Subject: [PATCH] refactor: unify exec shell parser parity and gateway websocket test helpers --- .../OpenClaw/ExecCommandResolution.swift | 43 +++++++--- .../OpenClawIPCTests/ExecAllowlistTests.swift | 47 +++++++++++ .../GatewayChannelConfigureTests.swift | 50 +---------- .../GatewayChannelConnectTests.swift | 41 +--------- .../GatewayChannelRequestTests.swift | 41 +--------- .../GatewayChannelShutdownTests.swift | 41 +--------- .../GatewayConnectionControlTests.swift | 4 - .../GatewayProcessManagerTests.swift | 50 +---------- .../GatewayWebSocketTestSupport.swift | 63 ++++++++++++++ src/infra/exec-approvals.test.ts | 41 ++++++++++ .../exec-allowlist-shell-parser-parity.json | 82 +++++++++++++++++++ 11 files changed, 278 insertions(+), 225 deletions(-) create mode 100644 apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift create mode 100644 test/fixtures/exec-allowlist-shell-parser-parity.json diff --git a/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift b/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift index 880fb0fa497..8910163456f 100644 --- a/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift +++ b/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift @@ -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 } diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift index 6dbe0e79ee9..17f4a1e24ce 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift @@ -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( diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConfigureTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConfigureTests.swift index 687d696e4c6..ec2caf6057c 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConfigureTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConfigureTests.swift @@ -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.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.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 { diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift index b80328fcc9f..afe9dea9e2c 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift @@ -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 { diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelRequestTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelRequestTests.swift index 25806e0384a..4c788a959f5 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelRequestTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelRequestTests.swift @@ -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 { diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelShutdownTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelShutdownTests.swift index a6ff1796c51..5f995cd394a 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelShutdownTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelShutdownTests.swift @@ -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.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 { diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift index 9c260ad1d2e..e95cf7a282d 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift @@ -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) } diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift index 459e2686d8b..dabb15f8bf1 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift @@ -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.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 { diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift new file mode 100644 index 00000000000..0ba41f2806b --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift @@ -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) + } +} diff --git a/src/infra/exec-approvals.test.ts b/src/infra/exec-approvals.test.ts index 0d4b2e3b1ee..c12a59014cf 100644 --- a/src/infra/exec-approvals.test.ts +++ b/src/infra/exec-approvals.test.ts @@ -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[] = [ diff --git a/test/fixtures/exec-allowlist-shell-parser-parity.json b/test/fixtures/exec-allowlist-shell-parser-parity.json new file mode 100644 index 00000000000..51a6f94186b --- /dev/null +++ b/test/fixtures/exec-allowlist-shell-parser-parity.json @@ -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"] + } + ] +}