Files
openclaw/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsSocketAuthTests.swift
Peter Steinberger 425c5b9f26 fix(macos): reject revoked forwarded exec approvals (#103968)
* fix(macos): reject revoked forwarded exec approvals

* fix(macos): split approval plan validation

* chore(i18n): refresh native source lines
2026-07-10 19:01:17 -07:00

205 lines
7.2 KiB
Swift

import Foundation
import OpenClawKit
import Testing
@testable import OpenClaw
struct ExecApprovalsSocketAuthTests {
@Test
func `timing safe hex compare matches equal strings`() {
#expect(timingSafeHexStringEquals(String(repeating: "a", count: 64), String(repeating: "a", count: 64)))
}
@Test
func `timing safe hex compare rejects mismatched strings`() {
let expected = String(repeating: "a", count: 63) + "b"
let provided = String(repeating: "a", count: 63) + "c"
#expect(!timingSafeHexStringEquals(expected, provided))
}
@Test
func `timing safe hex compare rejects different length strings`() {
#expect(!timingSafeHexStringEquals(String(repeating: "a", count: 64), "deadbeef"))
}
@Test
func `minimum timestamp is rejected before authentication without overflow`() async {
#expect(!execHostTimestampIsFresh(nowMs: 1_700_000_000_000, requestMs: Int.min))
#expect(await ExecApprovalsPromptServer._testExecHostTimestampFailureReason(Int.min) == "ttl")
}
@Test
func `exec host limiter preserves small output`() {
#expect(ExecHostOutputLimiter.truncate("hello") == "hello")
}
@Test
func `exec host limiter preserves a valid utf8 tail`() {
let input = String(repeating: "x", count: 2 * 1024 * 1024) + ""
let limited = ExecHostOutputLimiter.truncate(input)
#expect(limited.hasPrefix("... (truncated) "))
#expect(limited.hasSuffix(""))
#expect(limited.utf8.count <= ExecHostOutputLimiter.maxOutputFieldBytes)
}
@Test
func `exec host limiter keeps escaped output below the jsonl cap`() throws {
let escaped = String(repeating: "\u{0}", count: 2 * 1024 * 1024)
let limited = ExecHostOutputLimiter.truncate(escaped)
let response = EncodedExecHostResponse(
type: "exec-res",
id: "test",
ok: true,
payload: EncodedExecHostRunResult(
exitCode: 0,
timedOut: false,
success: true,
stdout: limited,
stderr: limited,
error: nil),
error: nil)
#expect(try JSONEncoder().encode(response).count < ExecHostOutputLimiter.maxJsonlResponseBytes)
}
@Test
func `exec host limiter bounds real command output`() async {
let result = await ShellExecutor.runDetailed(
command: [
"/usr/bin/perl",
"-e",
"print 'x' x (2 * 1024 * 1024); print STDERR 'y' x (2 * 1024 * 1024);",
],
cwd: nil,
env: nil,
timeout: 10)
#expect(ExecHostOutputLimiter.truncate(result.stdout).utf8.count <= ExecHostOutputLimiter.maxOutputFieldBytes)
#expect(ExecHostOutputLimiter.truncate(result.stderr).utf8.count <= ExecHostOutputLimiter.maxOutputFieldBytes)
#expect(result.exitCode == 0)
}
@Test
func `socket decoded argv reaches executor without token normalization`() async throws {
let command = ["/usr/bin/printf", "<%s>|<%s>", " padded ", "-n"]
let request = ExecHostRequest(
command: command,
rawCommand: nil,
cwd: nil,
env: nil,
timeoutMs: nil,
needsScreenRecording: nil,
agentId: nil,
sessionKey: nil,
approvalDecision: .allowOnce,
policySnapshot: Self.policySnapshot)
let requestJSON = try JSONEncoder().encode(request)
let socketDecodedRequest = try JSONDecoder().decode(ExecHostRequest.self, from: requestJSON)
let validated: ExecHostValidatedRequest
switch ExecHostRequestEvaluator.validateRequest(socketDecodedRequest) {
case let .success(request):
validated = request
case let .failure(error):
Issue.record("unexpected invalid request: \(error.message)")
return
}
let result = await ShellExecutor.runDetailed(
command: validated.command,
cwd: nil,
env: nil,
timeout: 2)
#expect(validated.command == command)
#expect(validated.displayCommand == ExecCommandFormatter.displayString(for: command))
#expect(result.stdout == "< padded >|<-n>")
#expect(result.exitCode == 0)
}
@Test
func `socket serialization preserves timeout fallback provenance`() throws {
let request = ExecHostRequest(
command: ["/usr/bin/printf", "ok"],
rawCommand: nil,
cwd: nil,
env: nil,
timeoutMs: nil,
needsScreenRecording: nil,
agentId: "main",
sessionKey: "agent:main:main",
approvalDecision: nil,
approvalSource: "ask-fallback")
let decoded = try JSONDecoder().decode(
ExecHostRequest.self,
from: JSONEncoder().encode(request))
#expect(decoded.approvalSource == "ask-fallback")
#expect(decoded.approvalDecision == nil)
}
@Test
func `socket decodes TypeScript auto review policy snapshot`() throws {
let requestJSON = Data(#"""
{
"command": ["/usr/bin/printf", "ok"],
"agentId": "main",
"sessionKey": "agent:main:main",
"approvalSource": "auto-review",
"policySnapshot": {
"security": "allowlist",
"ask": "on-miss",
"askFallback": "deny",
"autoAllowSkills": true,
"allowlistRules": [
{"pattern": "/ä"},
{"pattern": "/A", "source": "allow-always"},
{"pattern": "/"},
{"pattern": "/"},
{"pattern": "/A"}
]
}
}
"""#.utf8)
let decoded = try JSONDecoder().decode(ExecHostRequest.self, from: requestJSON)
#expect(decoded.approvalSource == "auto-review")
#expect(decoded.approvalDecision == nil)
#expect(decoded.policySnapshot?.allowlistRules == [
OpenClawSystemRunApprovalPolicySnapshot.Rule(pattern: "/"),
OpenClawSystemRunApprovalPolicySnapshot.Rule(pattern: "/A"),
OpenClawSystemRunApprovalPolicySnapshot.Rule(pattern: "/A", source: .allowAlways),
OpenClawSystemRunApprovalPolicySnapshot.Rule(pattern: ""),
])
switch ExecHostRequestEvaluator.validateRequest(decoded) {
case let .success(validated):
#expect(validated.delayedPolicySnapshot?.portable == decoded.policySnapshot)
case let .failure(error):
Issue.record("unexpected invalid request: \(error.message)")
}
}
private static let policySnapshot = OpenClawSystemRunApprovalPolicySnapshot(
security: .full,
ask: .off,
askFallback: .deny,
autoAllowSkills: false,
allowlistRules: [])
private struct EncodedExecHostResponse: Codable {
var type: String
var id: String
var ok: Bool
var payload: EncodedExecHostRunResult?
var error: String?
}
private struct EncodedExecHostRunResult: Codable {
var exitCode: Int?
var timedOut: Bool
var success: Bool
var stdout: String
var stderr: String
var error: String?
}
}