From a96d89f3438357f07f6668340c6a2d53cc39d176 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 10:26:06 +0100 Subject: [PATCH] refactor: unify exec wrapper resolution and parity fixtures --- .../OpenClaw/ExecCommandResolution.swift | 164 +----------- .../OpenClaw/ExecEnvInvocationUnwrapper.swift | 108 ++++++++ .../OpenClaw/ExecShellWrapperParser.swift | 106 ++++++++ .../OpenClawIPCTests/ExecAllowlistTests.swift | 34 ++- src/infra/exec-approvals-analysis.ts | 101 +------- src/infra/exec-approvals.test.ts | 34 +++ src/infra/exec-wrapper-resolution.ts | 242 ++++++++++++++++++ src/infra/system-run-command.ts | 163 +----------- .../exec-wrapper-resolution-parity.json | 39 +++ 9 files changed, 566 insertions(+), 425 deletions(-) create mode 100644 apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift create mode 100644 apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift create mode 100644 src/infra/exec-wrapper-resolution.ts create mode 100644 test/fixtures/exec-wrapper-resolution-parity.json diff --git a/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift b/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift index fc77509b97a..843062b2470 100644 --- a/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift +++ b/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift @@ -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.. 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 diff --git a/apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift b/apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift new file mode 100644 index 00000000000..ebb8965e755 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift @@ -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 + } +} diff --git a/apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift b/apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift new file mode 100644 index 00000000000..ca6a934adb5 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift @@ -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 + } + + 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.. [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( diff --git a/src/infra/exec-approvals-analysis.ts b/src/infra/exec-approvals-analysis.ts index 5914ea1b37b..c851c70702b 100644 --- a/src/infra/exec-approvals-analysis.ts +++ b/src/infra/exec-approvals-analysis.ts @@ -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); diff --git a/src/infra/exec-approvals.test.ts b/src/infra/exec-approvals.test.ts index 9a8cdc19d8b..bd2c0db3fa0 100644 --- a/src/infra/exec-approvals.test.ts +++ b/src/infra/exec-approvals.test.ts @@ -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<{ diff --git a/src/infra/exec-wrapper-resolution.ts b/src/infra/exec-wrapper-resolution.ts new file mode 100644 index 00000000000..05593cf4e4c --- /dev/null +++ b/src/infra/exec-wrapper-resolution.ts @@ -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; +}; + +const SHELL_WRAPPER_SPECS: ReadonlyArray = [ + { 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); +} diff --git a/src/infra/system-run-command.ts b/src/infra/system-run-command.ts index a8b7c3050ee..9436836a9d7 100644 --- a/src/infra/system-run-command.ts +++ b/src/infra/system-run-command.ts @@ -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; }; -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) { diff --git a/test/fixtures/exec-wrapper-resolution-parity.json b/test/fixtures/exec-wrapper-resolution-parity.json new file mode 100644 index 00000000000..096f91763b1 --- /dev/null +++ b/test/fixtures/exec-wrapper-resolution-parity.json @@ -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" + } + ] +}