refactor(exec): split command resolution and trusted-dir normalization
This commit is contained in:
@@ -6,7 +6,6 @@ import { isDeepStrictEqual } from "node:util";
|
||||
import JSON5 from "json5";
|
||||
import { ensureOwnerDisplaySecret } from "../agents/owner-display.js";
|
||||
import { loadDotEnv } from "../infra/dotenv.js";
|
||||
import { normalizeSafeBinProfileFixtures } from "../infra/exec-safe-bin-policy.js";
|
||||
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
|
||||
import {
|
||||
loadShellEnvFallback,
|
||||
@@ -37,6 +36,7 @@ import { applyConfigEnvVars } from "./env-vars.js";
|
||||
import { ConfigIncludeError, resolveConfigIncludes } from "./includes.js";
|
||||
import { findLegacyConfigIssues } from "./legacy.js";
|
||||
import { applyMergePatch } from "./merge-patch.js";
|
||||
import { normalizeExecSafeBinProfilesInConfig } from "./normalize-exec-safe-bin.js";
|
||||
import { normalizeConfigPaths } from "./normalize-paths.js";
|
||||
import { resolveConfigPath, resolveDefaultConfigCandidates, resolveStateDir } from "./paths.js";
|
||||
import { applyConfigOverrides } from "./runtime-overrides.js";
|
||||
@@ -556,45 +556,6 @@ function maybeLoadDotEnvForConfig(env: NodeJS.ProcessEnv): void {
|
||||
loadDotEnv({ quiet: true });
|
||||
}
|
||||
|
||||
function normalizeExecSafeBinProfilesInConfig(cfg: OpenClawConfig): void {
|
||||
const normalizeTrustedDirs = (entries?: readonly string[]) => {
|
||||
if (!Array.isArray(entries)) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = entries.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
||||
return normalized.length > 0 ? Array.from(new Set(normalized)) : undefined;
|
||||
};
|
||||
|
||||
const normalizeExec = (exec: unknown) => {
|
||||
if (!exec || typeof exec !== "object" || Array.isArray(exec)) {
|
||||
return;
|
||||
}
|
||||
const typedExec = exec as {
|
||||
safeBinProfiles?: Record<string, unknown>;
|
||||
safeBinTrustedDirs?: string[];
|
||||
};
|
||||
const normalized = normalizeSafeBinProfileFixtures(
|
||||
typedExec.safeBinProfiles as Record<
|
||||
string,
|
||||
{
|
||||
minPositional?: number;
|
||||
maxPositional?: number;
|
||||
allowedValueFlags?: readonly string[];
|
||||
deniedFlags?: readonly string[];
|
||||
}
|
||||
>,
|
||||
);
|
||||
typedExec.safeBinProfiles = Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||
typedExec.safeBinTrustedDirs = normalizeTrustedDirs(typedExec.safeBinTrustedDirs);
|
||||
};
|
||||
|
||||
normalizeExec(cfg.tools?.exec);
|
||||
const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
|
||||
for (const agent of agents) {
|
||||
normalizeExec(agent?.tools?.exec);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseConfigJson5(
|
||||
raw: string,
|
||||
json5: { parse: (value: string) => unknown } = JSON5,
|
||||
|
||||
37
src/config/normalize-exec-safe-bin.ts
Normal file
37
src/config/normalize-exec-safe-bin.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { normalizeSafeBinProfileFixtures } from "../infra/exec-safe-bin-policy.js";
|
||||
import { normalizeTrustedSafeBinDirs } from "../infra/exec-safe-bin-trust.js";
|
||||
import type { OpenClawConfig } from "./types.js";
|
||||
|
||||
export function normalizeExecSafeBinProfilesInConfig(cfg: OpenClawConfig): void {
|
||||
const normalizeExec = (exec: unknown) => {
|
||||
if (!exec || typeof exec !== "object" || Array.isArray(exec)) {
|
||||
return;
|
||||
}
|
||||
const typedExec = exec as {
|
||||
safeBinProfiles?: Record<string, unknown>;
|
||||
safeBinTrustedDirs?: string[];
|
||||
};
|
||||
const normalizedProfiles = normalizeSafeBinProfileFixtures(
|
||||
typedExec.safeBinProfiles as Record<
|
||||
string,
|
||||
{
|
||||
minPositional?: number;
|
||||
maxPositional?: number;
|
||||
allowedValueFlags?: readonly string[];
|
||||
deniedFlags?: readonly string[];
|
||||
}
|
||||
>,
|
||||
);
|
||||
typedExec.safeBinProfiles =
|
||||
Object.keys(normalizedProfiles).length > 0 ? normalizedProfiles : undefined;
|
||||
const normalizedTrustedDirs = normalizeTrustedSafeBinDirs(typedExec.safeBinTrustedDirs);
|
||||
typedExec.safeBinTrustedDirs =
|
||||
normalizedTrustedDirs.length > 0 ? normalizedTrustedDirs : undefined;
|
||||
};
|
||||
|
||||
normalizeExec(cfg.tools?.exec);
|
||||
const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
|
||||
for (const agent of agents) {
|
||||
normalizeExec(agent?.tools?.exec);
|
||||
}
|
||||
}
|
||||
@@ -1,228 +1,19 @@
|
||||
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";
|
||||
import {
|
||||
resolveCommandResolutionFromArgv,
|
||||
type CommandResolution,
|
||||
} from "./exec-command-resolution.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 effectiveArgv = unwrapDispatchWrappersForResolution(argv);
|
||||
const rawExecutable = effectiveArgv[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 {
|
||||
DEFAULT_SAFE_BINS,
|
||||
matchAllowlist,
|
||||
parseExecArgvToken,
|
||||
resolveAllowlistCandidatePath,
|
||||
resolveCommandResolution,
|
||||
resolveCommandResolutionFromArgv,
|
||||
type CommandResolution,
|
||||
type ExecArgvToken,
|
||||
} from "./exec-command-resolution.js";
|
||||
|
||||
export type ExecCommandSegment = {
|
||||
raw: string;
|
||||
@@ -230,78 +21,6 @@ export type ExecCommandSegment = {
|
||||
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;
|
||||
@@ -831,6 +550,50 @@ function shellEscapeSingleArg(value: string): string {
|
||||
return `'${value.replace(/'/g, singleQuoteEscape)}'`;
|
||||
}
|
||||
|
||||
type ShellSegmentRenderResult = { ok: true; rendered: string } | { ok: false; reason: string };
|
||||
|
||||
function rebuildShellCommandFromSource(params: {
|
||||
command: string;
|
||||
platform?: string | null;
|
||||
renderSegment: (rawSegment: string, segmentIndex: number) => ShellSegmentRenderResult;
|
||||
}): { ok: boolean; command?: string; reason?: string; segmentCount?: number } {
|
||||
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: ShellChainPart[] = chain ?? [{ part: source, opToNext: null }];
|
||||
let segmentCount = 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 renderedSegments: string[] = [];
|
||||
for (const segmentRaw of pipelineSplit.segments) {
|
||||
const rendered = params.renderSegment(segmentRaw, segmentCount);
|
||||
if (!rendered.ok) {
|
||||
return { ok: false, reason: rendered.reason };
|
||||
}
|
||||
renderedSegments.push(rendered.rendered);
|
||||
segmentCount += 1;
|
||||
}
|
||||
out += renderedSegments.join(" | ");
|
||||
if (part.opToNext) {
|
||||
out += ` ${part.opToNext} `;
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, command: out, segmentCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -842,40 +605,21 @@ export function buildSafeShellCommand(params: { command: string; platform?: stri
|
||||
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 rebuilt = rebuildShellCommandFromSource({
|
||||
command: params.command,
|
||||
platform: params.platform,
|
||||
renderSegment: (segmentRaw) => {
|
||||
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, rendered: argv.map((token) => shellEscapeSingleArg(token)).join(" ") };
|
||||
},
|
||||
});
|
||||
if (!rebuilt.ok) {
|
||||
return { ok: false, reason: rebuilt.reason };
|
||||
}
|
||||
|
||||
return { ok: true, command: out };
|
||||
return { ok: true, command: rebuilt.command };
|
||||
}
|
||||
|
||||
function renderQuotedArgv(argv: string[]): string {
|
||||
@@ -902,48 +646,29 @@ export function buildSafeBinsShellCommand(params: {
|
||||
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];
|
||||
const rebuilt = rebuildShellCommandFromSource({
|
||||
command: params.command,
|
||||
platform: params.platform,
|
||||
renderSegment: (raw, segmentIndex) => {
|
||||
const seg = params.segments[segmentIndex];
|
||||
const by = params.segmentSatisfiedBy[segmentIndex];
|
||||
if (!seg || by === undefined) {
|
||||
return { ok: false, reason: "segment mapping failed" };
|
||||
}
|
||||
const needsLiteral = by === "safeBins";
|
||||
rendered.push(needsLiteral ? renderSafeBinSegmentArgv(seg) : raw.trim());
|
||||
segIndex += 1;
|
||||
}
|
||||
|
||||
out += rendered.join(" | ");
|
||||
if (part.opToNext) {
|
||||
out += ` ${part.opToNext} `;
|
||||
}
|
||||
return { ok: true, rendered: needsLiteral ? renderSafeBinSegmentArgv(seg) : raw.trim() };
|
||||
},
|
||||
});
|
||||
if (!rebuilt.ok) {
|
||||
return { ok: false, reason: rebuilt.reason };
|
||||
}
|
||||
|
||||
if (segIndex !== params.segments.length) {
|
||||
if (rebuilt.segmentCount !== params.segments.length) {
|
||||
return { ok: false, reason: "segment count mismatch" };
|
||||
}
|
||||
|
||||
return { ok: true, command: out };
|
||||
return { ok: true, command: rebuilt.command };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
296
src/infra/exec-command-resolution.ts
Normal file
296
src/infra/exec-command-resolution.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
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"];
|
||||
|
||||
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 effectiveArgv = unwrapDispatchWrappersForResolution(argv);
|
||||
const rawExecutable = effectiveArgv[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 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}`),
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type SafeBinProfileFixture,
|
||||
type SafeBinProfileFixtures,
|
||||
} from "./exec-safe-bin-policy.js";
|
||||
import { getTrustedSafeBinDirs } from "./exec-safe-bin-trust.js";
|
||||
import { getTrustedSafeBinDirs, normalizeTrustedSafeBinDirs } from "./exec-safe-bin-trust.js";
|
||||
|
||||
export type ExecSafeBinConfigScope = {
|
||||
safeBins?: string[] | null;
|
||||
@@ -79,14 +79,6 @@ export function listInterpreterLikeSafeBins(entries: Iterable<string>): string[]
|
||||
.toSorted();
|
||||
}
|
||||
|
||||
function normalizeTrustedDirs(entries?: string[] | null): string[] {
|
||||
if (!Array.isArray(entries)) {
|
||||
return [];
|
||||
}
|
||||
const normalized = entries.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
||||
return Array.from(new Set(normalized));
|
||||
}
|
||||
|
||||
export function resolveMergedSafeBinProfileFixtures(params: {
|
||||
global?: ExecSafeBinConfigScope | null;
|
||||
local?: ExecSafeBinConfigScope | null;
|
||||
@@ -124,8 +116,8 @@ export function resolveExecSafeBinRuntimePolicy(params: {
|
||||
.toSorted();
|
||||
const trustedSafeBinDirs = getTrustedSafeBinDirs({
|
||||
extraDirs: [
|
||||
...normalizeTrustedDirs(params.global?.safeBinTrustedDirs),
|
||||
...normalizeTrustedDirs(params.local?.safeBinTrustedDirs),
|
||||
...normalizeTrustedSafeBinDirs(params.global?.safeBinTrustedDirs),
|
||||
...normalizeTrustedSafeBinDirs(params.local?.safeBinTrustedDirs),
|
||||
],
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -35,27 +35,35 @@ function normalizeTrustedDir(value: string): string | null {
|
||||
return path.resolve(trimmed);
|
||||
}
|
||||
|
||||
function buildTrustedSafeBinCacheKey(params: {
|
||||
baseDirs: readonly string[];
|
||||
extraDirs: readonly string[];
|
||||
}): string {
|
||||
return `${params.baseDirs.join("\u0001")}\u0000${params.extraDirs.join("\u0001")}`;
|
||||
export function normalizeTrustedSafeBinDirs(entries?: readonly string[] | null): string[] {
|
||||
if (!Array.isArray(entries)) {
|
||||
return [];
|
||||
}
|
||||
const normalized = entries.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
||||
return Array.from(new Set(normalized));
|
||||
}
|
||||
|
||||
function resolveTrustedSafeBinDirs(entries: readonly string[]): string[] {
|
||||
const resolved = entries
|
||||
.map((entry) => normalizeTrustedDir(entry))
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
return Array.from(new Set(resolved)).toSorted();
|
||||
}
|
||||
|
||||
function buildTrustedSafeBinCacheKey(entries: readonly string[]): string {
|
||||
return resolveTrustedSafeBinDirs(normalizeTrustedSafeBinDirs(entries)).join("\u0001");
|
||||
}
|
||||
|
||||
export function buildTrustedSafeBinDirs(params: TrustedSafeBinDirsParams = {}): Set<string> {
|
||||
const baseDirs = params.baseDirs ?? DEFAULT_SAFE_BIN_TRUSTED_DIRS;
|
||||
const extraDirs = params.extraDirs ?? [];
|
||||
const trusted = new Set<string>();
|
||||
|
||||
// Trust is explicit only. Do not derive from PATH, which is user/environment controlled.
|
||||
for (const entry of [...baseDirs, ...extraDirs]) {
|
||||
const normalized = normalizeTrustedDir(entry);
|
||||
if (normalized) {
|
||||
trusted.add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
return trusted;
|
||||
return new Set(
|
||||
resolveTrustedSafeBinDirs([
|
||||
...normalizeTrustedSafeBinDirs(baseDirs),
|
||||
...normalizeTrustedSafeBinDirs(extraDirs),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function getTrustedSafeBinDirs(
|
||||
@@ -67,7 +75,7 @@ export function getTrustedSafeBinDirs(
|
||||
): Set<string> {
|
||||
const baseDirs = params.baseDirs ?? DEFAULT_SAFE_BIN_TRUSTED_DIRS;
|
||||
const extraDirs = params.extraDirs ?? [];
|
||||
const key = buildTrustedSafeBinCacheKey({ baseDirs, extraDirs });
|
||||
const key = buildTrustedSafeBinCacheKey([...baseDirs, ...extraDirs]);
|
||||
|
||||
if (!params.refresh && trustedSafeBinCache?.key === key) {
|
||||
return trustedSafeBinCache.dirs;
|
||||
|
||||
Reference in New Issue
Block a user