mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
refactor: unify exec shell parser parity and gateway websocket test helpers
This commit is contained in:
@@ -142,6 +142,29 @@ struct ExecCommandResolution: Sendable {
|
|||||||
return (false, nil)
|
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]? {
|
private static func splitShellCommandChain(_ command: String) -> [String]? {
|
||||||
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return nil }
|
guard !trimmed.isEmpty else { return nil }
|
||||||
@@ -194,9 +217,9 @@ struct ExecCommandResolution: Sendable {
|
|||||||
continue
|
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,
|
// 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,15 +241,15 @@ struct ExecCommandResolution: Sendable {
|
|||||||
return segments
|
return segments
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func shouldFailClosedForShell(ch: Character, next: Character?) -> Bool {
|
private static func shouldFailClosedForShell(ch: Character, next: Character?, inDouble: Bool) -> Bool {
|
||||||
if ch == "`" {
|
let context: ShellTokenContext = inDouble ? .doubleQuoted : .unquoted
|
||||||
return true
|
guard let rules = self.shellFailClosedRules[context] else {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
if ch == "$", next == "(" {
|
for rule in rules {
|
||||||
return true
|
if ch == rule.token, rule.next == nil || next == rule.next {
|
||||||
}
|
return true
|
||||||
if ch == "<" || ch == ">", next == "(" {
|
}
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,35 @@ import Testing
|
|||||||
/// These cases cover optional `security=allowlist` behavior.
|
/// These cases cover optional `security=allowlist` behavior.
|
||||||
/// Default install posture remains deny-by-default for exec on macOS node-host.
|
/// Default install posture remains deny-by-default for exec on macOS node-host.
|
||||||
struct ExecAllowlistTests {
|
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() {
|
@Test func matchUsesResolvedPath() {
|
||||||
let entry = ExecAllowlistEntry(pattern: "/opt/homebrew/bin/rg")
|
let entry = ExecAllowlistEntry(pattern: "/opt/homebrew/bin/rg")
|
||||||
let resolution = ExecCommandResolution(
|
let resolution = ExecCommandResolution(
|
||||||
@@ -113,6 +142,24 @@ struct ExecAllowlistTests {
|
|||||||
#expect(resolutions.isEmpty)
|
#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() {
|
@Test func resolveForAllowlistTreatsPlainShInvocationAsDirectExec() {
|
||||||
let command = ["/bin/sh", "./script.sh"]
|
let command = ["/bin/sh", "./script.sh"]
|
||||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||||
|
|||||||
@@ -45,12 +45,7 @@ import Testing
|
|||||||
|
|
||||||
// First send is the connect handshake request. Subsequent sends are request frames.
|
// First send is the connect handshake request. Subsequent sends are request frames.
|
||||||
if currentSendCount == 0 {
|
if currentSendCount == 0 {
|
||||||
guard case let .data(data) = message else { return }
|
if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) {
|
||||||
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
|
|
||||||
{
|
|
||||||
self.connectRequestID.withLock { $0 = id }
|
self.connectRequestID.withLock { $0 = id }
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -65,21 +60,17 @@ import Testing
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = Self.responseData(id: id)
|
let response = GatewayWebSocketTestSupport.okResponseData(id: id)
|
||||||
let handler = self.pendingReceiveHandler.withLock { $0 }
|
let handler = self.pendingReceiveHandler.withLock { $0 }
|
||||||
handler?(Result<URLSessionWebSocketTask.Message, Error>.success(.data(response)))
|
handler?(Result<URLSessionWebSocketTask.Message, Error>.success(.data(response)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
|
|
||||||
pongReceiveHandler(nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func receive() async throws -> URLSessionWebSocketTask.Message {
|
func receive() async throws -> URLSessionWebSocketTask.Message {
|
||||||
if self.helloDelayMs > 0 {
|
if self.helloDelayMs > 0 {
|
||||||
try await Task.sleep(nanoseconds: UInt64(self.helloDelayMs) * 1_000_000)
|
try await Task.sleep(nanoseconds: UInt64(self.helloDelayMs) * 1_000_000)
|
||||||
}
|
}
|
||||||
let id = self.connectRequestID.withLock { $0 } ?? "connect"
|
let id = self.connectRequestID.withLock { $0 } ?? "connect"
|
||||||
return .data(Self.connectOkData(id: id))
|
return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
|
||||||
}
|
}
|
||||||
|
|
||||||
func receive(
|
func receive(
|
||||||
@@ -93,41 +84,6 @@ import Testing
|
|||||||
handler?(Result<URLSessionWebSocketTask.Message, Error>.success(.data(data)))
|
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 {
|
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
|
||||||
|
|||||||
@@ -38,25 +38,11 @@ import Testing
|
|||||||
}
|
}
|
||||||
|
|
||||||
func send(_ message: URLSessionWebSocketTask.Message) async throws {
|
func send(_ message: URLSessionWebSocketTask.Message) async throws {
|
||||||
let data: Data? = switch message {
|
if let id = GatewayWebSocketTestSupport.connectRequestID(from: 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
|
|
||||||
{
|
|
||||||
self.connectRequestID.withLock { $0 = id }
|
self.connectRequestID.withLock { $0 = id }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
|
|
||||||
pongReceiveHandler(nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func receive() async throws -> URLSessionWebSocketTask.Message {
|
func receive() async throws -> URLSessionWebSocketTask.Message {
|
||||||
let delayMs: Int
|
let delayMs: Int
|
||||||
let msg: URLSessionWebSocketTask.Message
|
let msg: URLSessionWebSocketTask.Message
|
||||||
@@ -64,7 +50,7 @@ import Testing
|
|||||||
case let .helloOk(ms):
|
case let .helloOk(ms):
|
||||||
delayMs = ms
|
delayMs = ms
|
||||||
let id = self.connectRequestID.withLock { $0 } ?? "connect"
|
let id = self.connectRequestID.withLock { $0 } ?? "connect"
|
||||||
msg = .data(Self.connectOkData(id: id))
|
msg = .data(GatewayWebSocketTestSupport.connectOkData(id: id))
|
||||||
case let .invalid(ms):
|
case let .invalid(ms):
|
||||||
delayMs = ms
|
delayMs = ms
|
||||||
msg = .string("not json")
|
msg = .string("not json")
|
||||||
@@ -81,29 +67,6 @@ import Testing
|
|||||||
self.pendingReceiveHandler.withLock { $0 = completionHandler }
|
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 {
|
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
|
||||||
|
|||||||
@@ -42,17 +42,7 @@ import Testing
|
|||||||
|
|
||||||
// First send is the connect handshake. Second send is the request frame.
|
// First send is the connect handshake. Second send is the request frame.
|
||||||
if currentSendCount == 0 {
|
if currentSendCount == 0 {
|
||||||
let data: Data? = switch message {
|
if let id = GatewayWebSocketTestSupport.connectRequestID(from: 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
|
|
||||||
{
|
|
||||||
self.connectRequestID.withLock { $0 = id }
|
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 {
|
func receive() async throws -> URLSessionWebSocketTask.Message {
|
||||||
let id = self.connectRequestID.withLock { $0 } ?? "connect"
|
let id = self.connectRequestID.withLock { $0 } ?? "connect"
|
||||||
return .data(Self.connectOkData(id: id))
|
return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
|
||||||
}
|
}
|
||||||
|
|
||||||
func receive(
|
func receive(
|
||||||
@@ -77,29 +63,6 @@ import Testing
|
|||||||
self.pendingReceiveHandler.withLock { $0 = completionHandler }
|
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 {
|
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
|
||||||
|
|||||||
@@ -32,28 +32,14 @@ import Testing
|
|||||||
}
|
}
|
||||||
|
|
||||||
func send(_ message: URLSessionWebSocketTask.Message) async throws {
|
func send(_ message: URLSessionWebSocketTask.Message) async throws {
|
||||||
let data: Data? = switch message {
|
if let id = GatewayWebSocketTestSupport.connectRequestID(from: 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
|
|
||||||
{
|
|
||||||
self.connectRequestID.withLock { $0 = id }
|
self.connectRequestID.withLock { $0 = id }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
|
|
||||||
pongReceiveHandler(nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func receive() async throws -> URLSessionWebSocketTask.Message {
|
func receive() async throws -> URLSessionWebSocketTask.Message {
|
||||||
let id = self.connectRequestID.withLock { $0 } ?? "connect"
|
let id = self.connectRequestID.withLock { $0 } ?? "connect"
|
||||||
return .data(Self.connectOkData(id: id))
|
return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
|
||||||
}
|
}
|
||||||
|
|
||||||
func receive(
|
func receive(
|
||||||
@@ -67,29 +53,6 @@ import Testing
|
|||||||
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(URLError(.networkConnectionLost)))
|
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 {
|
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
|
||||||
|
|||||||
@@ -15,10 +15,6 @@ private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
|
|||||||
|
|
||||||
func send(_: URLSessionWebSocketTask.Message) async throws {}
|
func send(_: URLSessionWebSocketTask.Message) async throws {}
|
||||||
|
|
||||||
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
|
|
||||||
pongReceiveHandler(nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func receive() async throws -> URLSessionWebSocketTask.Message {
|
func receive() async throws -> URLSessionWebSocketTask.Message {
|
||||||
throw URLError(.cannotConnectToHost)
|
throw URLError(.cannotConnectToHost)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,12 +39,7 @@ struct GatewayProcessManagerTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if currentSendCount == 0 {
|
if currentSendCount == 0 {
|
||||||
guard case let .data(data) = message else { return }
|
if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) {
|
||||||
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
|
|
||||||
{
|
|
||||||
self.connectRequestID.withLock { $0 = id }
|
self.connectRequestID.withLock { $0 = id }
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -59,18 +54,14 @@ struct GatewayProcessManagerTests {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = Self.responseData(id: id)
|
let response = GatewayWebSocketTestSupport.okResponseData(id: id)
|
||||||
let handler = self.pendingReceiveHandler.withLock { $0 }
|
let handler = self.pendingReceiveHandler.withLock { $0 }
|
||||||
handler?(Result<URLSessionWebSocketTask.Message, Error>.success(.data(response)))
|
handler?(Result<URLSessionWebSocketTask.Message, Error>.success(.data(response)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
|
|
||||||
pongReceiveHandler(nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func receive() async throws -> URLSessionWebSocketTask.Message {
|
func receive() async throws -> URLSessionWebSocketTask.Message {
|
||||||
let id = self.connectRequestID.withLock { $0 } ?? "connect"
|
let id = self.connectRequestID.withLock { $0 } ?? "connect"
|
||||||
return .data(Self.connectOkData(id: id))
|
return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
|
||||||
}
|
}
|
||||||
|
|
||||||
func receive(
|
func receive(
|
||||||
@@ -79,41 +70,6 @@ struct GatewayProcessManagerTests {
|
|||||||
self.pendingReceiveHandler.withLock { $0 = completionHandler }
|
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 {
|
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,28 @@ function makeTempDir() {
|
|||||||
return fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-exec-approvals-"));
|
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", () => {
|
describe("exec approvals allowlist matching", () => {
|
||||||
it("ignores basename-only patterns", () => {
|
it("ignores basename-only patterns", () => {
|
||||||
const resolution = {
|
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)", () => {
|
describe("exec approvals shell allowlist (chained commands)", () => {
|
||||||
it("allows chained commands when all parts are allowlisted", () => {
|
it("allows chained commands when all parts are allowlisted", () => {
|
||||||
const allowlist: ExecAllowlistEntry[] = [
|
const allowlist: ExecAllowlistEntry[] = [
|
||||||
|
|||||||
82
test/fixtures/exec-allowlist-shell-parser-parity.json
vendored
Normal file
82
test/fixtures/exec-allowlist-shell-parser-parity.json
vendored
Normal 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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user