diff --git a/src/config/io.ts b/src/config/io.ts index 697ed641c..fba3be8d6 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -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; - 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, diff --git a/src/config/normalize-exec-safe-bin.ts b/src/config/normalize-exec-safe-bin.ts new file mode 100644 index 000000000..f9bb9f52c --- /dev/null +++ b/src/config/normalize-exec-safe-bin.ts @@ -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; + 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); + } +} diff --git a/src/infra/exec-approvals-analysis.ts b/src/infra/exec-approvals-analysis.ts index 0f335acbc..9b187977c 100644 --- a/src/infra/exec-approvals-analysis.ts +++ b/src/infra/exec-approvals-analysis.ts @@ -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 }; } /** diff --git a/src/infra/exec-command-resolution.ts b/src/infra/exec-command-resolution.ts new file mode 100644 index 000000000..5a6b3fc75 --- /dev/null +++ b/src/infra/exec-command-resolution.ts @@ -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}`), + }; +} diff --git a/src/infra/exec-safe-bin-runtime-policy.ts b/src/infra/exec-safe-bin-runtime-policy.ts index 40e8b0997..a6f71d16f 100644 --- a/src/infra/exec-safe-bin-runtime-policy.ts +++ b/src/infra/exec-safe-bin-runtime-policy.ts @@ -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[] .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 { diff --git a/src/infra/exec-safe-bin-trust.ts b/src/infra/exec-safe-bin-trust.ts index c76991577..9edfb16a4 100644 --- a/src/infra/exec-safe-bin-trust.ts +++ b/src/infra/exec-safe-bin-trust.ts @@ -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 { const baseDirs = params.baseDirs ?? DEFAULT_SAFE_BIN_TRUSTED_DIRS; const extraDirs = params.extraDirs ?? []; - const trusted = new Set(); - // 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 { 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;