Files
openclaw/src/infra/exec-command-resolution.ts
2026-03-02 01:12:53 +00:00

343 lines
8.9 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import type { ExecAllowlistEntry } from "./exec-approvals.js";
import { resolveDispatchWrapperExecutionPlan } from "./exec-wrapper-resolution.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;
resolvedRealPath?: string;
executableName: string;
effectiveArgv?: string[];
wrapperChain?: string[];
policyBlocked?: boolean;
blockedWrapper?: 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;
}
function tryResolveRealpath(filePath: string | undefined): string | undefined {
if (!filePath) {
return undefined;
}
try {
return fs.realpathSync(filePath);
} catch {
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 resolvedRealPath = tryResolveRealpath(resolvedPath);
const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable;
return {
rawExecutable,
resolvedPath,
resolvedRealPath,
executableName,
effectiveArgv: [rawExecutable],
wrapperChain: [],
policyBlocked: false,
};
}
export function resolveCommandResolutionFromArgv(
argv: string[],
cwd?: string,
env?: NodeJS.ProcessEnv,
): CommandResolution | null {
const plan = resolveDispatchWrapperExecutionPlan(argv);
const effectiveArgv = plan.argv;
const rawExecutable = effectiveArgv[0]?.trim();
if (!rawExecutable) {
return null;
}
const resolvedPath = resolveExecutablePath(rawExecutable, cwd, env);
const resolvedRealPath = tryResolveRealpath(resolvedPath);
const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable;
return {
rawExecutable,
resolvedPath,
resolvedRealPath,
executableName,
effectiveArgv,
wrapperChain: plan.wrappers,
policyBlocked: plan.policyBlocked,
blockedWrapper: plan.blockedWrapper,
};
}
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) {
return null;
}
// A bare "*" wildcard allows any parsed executable command.
// Check it before the resolvedPath guard so unresolved PATH lookups still
// match (for example platform-specific executables without known extensions).
const bareWild = entries.find((e) => e.pattern?.trim() === "*");
if (bareWild && resolution) {
return bareWild;
}
if (!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 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}`),
};
}