mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
refactor(exec): split system.run phases and align ts/swift validator contracts
This commit is contained in:
@@ -38,7 +38,7 @@ private struct ExecHostSocketRequest: Codable {
|
|||||||
var requestJson: String
|
var requestJson: String
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ExecHostRequest: Codable {
|
struct ExecHostRequest: Codable {
|
||||||
var command: [String]
|
var command: [String]
|
||||||
var rawCommand: String?
|
var rawCommand: String?
|
||||||
var cwd: String?
|
var cwd: String?
|
||||||
@@ -59,7 +59,7 @@ private struct ExecHostRunResult: Codable {
|
|||||||
var error: String?
|
var error: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ExecHostError: Codable {
|
struct ExecHostError: Codable, Error {
|
||||||
var code: String
|
var code: String
|
||||||
var message: String
|
var message: String
|
||||||
var reason: String?
|
var reason: String?
|
||||||
@@ -353,55 +353,28 @@ private enum ExecHostExecutor {
|
|||||||
private typealias ExecApprovalContext = ExecApprovalEvaluation
|
private typealias ExecApprovalContext = ExecApprovalEvaluation
|
||||||
|
|
||||||
static func handle(_ request: ExecHostRequest) async -> ExecHostResponse {
|
static func handle(_ request: ExecHostRequest) async -> ExecHostResponse {
|
||||||
let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
let validatedRequest: ExecHostValidatedRequest
|
||||||
guard !command.isEmpty else {
|
switch ExecHostRequestEvaluator.validateRequest(request) {
|
||||||
return self.errorResponse(
|
case .success(let request):
|
||||||
code: "INVALID_REQUEST",
|
validatedRequest = request
|
||||||
message: "command required",
|
case .failure(let error):
|
||||||
reason: "invalid")
|
return self.errorResponse(error)
|
||||||
}
|
|
||||||
|
|
||||||
let validatedCommand = ExecSystemRunCommandValidator.resolve(
|
|
||||||
command: command,
|
|
||||||
rawCommand: request.rawCommand)
|
|
||||||
let displayCommand: String
|
|
||||||
switch validatedCommand {
|
|
||||||
case let .ok(resolved):
|
|
||||||
displayCommand = resolved.displayCommand
|
|
||||||
case let .invalid(message):
|
|
||||||
return self.errorResponse(
|
|
||||||
code: "INVALID_REQUEST",
|
|
||||||
message: message,
|
|
||||||
reason: "invalid")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let context = await self.buildContext(
|
let context = await self.buildContext(
|
||||||
request: request,
|
request: request,
|
||||||
command: command,
|
command: validatedRequest.command,
|
||||||
rawCommand: displayCommand)
|
rawCommand: validatedRequest.displayCommand)
|
||||||
if context.security == .deny {
|
|
||||||
return self.errorResponse(
|
|
||||||
code: "UNAVAILABLE",
|
|
||||||
message: "SYSTEM_RUN_DISABLED: security=deny",
|
|
||||||
reason: "security=deny")
|
|
||||||
}
|
|
||||||
|
|
||||||
let approvalDecision = request.approvalDecision
|
switch ExecHostRequestEvaluator.evaluate(
|
||||||
if approvalDecision == .deny {
|
context: context,
|
||||||
return self.errorResponse(
|
approvalDecision: request.approvalDecision)
|
||||||
code: "UNAVAILABLE",
|
|
||||||
message: "SYSTEM_RUN_DENIED: user denied",
|
|
||||||
reason: "user-denied")
|
|
||||||
}
|
|
||||||
|
|
||||||
var approvedByAsk = approvalDecision != nil
|
|
||||||
if ExecApprovalHelpers.requiresAsk(
|
|
||||||
ask: context.ask,
|
|
||||||
security: context.security,
|
|
||||||
allowlistMatch: context.allowlistMatch,
|
|
||||||
skillAllow: context.skillAllow),
|
|
||||||
approvalDecision == nil
|
|
||||||
{
|
{
|
||||||
|
case .deny(let error):
|
||||||
|
return self.errorResponse(error)
|
||||||
|
case .allow:
|
||||||
|
break
|
||||||
|
case .requiresPrompt:
|
||||||
let decision = ExecApprovalsPromptPresenter.prompt(
|
let decision = ExecApprovalsPromptPresenter.prompt(
|
||||||
ExecApprovalPromptRequest(
|
ExecApprovalPromptRequest(
|
||||||
command: context.displayCommand,
|
command: context.displayCommand,
|
||||||
@@ -413,32 +386,34 @@ private enum ExecHostExecutor {
|
|||||||
resolvedPath: context.resolution?.resolvedPath,
|
resolvedPath: context.resolution?.resolvedPath,
|
||||||
sessionKey: request.sessionKey))
|
sessionKey: request.sessionKey))
|
||||||
|
|
||||||
|
let followupDecision: ExecApprovalDecision
|
||||||
switch decision {
|
switch decision {
|
||||||
case .deny:
|
case .deny:
|
||||||
return self.errorResponse(
|
followupDecision = .deny
|
||||||
code: "UNAVAILABLE",
|
|
||||||
message: "SYSTEM_RUN_DENIED: user denied",
|
|
||||||
reason: "user-denied")
|
|
||||||
case .allowAlways:
|
case .allowAlways:
|
||||||
approvedByAsk = true
|
followupDecision = .allowAlways
|
||||||
self.persistAllowlistEntry(decision: decision, context: context)
|
self.persistAllowlistEntry(decision: decision, context: context)
|
||||||
case .allowOnce:
|
case .allowOnce:
|
||||||
approvedByAsk = true
|
followupDecision = .allowOnce
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ExecHostRequestEvaluator.evaluate(
|
||||||
|
context: context,
|
||||||
|
approvalDecision: followupDecision)
|
||||||
|
{
|
||||||
|
case .deny(let error):
|
||||||
|
return self.errorResponse(error)
|
||||||
|
case .allow:
|
||||||
|
break
|
||||||
|
case .requiresPrompt:
|
||||||
|
return self.errorResponse(
|
||||||
|
code: "INVALID_REQUEST",
|
||||||
|
message: "unexpected approval state",
|
||||||
|
reason: "invalid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.persistAllowlistEntry(decision: approvalDecision, context: context)
|
self.persistAllowlistEntry(decision: request.approvalDecision, context: context)
|
||||||
|
|
||||||
if context.security == .allowlist,
|
|
||||||
!context.allowlistSatisfied,
|
|
||||||
!context.skillAllow,
|
|
||||||
!approvedByAsk
|
|
||||||
{
|
|
||||||
return self.errorResponse(
|
|
||||||
code: "UNAVAILABLE",
|
|
||||||
message: "SYSTEM_RUN_DENIED: allowlist miss",
|
|
||||||
reason: "allowlist-miss")
|
|
||||||
}
|
|
||||||
|
|
||||||
if context.allowlistSatisfied {
|
if context.allowlistSatisfied {
|
||||||
var seenPatterns = Set<String>()
|
var seenPatterns = Set<String>()
|
||||||
@@ -462,7 +437,7 @@ private enum ExecHostExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return await self.runCommand(
|
return await self.runCommand(
|
||||||
command: command,
|
command: validatedRequest.command,
|
||||||
cwd: request.cwd,
|
cwd: request.cwd,
|
||||||
env: context.env,
|
env: context.env,
|
||||||
timeoutMs: request.timeoutMs)
|
timeoutMs: request.timeoutMs)
|
||||||
@@ -535,6 +510,17 @@ private enum ExecHostExecutor {
|
|||||||
return self.successResponse(payload)
|
return self.successResponse(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func errorResponse(
|
||||||
|
_ error: ExecHostError) -> ExecHostResponse
|
||||||
|
{
|
||||||
|
ExecHostResponse(
|
||||||
|
type: "response",
|
||||||
|
id: UUID().uuidString,
|
||||||
|
ok: false,
|
||||||
|
payload: nil,
|
||||||
|
error: error)
|
||||||
|
}
|
||||||
|
|
||||||
private static func errorResponse(
|
private static func errorResponse(
|
||||||
code: String,
|
code: String,
|
||||||
message: String,
|
message: String,
|
||||||
|
|||||||
84
apps/macos/Sources/OpenClaw/ExecHostRequestEvaluator.swift
Normal file
84
apps/macos/Sources/OpenClaw/ExecHostRequestEvaluator.swift
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ExecHostValidatedRequest {
|
||||||
|
let command: [String]
|
||||||
|
let displayCommand: String
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExecHostPolicyDecision {
|
||||||
|
case deny(ExecHostError)
|
||||||
|
case requiresPrompt
|
||||||
|
case allow(approvedByAsk: Bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExecHostRequestEvaluator {
|
||||||
|
static func validateRequest(_ request: ExecHostRequest) -> Result<ExecHostValidatedRequest, ExecHostError> {
|
||||||
|
let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
guard !command.isEmpty else {
|
||||||
|
return .failure(
|
||||||
|
ExecHostError(
|
||||||
|
code: "INVALID_REQUEST",
|
||||||
|
message: "command required",
|
||||||
|
reason: "invalid"))
|
||||||
|
}
|
||||||
|
|
||||||
|
let validatedCommand = ExecSystemRunCommandValidator.resolve(
|
||||||
|
command: command,
|
||||||
|
rawCommand: request.rawCommand)
|
||||||
|
switch validatedCommand {
|
||||||
|
case .ok(let resolved):
|
||||||
|
return .success(ExecHostValidatedRequest(command: command, displayCommand: resolved.displayCommand))
|
||||||
|
case .invalid(let message):
|
||||||
|
return .failure(
|
||||||
|
ExecHostError(
|
||||||
|
code: "INVALID_REQUEST",
|
||||||
|
message: message,
|
||||||
|
reason: "invalid"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func evaluate(
|
||||||
|
context: ExecApprovalEvaluation,
|
||||||
|
approvalDecision: ExecApprovalDecision?) -> ExecHostPolicyDecision
|
||||||
|
{
|
||||||
|
if context.security == .deny {
|
||||||
|
return .deny(
|
||||||
|
ExecHostError(
|
||||||
|
code: "UNAVAILABLE",
|
||||||
|
message: "SYSTEM_RUN_DISABLED: security=deny",
|
||||||
|
reason: "security=deny"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if approvalDecision == .deny {
|
||||||
|
return .deny(
|
||||||
|
ExecHostError(
|
||||||
|
code: "UNAVAILABLE",
|
||||||
|
message: "SYSTEM_RUN_DENIED: user denied",
|
||||||
|
reason: "user-denied"))
|
||||||
|
}
|
||||||
|
|
||||||
|
let approvedByAsk = approvalDecision != nil
|
||||||
|
let requiresPrompt = ExecApprovalHelpers.requiresAsk(
|
||||||
|
ask: context.ask,
|
||||||
|
security: context.security,
|
||||||
|
allowlistMatch: context.allowlistMatch,
|
||||||
|
skillAllow: context.skillAllow) && approvalDecision == nil
|
||||||
|
if requiresPrompt {
|
||||||
|
return .requiresPrompt
|
||||||
|
}
|
||||||
|
|
||||||
|
if context.security == .allowlist,
|
||||||
|
!context.allowlistSatisfied,
|
||||||
|
!context.skillAllow,
|
||||||
|
!approvedByAsk
|
||||||
|
{
|
||||||
|
return .deny(
|
||||||
|
ExecHostError(
|
||||||
|
code: "UNAVAILABLE",
|
||||||
|
message: "SYSTEM_RUN_DENIED: allowlist miss",
|
||||||
|
reason: "allowlist-miss"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return .allow(approvedByAsk: approvedByAsk)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import OpenClaw
|
||||||
|
|
||||||
|
struct ExecHostRequestEvaluatorTests {
|
||||||
|
@Test func validateRequestRejectsEmptyCommand() {
|
||||||
|
let request = ExecHostRequest(command: [], rawCommand: nil, cwd: nil, env: nil, timeoutMs: nil, needsScreenRecording: nil, agentId: nil, sessionKey: nil, approvalDecision: nil)
|
||||||
|
switch ExecHostRequestEvaluator.validateRequest(request) {
|
||||||
|
case .success:
|
||||||
|
Issue.record("expected invalid request")
|
||||||
|
case .failure(let error):
|
||||||
|
#expect(error.code == "INVALID_REQUEST")
|
||||||
|
#expect(error.message == "command required")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func evaluateRequiresPromptOnAllowlistMissWithoutDecision() {
|
||||||
|
let context = Self.makeContext(security: .allowlist, ask: .onMiss, allowlistSatisfied: false, skillAllow: false)
|
||||||
|
let decision = ExecHostRequestEvaluator.evaluate(context: context, approvalDecision: nil)
|
||||||
|
switch decision {
|
||||||
|
case .requiresPrompt:
|
||||||
|
break
|
||||||
|
case .allow:
|
||||||
|
Issue.record("expected prompt requirement")
|
||||||
|
case .deny(let error):
|
||||||
|
Issue.record("unexpected deny: \(error.message)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func evaluateAllowsAllowOnceDecisionOnAllowlistMiss() {
|
||||||
|
let context = Self.makeContext(security: .allowlist, ask: .onMiss, allowlistSatisfied: false, skillAllow: false)
|
||||||
|
let decision = ExecHostRequestEvaluator.evaluate(context: context, approvalDecision: .allowOnce)
|
||||||
|
switch decision {
|
||||||
|
case .allow(let approvedByAsk):
|
||||||
|
#expect(approvedByAsk)
|
||||||
|
case .requiresPrompt:
|
||||||
|
Issue.record("expected allow decision")
|
||||||
|
case .deny(let error):
|
||||||
|
Issue.record("unexpected deny: \(error.message)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func evaluateDeniesOnExplicitDenyDecision() {
|
||||||
|
let context = Self.makeContext(security: .full, ask: .off, allowlistSatisfied: true, skillAllow: false)
|
||||||
|
let decision = ExecHostRequestEvaluator.evaluate(context: context, approvalDecision: .deny)
|
||||||
|
switch decision {
|
||||||
|
case .deny(let error):
|
||||||
|
#expect(error.reason == "user-denied")
|
||||||
|
case .requiresPrompt:
|
||||||
|
Issue.record("expected deny decision")
|
||||||
|
case .allow:
|
||||||
|
Issue.record("expected deny decision")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeContext(
|
||||||
|
security: ExecSecurity,
|
||||||
|
ask: ExecAsk,
|
||||||
|
allowlistSatisfied: Bool,
|
||||||
|
skillAllow: Bool) -> ExecApprovalEvaluation
|
||||||
|
{
|
||||||
|
ExecApprovalEvaluation(
|
||||||
|
command: ["/usr/bin/echo", "hi"],
|
||||||
|
displayCommand: "/usr/bin/echo hi",
|
||||||
|
agentId: nil,
|
||||||
|
security: security,
|
||||||
|
ask: ask,
|
||||||
|
env: [:],
|
||||||
|
resolution: nil,
|
||||||
|
allowlistResolutions: [],
|
||||||
|
allowlistMatches: [],
|
||||||
|
allowlistSatisfied: allowlistSatisfied,
|
||||||
|
allowlistMatch: nil,
|
||||||
|
skillAllow: skillAllow)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,49 +2,75 @@ import Foundation
|
|||||||
import Testing
|
import Testing
|
||||||
@testable import OpenClaw
|
@testable import OpenClaw
|
||||||
|
|
||||||
|
private struct SystemRunCommandContractFixture: Decodable {
|
||||||
|
let cases: [SystemRunCommandContractCase]
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SystemRunCommandContractCase: Decodable {
|
||||||
|
let name: String
|
||||||
|
let command: [String]
|
||||||
|
let rawCommand: String?
|
||||||
|
let expected: SystemRunCommandContractExpected
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SystemRunCommandContractExpected: Decodable {
|
||||||
|
let valid: Bool
|
||||||
|
let displayCommand: String?
|
||||||
|
let errorContains: String?
|
||||||
|
}
|
||||||
|
|
||||||
struct ExecSystemRunCommandValidatorTests {
|
struct ExecSystemRunCommandValidatorTests {
|
||||||
@Test func rejectsPayloadOnlyRawForPositionalCarrierWrappers() {
|
@Test func matchesSharedSystemRunCommandContractFixture() throws {
|
||||||
let command = ["/bin/sh", "-lc", #"$0 "$1""#, "/usr/bin/touch", "/tmp/marker"]
|
for entry in try Self.loadContractCases() {
|
||||||
let result = ExecSystemRunCommandValidator.resolve(command: command, rawCommand: #"$0 "$1""#)
|
let result = ExecSystemRunCommandValidator.resolve(command: entry.command, rawCommand: entry.rawCommand)
|
||||||
switch result {
|
|
||||||
case .ok:
|
if !entry.expected.valid {
|
||||||
Issue.record("expected rawCommand mismatch")
|
switch result {
|
||||||
case .invalid(let message):
|
case .ok(let resolved):
|
||||||
#expect(message.contains("rawCommand does not match command"))
|
Issue.record("\(entry.name): expected invalid result, got displayCommand=\(resolved.displayCommand)")
|
||||||
|
case .invalid(let message):
|
||||||
|
if let expected = entry.expected.errorContains {
|
||||||
|
#expect(
|
||||||
|
message.contains(expected),
|
||||||
|
"\(entry.name): expected error containing \(expected), got \(message)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .ok(let resolved):
|
||||||
|
#expect(
|
||||||
|
resolved.displayCommand == entry.expected.displayCommand,
|
||||||
|
"\(entry.name): unexpected display command")
|
||||||
|
case .invalid(let message):
|
||||||
|
Issue.record("\(entry.name): unexpected invalid result: \(message)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func acceptsCanonicalDisplayForPositionalCarrierWrappers() {
|
private static func loadContractCases() throws -> [SystemRunCommandContractCase] {
|
||||||
let command = ["/bin/sh", "-lc", #"$0 "$1""#, "/usr/bin/touch", "/tmp/marker"]
|
let fixtureURL = try self.findContractFixtureURL()
|
||||||
let expected = ExecCommandFormatter.displayString(for: command)
|
let data = try Data(contentsOf: fixtureURL)
|
||||||
let result = ExecSystemRunCommandValidator.resolve(command: command, rawCommand: expected)
|
let decoded = try JSONDecoder().decode(SystemRunCommandContractFixture.self, from: data)
|
||||||
switch result {
|
return decoded.cases
|
||||||
case .ok(let resolved):
|
|
||||||
#expect(resolved.displayCommand == expected)
|
|
||||||
case .invalid(let message):
|
|
||||||
Issue.record("unexpected validation failure: \(message)")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func acceptsShellPayloadRawForTransparentEnvWrapper() {
|
private static func findContractFixtureURL() throws -> URL {
|
||||||
let command = ["/usr/bin/env", "bash", "-lc", "echo hi"]
|
var cursor = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
|
||||||
let result = ExecSystemRunCommandValidator.resolve(command: command, rawCommand: "echo hi")
|
for _ in 0..<8 {
|
||||||
switch result {
|
let candidate = cursor
|
||||||
case .ok(let resolved):
|
.appendingPathComponent("test")
|
||||||
#expect(resolved.displayCommand == "echo hi")
|
.appendingPathComponent("fixtures")
|
||||||
case .invalid(let message):
|
.appendingPathComponent("system-run-command-contract.json")
|
||||||
Issue.record("unexpected validation failure: \(message)")
|
if FileManager.default.fileExists(atPath: candidate.path) {
|
||||||
}
|
return candidate
|
||||||
}
|
}
|
||||||
|
cursor.deleteLastPathComponent()
|
||||||
@Test func rejectsShellPayloadRawForEnvModifierPrelude() {
|
|
||||||
let command = ["/usr/bin/env", "BASH_ENV=/tmp/payload.sh", "bash", "-lc", "echo hi"]
|
|
||||||
let result = ExecSystemRunCommandValidator.resolve(command: command, rawCommand: "echo hi")
|
|
||||||
switch result {
|
|
||||||
case .ok:
|
|
||||||
Issue.record("expected rawCommand mismatch")
|
|
||||||
case .invalid(let message):
|
|
||||||
#expect(message.contains("rawCommand does not match command"))
|
|
||||||
}
|
}
|
||||||
|
throw NSError(
|
||||||
|
domain: "ExecSystemRunCommandValidatorTests",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "missing shared system-run command contract fixture"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
54
src/infra/system-run-command.contract.test.ts
Normal file
54
src/infra/system-run-command.contract.test.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { resolveSystemRunCommand } from "./system-run-command.js";
|
||||||
|
|
||||||
|
type ContractFixture = {
|
||||||
|
cases: ContractCase[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ContractCase = {
|
||||||
|
name: string;
|
||||||
|
command: string[];
|
||||||
|
rawCommand?: string;
|
||||||
|
expected: {
|
||||||
|
valid: boolean;
|
||||||
|
displayCommand?: string;
|
||||||
|
errorContains?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const fixturePath = path.resolve(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
"../../test/fixtures/system-run-command-contract.json",
|
||||||
|
);
|
||||||
|
const fixture = JSON.parse(fs.readFileSync(fixturePath, "utf8")) as ContractFixture;
|
||||||
|
|
||||||
|
describe("system-run command contract fixtures", () => {
|
||||||
|
for (const entry of fixture.cases) {
|
||||||
|
test(entry.name, () => {
|
||||||
|
const result = resolveSystemRunCommand({
|
||||||
|
command: entry.command,
|
||||||
|
rawCommand: entry.rawCommand,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!entry.expected.valid) {
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) {
|
||||||
|
throw new Error("expected validation failure");
|
||||||
|
}
|
||||||
|
if (entry.expected.errorContains) {
|
||||||
|
expect(result.message).toContain(entry.expected.errorContains);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new Error(`unexpected validation failure: ${result.message}`);
|
||||||
|
}
|
||||||
|
expect(result.cmdText).toBe(entry.expected.displayCommand);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -55,6 +55,37 @@ type SystemRunAllowlistAnalysis = {
|
|||||||
segments: ExecCommandSegment[];
|
segments: ExecCommandSegment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ResolvedExecApprovals = ReturnType<typeof resolveExecApprovals>;
|
||||||
|
|
||||||
|
type SystemRunParsePhase = {
|
||||||
|
argv: string[];
|
||||||
|
shellCommand: string | null;
|
||||||
|
cmdText: string;
|
||||||
|
agentId: string | undefined;
|
||||||
|
sessionKey: string;
|
||||||
|
runId: string;
|
||||||
|
execution: SystemRunExecutionContext;
|
||||||
|
approvalDecision: ReturnType<typeof resolveExecApprovalDecision>;
|
||||||
|
envOverrides: Record<string, string> | undefined;
|
||||||
|
env: Record<string, string> | undefined;
|
||||||
|
cwd: string | undefined;
|
||||||
|
timeoutMs: number | undefined;
|
||||||
|
needsScreenRecording: boolean;
|
||||||
|
approved: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SystemRunPolicyPhase = SystemRunParsePhase & {
|
||||||
|
approvals: ResolvedExecApprovals;
|
||||||
|
security: ExecSecurity;
|
||||||
|
policy: ReturnType<typeof evaluateSystemRunPolicy>;
|
||||||
|
allowlistMatches: ExecAllowlistEntry[];
|
||||||
|
analysisOk: boolean;
|
||||||
|
allowlistSatisfied: boolean;
|
||||||
|
segments: ExecCommandSegment[];
|
||||||
|
plannedAllowlistArgv: string[] | undefined;
|
||||||
|
isWindows: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
const safeBinTrustedDirWarningCache = new Set<string>();
|
const safeBinTrustedDirWarningCache = new Set<string>();
|
||||||
|
|
||||||
function warnWritableTrustedDirOnce(message: string): void {
|
function warnWritableTrustedDirOnce(message: string): void {
|
||||||
@@ -270,7 +301,9 @@ function applyOutputTruncation(result: RunResult) {
|
|||||||
|
|
||||||
export { formatSystemRunAllowlistMissMessage } from "./exec-policy.js";
|
export { formatSystemRunAllowlistMissMessage } from "./exec-policy.js";
|
||||||
|
|
||||||
export async function handleSystemRunInvoke(opts: HandleSystemRunInvokeOptions): Promise<void> {
|
async function parseSystemRunPhase(
|
||||||
|
opts: HandleSystemRunInvokeOptions,
|
||||||
|
): Promise<SystemRunParsePhase | null> {
|
||||||
const command = resolveSystemRunCommand({
|
const command = resolveSystemRunCommand({
|
||||||
command: opts.params.command,
|
command: opts.params.command,
|
||||||
rawCommand: opts.params.rawCommand,
|
rawCommand: opts.params.rawCommand,
|
||||||
@@ -280,42 +313,62 @@ export async function handleSystemRunInvoke(opts: HandleSystemRunInvokeOptions):
|
|||||||
ok: false,
|
ok: false,
|
||||||
error: { code: "INVALID_REQUEST", message: command.message },
|
error: { code: "INVALID_REQUEST", message: command.message },
|
||||||
});
|
});
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
if (command.argv.length === 0) {
|
if (command.argv.length === 0) {
|
||||||
await opts.sendInvokeResult({
|
await opts.sendInvokeResult({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: { code: "INVALID_REQUEST", message: "command required" },
|
error: { code: "INVALID_REQUEST", message: "command required" },
|
||||||
});
|
});
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const argv = command.argv;
|
|
||||||
const shellCommand = command.shellCommand;
|
const shellCommand = command.shellCommand;
|
||||||
const cmdText = command.cmdText;
|
const cmdText = command.cmdText;
|
||||||
const agentId = opts.params.agentId?.trim() || undefined;
|
const agentId = opts.params.agentId?.trim() || undefined;
|
||||||
|
const sessionKey = opts.params.sessionKey?.trim() || "node";
|
||||||
|
const runId = opts.params.runId?.trim() || crypto.randomUUID();
|
||||||
|
const envOverrides = sanitizeSystemRunEnvOverrides({
|
||||||
|
overrides: opts.params.env ?? undefined,
|
||||||
|
shellWrapper: shellCommand !== null,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
argv: command.argv,
|
||||||
|
shellCommand,
|
||||||
|
cmdText,
|
||||||
|
agentId,
|
||||||
|
sessionKey,
|
||||||
|
runId,
|
||||||
|
execution: { sessionKey, runId, cmdText },
|
||||||
|
approvalDecision: resolveExecApprovalDecision(opts.params.approvalDecision),
|
||||||
|
envOverrides,
|
||||||
|
env: opts.sanitizeEnv(envOverrides),
|
||||||
|
cwd: opts.params.cwd?.trim() || undefined,
|
||||||
|
timeoutMs: opts.params.timeoutMs ?? undefined,
|
||||||
|
needsScreenRecording: opts.params.needsScreenRecording === true,
|
||||||
|
approved: opts.params.approved === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function evaluateSystemRunPolicyPhase(
|
||||||
|
opts: HandleSystemRunInvokeOptions,
|
||||||
|
parsed: SystemRunParsePhase,
|
||||||
|
): Promise<SystemRunPolicyPhase | null> {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const agentExec = agentId ? resolveAgentConfig(cfg, agentId)?.tools?.exec : undefined;
|
const agentExec = parsed.agentId
|
||||||
|
? resolveAgentConfig(cfg, parsed.agentId)?.tools?.exec
|
||||||
|
: undefined;
|
||||||
const configuredSecurity = opts.resolveExecSecurity(
|
const configuredSecurity = opts.resolveExecSecurity(
|
||||||
agentExec?.security ?? cfg.tools?.exec?.security,
|
agentExec?.security ?? cfg.tools?.exec?.security,
|
||||||
);
|
);
|
||||||
const configuredAsk = opts.resolveExecAsk(agentExec?.ask ?? cfg.tools?.exec?.ask);
|
const configuredAsk = opts.resolveExecAsk(agentExec?.ask ?? cfg.tools?.exec?.ask);
|
||||||
const approvals = resolveExecApprovals(agentId, {
|
const approvals = resolveExecApprovals(parsed.agentId, {
|
||||||
security: configuredSecurity,
|
security: configuredSecurity,
|
||||||
ask: configuredAsk,
|
ask: configuredAsk,
|
||||||
});
|
});
|
||||||
const security = approvals.agent.security;
|
const security = approvals.agent.security;
|
||||||
const ask = approvals.agent.ask;
|
const ask = approvals.agent.ask;
|
||||||
const autoAllowSkills = approvals.agent.autoAllowSkills;
|
const autoAllowSkills = approvals.agent.autoAllowSkills;
|
||||||
const sessionKey = opts.params.sessionKey?.trim() || "node";
|
|
||||||
const runId = opts.params.runId?.trim() || crypto.randomUUID();
|
|
||||||
const execution: SystemRunExecutionContext = { sessionKey, runId, cmdText };
|
|
||||||
const approvalDecision = resolveExecApprovalDecision(opts.params.approvalDecision);
|
|
||||||
const envOverrides = sanitizeSystemRunEnvOverrides({
|
|
||||||
overrides: opts.params.env ?? undefined,
|
|
||||||
shellWrapper: shellCommand !== null,
|
|
||||||
});
|
|
||||||
const env = opts.sanitizeEnv(envOverrides);
|
|
||||||
const { safeBins, safeBinProfiles, trustedSafeBinDirs } = resolveExecSafeBinRuntimePolicy({
|
const { safeBins, safeBinProfiles, trustedSafeBinDirs } = resolveExecSafeBinRuntimePolicy({
|
||||||
global: cfg.tools?.exec,
|
global: cfg.tools?.exec,
|
||||||
local: agentExec,
|
local: agentExec,
|
||||||
@@ -323,99 +376,124 @@ export async function handleSystemRunInvoke(opts: HandleSystemRunInvokeOptions):
|
|||||||
});
|
});
|
||||||
const bins = autoAllowSkills ? await opts.skillBins.current() : [];
|
const bins = autoAllowSkills ? await opts.skillBins.current() : [];
|
||||||
let { analysisOk, allowlistMatches, allowlistSatisfied, segments } = evaluateSystemRunAllowlist({
|
let { analysisOk, allowlistMatches, allowlistSatisfied, segments } = evaluateSystemRunAllowlist({
|
||||||
shellCommand,
|
shellCommand: parsed.shellCommand,
|
||||||
argv,
|
argv: parsed.argv,
|
||||||
approvals,
|
approvals,
|
||||||
security,
|
security,
|
||||||
safeBins,
|
safeBins,
|
||||||
safeBinProfiles,
|
safeBinProfiles,
|
||||||
trustedSafeBinDirs,
|
trustedSafeBinDirs,
|
||||||
cwd: opts.params.cwd ?? undefined,
|
cwd: parsed.cwd,
|
||||||
env,
|
env: parsed.env,
|
||||||
skillBins: bins,
|
skillBins: bins,
|
||||||
autoAllowSkills,
|
autoAllowSkills,
|
||||||
});
|
});
|
||||||
const isWindows = process.platform === "win32";
|
const isWindows = process.platform === "win32";
|
||||||
const cmdInvocation = shellCommand
|
const cmdInvocation = parsed.shellCommand
|
||||||
? opts.isCmdExeInvocation(segments[0]?.argv ?? [])
|
? opts.isCmdExeInvocation(segments[0]?.argv ?? [])
|
||||||
: opts.isCmdExeInvocation(argv);
|
: opts.isCmdExeInvocation(parsed.argv);
|
||||||
const policy = evaluateSystemRunPolicy({
|
const policy = evaluateSystemRunPolicy({
|
||||||
security,
|
security,
|
||||||
ask,
|
ask,
|
||||||
analysisOk,
|
analysisOk,
|
||||||
allowlistSatisfied,
|
allowlistSatisfied,
|
||||||
approvalDecision,
|
approvalDecision: parsed.approvalDecision,
|
||||||
approved: opts.params.approved === true,
|
approved: parsed.approved,
|
||||||
isWindows,
|
isWindows,
|
||||||
cmdInvocation,
|
cmdInvocation,
|
||||||
shellWrapperInvocation: shellCommand !== null,
|
shellWrapperInvocation: parsed.shellCommand !== null,
|
||||||
});
|
});
|
||||||
analysisOk = policy.analysisOk;
|
analysisOk = policy.analysisOk;
|
||||||
allowlistSatisfied = policy.allowlistSatisfied;
|
allowlistSatisfied = policy.allowlistSatisfied;
|
||||||
if (!policy.allowed) {
|
if (!policy.allowed) {
|
||||||
await sendSystemRunDenied(opts, execution, {
|
await sendSystemRunDenied(opts, parsed.execution, {
|
||||||
reason: policy.eventReason,
|
reason: policy.eventReason,
|
||||||
message: policy.errorMessage,
|
message: policy.errorMessage,
|
||||||
});
|
});
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fail closed if policy/runtime drift re-allows unapproved shell wrappers.
|
// Fail closed if policy/runtime drift re-allows unapproved shell wrappers.
|
||||||
if (security === "allowlist" && shellCommand && !policy.approvedByAsk) {
|
if (security === "allowlist" && parsed.shellCommand && !policy.approvedByAsk) {
|
||||||
await sendSystemRunDenied(opts, execution, {
|
await sendSystemRunDenied(opts, parsed.execution, {
|
||||||
reason: "approval-required",
|
reason: "approval-required",
|
||||||
message: "SYSTEM_RUN_DENIED: approval required",
|
message: "SYSTEM_RUN_DENIED: approval required",
|
||||||
});
|
});
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const plannedAllowlistArgv = resolvePlannedAllowlistArgv({
|
const plannedAllowlistArgv = resolvePlannedAllowlistArgv({
|
||||||
security,
|
security,
|
||||||
shellCommand,
|
shellCommand: parsed.shellCommand,
|
||||||
policy,
|
policy,
|
||||||
segments,
|
segments,
|
||||||
});
|
});
|
||||||
if (plannedAllowlistArgv === null) {
|
if (plannedAllowlistArgv === null) {
|
||||||
await sendSystemRunDenied(opts, execution, {
|
await sendSystemRunDenied(opts, parsed.execution, {
|
||||||
reason: "execution-plan-miss",
|
reason: "execution-plan-miss",
|
||||||
message: "SYSTEM_RUN_DENIED: execution plan mismatch",
|
message: "SYSTEM_RUN_DENIED: execution plan mismatch",
|
||||||
});
|
});
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
approvals,
|
||||||
|
security,
|
||||||
|
policy,
|
||||||
|
allowlistMatches,
|
||||||
|
analysisOk,
|
||||||
|
allowlistSatisfied,
|
||||||
|
segments,
|
||||||
|
plannedAllowlistArgv: plannedAllowlistArgv ?? undefined,
|
||||||
|
isWindows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeSystemRunPhase(
|
||||||
|
opts: HandleSystemRunInvokeOptions,
|
||||||
|
phase: SystemRunPolicyPhase,
|
||||||
|
): Promise<void> {
|
||||||
const useMacAppExec = opts.preferMacAppExecHost;
|
const useMacAppExec = opts.preferMacAppExecHost;
|
||||||
if (useMacAppExec) {
|
if (useMacAppExec) {
|
||||||
const execRequest: ExecHostRequest = {
|
const execRequest: ExecHostRequest = {
|
||||||
command: plannedAllowlistArgv ?? argv,
|
command: phase.plannedAllowlistArgv ?? phase.argv,
|
||||||
// Forward canonical display text so companion approval/prompt surfaces bind to
|
// Forward canonical display text so companion approval/prompt surfaces bind to
|
||||||
// the exact command context already validated on the node-host.
|
// the exact command context already validated on the node-host.
|
||||||
rawCommand: cmdText || null,
|
rawCommand: phase.cmdText || null,
|
||||||
cwd: opts.params.cwd ?? null,
|
cwd: phase.cwd ?? null,
|
||||||
env: envOverrides ?? null,
|
env: phase.envOverrides ?? null,
|
||||||
timeoutMs: opts.params.timeoutMs ?? null,
|
timeoutMs: phase.timeoutMs ?? null,
|
||||||
needsScreenRecording: opts.params.needsScreenRecording ?? null,
|
needsScreenRecording: phase.needsScreenRecording,
|
||||||
agentId: agentId ?? null,
|
agentId: phase.agentId ?? null,
|
||||||
sessionKey: sessionKey ?? null,
|
sessionKey: phase.sessionKey ?? null,
|
||||||
approvalDecision,
|
approvalDecision: phase.approvalDecision,
|
||||||
};
|
};
|
||||||
const response = await opts.runViaMacAppExecHost({ approvals, request: execRequest });
|
const response = await opts.runViaMacAppExecHost({
|
||||||
|
approvals: phase.approvals,
|
||||||
|
request: execRequest,
|
||||||
|
});
|
||||||
if (!response) {
|
if (!response) {
|
||||||
if (opts.execHostEnforced || !opts.execHostFallbackAllowed) {
|
if (opts.execHostEnforced || !opts.execHostFallbackAllowed) {
|
||||||
await sendSystemRunDenied(opts, execution, {
|
await sendSystemRunDenied(opts, phase.execution, {
|
||||||
reason: "companion-unavailable",
|
reason: "companion-unavailable",
|
||||||
message: "COMPANION_APP_UNAVAILABLE: macOS app exec host unreachable",
|
message: "COMPANION_APP_UNAVAILABLE: macOS app exec host unreachable",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else if (!response.ok) {
|
} else if (!response.ok) {
|
||||||
await sendSystemRunDenied(opts, execution, {
|
await sendSystemRunDenied(opts, phase.execution, {
|
||||||
reason: normalizeDeniedReason(response.error.reason),
|
reason: normalizeDeniedReason(response.error.reason),
|
||||||
message: response.error.message,
|
message: response.error.message,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
const result: ExecHostRunResult = response.payload;
|
const result: ExecHostRunResult = response.payload;
|
||||||
await opts.sendExecFinishedEvent({ sessionKey, runId, cmdText, result });
|
await opts.sendExecFinishedEvent({
|
||||||
|
sessionKey: phase.sessionKey,
|
||||||
|
runId: phase.runId,
|
||||||
|
cmdText: phase.cmdText,
|
||||||
|
result,
|
||||||
|
});
|
||||||
await opts.sendInvokeResult({
|
await opts.sendInvokeResult({
|
||||||
ok: true,
|
ok: true,
|
||||||
payloadJSON: JSON.stringify(result),
|
payloadJSON: JSON.stringify(result),
|
||||||
@@ -424,41 +502,41 @@ export async function handleSystemRunInvoke(opts: HandleSystemRunInvokeOptions):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (policy.approvalDecision === "allow-always" && security === "allowlist") {
|
if (phase.policy.approvalDecision === "allow-always" && phase.security === "allowlist") {
|
||||||
if (policy.analysisOk) {
|
if (phase.policy.analysisOk) {
|
||||||
const patterns = resolveAllowAlwaysPatterns({
|
const patterns = resolveAllowAlwaysPatterns({
|
||||||
segments,
|
segments: phase.segments,
|
||||||
cwd: opts.params.cwd ?? undefined,
|
cwd: phase.cwd,
|
||||||
env,
|
env: phase.env,
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
});
|
});
|
||||||
for (const pattern of patterns) {
|
for (const pattern of patterns) {
|
||||||
if (pattern) {
|
if (pattern) {
|
||||||
addAllowlistEntry(approvals.file, agentId, pattern);
|
addAllowlistEntry(phase.approvals.file, phase.agentId, pattern);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allowlistMatches.length > 0) {
|
if (phase.allowlistMatches.length > 0) {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
for (const match of allowlistMatches) {
|
for (const match of phase.allowlistMatches) {
|
||||||
if (!match?.pattern || seen.has(match.pattern)) {
|
if (!match?.pattern || seen.has(match.pattern)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
seen.add(match.pattern);
|
seen.add(match.pattern);
|
||||||
recordAllowlistUse(
|
recordAllowlistUse(
|
||||||
approvals.file,
|
phase.approvals.file,
|
||||||
agentId,
|
phase.agentId,
|
||||||
match,
|
match,
|
||||||
cmdText,
|
phase.cmdText,
|
||||||
segments[0]?.resolution?.resolvedPath,
|
phase.segments[0]?.resolution?.resolvedPath,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.params.needsScreenRecording === true) {
|
if (phase.needsScreenRecording) {
|
||||||
await sendSystemRunDenied(opts, execution, {
|
await sendSystemRunDenied(opts, phase.execution, {
|
||||||
reason: "permission:screenRecording",
|
reason: "permission:screenRecording",
|
||||||
message: "PERMISSION_MISSING: screenRecording",
|
message: "PERMISSION_MISSING: screenRecording",
|
||||||
});
|
});
|
||||||
@@ -466,23 +544,23 @@ export async function handleSystemRunInvoke(opts: HandleSystemRunInvokeOptions):
|
|||||||
}
|
}
|
||||||
|
|
||||||
const execArgv = resolveSystemRunExecArgv({
|
const execArgv = resolveSystemRunExecArgv({
|
||||||
plannedAllowlistArgv: plannedAllowlistArgv ?? undefined,
|
plannedAllowlistArgv: phase.plannedAllowlistArgv,
|
||||||
argv,
|
argv: phase.argv,
|
||||||
security,
|
security: phase.security,
|
||||||
isWindows,
|
isWindows: phase.isWindows,
|
||||||
policy,
|
policy: phase.policy,
|
||||||
shellCommand,
|
shellCommand: phase.shellCommand,
|
||||||
segments,
|
segments: phase.segments,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await opts.runCommand(
|
const result = await opts.runCommand(execArgv, phase.cwd, phase.env, phase.timeoutMs);
|
||||||
execArgv,
|
|
||||||
opts.params.cwd?.trim() || undefined,
|
|
||||||
env,
|
|
||||||
opts.params.timeoutMs ?? undefined,
|
|
||||||
);
|
|
||||||
applyOutputTruncation(result);
|
applyOutputTruncation(result);
|
||||||
await opts.sendExecFinishedEvent({ sessionKey, runId, cmdText, result });
|
await opts.sendExecFinishedEvent({
|
||||||
|
sessionKey: phase.sessionKey,
|
||||||
|
runId: phase.runId,
|
||||||
|
cmdText: phase.cmdText,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
|
||||||
await opts.sendInvokeResult({
|
await opts.sendInvokeResult({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -496,3 +574,15 @@ export async function handleSystemRunInvoke(opts: HandleSystemRunInvokeOptions):
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function handleSystemRunInvoke(opts: HandleSystemRunInvokeOptions): Promise<void> {
|
||||||
|
const parsed = await parseSystemRunPhase(opts);
|
||||||
|
if (!parsed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const policyPhase = await evaluateSystemRunPolicyPhase(opts, parsed);
|
||||||
|
if (!policyPhase) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await executeSystemRunPhase(opts, policyPhase);
|
||||||
|
}
|
||||||
|
|||||||
75
test/fixtures/system-run-command-contract.json
vendored
Normal file
75
test/fixtures/system-run-command-contract.json
vendored
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "direct argv infers display command",
|
||||||
|
"command": ["echo", "hi there"],
|
||||||
|
"expected": {
|
||||||
|
"valid": true,
|
||||||
|
"displayCommand": "echo \"hi there\""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "direct argv rejects mismatched raw command",
|
||||||
|
"command": ["uname", "-a"],
|
||||||
|
"rawCommand": "echo hi",
|
||||||
|
"expected": {
|
||||||
|
"valid": false,
|
||||||
|
"errorContains": "rawCommand does not match command"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "shell wrapper accepts shell payload raw command when no positional argv carriers",
|
||||||
|
"command": ["/bin/sh", "-lc", "echo hi"],
|
||||||
|
"rawCommand": "echo hi",
|
||||||
|
"expected": {
|
||||||
|
"valid": true,
|
||||||
|
"displayCommand": "echo hi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "shell wrapper positional argv carrier requires full argv display binding",
|
||||||
|
"command": ["/bin/sh", "-lc", "$0 \"$1\"", "/usr/bin/touch", "/tmp/marker"],
|
||||||
|
"rawCommand": "$0 \"$1\"",
|
||||||
|
"expected": {
|
||||||
|
"valid": false,
|
||||||
|
"errorContains": "rawCommand does not match command"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "shell wrapper positional argv carrier accepts canonical full argv raw command",
|
||||||
|
"command": ["/bin/sh", "-lc", "$0 \"$1\"", "/usr/bin/touch", "/tmp/marker"],
|
||||||
|
"rawCommand": "/bin/sh -lc \"$0 \\\"$1\\\"\" /usr/bin/touch /tmp/marker",
|
||||||
|
"expected": {
|
||||||
|
"valid": true,
|
||||||
|
"displayCommand": "/bin/sh -lc \"$0 \\\"$1\\\"\" /usr/bin/touch /tmp/marker"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "env wrapper shell payload accepted when prelude has no env modifiers",
|
||||||
|
"command": ["/usr/bin/env", "bash", "-lc", "echo hi"],
|
||||||
|
"rawCommand": "echo hi",
|
||||||
|
"expected": {
|
||||||
|
"valid": true,
|
||||||
|
"displayCommand": "echo hi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "env assignment prelude requires full argv display binding",
|
||||||
|
"command": ["/usr/bin/env", "BASH_ENV=/tmp/payload.sh", "bash", "-lc", "echo hi"],
|
||||||
|
"rawCommand": "echo hi",
|
||||||
|
"expected": {
|
||||||
|
"valid": false,
|
||||||
|
"errorContains": "rawCommand does not match command"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "env assignment prelude accepts canonical full argv raw command",
|
||||||
|
"command": ["/usr/bin/env", "BASH_ENV=/tmp/payload.sh", "bash", "-lc", "echo hi"],
|
||||||
|
"rawCommand": "/usr/bin/env BASH_ENV=/tmp/payload.sh bash -lc \"echo hi\"",
|
||||||
|
"expected": {
|
||||||
|
"valid": true,
|
||||||
|
"displayCommand": "/usr/bin/env BASH_ENV=/tmp/payload.sh bash -lc \"echo hi\""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user