From 64b273a71cf0b2f2419c974832cede1fc2158729 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 22:42:29 +0100 Subject: [PATCH] fix(exec): harden safe-bin trust and add explicit trusted dirs --- CHANGELOG.md | 1 + docs/tools/exec-approvals.md | 5 +- docs/tools/exec.md | 2 + src/agents/bash-tools.exec-types.ts | 1 + src/agents/bash-tools.exec.ts | 1 + src/agents/pi-tools.ts | 2 + src/config/io.compat.test.ts | 6 ++- src/config/io.ts | 14 +++++- src/config/schema.help.ts | 2 + src/config/schema.labels.ts | 1 + src/config/types.tools.ts | 2 + src/config/zod-schema.agent-runtime.ts | 1 + src/infra/exec-approvals-analysis.ts | 11 ++++- src/infra/exec-approvals.test.ts | 28 ++++++++++- .../exec-safe-bin-runtime-policy.test.ts | 14 ++++++ src/infra/exec-safe-bin-runtime-policy.ts | 19 ++++++-- src/infra/exec-safe-bin-trust.test.ts | 20 +++----- src/infra/exec-safe-bin-trust.ts | 48 +++++++------------ 18 files changed, 123 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2206bf353f0..5e55306dc22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai - Agents/Workspace guard: map sandbox container-workdir file-tool paths (for example `/workspace/...` and `file:///workspace/...`) to host workspace roots before workspace-only validation, preventing false `Path escapes sandbox root` rejections for sandbox file tools. (#9560) - Gateway/Exec approvals: expire approval requests immediately when no approval-capable gateway clients are connected and no forwarding targets are available, avoiding delayed approvals after restarts/offline approver windows. (#22144) - Node/macOS exec host: default headless macOS node `system.run` to local execution and only route through the companion app when `OPENCLAW_NODE_EXEC_HOST=app` is explicitly set, avoiding companion-app filesystem namespace mismatches during exec. (#23547) +- Security/Exec: stop trusting `PATH`-derived directories for safe-bin allowlist checks, add explicit `tools.exec.safeBinTrustedDirs`, and pin safe-bin shell execution to resolved absolute executable paths to prevent binary-shadowing approval bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. - Slack/Threading: sessions: keep parent-session forking and thread-history context active beyond first turn by removing first-turn-only gates in session init, thread-history fetch, and reply prompt context injection. (#23843, #23090) Thanks @vincentkoc and @Taskle. - Slack/Threading: respect `replyToMode` when Slack auto-populates top-level `thread_ts`, and ignore inline `replyToId` directive tags when `replyToMode` is `off` so thread forcing stays disabled unless explicitly configured. (#23839, #23320, #23513) Thanks @vincentkoc and @dorukardahan. - Slack/Extension: forward `message read` `threadId` to `readMessages` and use delivery-context `threadId` as outbound `thread_ts` fallback so extension replies/reads stay in the correct Slack thread. (#22216, #22485, #23836) Thanks @vincentkoc, @lan17 and @dorukardahan. diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index 8d34522770f..5cc8f697b83 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -148,8 +148,8 @@ Denied flags by safe-bin profile: Safe bins also force argv tokens to be treated as **literal text** at execution time (no globbing and no `$VARS` expansion) for stdin-only segments, so patterns like `*` or `$HOME/...` cannot be used to smuggle file reads. -Safe bins must also resolve from trusted binary directories (system defaults plus the gateway -process `PATH` at startup). This blocks request-scoped PATH hijacking attempts. +Safe bins must also resolve from trusted binary directories (system defaults plus optional +`tools.exec.safeBinTrustedDirs`). `PATH` entries are never auto-trusted. Shell chaining and redirections are not auto-allowed in allowlist mode. Shell chaining (`&&`, `||`, `;`) is allowed when every top-level segment satisfies the allowlist @@ -182,6 +182,7 @@ rejected so file operands cannot be smuggled as ambiguous positionals. Configuration location: - `safeBins` comes from config (`tools.exec.safeBins` or per-agent `agents.list[].tools.exec.safeBins`). +- `safeBinTrustedDirs` comes from config (`tools.exec.safeBinTrustedDirs` or per-agent `agents.list[].tools.exec.safeBinTrustedDirs`). - `safeBinProfiles` comes from config (`tools.exec.safeBinProfiles` or per-agent `agents.list[].tools.exec.safeBinProfiles`). Per-agent profile keys override global keys. - allowlist entries live in host-local `~/.openclaw/exec-approvals.json` under `agents..allowlist` (or via Control UI / `openclaw approvals allowlist ...`). - `openclaw security audit` warns with `tools.exec.safe_bins_interpreter_unprofiled` when interpreter/runtime bins appear in `safeBins` without explicit profiles. diff --git a/docs/tools/exec.md b/docs/tools/exec.md index 47842a7bb4c..181a7610432 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -55,6 +55,7 @@ Notes: - `tools.exec.node` (default: unset) - `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs (gateway + sandbox only). - `tools.exec.safeBins`: stdin-only safe binaries that can run without explicit allowlist entries. For behavior details, see [Safe bins](/tools/exec-approvals#safe-bins-stdin-only). +- `tools.exec.safeBinTrustedDirs`: additional explicit directories trusted for `safeBins` path checks. `PATH` entries are never auto-trusted. - `tools.exec.safeBinProfiles`: optional custom argv policy per safe bin (`minPositional`, `maxPositional`, `allowedValueFlags`, `deniedFlags`). Example: @@ -130,6 +131,7 @@ Redirections remain unsupported. Use the two controls for different jobs: - `tools.exec.safeBins`: small, stdin-only stream filters. +- `tools.exec.safeBinTrustedDirs`: explicit extra trusted directories for safe-bin executable paths. - `tools.exec.safeBinProfiles`: explicit argv policy for custom safe bins. - allowlist: explicit trust for executable paths. diff --git a/src/agents/bash-tools.exec-types.ts b/src/agents/bash-tools.exec-types.ts index b6947de79bf..24227a134c4 100644 --- a/src/agents/bash-tools.exec-types.ts +++ b/src/agents/bash-tools.exec-types.ts @@ -9,6 +9,7 @@ export type ExecToolDefaults = { node?: string; pathPrepend?: string[]; safeBins?: string[]; + safeBinTrustedDirs?: string[]; safeBinProfiles?: Record; agentId?: string; backgroundMs?: number; diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 3776cce960c..6b41db5fe4f 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -172,6 +172,7 @@ export function createExecTool( } = resolveExecSafeBinRuntimePolicy({ local: { safeBins: defaults?.safeBins, + safeBinTrustedDirs: defaults?.safeBinTrustedDirs, safeBinProfiles: defaults?.safeBinProfiles, }, }); diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index a338236c74a..4edc9d42355 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -106,6 +106,7 @@ function resolveExecConfig(params: { cfg?: OpenClawConfig; agentId?: string }) { node: agentExec?.node ?? globalExec?.node, pathPrepend: agentExec?.pathPrepend ?? globalExec?.pathPrepend, safeBins: agentExec?.safeBins ?? globalExec?.safeBins, + safeBinTrustedDirs: agentExec?.safeBinTrustedDirs ?? globalExec?.safeBinTrustedDirs, safeBinProfiles: resolveMergedSafeBinProfileFixtures({ global: globalExec, local: agentExec, @@ -373,6 +374,7 @@ export function createOpenClawCodingTools(options?: { node: options?.exec?.node ?? execConfig.node, pathPrepend: options?.exec?.pathPrepend ?? execConfig.pathPrepend, safeBins: options?.exec?.safeBins ?? execConfig.safeBins, + safeBinTrustedDirs: options?.exec?.safeBinTrustedDirs ?? execConfig.safeBinTrustedDirs, safeBinProfiles: options?.exec?.safeBinProfiles ?? execConfig.safeBinProfiles, agentId, cwd: workspaceRoot, diff --git a/src/config/io.compat.test.ts b/src/config/io.compat.test.ts index 6ac794b19b0..dbdfee7280c 100644 --- a/src/config/io.compat.test.ts +++ b/src/config/io.compat.test.ts @@ -78,7 +78,7 @@ describe("config io paths", () => { }); }); - it("normalizes safeBinProfiles at config load time", async () => { + it("normalizes safe-bin config entries at config load time", async () => { await withTempHome(async (home) => { const configDir = path.join(home, ".openclaw"); await fs.mkdir(configDir, { recursive: true }); @@ -89,6 +89,7 @@ describe("config io paths", () => { { tools: { exec: { + safeBinTrustedDirs: [" /custom/bin ", "", "/custom/bin", "/agent/bin"], safeBinProfiles: { " MyFilter ": { allowedValueFlags: ["--limit", " --limit ", ""], @@ -102,6 +103,7 @@ describe("config io paths", () => { id: "ops", tools: { exec: { + safeBinTrustedDirs: [" /ops/bin ", "/ops/bin"], safeBinProfiles: { " Custom ": { deniedFlags: ["-f", " -f ", ""], @@ -126,11 +128,13 @@ describe("config io paths", () => { allowedValueFlags: ["--limit"], }, }); + expect(cfg.tools?.exec?.safeBinTrustedDirs).toEqual(["/custom/bin", "/agent/bin"]); expect(cfg.agents?.list?.[0]?.tools?.exec?.safeBinProfiles).toEqual({ custom: { deniedFlags: ["-f"], }, }); + expect(cfg.agents?.list?.[0]?.tools?.exec?.safeBinTrustedDirs).toEqual(["/ops/bin"]); }); }); }); diff --git a/src/config/io.ts b/src/config/io.ts index 2a41883f7ea..697ed641c40 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -557,11 +557,22 @@ function maybeLoadDotEnvForConfig(env: NodeJS.ProcessEnv): void { } 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 }; + const typedExec = exec as { + safeBinProfiles?: Record; + safeBinTrustedDirs?: string[]; + }; const normalized = normalizeSafeBinProfileFixtures( typedExec.safeBinProfiles as Record< string, @@ -574,6 +585,7 @@ function normalizeExecSafeBinProfilesInConfig(cfg: OpenClawConfig): void { >, ); typedExec.safeBinProfiles = Object.keys(normalized).length > 0 ? normalized : undefined; + typedExec.safeBinTrustedDirs = normalizeTrustedDirs(typedExec.safeBinTrustedDirs); }; normalizeExec(cfg.tools?.exec); diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 825d34710a8..8fd195c8f3e 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -418,6 +418,8 @@ export const FIELD_HELP: Record = { "tools.exec.pathPrepend": "Directories to prepend to PATH for exec runs (gateway/sandbox).", "tools.exec.safeBins": "Allow stdin-only safe binaries to run without explicit allowlist entries.", + "tools.exec.safeBinTrustedDirs": + "Additional explicit directories trusted for safe-bin path checks (PATH entries are never auto-trusted).", "tools.exec.safeBinProfiles": "Optional per-binary safe-bin profiles (positional limits + allowed/denied flags).", "tools.profile": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 479448ad584..0f85a61d0b9 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -183,6 +183,7 @@ export const FIELD_LABELS: Record = { "tools.sandbox.tools": "Sandbox Tool Allow/Deny Policy", "tools.exec.pathPrepend": "Exec PATH Prepend", "tools.exec.safeBins": "Exec Safe Bins", + "tools.exec.safeBinTrustedDirs": "Exec Safe Bin Trusted Dirs", "tools.exec.safeBinProfiles": "Exec Safe Bin Profiles", approvals: "Approvals", "approvals.exec": "Exec Approval Forwarding", diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index 53014d530ea..84f124d2e78 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -227,6 +227,8 @@ export type ExecToolConfig = { pathPrepend?: string[]; /** Safe stdin-only binaries that can run without allowlist entries. */ safeBins?: string[]; + /** Extra explicit directories trusted for safeBins path checks (never derived from PATH). */ + safeBinTrustedDirs?: string[]; /** Optional custom safe-bin profiles for entries in tools.exec.safeBins. */ safeBinProfiles?: Record; /** Default time (ms) before an exec command auto-backgrounds. */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 4cd90203766..43a2e0ef96d 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -353,6 +353,7 @@ const ToolExecBaseShape = { node: z.string().optional(), pathPrepend: z.array(z.string()).optional(), safeBins: z.array(z.string()).optional(), + safeBinTrustedDirs: z.array(z.string()).optional(), safeBinProfiles: z.record(z.string(), ToolExecSafeBinProfileSchema).optional(), backgroundMs: z.number().int().positive().optional(), timeoutSec: z.number().int().positive().optional(), diff --git a/src/infra/exec-approvals-analysis.ts b/src/infra/exec-approvals-analysis.ts index eab60799704..0f335acbc86 100644 --- a/src/infra/exec-approvals-analysis.ts +++ b/src/infra/exec-approvals-analysis.ts @@ -882,6 +882,15 @@ function renderQuotedArgv(argv: string[]): string { return argv.map((token) => shellEscapeSingleArg(token)).join(" "); } +function renderSafeBinSegmentArgv(segment: ExecCommandSegment): string { + if (segment.argv.length === 0) { + return ""; + } + const resolvedExecutable = segment.resolution?.resolvedPath?.trim(); + const argv = resolvedExecutable ? [resolvedExecutable, ...segment.argv.slice(1)] : segment.argv; + return renderQuotedArgv(argv); +} + /** * Rebuilds a shell command and selectively single-quotes argv tokens for segments that * must be treated as literal (safeBins hardening) while preserving the rest of the @@ -920,7 +929,7 @@ export function buildSafeBinsShellCommand(params: { return { ok: false, reason: "segment mapping failed" }; } const needsLiteral = by === "safeBins"; - rendered.push(needsLiteral ? renderQuotedArgv(seg.argv) : raw.trim()); + rendered.push(needsLiteral ? renderSafeBinSegmentArgv(seg) : raw.trim()); segIndex += 1; } diff --git a/src/infra/exec-approvals.test.ts b/src/infra/exec-approvals.test.ts index 3b52da2a084..7f508426f74 100644 --- a/src/infra/exec-approvals.test.ts +++ b/src/infra/exec-approvals.test.ts @@ -195,8 +195,8 @@ describe("exec approvals safe shell command builder", () => { expect(res.ok).toBe(true); // Preserve non-safeBins segment raw (glob stays unquoted) expect(res.command).toContain("rg foo src/*.ts"); - // SafeBins segment is fully quoted - expect(res.command).toContain("'head' '-n' '5'"); + // SafeBins segment is fully quoted and pinned to its resolved absolute path. + expect(res.command).toMatch(/'[^']*\/head' '-n' '5'/); }); }); @@ -936,6 +936,30 @@ describe("exec approvals safe bins", () => { }); expect(allowed.allowlistSatisfied).toBe(true); }); + + it("does not auto-trust PATH-shadowed safe bins without explicit trusted dirs", () => { + if (process.platform === "win32") { + return; + } + const tmp = makeTempDir(); + const fakeDir = path.join(tmp, "fake-bin"); + fs.mkdirSync(fakeDir, { recursive: true }); + const fakeHead = path.join(fakeDir, "head"); + fs.writeFileSync(fakeHead, "#!/bin/sh\nexit 0\n"); + fs.chmodSync(fakeHead, 0o755); + + const result = evaluateShellAllowlist({ + command: "head -n 1", + allowlist: [], + safeBins: normalizeSafeBins(["head"]), + env: makePathEnv(fakeDir), + cwd: tmp, + }); + expect(result.analysisOk).toBe(true); + expect(result.allowlistSatisfied).toBe(false); + expect(result.segmentSatisfiedBy).toEqual([null]); + expect(result.segments[0]?.resolution?.resolvedPath).toBe(fakeHead); + }); }); describe("exec approvals allowlist evaluation", () => { diff --git a/src/infra/exec-safe-bin-runtime-policy.test.ts b/src/infra/exec-safe-bin-runtime-policy.test.ts index ef7f4504915..51846c79473 100644 --- a/src/infra/exec-safe-bin-runtime-policy.test.ts +++ b/src/infra/exec-safe-bin-runtime-policy.test.ts @@ -70,4 +70,18 @@ describe("exec safe-bin runtime policy", () => { expect(policy.unprofiledSafeBins).toEqual(["python3"]); expect(policy.unprofiledInterpreterSafeBins).toEqual(["python3"]); }); + + it("merges explicit safe-bin trusted dirs from global and local config", () => { + const policy = resolveExecSafeBinRuntimePolicy({ + global: { + safeBinTrustedDirs: [" /custom/bin ", "/custom/bin"], + }, + local: { + safeBinTrustedDirs: ["/agent/bin"], + }, + }); + + expect(policy.trustedSafeBinDirs.has("/custom/bin")).toBe(true); + expect(policy.trustedSafeBinDirs.has("/agent/bin")).toBe(true); + }); }); diff --git a/src/infra/exec-safe-bin-runtime-policy.ts b/src/infra/exec-safe-bin-runtime-policy.ts index 930206a70f3..40e8b099733 100644 --- a/src/infra/exec-safe-bin-runtime-policy.ts +++ b/src/infra/exec-safe-bin-runtime-policy.ts @@ -11,6 +11,7 @@ import { getTrustedSafeBinDirs } from "./exec-safe-bin-trust.js"; export type ExecSafeBinConfigScope = { safeBins?: string[] | null; safeBinProfiles?: SafeBinProfileFixtures | null; + safeBinTrustedDirs?: string[] | null; }; const INTERPRETER_LIKE_SAFE_BINS = new Set([ @@ -78,6 +79,14 @@ 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; @@ -96,7 +105,6 @@ export function resolveMergedSafeBinProfileFixtures(params: { export function resolveExecSafeBinRuntimePolicy(params: { global?: ExecSafeBinConfigScope | null; local?: ExecSafeBinConfigScope | null; - pathEnv?: string | null; }): { safeBins: Set; safeBinProfiles: Readonly>; @@ -114,9 +122,12 @@ export function resolveExecSafeBinRuntimePolicy(params: { const unprofiledSafeBins = Array.from(safeBins) .filter((entry) => !safeBinProfiles[entry]) .toSorted(); - const trustedSafeBinDirs = params.pathEnv - ? getTrustedSafeBinDirs({ pathEnv: params.pathEnv }) - : getTrustedSafeBinDirs(); + const trustedSafeBinDirs = getTrustedSafeBinDirs({ + extraDirs: [ + ...normalizeTrustedDirs(params.global?.safeBinTrustedDirs), + ...normalizeTrustedDirs(params.local?.safeBinTrustedDirs), + ], + }); return { safeBins, safeBinProfiles, diff --git a/src/infra/exec-safe-bin-trust.test.ts b/src/infra/exec-safe-bin-trust.test.ts index f7b19f28379..f653b13ca7e 100644 --- a/src/infra/exec-safe-bin-trust.test.ts +++ b/src/infra/exec-safe-bin-trust.test.ts @@ -8,11 +8,10 @@ import { } from "./exec-safe-bin-trust.js"; describe("exec safe bin trust", () => { - it("builds trusted dirs from defaults and injected PATH", () => { + it("builds trusted dirs from defaults and explicit extra dirs", () => { const dirs = buildTrustedSafeBinDirs({ - pathEnv: "/custom/bin:/alt/bin:/custom/bin", - delimiter: ":", baseDirs: ["/usr/bin"], + extraDirs: ["/custom/bin", "/alt/bin", "/custom/bin"], }); expect(dirs.has(path.resolve("/usr/bin"))).toBe(true); @@ -21,19 +20,16 @@ describe("exec safe bin trust", () => { expect(dirs.size).toBe(3); }); - it("memoizes trusted dirs per PATH snapshot", () => { + it("memoizes trusted dirs per explicit trusted-dir snapshot", () => { const a = getTrustedSafeBinDirs({ - pathEnv: "/first/bin", - delimiter: ":", + extraDirs: ["/first/bin"], refresh: true, }); const b = getTrustedSafeBinDirs({ - pathEnv: "/first/bin", - delimiter: ":", + extraDirs: ["/first/bin"], }); const c = getTrustedSafeBinDirs({ - pathEnv: "/second/bin", - delimiter: ":", + extraDirs: ["/second/bin"], }); expect(a).toBe(b); @@ -56,14 +52,12 @@ describe("exec safe bin trust", () => { ).toBe(false); }); - it("uses startup PATH snapshot when pathEnv is omitted", () => { + it("does not trust PATH entries by default", () => { const injected = `/tmp/openclaw-path-injected-${Date.now()}`; - const initial = getTrustedSafeBinDirs({ refresh: true }); withEnv({ PATH: `${injected}${path.delimiter}${process.env.PATH ?? ""}` }, () => { const refreshed = getTrustedSafeBinDirs({ refresh: true }); expect(refreshed.has(path.resolve(injected))).toBe(false); - expect([...refreshed].toSorted()).toEqual([...initial].toSorted()); }); }); }); diff --git a/src/infra/exec-safe-bin-trust.ts b/src/infra/exec-safe-bin-trust.ts index dfdffbc6bb0..c76991577fb 100644 --- a/src/infra/exec-safe-bin-trust.ts +++ b/src/infra/exec-safe-bin-trust.ts @@ -11,16 +11,13 @@ const DEFAULT_SAFE_BIN_TRUSTED_DIRS = [ ]; type TrustedSafeBinDirsParams = { - pathEnv?: string | null; - delimiter?: string; baseDirs?: readonly string[]; + extraDirs?: readonly string[]; }; type TrustedSafeBinPathParams = { resolvedPath: string; trustedDirs?: ReadonlySet; - pathEnv?: string | null; - delimiter?: string; }; type TrustedSafeBinCache = { @@ -29,7 +26,6 @@ type TrustedSafeBinCache = { }; let trustedSafeBinCache: TrustedSafeBinCache | null = null; -const STARTUP_PATH_ENV = process.env.PATH ?? process.env.Path ?? ""; function normalizeTrustedDir(value: string): string | null { const trimmed = value.trim(); @@ -39,64 +35,54 @@ function normalizeTrustedDir(value: string): string | null { return path.resolve(trimmed); } -function buildTrustedSafeBinCacheKey(pathEnv: string, delimiter: string): string { - return `${delimiter}\u0000${pathEnv}`; +function buildTrustedSafeBinCacheKey(params: { + baseDirs: readonly string[]; + extraDirs: readonly string[]; +}): string { + return `${params.baseDirs.join("\u0001")}\u0000${params.extraDirs.join("\u0001")}`; } export function buildTrustedSafeBinDirs(params: TrustedSafeBinDirsParams = {}): Set { - const delimiter = params.delimiter ?? path.delimiter; - const pathEnv = params.pathEnv ?? ""; const baseDirs = params.baseDirs ?? DEFAULT_SAFE_BIN_TRUSTED_DIRS; + const extraDirs = params.extraDirs ?? []; const trusted = new Set(); - for (const entry of baseDirs) { + // 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); } } - const pathEntries = pathEnv - .split(delimiter) - .map((entry) => normalizeTrustedDir(entry)) - .filter((entry): entry is string => Boolean(entry)); - for (const entry of pathEntries) { - trusted.add(entry); - } - return trusted; } export function getTrustedSafeBinDirs( params: { - pathEnv?: string | null; - delimiter?: string; + baseDirs?: readonly string[]; + extraDirs?: readonly string[]; refresh?: boolean; } = {}, ): Set { - const delimiter = params.delimiter ?? path.delimiter; - const pathEnv = params.pathEnv ?? STARTUP_PATH_ENV; - const key = buildTrustedSafeBinCacheKey(pathEnv, delimiter); + const baseDirs = params.baseDirs ?? DEFAULT_SAFE_BIN_TRUSTED_DIRS; + const extraDirs = params.extraDirs ?? []; + const key = buildTrustedSafeBinCacheKey({ baseDirs, extraDirs }); if (!params.refresh && trustedSafeBinCache?.key === key) { return trustedSafeBinCache.dirs; } const dirs = buildTrustedSafeBinDirs({ - pathEnv, - delimiter, + baseDirs, + extraDirs, }); trustedSafeBinCache = { key, dirs }; return dirs; } export function isTrustedSafeBinPath(params: TrustedSafeBinPathParams): boolean { - const trustedDirs = - params.trustedDirs ?? - getTrustedSafeBinDirs({ - pathEnv: params.pathEnv, - delimiter: params.delimiter, - }); + const trustedDirs = params.trustedDirs ?? getTrustedSafeBinDirs(); const resolvedDir = path.dirname(path.resolve(params.resolvedPath)); return trustedDirs.has(resolvedDir); }