mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
343 lines
8.9 KiB
TypeScript
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}`),
|
|
};
|
|
}
|