fix(macos): block quoted shell substitution in allowlist checks

This commit is contained in:
Peter Steinberger
2026-02-21 22:51:38 +01:00
parent 861718e4dc
commit 90a378ca3a
9 changed files with 53 additions and 6 deletions

View File

@@ -194,11 +194,13 @@ struct ExecCommandResolution: Sendable {
continue
}
if !inSingle, self.shouldFailClosedForShell(ch: ch, next: next) {
// Fail closed on command/process substitution in allowlist mode,
// including inside double-quoted shell strings.
return nil
}
if !inSingle, !inDouble {
if self.shouldFailClosedForUnquotedShell(ch: ch, next: next) {
// Fail closed on command/process substitution in allowlist mode.
return nil
}
let prev: Character? = idx > 0 ? chars[idx - 1] : nil
if let delimiterStep = self.chainDelimiterStep(ch: ch, prev: prev, next: next) {
guard appendCurrent() else { return nil }
@@ -216,7 +218,7 @@ struct ExecCommandResolution: Sendable {
return segments
}
private static func shouldFailClosedForUnquotedShell(ch: Character, next: Character?) -> Bool {
private static func shouldFailClosedForShell(ch: Character, next: Character?) -> Bool {
if ch == "`" {
return true
}

View File

@@ -80,6 +80,26 @@ struct ExecAllowlistTests {
#expect(resolutions.isEmpty)
}
@Test func resolveForAllowlistFailsClosedOnQuotedCommandSubstitution() {
let command = ["/bin/sh", "-lc", "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 resolveForAllowlistFailsClosedOnQuotedBackticks() {
let command = ["/bin/sh", "-lc", "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 resolveForAllowlistTreatsPlainShInvocationAsDirectExec() {
let command = ["/bin/sh", "./script.sh"]
let resolutions = ExecCommandResolution.resolveForAllowlist(

View File

@@ -70,6 +70,10 @@ import Testing
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)

View File

@@ -53,6 +53,10 @@ import Testing
}
}
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
pongReceiveHandler(nil)
}
func receive() async throws -> URLSessionWebSocketTask.Message {
let delayMs: Int
let msg: URLSessionWebSocketTask.Message

View File

@@ -62,6 +62,10 @@ 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))

View File

@@ -47,6 +47,10 @@ 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))

View File

@@ -15,6 +15,10 @@ 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

@@ -64,6 +64,10 @@ struct GatewayProcessManagerTests {
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))

View File

@@ -13,7 +13,8 @@ import Testing
configpath: nil,
statedir: nil,
sessiondefaults: nil,
authmode: nil)
authmode: nil,
updateavailable: nil)
let hello = HelloOk(
type: "hello",