import path from "node:path"; import { POSIX_INLINE_COMMAND_FLAGS, POWERSHELL_INLINE_COMMAND_FLAGS, resolveInlineCommandMatch, } from "./shell-inline-command.js"; export const MAX_DISPATCH_WRAPPER_DEPTH = 4; const WINDOWS_EXE_SUFFIX = ".exe"; const POSIX_SHELL_WRAPPER_NAMES = ["ash", "bash", "dash", "fish", "ksh", "sh", "zsh"] as const; const WINDOWS_CMD_WRAPPER_NAMES = ["cmd"] as const; const POWERSHELL_WRAPPER_NAMES = ["powershell", "pwsh"] as const; const SHELL_MULTIPLEXER_WRAPPER_NAMES = ["busybox", "toybox"] as const; const DISPATCH_WRAPPER_NAMES = [ "chrt", "doas", "env", "ionice", "nice", "nohup", "setsid", "stdbuf", "sudo", "taskset", "timeout", ] as const; function withWindowsExeAliases(names: readonly string[]): string[] { const expanded = new Set(); for (const name of names) { expanded.add(name); expanded.add(`${name}${WINDOWS_EXE_SUFFIX}`); } return Array.from(expanded); } function stripWindowsExeSuffix(value: string): string { return value.endsWith(WINDOWS_EXE_SUFFIX) ? value.slice(0, -WINDOWS_EXE_SUFFIX.length) : value; } export const POSIX_SHELL_WRAPPERS = new Set(POSIX_SHELL_WRAPPER_NAMES); export const WINDOWS_CMD_WRAPPERS = new Set(withWindowsExeAliases(WINDOWS_CMD_WRAPPER_NAMES)); export const POWERSHELL_WRAPPERS = new Set(withWindowsExeAliases(POWERSHELL_WRAPPER_NAMES)); export const DISPATCH_WRAPPER_EXECUTABLES = new Set(withWindowsExeAliases(DISPATCH_WRAPPER_NAMES)); const POSIX_SHELL_WRAPPER_CANONICAL = new Set(POSIX_SHELL_WRAPPER_NAMES); const WINDOWS_CMD_WRAPPER_CANONICAL = new Set(WINDOWS_CMD_WRAPPER_NAMES); const POWERSHELL_WRAPPER_CANONICAL = new Set(POWERSHELL_WRAPPER_NAMES); const SHELL_MULTIPLEXER_WRAPPER_CANONICAL = new Set(SHELL_MULTIPLEXER_WRAPPER_NAMES); const DISPATCH_WRAPPER_CANONICAL = new Set(DISPATCH_WRAPPER_NAMES); const SHELL_WRAPPER_CANONICAL = new Set([ ...POSIX_SHELL_WRAPPER_NAMES, ...WINDOWS_CMD_WRAPPER_NAMES, ...POWERSHELL_WRAPPER_NAMES, ]); const ENV_OPTIONS_WITH_VALUE = new Set([ "-u", "--unset", "-c", "--chdir", "-s", "--split-string", "--default-signal", "--ignore-signal", "--block-signal", ]); const ENV_INLINE_VALUE_PREFIXES = [ "-u", "-c", "-s", "--unset=", "--chdir=", "--split-string=", "--default-signal=", "--ignore-signal=", "--block-signal=", ] as const; const ENV_FLAG_OPTIONS = new Set(["-i", "--ignore-environment", "-0", "--null"]); const NICE_OPTIONS_WITH_VALUE = new Set(["-n", "--adjustment", "--priority"]); const STDBUF_OPTIONS_WITH_VALUE = new Set(["-i", "--input", "-o", "--output", "-e", "--error"]); const TIMEOUT_FLAG_OPTIONS = new Set(["--foreground", "--preserve-status", "-v", "--verbose"]); const TIMEOUT_OPTIONS_WITH_VALUE = new Set(["-k", "--kill-after", "-s", "--signal"]); const TRANSPARENT_DISPATCH_WRAPPERS = new Set(["nice", "nohup", "stdbuf", "timeout"]); type ShellWrapperKind = "posix" | "cmd" | "powershell"; type ShellWrapperSpec = { kind: ShellWrapperKind; names: ReadonlySet; }; const SHELL_WRAPPER_SPECS: ReadonlyArray = [ { kind: "posix", names: POSIX_SHELL_WRAPPER_CANONICAL }, { kind: "cmd", names: WINDOWS_CMD_WRAPPER_CANONICAL }, { kind: "powershell", names: POWERSHELL_WRAPPER_CANONICAL }, ]; 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(); } export function normalizeExecutableToken(token: string): string { return stripWindowsExeSuffix(basenameLower(token)); } export function isDispatchWrapperExecutable(token: string): boolean { return DISPATCH_WRAPPER_CANONICAL.has(normalizeExecutableToken(token)); } export function isShellWrapperExecutable(token: string): boolean { return SHELL_WRAPPER_CANONICAL.has(normalizeExecutableToken(token)); } function normalizeRawCommand(rawCommand?: string | null): string | null { const trimmed = rawCommand?.trim() ?? ""; return trimmed.length > 0 ? trimmed : null; } function findShellWrapperSpec(baseExecutable: string): ShellWrapperSpec | null { const canonicalBase = stripWindowsExeSuffix(baseExecutable); for (const spec of SHELL_WRAPPER_SPECS) { if (spec.names.has(canonicalBase)) { return spec; } } return null; } export type ShellMultiplexerUnwrapResult = | { kind: "not-wrapper" } | { kind: "blocked"; wrapper: string } | { kind: "unwrapped"; wrapper: string; argv: string[] }; export function unwrapKnownShellMultiplexerInvocation( argv: string[], ): ShellMultiplexerUnwrapResult { const token0 = argv[0]?.trim(); if (!token0) { return { kind: "not-wrapper" }; } const wrapper = normalizeExecutableToken(token0); if (!SHELL_MULTIPLEXER_WRAPPER_CANONICAL.has(wrapper)) { return { kind: "not-wrapper" }; } let appletIndex = 1; if (argv[appletIndex]?.trim() === "--") { appletIndex += 1; } const applet = argv[appletIndex]?.trim(); if (!applet || !isShellWrapperExecutable(applet)) { return { kind: "blocked", wrapper }; } const unwrapped = argv.slice(appletIndex); if (unwrapped.length === 0) { return { kind: "blocked", wrapper }; } return { kind: "unwrapped", wrapper, argv: unwrapped }; } export function isEnvAssignment(token: string): boolean { return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token); } function hasEnvInlineValuePrefix(lower: string): boolean { for (const prefix of ENV_INLINE_VALUE_PREFIXES) { if (lower.startsWith(prefix)) { return true; } } return false; } type WrapperScanDirective = "continue" | "consume-next" | "stop" | "invalid"; function scanWrapperInvocation( argv: string[], params: { separators?: ReadonlySet; onToken: (token: string, lowerToken: string) => WrapperScanDirective; adjustCommandIndex?: (commandIndex: number, argv: string[]) => number | null; }, ): 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 (params.separators?.has(token)) { idx += 1; break; } const directive = params.onToken(token, token.toLowerCase()); if (directive === "stop") { break; } if (directive === "invalid") { return null; } if (directive === "consume-next") { expectsOptionValue = true; } idx += 1; } if (expectsOptionValue) { return null; } const commandIndex = params.adjustCommandIndex ? params.adjustCommandIndex(idx, argv) : idx; if (commandIndex === null || commandIndex >= argv.length) { return null; } return argv.slice(commandIndex); } export function unwrapEnvInvocation(argv: string[]): string[] | null { return scanWrapperInvocation(argv, { separators: new Set(["--", "-"]), onToken: (token, lower) => { if (isEnvAssignment(token)) { return "continue"; } if (!token.startsWith("-") || token === "-") { return "stop"; } const [flag] = lower.split("=", 2); if (ENV_FLAG_OPTIONS.has(flag)) { return "continue"; } if (ENV_OPTIONS_WITH_VALUE.has(flag)) { return lower.includes("=") ? "continue" : "consume-next"; } if (hasEnvInlineValuePrefix(lower)) { return "continue"; } return "invalid"; }, }); } function envInvocationUsesModifiers(argv: string[]): boolean { let idx = 1; let expectsOptionValue = false; while (idx < argv.length) { const token = argv[idx]?.trim() ?? ""; if (!token) { idx += 1; continue; } if (expectsOptionValue) { return true; } if (token === "--" || token === "-") { idx += 1; break; } if (isEnvAssignment(token)) { return true; } if (!token.startsWith("-") || token === "-") { break; } const lower = token.toLowerCase(); const [flag] = lower.split("=", 2); if (ENV_FLAG_OPTIONS.has(flag)) { return true; } if (ENV_OPTIONS_WITH_VALUE.has(flag)) { if (lower.includes("=")) { return true; } expectsOptionValue = true; idx += 1; continue; } if (hasEnvInlineValuePrefix(lower)) { return true; } // Unknown env flags are treated conservatively as modifiers. return true; } return false; } function unwrapNiceInvocation(argv: string[]): string[] | null { return unwrapDashOptionInvocation(argv, { onFlag: (flag, lower) => { if (/^-\d+$/.test(lower)) { return "continue"; } if (NICE_OPTIONS_WITH_VALUE.has(flag)) { return lower.includes("=") || lower !== flag ? "continue" : "consume-next"; } if (lower.startsWith("-n") && lower.length > 2) { return "continue"; } return "invalid"; }, }); } function unwrapNohupInvocation(argv: string[]): string[] | null { return scanWrapperInvocation(argv, { separators: new Set(["--"]), onToken: (token, lower) => { if (!token.startsWith("-") || token === "-") { return "stop"; } return lower === "--help" || lower === "--version" ? "continue" : "invalid"; }, }); } function unwrapDashOptionInvocation( argv: string[], params: { onFlag: (flag: string, lowerToken: string) => WrapperScanDirective; adjustCommandIndex?: (commandIndex: number, argv: string[]) => number | null; }, ): string[] | null { return scanWrapperInvocation(argv, { separators: new Set(["--"]), onToken: (token, lower) => { if (!token.startsWith("-") || token === "-") { return "stop"; } const [flag] = lower.split("=", 2); return params.onFlag(flag, lower); }, adjustCommandIndex: params.adjustCommandIndex, }); } function unwrapStdbufInvocation(argv: string[]): string[] | null { return unwrapDashOptionInvocation(argv, { onFlag: (flag, lower) => { if (!STDBUF_OPTIONS_WITH_VALUE.has(flag)) { return "invalid"; } return lower.includes("=") ? "continue" : "consume-next"; }, }); } function unwrapTimeoutInvocation(argv: string[]): string[] | null { return unwrapDashOptionInvocation(argv, { onFlag: (flag, lower) => { if (TIMEOUT_FLAG_OPTIONS.has(flag)) { return "continue"; } if (TIMEOUT_OPTIONS_WITH_VALUE.has(flag)) { return lower.includes("=") ? "continue" : "consume-next"; } return "invalid"; }, adjustCommandIndex: (commandIndex, currentArgv) => { // timeout consumes a required duration token before the wrapped command. const wrappedCommandIndex = commandIndex + 1; return wrappedCommandIndex < currentArgv.length ? wrappedCommandIndex : null; }, }); } export type DispatchWrapperUnwrapResult = | { kind: "not-wrapper" } | { kind: "blocked"; wrapper: string } | { kind: "unwrapped"; wrapper: string; argv: string[] }; export type DispatchWrapperExecutionPlan = { argv: string[]; wrappers: string[]; policyBlocked: boolean; blockedWrapper?: string; }; function blockDispatchWrapper(wrapper: string): DispatchWrapperUnwrapResult { return { kind: "blocked", wrapper }; } function unwrapDispatchWrapper( wrapper: string, unwrapped: string[] | null, ): DispatchWrapperUnwrapResult { return unwrapped ? { kind: "unwrapped", wrapper, argv: unwrapped } : blockDispatchWrapper(wrapper); } export function unwrapKnownDispatchWrapperInvocation(argv: string[]): DispatchWrapperUnwrapResult { const token0 = argv[0]?.trim(); if (!token0) { return { kind: "not-wrapper" }; } const wrapper = normalizeExecutableToken(token0); switch (wrapper) { case "env": return unwrapDispatchWrapper(wrapper, unwrapEnvInvocation(argv)); case "nice": return unwrapDispatchWrapper(wrapper, unwrapNiceInvocation(argv)); case "nohup": return unwrapDispatchWrapper(wrapper, unwrapNohupInvocation(argv)); case "stdbuf": return unwrapDispatchWrapper(wrapper, unwrapStdbufInvocation(argv)); case "timeout": return unwrapDispatchWrapper(wrapper, unwrapTimeoutInvocation(argv)); case "chrt": case "doas": case "ionice": case "setsid": case "sudo": case "taskset": return blockDispatchWrapper(wrapper); default: return { kind: "not-wrapper" }; } } export function unwrapDispatchWrappersForResolution( argv: string[], maxDepth = MAX_DISPATCH_WRAPPER_DEPTH, ): string[] { const plan = resolveDispatchWrapperExecutionPlan(argv, maxDepth); return plan.argv; } function isSemanticDispatchWrapperUsage(wrapper: string, argv: string[]): boolean { if (wrapper === "env") { return envInvocationUsesModifiers(argv); } return !TRANSPARENT_DISPATCH_WRAPPERS.has(wrapper); } function blockedDispatchWrapperPlan(params: { argv: string[]; wrappers: string[]; blockedWrapper: string; }): DispatchWrapperExecutionPlan { return { argv: params.argv, wrappers: params.wrappers, policyBlocked: true, blockedWrapper: params.blockedWrapper, }; } export function resolveDispatchWrapperExecutionPlan( argv: string[], maxDepth = MAX_DISPATCH_WRAPPER_DEPTH, ): DispatchWrapperExecutionPlan { let current = argv; const wrappers: string[] = []; for (let depth = 0; depth < maxDepth; depth += 1) { const unwrap = unwrapKnownDispatchWrapperInvocation(current); if (unwrap.kind === "blocked") { return blockedDispatchWrapperPlan({ argv: current, wrappers, blockedWrapper: unwrap.wrapper, }); } if (unwrap.kind !== "unwrapped" || unwrap.argv.length === 0) { break; } wrappers.push(unwrap.wrapper); if (isSemanticDispatchWrapperUsage(unwrap.wrapper, current)) { return blockedDispatchWrapperPlan({ argv: current, wrappers, blockedWrapper: unwrap.wrapper, }); } current = unwrap.argv; } if (wrappers.length >= maxDepth) { const overflow = unwrapKnownDispatchWrapperInvocation(current); if (overflow.kind === "blocked" || overflow.kind === "unwrapped") { return blockedDispatchWrapperPlan({ argv: current, wrappers, blockedWrapper: overflow.wrapper, }); } } return { argv: current, wrappers, policyBlocked: false }; } function hasEnvManipulationBeforeShellWrapperInternal( argv: string[], depth: number, envManipulationSeen: boolean, ): boolean { if (depth >= MAX_DISPATCH_WRAPPER_DEPTH) { return false; } const token0 = argv[0]?.trim(); if (!token0) { return false; } const dispatchUnwrap = unwrapKnownDispatchWrapperInvocation(argv); if (dispatchUnwrap.kind === "blocked") { return false; } if (dispatchUnwrap.kind === "unwrapped") { const nextEnvManipulationSeen = envManipulationSeen || (dispatchUnwrap.wrapper === "env" && envInvocationUsesModifiers(argv)); return hasEnvManipulationBeforeShellWrapperInternal( dispatchUnwrap.argv, depth + 1, nextEnvManipulationSeen, ); } const shellMultiplexerUnwrap = unwrapKnownShellMultiplexerInvocation(argv); if (shellMultiplexerUnwrap.kind === "blocked") { return false; } if (shellMultiplexerUnwrap.kind === "unwrapped") { return hasEnvManipulationBeforeShellWrapperInternal( shellMultiplexerUnwrap.argv, depth + 1, envManipulationSeen, ); } const wrapper = findShellWrapperSpec(normalizeExecutableToken(token0)); if (!wrapper) { return false; } const payload = extractShellWrapperPayload(argv, wrapper); if (!payload) { return false; } return envManipulationSeen; } export function hasEnvManipulationBeforeShellWrapper(argv: string[]): boolean { return hasEnvManipulationBeforeShellWrapperInternal(argv, 0, false); } function extractPosixShellInlineCommand(argv: string[]): string | null { return extractInlineCommandByFlags(argv, POSIX_INLINE_COMMAND_FLAGS, { allowCombinedC: true }); } function extractCmdInlineCommand(argv: string[]): string | null { const idx = argv.findIndex((item) => { const token = item.trim().toLowerCase(); return token === "/c" || token === "/k"; }); 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 { return extractInlineCommandByFlags(argv, POWERSHELL_INLINE_COMMAND_FLAGS); } function extractInlineCommandByFlags( argv: string[], flags: ReadonlySet, options: { allowCombinedC?: boolean } = {}, ): string | null { return resolveInlineCommandMatch(argv, flags, options).command; } 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 dispatchUnwrap = unwrapKnownDispatchWrapperInvocation(argv); if (dispatchUnwrap.kind === "blocked") { return { isWrapper: false, command: null }; } if (dispatchUnwrap.kind === "unwrapped") { return extractShellWrapperCommandInternal(dispatchUnwrap.argv, rawCommand, depth + 1); } const shellMultiplexerUnwrap = unwrapKnownShellMultiplexerInvocation(argv); if (shellMultiplexerUnwrap.kind === "blocked") { return { isWrapper: false, command: null }; } if (shellMultiplexerUnwrap.kind === "unwrapped") { return extractShellWrapperCommandInternal(shellMultiplexerUnwrap.argv, rawCommand, depth + 1); } const base0 = normalizeExecutableToken(token0); 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 extractShellWrapperInlineCommand(argv: string[]): string | null { const extracted = extractShellWrapperCommandInternal(argv, null, 0); return extracted.isWrapper ? extracted.command : null; } export function extractShellWrapperCommand( argv: string[], rawCommand?: string | null, ): ShellWrapperCommand { return extractShellWrapperCommandInternal(argv, normalizeRawCommand(rawCommand), 0); }