mirror of https://github.com/openclaw/openclaw.git
1002 lines
26 KiB
TypeScript
1002 lines
26 KiB
TypeScript
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 { expandHomePrefix } from "./home-dir.js";
|
|
|
|
export const DEFAULT_SAFE_BINS = ["jq", "cut", "uniq", "head", "tail", "tr", "wc"];
|
|
|
|
export type CommandResolution = {
|
|
rawExecutable: string;
|
|
resolvedPath?: string;
|
|
executableName: string;
|
|
};
|
|
|
|
function isExecutableFile(filePath: string): boolean {
|
|
try {
|
|
const stat = fs.statSync(filePath);
|
|
if (!stat.isFile()) {
|
|
return false;
|
|
}
|
|
if (process.platform !== "win32") {
|
|
fs.accessSync(filePath, fs.constants.X_OK);
|
|
}
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function parseFirstToken(command: string): string | null {
|
|
const trimmed = command.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
const first = trimmed[0];
|
|
if (first === '"' || first === "'") {
|
|
const end = trimmed.indexOf(first, 1);
|
|
if (end > 1) {
|
|
return trimmed.slice(1, end);
|
|
}
|
|
return trimmed.slice(1);
|
|
}
|
|
const match = /^[^\s]+/.exec(trimmed);
|
|
return match ? match[0] : null;
|
|
}
|
|
|
|
function resolveExecutablePath(rawExecutable: string, cwd?: string, env?: NodeJS.ProcessEnv) {
|
|
const expanded = rawExecutable.startsWith("~") ? expandHomePrefix(rawExecutable) : rawExecutable;
|
|
if (expanded.includes("/") || expanded.includes("\\")) {
|
|
if (path.isAbsolute(expanded)) {
|
|
return isExecutableFile(expanded) ? expanded : undefined;
|
|
}
|
|
const base = cwd && cwd.trim() ? cwd.trim() : process.cwd();
|
|
const candidate = path.resolve(base, expanded);
|
|
return isExecutableFile(candidate) ? candidate : undefined;
|
|
}
|
|
const envPath = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? "";
|
|
const entries = envPath.split(path.delimiter).filter(Boolean);
|
|
const hasExtension = process.platform === "win32" && path.extname(expanded).length > 0;
|
|
const extensions =
|
|
process.platform === "win32"
|
|
? hasExtension
|
|
? [""]
|
|
: (
|
|
env?.PATHEXT ??
|
|
env?.Pathext ??
|
|
process.env.PATHEXT ??
|
|
process.env.Pathext ??
|
|
".EXE;.CMD;.BAT;.COM"
|
|
)
|
|
.split(";")
|
|
.map((ext) => ext.toLowerCase())
|
|
: [""];
|
|
for (const entry of entries) {
|
|
for (const ext of extensions) {
|
|
const candidate = path.join(entry, expanded + ext);
|
|
if (isExecutableFile(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function resolveCommandResolution(
|
|
command: string,
|
|
cwd?: string,
|
|
env?: NodeJS.ProcessEnv,
|
|
): CommandResolution | null {
|
|
const rawExecutable = parseFirstToken(command);
|
|
if (!rawExecutable) {
|
|
return null;
|
|
}
|
|
const resolvedPath = resolveExecutablePath(rawExecutable, cwd, env);
|
|
const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable;
|
|
return { rawExecutable, resolvedPath, executableName };
|
|
}
|
|
|
|
export function resolveCommandResolutionFromArgv(
|
|
argv: string[],
|
|
cwd?: string,
|
|
env?: NodeJS.ProcessEnv,
|
|
): CommandResolution | null {
|
|
const rawExecutable = argv[0]?.trim();
|
|
if (!rawExecutable) {
|
|
return null;
|
|
}
|
|
const resolvedPath = resolveExecutablePath(rawExecutable, cwd, env);
|
|
const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable;
|
|
return { rawExecutable, resolvedPath, executableName };
|
|
}
|
|
|
|
function normalizeMatchTarget(value: string): string {
|
|
if (process.platform === "win32") {
|
|
const stripped = value.replace(/^\\\\[?.]\\/, "");
|
|
return stripped.replace(/\\/g, "/").toLowerCase();
|
|
}
|
|
return value.replace(/\\\\/g, "/").toLowerCase();
|
|
}
|
|
|
|
function tryRealpath(value: string): string | null {
|
|
try {
|
|
return fs.realpathSync(value);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function globToRegExp(pattern: string): RegExp {
|
|
let regex = "^";
|
|
let i = 0;
|
|
while (i < pattern.length) {
|
|
const ch = pattern[i];
|
|
if (ch === "*") {
|
|
const next = pattern[i + 1];
|
|
if (next === "*") {
|
|
regex += ".*";
|
|
i += 2;
|
|
continue;
|
|
}
|
|
regex += "[^/]*";
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (ch === "?") {
|
|
regex += ".";
|
|
i += 1;
|
|
continue;
|
|
}
|
|
regex += ch.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&");
|
|
i += 1;
|
|
}
|
|
regex += "$";
|
|
return new RegExp(regex, "i");
|
|
}
|
|
|
|
function matchesPattern(pattern: string, target: string): boolean {
|
|
const trimmed = pattern.trim();
|
|
if (!trimmed) {
|
|
return false;
|
|
}
|
|
const expanded = trimmed.startsWith("~") ? expandHomePrefix(trimmed) : trimmed;
|
|
const hasWildcard = /[*?]/.test(expanded);
|
|
let normalizedPattern = expanded;
|
|
let normalizedTarget = target;
|
|
if (process.platform === "win32" && !hasWildcard) {
|
|
normalizedPattern = tryRealpath(expanded) ?? expanded;
|
|
normalizedTarget = tryRealpath(target) ?? target;
|
|
}
|
|
normalizedPattern = normalizeMatchTarget(normalizedPattern);
|
|
normalizedTarget = normalizeMatchTarget(normalizedTarget);
|
|
const regex = globToRegExp(normalizedPattern);
|
|
return regex.test(normalizedTarget);
|
|
}
|
|
|
|
export function resolveAllowlistCandidatePath(
|
|
resolution: CommandResolution | null,
|
|
cwd?: string,
|
|
): string | undefined {
|
|
if (!resolution) {
|
|
return undefined;
|
|
}
|
|
if (resolution.resolvedPath) {
|
|
return resolution.resolvedPath;
|
|
}
|
|
const raw = resolution.rawExecutable?.trim();
|
|
if (!raw) {
|
|
return undefined;
|
|
}
|
|
const expanded = raw.startsWith("~") ? expandHomePrefix(raw) : raw;
|
|
if (!expanded.includes("/") && !expanded.includes("\\")) {
|
|
return undefined;
|
|
}
|
|
if (path.isAbsolute(expanded)) {
|
|
return expanded;
|
|
}
|
|
const base = cwd && cwd.trim() ? cwd.trim() : process.cwd();
|
|
return path.resolve(base, expanded);
|
|
}
|
|
|
|
export function matchAllowlist(
|
|
entries: ExecAllowlistEntry[],
|
|
resolution: CommandResolution | null,
|
|
): ExecAllowlistEntry | null {
|
|
if (!entries.length || !resolution?.resolvedPath) {
|
|
return null;
|
|
}
|
|
const resolvedPath = resolution.resolvedPath;
|
|
for (const entry of entries) {
|
|
const pattern = entry.pattern?.trim();
|
|
if (!pattern) {
|
|
continue;
|
|
}
|
|
const hasPath = pattern.includes("/") || pattern.includes("\\") || pattern.includes("~");
|
|
if (!hasPath) {
|
|
continue;
|
|
}
|
|
if (matchesPattern(pattern, resolvedPath)) {
|
|
return entry;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export type ExecCommandSegment = {
|
|
raw: string;
|
|
argv: string[];
|
|
resolution: CommandResolution | null;
|
|
};
|
|
|
|
export type ExecArgvToken =
|
|
| {
|
|
kind: "empty";
|
|
raw: string;
|
|
}
|
|
| {
|
|
kind: "terminator";
|
|
raw: string;
|
|
}
|
|
| {
|
|
kind: "stdin";
|
|
raw: string;
|
|
}
|
|
| {
|
|
kind: "positional";
|
|
raw: string;
|
|
}
|
|
| {
|
|
kind: "option";
|
|
raw: string;
|
|
style: "long";
|
|
flag: string;
|
|
inlineValue?: string;
|
|
}
|
|
| {
|
|
kind: "option";
|
|
raw: string;
|
|
style: "short-cluster";
|
|
cluster: string;
|
|
flags: string[];
|
|
};
|
|
|
|
/**
|
|
* Tokenizes a single argv entry into a normalized option/positional model.
|
|
* Consumers can share this model to keep argv parsing behavior consistent.
|
|
*/
|
|
export function parseExecArgvToken(raw: string): ExecArgvToken {
|
|
if (!raw) {
|
|
return { kind: "empty", raw };
|
|
}
|
|
if (raw === "--") {
|
|
return { kind: "terminator", raw };
|
|
}
|
|
if (raw === "-") {
|
|
return { kind: "stdin", raw };
|
|
}
|
|
if (!raw.startsWith("-")) {
|
|
return { kind: "positional", raw };
|
|
}
|
|
if (raw.startsWith("--")) {
|
|
const eqIndex = raw.indexOf("=");
|
|
if (eqIndex > 0) {
|
|
return {
|
|
kind: "option",
|
|
raw,
|
|
style: "long",
|
|
flag: raw.slice(0, eqIndex),
|
|
inlineValue: raw.slice(eqIndex + 1),
|
|
};
|
|
}
|
|
return { kind: "option", raw, style: "long", flag: raw };
|
|
}
|
|
const cluster = raw.slice(1);
|
|
return {
|
|
kind: "option",
|
|
raw,
|
|
style: "short-cluster",
|
|
cluster,
|
|
flags: cluster.split("").map((entry) => `-${entry}`),
|
|
};
|
|
}
|
|
|
|
export type ExecCommandAnalysis = {
|
|
ok: boolean;
|
|
reason?: string;
|
|
segments: ExecCommandSegment[];
|
|
chains?: ExecCommandSegment[][]; // Segments grouped by chain operator (&&, ||, ;)
|
|
};
|
|
|
|
export type ShellChainOperator = "&&" | "||" | ";";
|
|
|
|
export type ShellChainPart = {
|
|
part: string;
|
|
opToNext: ShellChainOperator | null;
|
|
};
|
|
|
|
const DISALLOWED_PIPELINE_TOKENS = new Set([">", "<", "`", "\n", "\r", "(", ")"]);
|
|
const DOUBLE_QUOTE_ESCAPES = new Set(["\\", '"', "$", "`", "\n", "\r"]);
|
|
const WINDOWS_UNSUPPORTED_TOKENS = new Set([
|
|
"&",
|
|
"|",
|
|
"<",
|
|
">",
|
|
"^",
|
|
"(",
|
|
")",
|
|
"%",
|
|
"!",
|
|
"\n",
|
|
"\r",
|
|
]);
|
|
|
|
function isDoubleQuoteEscape(next: string | undefined): next is string {
|
|
return Boolean(next && DOUBLE_QUOTE_ESCAPES.has(next));
|
|
}
|
|
|
|
function splitShellPipeline(command: string): { ok: boolean; reason?: string; segments: string[] } {
|
|
type HeredocSpec = {
|
|
delimiter: string;
|
|
stripTabs: boolean;
|
|
quoted: boolean;
|
|
};
|
|
|
|
const parseHeredocDelimiter = (
|
|
source: string,
|
|
start: number,
|
|
): { delimiter: string; end: number; quoted: boolean } | null => {
|
|
let i = start;
|
|
while (i < source.length && (source[i] === " " || source[i] === "\t")) {
|
|
i += 1;
|
|
}
|
|
if (i >= source.length) {
|
|
return null;
|
|
}
|
|
|
|
const first = source[i];
|
|
if (first === "'" || first === '"') {
|
|
const quote = first;
|
|
i += 1;
|
|
let delimiter = "";
|
|
while (i < source.length) {
|
|
const ch = source[i];
|
|
if (ch === "\n" || ch === "\r") {
|
|
return null;
|
|
}
|
|
if (quote === '"' && ch === "\\" && i + 1 < source.length) {
|
|
delimiter += source[i + 1];
|
|
i += 2;
|
|
continue;
|
|
}
|
|
if (ch === quote) {
|
|
return { delimiter, end: i + 1, quoted: true };
|
|
}
|
|
delimiter += ch;
|
|
i += 1;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
let delimiter = "";
|
|
while (i < source.length) {
|
|
const ch = source[i];
|
|
if (/\s/.test(ch) || ch === "|" || ch === "&" || ch === ";" || ch === "<" || ch === ">") {
|
|
break;
|
|
}
|
|
delimiter += ch;
|
|
i += 1;
|
|
}
|
|
if (!delimiter) {
|
|
return null;
|
|
}
|
|
return { delimiter, end: i, quoted: false };
|
|
};
|
|
|
|
const segments: string[] = [];
|
|
let buf = "";
|
|
let inSingle = false;
|
|
let inDouble = false;
|
|
let escaped = false;
|
|
let emptySegment = false;
|
|
const pendingHeredocs: HeredocSpec[] = [];
|
|
let inHeredocBody = false;
|
|
let heredocLine = "";
|
|
|
|
const pushPart = () => {
|
|
const trimmed = buf.trim();
|
|
if (trimmed) {
|
|
segments.push(trimmed);
|
|
}
|
|
buf = "";
|
|
};
|
|
|
|
const isEscapedInHeredocLine = (line: string, index: number): boolean => {
|
|
let slashes = 0;
|
|
for (let i = index - 1; i >= 0 && line[i] === "\\"; i -= 1) {
|
|
slashes += 1;
|
|
}
|
|
return slashes % 2 === 1;
|
|
};
|
|
|
|
const hasUnquotedHeredocExpansionToken = (line: string): boolean => {
|
|
for (let i = 0; i < line.length; i += 1) {
|
|
const ch = line[i];
|
|
if (ch === "`" && !isEscapedInHeredocLine(line, i)) {
|
|
return true;
|
|
}
|
|
if (ch === "$" && !isEscapedInHeredocLine(line, i)) {
|
|
const next = line[i + 1];
|
|
if (next === "(" || next === "{") {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
for (let i = 0; i < command.length; i += 1) {
|
|
const ch = command[i];
|
|
const next = command[i + 1];
|
|
|
|
if (inHeredocBody) {
|
|
if (ch === "\n" || ch === "\r") {
|
|
const current = pendingHeredocs[0];
|
|
if (current) {
|
|
const line = current.stripTabs ? heredocLine.replace(/^\t+/, "") : heredocLine;
|
|
if (line === current.delimiter) {
|
|
pendingHeredocs.shift();
|
|
} else if (!current.quoted && hasUnquotedHeredocExpansionToken(heredocLine)) {
|
|
return { ok: false, reason: "command substitution in unquoted heredoc", segments: [] };
|
|
}
|
|
}
|
|
heredocLine = "";
|
|
if (pendingHeredocs.length === 0) {
|
|
inHeredocBody = false;
|
|
}
|
|
if (ch === "\r" && next === "\n") {
|
|
i += 1;
|
|
}
|
|
} else {
|
|
heredocLine += ch;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (escaped) {
|
|
buf += ch;
|
|
escaped = false;
|
|
emptySegment = false;
|
|
continue;
|
|
}
|
|
if (!inSingle && !inDouble && ch === "\\") {
|
|
escaped = true;
|
|
buf += ch;
|
|
emptySegment = false;
|
|
continue;
|
|
}
|
|
if (inSingle) {
|
|
if (ch === "'") {
|
|
inSingle = false;
|
|
}
|
|
buf += ch;
|
|
emptySegment = false;
|
|
continue;
|
|
}
|
|
if (inDouble) {
|
|
if (ch === "\\" && isDoubleQuoteEscape(next)) {
|
|
buf += ch;
|
|
buf += next;
|
|
i += 1;
|
|
emptySegment = false;
|
|
continue;
|
|
}
|
|
if (ch === "$" && next === "(") {
|
|
return { ok: false, reason: "unsupported shell token: $()", segments: [] };
|
|
}
|
|
if (ch === "`") {
|
|
return { ok: false, reason: "unsupported shell token: `", segments: [] };
|
|
}
|
|
if (ch === "\n" || ch === "\r") {
|
|
return { ok: false, reason: "unsupported shell token: newline", segments: [] };
|
|
}
|
|
if (ch === '"') {
|
|
inDouble = false;
|
|
}
|
|
buf += ch;
|
|
emptySegment = false;
|
|
continue;
|
|
}
|
|
if (ch === "'") {
|
|
inSingle = true;
|
|
buf += ch;
|
|
emptySegment = false;
|
|
continue;
|
|
}
|
|
if (ch === '"') {
|
|
inDouble = true;
|
|
buf += ch;
|
|
emptySegment = false;
|
|
continue;
|
|
}
|
|
|
|
if ((ch === "\n" || ch === "\r") && pendingHeredocs.length > 0) {
|
|
inHeredocBody = true;
|
|
heredocLine = "";
|
|
if (ch === "\r" && next === "\n") {
|
|
i += 1;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (ch === "|" && next === "|") {
|
|
return { ok: false, reason: "unsupported shell token: ||", segments: [] };
|
|
}
|
|
if (ch === "|" && next === "&") {
|
|
return { ok: false, reason: "unsupported shell token: |&", segments: [] };
|
|
}
|
|
if (ch === "|") {
|
|
emptySegment = true;
|
|
pushPart();
|
|
continue;
|
|
}
|
|
if (ch === "&" || ch === ";") {
|
|
return { ok: false, reason: `unsupported shell token: ${ch}`, segments: [] };
|
|
}
|
|
if (ch === "<" && next === "<") {
|
|
buf += "<<";
|
|
emptySegment = false;
|
|
i += 1;
|
|
|
|
let scanIndex = i + 1;
|
|
let stripTabs = false;
|
|
if (command[scanIndex] === "-") {
|
|
stripTabs = true;
|
|
buf += "-";
|
|
scanIndex += 1;
|
|
}
|
|
|
|
const parsed = parseHeredocDelimiter(command, scanIndex);
|
|
if (parsed) {
|
|
pendingHeredocs.push({ delimiter: parsed.delimiter, stripTabs, quoted: parsed.quoted });
|
|
buf += command.slice(scanIndex, parsed.end);
|
|
i = parsed.end - 1;
|
|
}
|
|
continue;
|
|
}
|
|
if (DISALLOWED_PIPELINE_TOKENS.has(ch)) {
|
|
return { ok: false, reason: `unsupported shell token: ${ch}`, segments: [] };
|
|
}
|
|
if (ch === "$" && next === "(") {
|
|
return { ok: false, reason: "unsupported shell token: $()", segments: [] };
|
|
}
|
|
buf += ch;
|
|
emptySegment = false;
|
|
}
|
|
|
|
if (inHeredocBody && pendingHeredocs.length > 0) {
|
|
const current = pendingHeredocs[0];
|
|
const line = current.stripTabs ? heredocLine.replace(/^\t+/, "") : heredocLine;
|
|
if (line === current.delimiter) {
|
|
pendingHeredocs.shift();
|
|
if (pendingHeredocs.length === 0) {
|
|
inHeredocBody = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (pendingHeredocs.length > 0 || inHeredocBody) {
|
|
return { ok: false, reason: "unterminated heredoc", segments: [] };
|
|
}
|
|
|
|
if (escaped || inSingle || inDouble) {
|
|
return { ok: false, reason: "unterminated shell quote/escape", segments: [] };
|
|
}
|
|
|
|
pushPart();
|
|
if (emptySegment || segments.length === 0) {
|
|
return {
|
|
ok: false,
|
|
reason: segments.length === 0 ? "empty command" : "empty pipeline segment",
|
|
segments: [],
|
|
};
|
|
}
|
|
return { ok: true, segments };
|
|
}
|
|
|
|
function findWindowsUnsupportedToken(command: string): string | null {
|
|
for (const ch of command) {
|
|
if (WINDOWS_UNSUPPORTED_TOKENS.has(ch)) {
|
|
if (ch === "\n" || ch === "\r") {
|
|
return "newline";
|
|
}
|
|
return ch;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function tokenizeWindowsSegment(segment: string): string[] | null {
|
|
const tokens: string[] = [];
|
|
let buf = "";
|
|
let inDouble = false;
|
|
|
|
const pushToken = () => {
|
|
if (buf.length > 0) {
|
|
tokens.push(buf);
|
|
buf = "";
|
|
}
|
|
};
|
|
|
|
for (let i = 0; i < segment.length; i += 1) {
|
|
const ch = segment[i];
|
|
if (ch === '"') {
|
|
inDouble = !inDouble;
|
|
continue;
|
|
}
|
|
if (!inDouble && /\s/.test(ch)) {
|
|
pushToken();
|
|
continue;
|
|
}
|
|
buf += ch;
|
|
}
|
|
|
|
if (inDouble) {
|
|
return null;
|
|
}
|
|
pushToken();
|
|
return tokens.length > 0 ? tokens : null;
|
|
}
|
|
|
|
function analyzeWindowsShellCommand(params: {
|
|
command: string;
|
|
cwd?: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
}): ExecCommandAnalysis {
|
|
const unsupported = findWindowsUnsupportedToken(params.command);
|
|
if (unsupported) {
|
|
return {
|
|
ok: false,
|
|
reason: `unsupported windows shell token: ${unsupported}`,
|
|
segments: [],
|
|
};
|
|
}
|
|
const argv = tokenizeWindowsSegment(params.command);
|
|
if (!argv || argv.length === 0) {
|
|
return { ok: false, reason: "unable to parse windows command", segments: [] };
|
|
}
|
|
return {
|
|
ok: true,
|
|
segments: [
|
|
{
|
|
raw: params.command,
|
|
argv,
|
|
resolution: resolveCommandResolutionFromArgv(argv, params.cwd, params.env),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
export function isWindowsPlatform(platform?: string | null): boolean {
|
|
const normalized = String(platform ?? "")
|
|
.trim()
|
|
.toLowerCase();
|
|
return normalized.startsWith("win");
|
|
}
|
|
|
|
function parseSegmentsFromParts(
|
|
parts: string[],
|
|
cwd?: string,
|
|
env?: NodeJS.ProcessEnv,
|
|
): ExecCommandSegment[] | null {
|
|
const segments: ExecCommandSegment[] = [];
|
|
for (const raw of parts) {
|
|
const argv = splitShellArgs(raw);
|
|
if (!argv || argv.length === 0) {
|
|
return null;
|
|
}
|
|
segments.push({
|
|
raw,
|
|
argv,
|
|
resolution: resolveCommandResolutionFromArgv(argv, cwd, env),
|
|
});
|
|
}
|
|
return segments;
|
|
}
|
|
|
|
/**
|
|
* Splits a command string by chain operators (&&, ||, ;) while preserving the operators.
|
|
* Returns null when no chain is present or when the chain is malformed.
|
|
*/
|
|
export function splitCommandChainWithOperators(command: string): ShellChainPart[] | null {
|
|
const parts: ShellChainPart[] = [];
|
|
let buf = "";
|
|
let inSingle = false;
|
|
let inDouble = false;
|
|
let escaped = false;
|
|
let foundChain = false;
|
|
let invalidChain = false;
|
|
|
|
const pushPart = (opToNext: ShellChainOperator | null) => {
|
|
const trimmed = buf.trim();
|
|
buf = "";
|
|
if (!trimmed) {
|
|
return false;
|
|
}
|
|
parts.push({ part: trimmed, opToNext });
|
|
return true;
|
|
};
|
|
|
|
for (let i = 0; i < command.length; i += 1) {
|
|
const ch = command[i];
|
|
const next = command[i + 1];
|
|
if (escaped) {
|
|
buf += ch;
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
if (!inSingle && !inDouble && ch === "\\") {
|
|
escaped = true;
|
|
buf += ch;
|
|
continue;
|
|
}
|
|
if (inSingle) {
|
|
if (ch === "'") {
|
|
inSingle = false;
|
|
}
|
|
buf += ch;
|
|
continue;
|
|
}
|
|
if (inDouble) {
|
|
if (ch === "\\" && isDoubleQuoteEscape(next)) {
|
|
buf += ch;
|
|
buf += next;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (ch === '"') {
|
|
inDouble = false;
|
|
}
|
|
buf += ch;
|
|
continue;
|
|
}
|
|
if (ch === "'") {
|
|
inSingle = true;
|
|
buf += ch;
|
|
continue;
|
|
}
|
|
if (ch === '"') {
|
|
inDouble = true;
|
|
buf += ch;
|
|
continue;
|
|
}
|
|
|
|
if (ch === "&" && next === "&") {
|
|
if (!pushPart("&&")) {
|
|
invalidChain = true;
|
|
}
|
|
i += 1;
|
|
foundChain = true;
|
|
continue;
|
|
}
|
|
if (ch === "|" && next === "|") {
|
|
if (!pushPart("||")) {
|
|
invalidChain = true;
|
|
}
|
|
i += 1;
|
|
foundChain = true;
|
|
continue;
|
|
}
|
|
if (ch === ";") {
|
|
if (!pushPart(";")) {
|
|
invalidChain = true;
|
|
}
|
|
foundChain = true;
|
|
continue;
|
|
}
|
|
|
|
buf += ch;
|
|
}
|
|
|
|
if (!foundChain) {
|
|
return null;
|
|
}
|
|
const trimmed = buf.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
parts.push({ part: trimmed, opToNext: null });
|
|
if (invalidChain || parts.length === 0) {
|
|
return null;
|
|
}
|
|
return parts;
|
|
}
|
|
|
|
function shellEscapeSingleArg(value: string): string {
|
|
// Shell-safe across sh/bash/zsh: single-quote everything, escape embedded single quotes.
|
|
// Example: foo'bar -> 'foo'"'"'bar'
|
|
const singleQuoteEscape = `'"'"'`;
|
|
return `'${value.replace(/'/g, singleQuoteEscape)}'`;
|
|
}
|
|
|
|
/**
|
|
* Builds a shell command string that preserves pipes/chaining, but forces *arguments* to be
|
|
* literal (no globbing, no env-var expansion) by single-quoting every argv token.
|
|
*
|
|
* Used to make "safe bins" actually stdin-only even though execution happens via `shell -c`.
|
|
*/
|
|
export function buildSafeShellCommand(params: { command: string; platform?: string | null }): {
|
|
ok: boolean;
|
|
command?: string;
|
|
reason?: string;
|
|
} {
|
|
const platform = params.platform ?? null;
|
|
if (isWindowsPlatform(platform)) {
|
|
return { ok: false, reason: "unsupported platform" };
|
|
}
|
|
const source = params.command.trim();
|
|
if (!source) {
|
|
return { ok: false, reason: "empty command" };
|
|
}
|
|
|
|
const chain = splitCommandChainWithOperators(source);
|
|
const chainParts = chain ?? [{ part: source, opToNext: null }];
|
|
let out = "";
|
|
|
|
for (let i = 0; i < chainParts.length; i += 1) {
|
|
const part = chainParts[i];
|
|
const pipelineSplit = splitShellPipeline(part.part);
|
|
if (!pipelineSplit.ok) {
|
|
return { ok: false, reason: pipelineSplit.reason ?? "unable to parse pipeline" };
|
|
}
|
|
const renderedSegments: string[] = [];
|
|
for (const segmentRaw of pipelineSplit.segments) {
|
|
const argv = splitShellArgs(segmentRaw);
|
|
if (!argv || argv.length === 0) {
|
|
return { ok: false, reason: "unable to parse shell segment" };
|
|
}
|
|
renderedSegments.push(argv.map((token) => shellEscapeSingleArg(token)).join(" "));
|
|
}
|
|
out += renderedSegments.join(" | ");
|
|
if (part.opToNext) {
|
|
out += ` ${part.opToNext} `;
|
|
}
|
|
}
|
|
|
|
return { ok: true, command: out };
|
|
}
|
|
|
|
function renderQuotedArgv(argv: string[]): string {
|
|
return argv.map((token) => shellEscapeSingleArg(token)).join(" ");
|
|
}
|
|
|
|
/**
|
|
* Rebuilds a shell command and selectively single-quotes argv tokens for segments that
|
|
* must be treated as literal (safeBins hardening) while preserving the rest of the
|
|
* shell syntax (pipes + chaining).
|
|
*/
|
|
export function buildSafeBinsShellCommand(params: {
|
|
command: string;
|
|
segments: ExecCommandSegment[];
|
|
segmentSatisfiedBy: ("allowlist" | "safeBins" | "skills" | null)[];
|
|
platform?: string | null;
|
|
}): { ok: boolean; command?: string; reason?: string } {
|
|
const platform = params.platform ?? null;
|
|
if (isWindowsPlatform(platform)) {
|
|
return { ok: false, reason: "unsupported platform" };
|
|
}
|
|
if (params.segments.length !== params.segmentSatisfiedBy.length) {
|
|
return { ok: false, reason: "segment metadata mismatch" };
|
|
}
|
|
|
|
const chain = splitCommandChainWithOperators(params.command.trim());
|
|
const chainParts: ShellChainPart[] = chain ?? [{ part: params.command.trim(), opToNext: null }];
|
|
let segIndex = 0;
|
|
let out = "";
|
|
|
|
for (const part of chainParts) {
|
|
const pipelineSplit = splitShellPipeline(part.part);
|
|
if (!pipelineSplit.ok) {
|
|
return { ok: false, reason: pipelineSplit.reason ?? "unable to parse pipeline" };
|
|
}
|
|
|
|
const rendered: string[] = [];
|
|
for (const raw of pipelineSplit.segments) {
|
|
const seg = params.segments[segIndex];
|
|
const by = params.segmentSatisfiedBy[segIndex];
|
|
if (!seg || by === undefined) {
|
|
return { ok: false, reason: "segment mapping failed" };
|
|
}
|
|
const needsLiteral = by === "safeBins";
|
|
rendered.push(needsLiteral ? renderQuotedArgv(seg.argv) : raw.trim());
|
|
segIndex += 1;
|
|
}
|
|
|
|
out += rendered.join(" | ");
|
|
if (part.opToNext) {
|
|
out += ` ${part.opToNext} `;
|
|
}
|
|
}
|
|
|
|
if (segIndex !== params.segments.length) {
|
|
return { ok: false, reason: "segment count mismatch" };
|
|
}
|
|
|
|
return { ok: true, command: out };
|
|
}
|
|
|
|
/**
|
|
* Splits a command string by chain operators (&&, ||, ;) while respecting quotes.
|
|
* Returns null when no chain is present or when the chain is malformed.
|
|
*/
|
|
export function splitCommandChain(command: string): string[] | null {
|
|
const parts = splitCommandChainWithOperators(command);
|
|
if (!parts) {
|
|
return null;
|
|
}
|
|
return parts.map((p) => p.part);
|
|
}
|
|
|
|
export function analyzeShellCommand(params: {
|
|
command: string;
|
|
cwd?: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
platform?: string | null;
|
|
}): ExecCommandAnalysis {
|
|
if (isWindowsPlatform(params.platform)) {
|
|
return analyzeWindowsShellCommand(params);
|
|
}
|
|
// First try splitting by chain operators (&&, ||, ;)
|
|
const chainParts = splitCommandChain(params.command);
|
|
if (chainParts) {
|
|
const chains: ExecCommandSegment[][] = [];
|
|
const allSegments: ExecCommandSegment[] = [];
|
|
|
|
for (const part of chainParts) {
|
|
const pipelineSplit = splitShellPipeline(part);
|
|
if (!pipelineSplit.ok) {
|
|
return { ok: false, reason: pipelineSplit.reason, segments: [] };
|
|
}
|
|
const segments = parseSegmentsFromParts(pipelineSplit.segments, params.cwd, params.env);
|
|
if (!segments) {
|
|
return { ok: false, reason: "unable to parse shell segment", segments: [] };
|
|
}
|
|
chains.push(segments);
|
|
allSegments.push(...segments);
|
|
}
|
|
|
|
return { ok: true, segments: allSegments, chains };
|
|
}
|
|
|
|
// No chain operators, parse as simple pipeline
|
|
const split = splitShellPipeline(params.command);
|
|
if (!split.ok) {
|
|
return { ok: false, reason: split.reason, segments: [] };
|
|
}
|
|
const segments = parseSegmentsFromParts(split.segments, params.cwd, params.env);
|
|
if (!segments) {
|
|
return { ok: false, reason: "unable to parse shell segment", segments: [] };
|
|
}
|
|
return { ok: true, segments };
|
|
}
|
|
|
|
export function analyzeArgvCommand(params: {
|
|
argv: string[];
|
|
cwd?: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
}): ExecCommandAnalysis {
|
|
const argv = params.argv.filter((entry) => entry.trim().length > 0);
|
|
if (argv.length === 0) {
|
|
return { ok: false, reason: "empty argv", segments: [] };
|
|
}
|
|
return {
|
|
ok: true,
|
|
segments: [
|
|
{
|
|
raw: argv.join(" "),
|
|
argv,
|
|
resolution: resolveCommandResolutionFromArgv(argv, params.cwd, params.env),
|
|
},
|
|
],
|
|
};
|
|
}
|