refactor(exec): split command resolution and trusted-dir normalization

This commit is contained in:
Peter Steinberger
2026-02-22 22:59:53 +01:00
parent 70cac824b1
commit 862975507a
6 changed files with 442 additions and 423 deletions

View File

@@ -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,

View 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);
}
}

View File

@@ -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 };
}
/**

View 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}`),
};
}

View File

@@ -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 {

View File

@@ -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;