refactor: unify exec wrapper resolution and parity fixtures

This commit is contained in:
Peter Steinberger
2026-02-22 10:26:06 +01:00
parent f4dd0577b0
commit a96d89f343
9 changed files with 566 additions and 425 deletions

View File

@@ -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

View 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
}
}

View 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
}
}

View File

@@ -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(

View File

@@ -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);

View File

@@ -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<{

View 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);
}

View File

@@ -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) {

View 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"
}
]
}