mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
refactor: unify exec wrapper resolution and parity fixtures
This commit is contained in:
@@ -25,7 +25,7 @@ struct ExecCommandResolution: Sendable {
|
||||
cwd: String?,
|
||||
env: [String: String]?) -> [ExecCommandResolution]
|
||||
{
|
||||
let shell = self.extractShellCommandFromArgv(command: command, rawCommand: rawCommand)
|
||||
let shell = ExecShellWrapperParser.extract(command: command, rawCommand: rawCommand)
|
||||
if shell.isWrapper {
|
||||
guard let shellCommand = shell.command,
|
||||
let segments = self.splitShellCommandChain(shellCommand)
|
||||
@@ -54,7 +54,7 @@ struct ExecCommandResolution: Sendable {
|
||||
}
|
||||
|
||||
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
|
||||
let effective = self.unwrapDispatchWrappersForResolution(command)
|
||||
let effective = ExecEnvInvocationUnwrapper.unwrapDispatchWrappersForResolution(command)
|
||||
guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
@@ -102,166 +102,6 @@ struct ExecCommandResolution: Sendable {
|
||||
return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
|
||||
}
|
||||
|
||||
private static func basenameLower(_ token: String) -> String {
|
||||
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return "" }
|
||||
let normalized = trimmed.replacingOccurrences(of: "\\", with: "/")
|
||||
return normalized.split(separator: "/").last.map { String($0).lowercased() } ?? normalized.lowercased()
|
||||
}
|
||||
|
||||
private static func extractShellCommandFromArgv(
|
||||
command: [String],
|
||||
rawCommand: String?) -> (isWrapper: Bool, command: String?)
|
||||
{
|
||||
guard let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else {
|
||||
return (false, nil)
|
||||
}
|
||||
let base0 = self.basenameLower(token0)
|
||||
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let preferredRaw = trimmedRaw.isEmpty ? nil : trimmedRaw
|
||||
|
||||
if base0 == "env" {
|
||||
guard let unwrapped = self.unwrapEnvInvocation(command) else {
|
||||
return (false, nil)
|
||||
}
|
||||
return self.extractShellCommandFromArgv(command: unwrapped, rawCommand: rawCommand)
|
||||
}
|
||||
|
||||
if ["ash", "sh", "bash", "zsh", "dash", "ksh", "fish"].contains(base0) {
|
||||
let flag = command.count > 1 ? command[1].trimmingCharacters(in: .whitespacesAndNewlines) : ""
|
||||
let normalizedFlag = flag.lowercased()
|
||||
guard normalizedFlag == "-lc" || normalizedFlag == "-c" || normalizedFlag == "--command" else {
|
||||
return (false, nil)
|
||||
}
|
||||
let payload = command.count > 2 ? command[2].trimmingCharacters(in: .whitespacesAndNewlines) : ""
|
||||
let normalized = preferredRaw ?? (payload.isEmpty ? nil : payload)
|
||||
return (true, normalized)
|
||||
}
|
||||
|
||||
if base0 == "cmd.exe" || base0 == "cmd" {
|
||||
guard let idx = command
|
||||
.firstIndex(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "/c" })
|
||||
else {
|
||||
return (false, nil)
|
||||
}
|
||||
let tail = command.suffix(from: command.index(after: idx)).joined(separator: " ")
|
||||
let payload = tail.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalized = preferredRaw ?? (payload.isEmpty ? nil : payload)
|
||||
return (true, normalized)
|
||||
}
|
||||
|
||||
if ["powershell", "powershell.exe", "pwsh", "pwsh.exe"].contains(base0) {
|
||||
for idx in 1..<command.count {
|
||||
let token = command[idx].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if token.isEmpty { continue }
|
||||
if token == "--" { break }
|
||||
if token == "-c" || token == "-command" || token == "--command" {
|
||||
let payload = idx + 1 < command.count
|
||||
? command[idx + 1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
: ""
|
||||
let normalized = preferredRaw ?? (payload.isEmpty ? nil : payload)
|
||||
return (true, normalized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (false, nil)
|
||||
}
|
||||
|
||||
private static let envOptionsWithValue = Set([
|
||||
"-u",
|
||||
"--unset",
|
||||
"-c",
|
||||
"--chdir",
|
||||
"-s",
|
||||
"--split-string",
|
||||
"--default-signal",
|
||||
"--ignore-signal",
|
||||
"--block-signal",
|
||||
])
|
||||
private static let envFlagOptions = Set(["-i", "--ignore-environment", "-0", "--null"])
|
||||
|
||||
private static func isEnvAssignment(_ token: String) -> Bool {
|
||||
let pattern = #"^[A-Za-z_][A-Za-z0-9_]*=.*"#
|
||||
return token.range(of: pattern, options: .regularExpression) != nil
|
||||
}
|
||||
|
||||
private static func unwrapEnvInvocation(_ command: [String]) -> [String]? {
|
||||
var idx = 1
|
||||
var expectsOptionValue = false
|
||||
while idx < command.count {
|
||||
let token = command[idx].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if token.isEmpty {
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
if expectsOptionValue {
|
||||
expectsOptionValue = false
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
if token == "--" || token == "-" {
|
||||
idx += 1
|
||||
break
|
||||
}
|
||||
if self.isEnvAssignment(token) {
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
if token.hasPrefix("-"), token != "-" {
|
||||
let lower = token.lowercased()
|
||||
let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower
|
||||
if self.envFlagOptions.contains(flag) {
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
if self.envOptionsWithValue.contains(flag) {
|
||||
if !lower.contains("=") {
|
||||
expectsOptionValue = true
|
||||
}
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
if lower.hasPrefix("-u") ||
|
||||
lower.hasPrefix("-c") ||
|
||||
lower.hasPrefix("-s") ||
|
||||
lower.hasPrefix("--unset=") ||
|
||||
lower.hasPrefix("--chdir=") ||
|
||||
lower.hasPrefix("--split-string=") ||
|
||||
lower.hasPrefix("--default-signal=") ||
|
||||
lower.hasPrefix("--ignore-signal=") ||
|
||||
lower.hasPrefix("--block-signal=")
|
||||
{
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
break
|
||||
}
|
||||
guard idx < command.count else { return nil }
|
||||
return Array(command[idx...])
|
||||
}
|
||||
|
||||
private static func unwrapDispatchWrappersForResolution(_ command: [String]) -> [String] {
|
||||
var current = command
|
||||
var depth = 0
|
||||
while depth < 4 {
|
||||
guard let token = current.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty else {
|
||||
break
|
||||
}
|
||||
guard self.basenameLower(token) == "env" else {
|
||||
break
|
||||
}
|
||||
guard let unwrapped = self.unwrapEnvInvocation(current), !unwrapped.isEmpty else {
|
||||
break
|
||||
}
|
||||
current = unwrapped
|
||||
depth += 1
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
private enum ShellTokenContext {
|
||||
case unquoted
|
||||
case doubleQuoted
|
||||
|
||||
108
apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift
Normal file
108
apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift
Normal file
@@ -0,0 +1,108 @@
|
||||
import Foundation
|
||||
|
||||
enum ExecCommandToken {
|
||||
static func basenameLower(_ token: String) -> String {
|
||||
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return "" }
|
||||
let normalized = trimmed.replacingOccurrences(of: "\\", with: "/")
|
||||
return normalized.split(separator: "/").last.map { String($0).lowercased() } ?? normalized.lowercased()
|
||||
}
|
||||
}
|
||||
|
||||
enum ExecEnvInvocationUnwrapper {
|
||||
static let maxWrapperDepth = 4
|
||||
|
||||
private static let optionsWithValue = Set([
|
||||
"-u",
|
||||
"--unset",
|
||||
"-c",
|
||||
"--chdir",
|
||||
"-s",
|
||||
"--split-string",
|
||||
"--default-signal",
|
||||
"--ignore-signal",
|
||||
"--block-signal",
|
||||
])
|
||||
private static let flagOptions = Set(["-i", "--ignore-environment", "-0", "--null"])
|
||||
|
||||
private static func isEnvAssignment(_ token: String) -> Bool {
|
||||
let pattern = #"^[A-Za-z_][A-Za-z0-9_]*=.*"#
|
||||
return token.range(of: pattern, options: .regularExpression) != nil
|
||||
}
|
||||
|
||||
static func unwrap(_ command: [String]) -> [String]? {
|
||||
var idx = 1
|
||||
var expectsOptionValue = false
|
||||
while idx < command.count {
|
||||
let token = command[idx].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if token.isEmpty {
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
if expectsOptionValue {
|
||||
expectsOptionValue = false
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
if token == "--" || token == "-" {
|
||||
idx += 1
|
||||
break
|
||||
}
|
||||
if self.isEnvAssignment(token) {
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
if token.hasPrefix("-"), token != "-" {
|
||||
let lower = token.lowercased()
|
||||
let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower
|
||||
if self.flagOptions.contains(flag) {
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
if self.optionsWithValue.contains(flag) {
|
||||
if !lower.contains("=") {
|
||||
expectsOptionValue = true
|
||||
}
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
if lower.hasPrefix("-u") ||
|
||||
lower.hasPrefix("-c") ||
|
||||
lower.hasPrefix("-s") ||
|
||||
lower.hasPrefix("--unset=") ||
|
||||
lower.hasPrefix("--chdir=") ||
|
||||
lower.hasPrefix("--split-string=") ||
|
||||
lower.hasPrefix("--default-signal=") ||
|
||||
lower.hasPrefix("--ignore-signal=") ||
|
||||
lower.hasPrefix("--block-signal=")
|
||||
{
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
break
|
||||
}
|
||||
guard idx < command.count else { return nil }
|
||||
return Array(command[idx...])
|
||||
}
|
||||
|
||||
static func unwrapDispatchWrappersForResolution(_ command: [String]) -> [String] {
|
||||
var current = command
|
||||
var depth = 0
|
||||
while depth < self.maxWrapperDepth {
|
||||
guard let token = current.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty else {
|
||||
break
|
||||
}
|
||||
guard ExecCommandToken.basenameLower(token) == "env" else {
|
||||
break
|
||||
}
|
||||
guard let unwrapped = self.unwrap(current), !unwrapped.isEmpty else {
|
||||
break
|
||||
}
|
||||
current = unwrapped
|
||||
depth += 1
|
||||
}
|
||||
return current
|
||||
}
|
||||
}
|
||||
106
apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift
Normal file
106
apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift
Normal file
@@ -0,0 +1,106 @@
|
||||
import Foundation
|
||||
|
||||
enum ExecShellWrapperParser {
|
||||
struct ParsedShellWrapper {
|
||||
let isWrapper: Bool
|
||||
let command: String?
|
||||
|
||||
static let notWrapper = ParsedShellWrapper(isWrapper: false, command: nil)
|
||||
}
|
||||
|
||||
private enum Kind {
|
||||
case posix
|
||||
case cmd
|
||||
case powershell
|
||||
}
|
||||
|
||||
private struct WrapperSpec {
|
||||
let kind: Kind
|
||||
let names: Set<String>
|
||||
}
|
||||
|
||||
private static let posixInlineFlags = Set(["-lc", "-c", "--command"])
|
||||
private static let powershellInlineFlags = Set(["-c", "-command", "--command"])
|
||||
|
||||
private static let wrapperSpecs: [WrapperSpec] = [
|
||||
WrapperSpec(kind: .posix, names: ["ash", "sh", "bash", "zsh", "dash", "ksh", "fish"]),
|
||||
WrapperSpec(kind: .cmd, names: ["cmd.exe", "cmd"]),
|
||||
WrapperSpec(kind: .powershell, names: ["powershell", "powershell.exe", "pwsh", "pwsh.exe"]),
|
||||
]
|
||||
|
||||
static func extract(command: [String], rawCommand: String?) -> ParsedShellWrapper {
|
||||
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let preferredRaw = trimmedRaw.isEmpty ? nil : trimmedRaw
|
||||
return self.extract(command: command, preferredRaw: preferredRaw, depth: 0)
|
||||
}
|
||||
|
||||
private static func extract(command: [String], preferredRaw: String?, depth: Int) -> ParsedShellWrapper {
|
||||
guard depth < ExecEnvInvocationUnwrapper.maxWrapperDepth else {
|
||||
return .notWrapper
|
||||
}
|
||||
guard let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else {
|
||||
return .notWrapper
|
||||
}
|
||||
|
||||
let base0 = ExecCommandToken.basenameLower(token0)
|
||||
if base0 == "env" {
|
||||
guard let unwrapped = ExecEnvInvocationUnwrapper.unwrap(command) else {
|
||||
return .notWrapper
|
||||
}
|
||||
return self.extract(command: unwrapped, preferredRaw: preferredRaw, depth: depth + 1)
|
||||
}
|
||||
|
||||
guard let spec = self.wrapperSpecs.first(where: { $0.names.contains(base0) }) else {
|
||||
return .notWrapper
|
||||
}
|
||||
guard let payload = self.extractPayload(command: command, spec: spec) else {
|
||||
return .notWrapper
|
||||
}
|
||||
let normalized = preferredRaw ?? payload
|
||||
return ParsedShellWrapper(isWrapper: true, command: normalized)
|
||||
}
|
||||
|
||||
private static func extractPayload(command: [String], spec: WrapperSpec) -> String? {
|
||||
switch spec.kind {
|
||||
case .posix:
|
||||
return self.extractPosixInlineCommand(command)
|
||||
case .cmd:
|
||||
return self.extractCmdInlineCommand(command)
|
||||
case .powershell:
|
||||
return self.extractPowerShellInlineCommand(command)
|
||||
}
|
||||
}
|
||||
|
||||
private static func extractPosixInlineCommand(_ command: [String]) -> String? {
|
||||
let flag = command.count > 1 ? command[1].trimmingCharacters(in: .whitespacesAndNewlines) : ""
|
||||
guard self.posixInlineFlags.contains(flag.lowercased()) else {
|
||||
return nil
|
||||
}
|
||||
let payload = command.count > 2 ? command[2].trimmingCharacters(in: .whitespacesAndNewlines) : ""
|
||||
return payload.isEmpty ? nil : payload
|
||||
}
|
||||
|
||||
private static func extractCmdInlineCommand(_ command: [String]) -> String? {
|
||||
guard let idx = command.firstIndex(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "/c" }) else {
|
||||
return nil
|
||||
}
|
||||
let tail = command.suffix(from: command.index(after: idx)).joined(separator: " ")
|
||||
let payload = tail.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return payload.isEmpty ? nil : payload
|
||||
}
|
||||
|
||||
private static func extractPowerShellInlineCommand(_ command: [String]) -> String? {
|
||||
for idx in 1..<command.count {
|
||||
let token = command[idx].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if token.isEmpty { continue }
|
||||
if token == "--" { break }
|
||||
if self.powershellInlineFlags.contains(token) {
|
||||
let payload = idx + 1 < command.count
|
||||
? command[idx + 1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
: ""
|
||||
return payload.isEmpty ? nil : payload
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -16,14 +16,31 @@ struct ExecAllowlistTests {
|
||||
let cases: [Case]
|
||||
}
|
||||
|
||||
private struct WrapperResolutionParityFixture: Decodable {
|
||||
struct Case: Decodable {
|
||||
let id: String
|
||||
let argv: [String]
|
||||
let expectedRawExecutable: String?
|
||||
}
|
||||
|
||||
let cases: [Case]
|
||||
}
|
||||
|
||||
private static func loadShellParserParityCases() throws -> [ShellParserParityFixture.Case] {
|
||||
let fixtureURL = self.shellParserParityFixtureURL()
|
||||
let fixtureURL = self.fixtureURL(filename: "exec-allowlist-shell-parser-parity.json")
|
||||
let data = try Data(contentsOf: fixtureURL)
|
||||
let fixture = try JSONDecoder().decode(ShellParserParityFixture.self, from: data)
|
||||
return fixture.cases
|
||||
}
|
||||
|
||||
private static func shellParserParityFixtureURL() -> URL {
|
||||
private static func loadWrapperResolutionParityCases() throws -> [WrapperResolutionParityFixture.Case] {
|
||||
let fixtureURL = self.fixtureURL(filename: "exec-wrapper-resolution-parity.json")
|
||||
let data = try Data(contentsOf: fixtureURL)
|
||||
let fixture = try JSONDecoder().decode(WrapperResolutionParityFixture.self, from: data)
|
||||
return fixture.cases
|
||||
}
|
||||
|
||||
private static func fixtureURL(filename: String) -> URL {
|
||||
var repoRoot = URL(fileURLWithPath: #filePath)
|
||||
for _ in 0..<5 {
|
||||
repoRoot.deleteLastPathComponent()
|
||||
@@ -31,7 +48,7 @@ struct ExecAllowlistTests {
|
||||
return repoRoot
|
||||
.appendingPathComponent("test")
|
||||
.appendingPathComponent("fixtures")
|
||||
.appendingPathComponent("exec-allowlist-shell-parser-parity.json")
|
||||
.appendingPathComponent(filename)
|
||||
}
|
||||
|
||||
@Test func matchUsesResolvedPath() {
|
||||
@@ -160,6 +177,17 @@ struct ExecAllowlistTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test func resolveMatchesSharedWrapperResolutionFixture() throws {
|
||||
let fixtures = try Self.loadWrapperResolutionParityCases()
|
||||
for fixture in fixtures {
|
||||
let resolution = ExecCommandResolution.resolve(
|
||||
command: fixture.argv,
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolution?.rawExecutable == fixture.expectedRawExecutable)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func resolveForAllowlistTreatsPlainShInvocationAsDirectExec() {
|
||||
let command = ["/bin/sh", "./script.sh"]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { splitShellArgs } from "../utils/shell-argv.js";
|
||||
import type { ExecAllowlistEntry } from "./exec-approvals.js";
|
||||
import { unwrapDispatchWrappersForResolution } from "./exec-wrapper-resolution.js";
|
||||
import { expandHomePrefix } from "./home-dir.js";
|
||||
|
||||
export const DEFAULT_SAFE_BINS = ["jq", "cut", "uniq", "head", "tail", "tr", "wc"];
|
||||
@@ -12,106 +13,6 @@ export type CommandResolution = {
|
||||
executableName: string;
|
||||
};
|
||||
|
||||
const ENV_OPTIONS_WITH_VALUE = new Set([
|
||||
"-u",
|
||||
"--unset",
|
||||
"-c",
|
||||
"--chdir",
|
||||
"-s",
|
||||
"--split-string",
|
||||
"--default-signal",
|
||||
"--ignore-signal",
|
||||
"--block-signal",
|
||||
]);
|
||||
const ENV_FLAG_OPTIONS = new Set(["-i", "--ignore-environment", "-0", "--null"]);
|
||||
|
||||
function basenameLower(token: string): string {
|
||||
const win = path.win32.basename(token);
|
||||
const posix = path.posix.basename(token);
|
||||
const base = win.length < posix.length ? win : posix;
|
||||
return base.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isEnvAssignment(token: string): boolean {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token);
|
||||
}
|
||||
|
||||
function unwrapEnvInvocation(argv: string[]): string[] | null {
|
||||
let idx = 1;
|
||||
let expectsOptionValue = false;
|
||||
while (idx < argv.length) {
|
||||
const token = argv[idx]?.trim() ?? "";
|
||||
if (!token) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (expectsOptionValue) {
|
||||
expectsOptionValue = false;
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--" || token === "-") {
|
||||
idx += 1;
|
||||
break;
|
||||
}
|
||||
if (isEnvAssignment(token)) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith("-") && token !== "-") {
|
||||
const lower = token.toLowerCase();
|
||||
const [flag] = lower.split("=", 2);
|
||||
if (ENV_FLAG_OPTIONS.has(flag)) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (ENV_OPTIONS_WITH_VALUE.has(flag)) {
|
||||
if (!lower.includes("=")) {
|
||||
expectsOptionValue = true;
|
||||
}
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
lower.startsWith("-u") ||
|
||||
lower.startsWith("-c") ||
|
||||
lower.startsWith("-s") ||
|
||||
lower.startsWith("--unset=") ||
|
||||
lower.startsWith("--chdir=") ||
|
||||
lower.startsWith("--split-string=") ||
|
||||
lower.startsWith("--default-signal=") ||
|
||||
lower.startsWith("--ignore-signal=") ||
|
||||
lower.startsWith("--block-signal=")
|
||||
) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return idx < argv.length ? argv.slice(idx) : null;
|
||||
}
|
||||
|
||||
function unwrapDispatchWrappersForResolution(argv: string[]): string[] {
|
||||
let current = argv;
|
||||
for (let depth = 0; depth < 4; depth += 1) {
|
||||
const token0 = current[0]?.trim();
|
||||
if (!token0) {
|
||||
break;
|
||||
}
|
||||
if (basenameLower(token0) !== "env") {
|
||||
break;
|
||||
}
|
||||
const unwrapped = unwrapEnvInvocation(current);
|
||||
if (!unwrapped || unwrapped.length === 0) {
|
||||
break;
|
||||
}
|
||||
current = unwrapped;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function isExecutableFile(filePath: string): boolean {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
|
||||
@@ -53,6 +53,16 @@ type ShellParserParityFixture = {
|
||||
cases: ShellParserParityFixtureCase[];
|
||||
};
|
||||
|
||||
type WrapperResolutionParityFixtureCase = {
|
||||
id: string;
|
||||
argv: string[];
|
||||
expectedRawExecutable: string | null;
|
||||
};
|
||||
|
||||
type WrapperResolutionParityFixture = {
|
||||
cases: WrapperResolutionParityFixtureCase[];
|
||||
};
|
||||
|
||||
function loadShellParserParityFixtureCases(): ShellParserParityFixtureCase[] {
|
||||
const fixturePath = path.join(
|
||||
process.cwd(),
|
||||
@@ -64,6 +74,19 @@ function loadShellParserParityFixtureCases(): ShellParserParityFixtureCase[] {
|
||||
return fixture.cases;
|
||||
}
|
||||
|
||||
function loadWrapperResolutionParityFixtureCases(): WrapperResolutionParityFixtureCase[] {
|
||||
const fixturePath = path.join(
|
||||
process.cwd(),
|
||||
"test",
|
||||
"fixtures",
|
||||
"exec-wrapper-resolution-parity.json",
|
||||
);
|
||||
const fixture = JSON.parse(
|
||||
fs.readFileSync(fixturePath, "utf8"),
|
||||
) as WrapperResolutionParityFixture;
|
||||
return fixture.cases;
|
||||
}
|
||||
|
||||
describe("exec approvals allowlist matching", () => {
|
||||
const baseResolution = {
|
||||
rawExecutable: "rg",
|
||||
@@ -447,6 +470,17 @@ describe("exec approvals shell parser parity fixture", () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("exec approvals wrapper resolution parity fixture", () => {
|
||||
const fixtures = loadWrapperResolutionParityFixtureCases();
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
it(`matches wrapper fixture: ${fixture.id}`, () => {
|
||||
const resolution = resolveCommandResolutionFromArgv(fixture.argv);
|
||||
expect(resolution?.rawExecutable ?? null).toBe(fixture.expectedRawExecutable);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("exec approvals shell allowlist (chained commands)", () => {
|
||||
it("evaluates chained command allowlist scenarios", () => {
|
||||
const cases: Array<{
|
||||
|
||||
242
src/infra/exec-wrapper-resolution.ts
Normal file
242
src/infra/exec-wrapper-resolution.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import path from "node:path";
|
||||
|
||||
export const MAX_DISPATCH_WRAPPER_DEPTH = 4;
|
||||
|
||||
export const POSIX_SHELL_WRAPPERS = new Set(["ash", "bash", "dash", "fish", "ksh", "sh", "zsh"]);
|
||||
export const WINDOWS_CMD_WRAPPERS = new Set(["cmd.exe", "cmd"]);
|
||||
export const POWERSHELL_WRAPPERS = new Set(["powershell", "powershell.exe", "pwsh", "pwsh.exe"]);
|
||||
|
||||
const POSIX_INLINE_COMMAND_FLAGS = new Set(["-lc", "-c", "--command"]);
|
||||
const POWERSHELL_INLINE_COMMAND_FLAGS = new Set(["-c", "-command", "--command"]);
|
||||
|
||||
const ENV_OPTIONS_WITH_VALUE = new Set([
|
||||
"-u",
|
||||
"--unset",
|
||||
"-c",
|
||||
"--chdir",
|
||||
"-s",
|
||||
"--split-string",
|
||||
"--default-signal",
|
||||
"--ignore-signal",
|
||||
"--block-signal",
|
||||
]);
|
||||
const ENV_FLAG_OPTIONS = new Set(["-i", "--ignore-environment", "-0", "--null"]);
|
||||
|
||||
type ShellWrapperKind = "posix" | "cmd" | "powershell";
|
||||
|
||||
type ShellWrapperSpec = {
|
||||
kind: ShellWrapperKind;
|
||||
names: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
const SHELL_WRAPPER_SPECS: ReadonlyArray<ShellWrapperSpec> = [
|
||||
{ kind: "posix", names: POSIX_SHELL_WRAPPERS },
|
||||
{ kind: "cmd", names: WINDOWS_CMD_WRAPPERS },
|
||||
{ kind: "powershell", names: POWERSHELL_WRAPPERS },
|
||||
];
|
||||
|
||||
export type ShellWrapperCommand = {
|
||||
isWrapper: boolean;
|
||||
command: string | null;
|
||||
};
|
||||
|
||||
export function basenameLower(token: string): string {
|
||||
const win = path.win32.basename(token);
|
||||
const posix = path.posix.basename(token);
|
||||
const base = win.length < posix.length ? win : posix;
|
||||
return base.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeRawCommand(rawCommand?: string | null): string | null {
|
||||
const trimmed = rawCommand?.trim() ?? "";
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function findShellWrapperSpec(baseExecutable: string): ShellWrapperSpec | null {
|
||||
for (const spec of SHELL_WRAPPER_SPECS) {
|
||||
if (spec.names.has(baseExecutable)) {
|
||||
return spec;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isEnvAssignment(token: string): boolean {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token);
|
||||
}
|
||||
|
||||
export function unwrapEnvInvocation(argv: string[]): string[] | null {
|
||||
let idx = 1;
|
||||
let expectsOptionValue = false;
|
||||
while (idx < argv.length) {
|
||||
const token = argv[idx]?.trim() ?? "";
|
||||
if (!token) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (expectsOptionValue) {
|
||||
expectsOptionValue = false;
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--" || token === "-") {
|
||||
idx += 1;
|
||||
break;
|
||||
}
|
||||
if (isEnvAssignment(token)) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith("-") && token !== "-") {
|
||||
const lower = token.toLowerCase();
|
||||
const [flag] = lower.split("=", 2);
|
||||
if (ENV_FLAG_OPTIONS.has(flag)) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (ENV_OPTIONS_WITH_VALUE.has(flag)) {
|
||||
if (!lower.includes("=")) {
|
||||
expectsOptionValue = true;
|
||||
}
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
lower.startsWith("-u") ||
|
||||
lower.startsWith("-c") ||
|
||||
lower.startsWith("-s") ||
|
||||
lower.startsWith("--unset=") ||
|
||||
lower.startsWith("--chdir=") ||
|
||||
lower.startsWith("--split-string=") ||
|
||||
lower.startsWith("--default-signal=") ||
|
||||
lower.startsWith("--ignore-signal=") ||
|
||||
lower.startsWith("--block-signal=")
|
||||
) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return idx < argv.length ? argv.slice(idx) : null;
|
||||
}
|
||||
|
||||
export function unwrapDispatchWrappersForResolution(
|
||||
argv: string[],
|
||||
maxDepth = MAX_DISPATCH_WRAPPER_DEPTH,
|
||||
): string[] {
|
||||
let current = argv;
|
||||
for (let depth = 0; depth < maxDepth; depth += 1) {
|
||||
const token0 = current[0]?.trim();
|
||||
if (!token0) {
|
||||
break;
|
||||
}
|
||||
if (basenameLower(token0) !== "env") {
|
||||
break;
|
||||
}
|
||||
const unwrapped = unwrapEnvInvocation(current);
|
||||
if (!unwrapped || unwrapped.length === 0) {
|
||||
break;
|
||||
}
|
||||
current = unwrapped;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function extractPosixShellInlineCommand(argv: string[]): string | null {
|
||||
const flag = argv[1]?.trim();
|
||||
if (!flag) {
|
||||
return null;
|
||||
}
|
||||
if (!POSIX_INLINE_COMMAND_FLAGS.has(flag.toLowerCase())) {
|
||||
return null;
|
||||
}
|
||||
const cmd = argv[2]?.trim();
|
||||
return cmd ? cmd : null;
|
||||
}
|
||||
|
||||
function extractCmdInlineCommand(argv: string[]): string | null {
|
||||
const idx = argv.findIndex((item) => item.trim().toLowerCase() === "/c");
|
||||
if (idx === -1) {
|
||||
return null;
|
||||
}
|
||||
const tail = argv.slice(idx + 1);
|
||||
if (tail.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const cmd = tail.join(" ").trim();
|
||||
return cmd.length > 0 ? cmd : null;
|
||||
}
|
||||
|
||||
function extractPowerShellInlineCommand(argv: string[]): string | null {
|
||||
for (let i = 1; i < argv.length; i += 1) {
|
||||
const token = argv[i]?.trim();
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
const lower = token.toLowerCase();
|
||||
if (lower === "--") {
|
||||
break;
|
||||
}
|
||||
if (POWERSHELL_INLINE_COMMAND_FLAGS.has(lower)) {
|
||||
const cmd = argv[i + 1]?.trim();
|
||||
return cmd ? cmd : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractShellWrapperPayload(argv: string[], spec: ShellWrapperSpec): string | null {
|
||||
switch (spec.kind) {
|
||||
case "posix":
|
||||
return extractPosixShellInlineCommand(argv);
|
||||
case "cmd":
|
||||
return extractCmdInlineCommand(argv);
|
||||
case "powershell":
|
||||
return extractPowerShellInlineCommand(argv);
|
||||
}
|
||||
}
|
||||
|
||||
function extractShellWrapperCommandInternal(
|
||||
argv: string[],
|
||||
rawCommand: string | null,
|
||||
depth: number,
|
||||
): ShellWrapperCommand {
|
||||
if (depth >= MAX_DISPATCH_WRAPPER_DEPTH) {
|
||||
return { isWrapper: false, command: null };
|
||||
}
|
||||
|
||||
const token0 = argv[0]?.trim();
|
||||
if (!token0) {
|
||||
return { isWrapper: false, command: null };
|
||||
}
|
||||
|
||||
const base0 = basenameLower(token0);
|
||||
if (base0 === "env") {
|
||||
const unwrapped = unwrapEnvInvocation(argv);
|
||||
if (!unwrapped) {
|
||||
return { isWrapper: false, command: null };
|
||||
}
|
||||
return extractShellWrapperCommandInternal(unwrapped, rawCommand, depth + 1);
|
||||
}
|
||||
|
||||
const wrapper = findShellWrapperSpec(base0);
|
||||
if (!wrapper) {
|
||||
return { isWrapper: false, command: null };
|
||||
}
|
||||
|
||||
const payload = extractShellWrapperPayload(argv, wrapper);
|
||||
if (!payload) {
|
||||
return { isWrapper: false, command: null };
|
||||
}
|
||||
|
||||
return { isWrapper: true, command: rawCommand ?? payload };
|
||||
}
|
||||
|
||||
export function extractShellWrapperCommand(
|
||||
argv: string[],
|
||||
rawCommand?: string | null,
|
||||
): ShellWrapperCommand {
|
||||
return extractShellWrapperCommandInternal(argv, normalizeRawCommand(rawCommand), 0);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import path from "node:path";
|
||||
import { extractShellWrapperCommand } from "./exec-wrapper-resolution.js";
|
||||
|
||||
export type SystemRunCommandValidation =
|
||||
| {
|
||||
@@ -26,163 +26,6 @@ export type ResolvedSystemRunCommand =
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function basenameLower(token: string): string {
|
||||
const win = path.win32.basename(token);
|
||||
const posix = path.posix.basename(token);
|
||||
const base = win.length < posix.length ? win : posix;
|
||||
return base.trim().toLowerCase();
|
||||
}
|
||||
|
||||
const POSIX_SHELL_WRAPPERS = new Set(["ash", "bash", "dash", "fish", "ksh", "sh", "zsh"]);
|
||||
const WINDOWS_CMD_WRAPPERS = new Set(["cmd.exe", "cmd"]);
|
||||
const POWERSHELL_WRAPPERS = new Set(["powershell", "powershell.exe", "pwsh", "pwsh.exe"]);
|
||||
const ENV_OPTIONS_WITH_VALUE = new Set([
|
||||
"-u",
|
||||
"--unset",
|
||||
"-c",
|
||||
"--chdir",
|
||||
"-s",
|
||||
"--split-string",
|
||||
"--default-signal",
|
||||
"--ignore-signal",
|
||||
"--block-signal",
|
||||
]);
|
||||
const ENV_FLAG_OPTIONS = new Set(["-i", "--ignore-environment", "-0", "--null"]);
|
||||
|
||||
function isEnvAssignment(token: string): boolean {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token);
|
||||
}
|
||||
|
||||
function unwrapEnvInvocation(argv: string[]): string[] | null {
|
||||
let idx = 1;
|
||||
let expectsOptionValue = false;
|
||||
while (idx < argv.length) {
|
||||
const token = argv[idx]?.trim() ?? "";
|
||||
if (!token) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (expectsOptionValue) {
|
||||
expectsOptionValue = false;
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--" || token === "-") {
|
||||
idx += 1;
|
||||
break;
|
||||
}
|
||||
if (isEnvAssignment(token)) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith("-") && token !== "-") {
|
||||
const lower = token.toLowerCase();
|
||||
const [flag] = lower.split("=", 2);
|
||||
if (ENV_FLAG_OPTIONS.has(flag)) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (ENV_OPTIONS_WITH_VALUE.has(flag)) {
|
||||
if (!lower.includes("=")) {
|
||||
expectsOptionValue = true;
|
||||
}
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
lower.startsWith("-u") ||
|
||||
lower.startsWith("-c") ||
|
||||
lower.startsWith("-s") ||
|
||||
lower.startsWith("--unset=") ||
|
||||
lower.startsWith("--chdir=") ||
|
||||
lower.startsWith("--split-string=") ||
|
||||
lower.startsWith("--default-signal=") ||
|
||||
lower.startsWith("--ignore-signal=") ||
|
||||
lower.startsWith("--block-signal=")
|
||||
) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return idx < argv.length ? argv.slice(idx) : null;
|
||||
}
|
||||
|
||||
function extractPosixShellInlineCommand(argv: string[]): string | null {
|
||||
const flag = argv[1]?.trim();
|
||||
if (!flag) {
|
||||
return null;
|
||||
}
|
||||
const lower = flag.toLowerCase();
|
||||
if (lower !== "-lc" && lower !== "-c" && lower !== "--command") {
|
||||
return null;
|
||||
}
|
||||
const cmd = argv[2]?.trim();
|
||||
return cmd ? cmd : null;
|
||||
}
|
||||
|
||||
function extractCmdInlineCommand(argv: string[]): string | null {
|
||||
const idx = argv.findIndex((item) => String(item).trim().toLowerCase() === "/c");
|
||||
if (idx === -1) {
|
||||
return null;
|
||||
}
|
||||
const tail = argv.slice(idx + 1).map((item) => String(item));
|
||||
if (tail.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const cmd = tail.join(" ").trim();
|
||||
return cmd.length > 0 ? cmd : null;
|
||||
}
|
||||
|
||||
function extractPowerShellInlineCommand(argv: string[]): string | null {
|
||||
for (let i = 1; i < argv.length; i += 1) {
|
||||
const token = argv[i]?.trim();
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
const lower = token.toLowerCase();
|
||||
if (lower === "--") {
|
||||
break;
|
||||
}
|
||||
if (lower === "-c" || lower === "-command" || lower === "--command") {
|
||||
const cmd = argv[i + 1]?.trim();
|
||||
return cmd ? cmd : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractShellCommandFromArgvInternal(argv: string[], depth: number): string | null {
|
||||
if (depth >= 4) {
|
||||
return null;
|
||||
}
|
||||
const token0 = argv[0]?.trim();
|
||||
if (!token0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const base0 = basenameLower(token0);
|
||||
if (base0 === "env") {
|
||||
const unwrapped = unwrapEnvInvocation(argv);
|
||||
if (!unwrapped) {
|
||||
return null;
|
||||
}
|
||||
return extractShellCommandFromArgvInternal(unwrapped, depth + 1);
|
||||
}
|
||||
if (POSIX_SHELL_WRAPPERS.has(base0)) {
|
||||
return extractPosixShellInlineCommand(argv);
|
||||
}
|
||||
if (WINDOWS_CMD_WRAPPERS.has(base0)) {
|
||||
return extractCmdInlineCommand(argv);
|
||||
}
|
||||
if (POWERSHELL_WRAPPERS.has(base0)) {
|
||||
return extractPowerShellInlineCommand(argv);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatExecCommand(argv: string[]): string {
|
||||
return argv
|
||||
.map((arg) => {
|
||||
@@ -200,7 +43,7 @@ export function formatExecCommand(argv: string[]): string {
|
||||
}
|
||||
|
||||
export function extractShellCommandFromArgv(argv: string[]): string | null {
|
||||
return extractShellCommandFromArgvInternal(argv, 0);
|
||||
return extractShellWrapperCommand(argv).command;
|
||||
}
|
||||
|
||||
export function validateSystemRunCommandConsistency(params: {
|
||||
@@ -211,7 +54,7 @@ export function validateSystemRunCommandConsistency(params: {
|
||||
typeof params.rawCommand === "string" && params.rawCommand.trim().length > 0
|
||||
? params.rawCommand.trim()
|
||||
: null;
|
||||
const shellCommand = extractShellCommandFromArgv(params.argv);
|
||||
const shellCommand = extractShellWrapperCommand(params.argv).command;
|
||||
const inferred = shellCommand !== null ? shellCommand.trim() : formatExecCommand(params.argv);
|
||||
|
||||
if (raw && raw !== inferred) {
|
||||
|
||||
39
test/fixtures/exec-wrapper-resolution-parity.json
vendored
Normal file
39
test/fixtures/exec-wrapper-resolution-parity.json
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "direct-absolute-executable",
|
||||
"argv": ["/usr/bin/printf", "ok"],
|
||||
"expectedRawExecutable": "/usr/bin/printf"
|
||||
},
|
||||
{
|
||||
"id": "env-assignment-prefix",
|
||||
"argv": ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"],
|
||||
"expectedRawExecutable": "/usr/bin/printf"
|
||||
},
|
||||
{
|
||||
"id": "env-option-with-separate-value",
|
||||
"argv": ["/usr/bin/env", "-u", "HOME", "/usr/bin/printf", "ok"],
|
||||
"expectedRawExecutable": "/usr/bin/printf"
|
||||
},
|
||||
{
|
||||
"id": "env-option-with-inline-value",
|
||||
"argv": ["/usr/bin/env", "-uHOME", "/usr/bin/printf", "ok"],
|
||||
"expectedRawExecutable": "/usr/bin/printf"
|
||||
},
|
||||
{
|
||||
"id": "nested-env-wrappers",
|
||||
"argv": ["/usr/bin/env", "/usr/bin/env", "FOO=bar", "printf", "ok"],
|
||||
"expectedRawExecutable": "printf"
|
||||
},
|
||||
{
|
||||
"id": "env-shell-wrapper-stops-at-shell",
|
||||
"argv": ["/usr/bin/env", "bash", "-lc", "echo ok"],
|
||||
"expectedRawExecutable": "bash"
|
||||
},
|
||||
{
|
||||
"id": "env-missing-effective-command",
|
||||
"argv": ["/usr/bin/env", "FOO=bar"],
|
||||
"expectedRawExecutable": "/usr/bin/env"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user