From 3957f29e2f9f35e56bdb19ef503c04b59408459c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 20:33:18 +0000 Subject: [PATCH] test: extract exec command resolution coverage --- src/infra/exec-approvals.test.ts | 217 ------------------- src/infra/exec-command-resolution.test.ts | 242 ++++++++++++++++++++++ 2 files changed, 242 insertions(+), 217 deletions(-) create mode 100644 src/infra/exec-command-resolution.test.ts diff --git a/src/infra/exec-approvals.test.ts b/src/infra/exec-approvals.test.ts index 57290c07116..b26060514d9 100644 --- a/src/infra/exec-approvals.test.ts +++ b/src/infra/exec-approvals.test.ts @@ -14,55 +14,13 @@ import { mergeExecApprovalsSocketDefaults, minSecurity, normalizeExecApprovals, - parseExecArgvToken, normalizeSafeBins, requiresExecApproval, - resolveCommandResolution, - resolveCommandResolutionFromArgv, resolveExecApprovalsPath, resolveExecApprovalsSocketPath, type ExecAllowlistEntry, } from "./exec-approvals.js"; -function buildNestedEnvShellCommand(params: { - envExecutable: string; - depth: number; - payload: string; -}): string[] { - return [...Array(params.depth).fill(params.envExecutable), "/bin/sh", "-c", params.payload]; -} - -function analyzeEnvWrapperAllowlist(params: { argv: string[]; envPath: string; cwd: string }) { - const analysis = analyzeArgvCommand({ - argv: params.argv, - cwd: params.cwd, - env: makePathEnv(params.envPath), - }); - const allowlistEval = evaluateExecAllowlist({ - analysis, - allowlist: [{ pattern: params.envPath }], - safeBins: normalizeSafeBins([]), - cwd: params.cwd, - }); - return { analysis, allowlistEval }; -} - -function createPathExecutableFixture(params?: { executable?: string }): { - exeName: string; - exePath: string; - binDir: string; -} { - const dir = makeTempDir(); - const binDir = path.join(dir, "bin"); - fs.mkdirSync(binDir, { recursive: true }); - const baseName = params?.executable ?? "rg"; - const exeName = process.platform === "win32" ? `${baseName}.exe` : baseName; - const exePath = path.join(binDir, exeName); - fs.writeFileSync(exePath, ""); - fs.chmodSync(exePath, 0o755); - return { exeName, exePath, binDir }; -} - describe("exec approvals allowlist matching", () => { const baseResolution = { rawExecutable: "rg", @@ -231,161 +189,6 @@ describe("exec approvals safe shell command builder", () => { }); }); -describe("exec approvals command resolution", () => { - it("resolves PATH, relative, and quoted executables", () => { - const cases = [ - { - name: "PATH executable", - setup: () => { - const fixture = createPathExecutableFixture(); - return { - command: "rg -n foo", - cwd: undefined as string | undefined, - envPath: makePathEnv(fixture.binDir), - expectedPath: fixture.exePath, - expectedExecutableName: fixture.exeName, - }; - }, - }, - { - name: "relative executable", - setup: () => { - const dir = makeTempDir(); - const cwd = path.join(dir, "project"); - const script = path.join(cwd, "scripts", "run.sh"); - fs.mkdirSync(path.dirname(script), { recursive: true }); - fs.writeFileSync(script, ""); - fs.chmodSync(script, 0o755); - return { - command: "./scripts/run.sh --flag", - cwd, - envPath: undefined as NodeJS.ProcessEnv | undefined, - expectedPath: script, - expectedExecutableName: undefined, - }; - }, - }, - { - name: "quoted executable", - setup: () => { - const dir = makeTempDir(); - const cwd = path.join(dir, "project"); - const script = path.join(cwd, "bin", "tool"); - fs.mkdirSync(path.dirname(script), { recursive: true }); - fs.writeFileSync(script, ""); - fs.chmodSync(script, 0o755); - return { - command: '"./bin/tool" --version', - cwd, - envPath: undefined as NodeJS.ProcessEnv | undefined, - expectedPath: script, - expectedExecutableName: undefined, - }; - }, - }, - ] as const; - - for (const testCase of cases) { - const setup = testCase.setup(); - const res = resolveCommandResolution(setup.command, setup.cwd, setup.envPath); - expect(res?.resolvedPath, testCase.name).toBe(setup.expectedPath); - if (setup.expectedExecutableName) { - expect(res?.executableName, testCase.name).toBe(setup.expectedExecutableName); - } - } - }); - - it("unwraps transparent env wrapper argv to resolve the effective executable", () => { - const fixture = createPathExecutableFixture(); - - const resolution = resolveCommandResolutionFromArgv( - ["/usr/bin/env", "rg", "-n", "needle"], - undefined, - makePathEnv(fixture.binDir), - ); - expect(resolution?.resolvedPath).toBe(fixture.exePath); - expect(resolution?.executableName).toBe(fixture.exeName); - }); - - it("blocks semantic env wrappers from allowlist/safeBins auto-resolution", () => { - const resolution = resolveCommandResolutionFromArgv([ - "/usr/bin/env", - "FOO=bar", - "rg", - "-n", - "needle", - ]); - expect(resolution?.policyBlocked).toBe(true); - expect(resolution?.rawExecutable).toBe("/usr/bin/env"); - }); - - it("fails closed for env -S even when env itself is allowlisted", () => { - const dir = makeTempDir(); - const binDir = path.join(dir, "bin"); - fs.mkdirSync(binDir, { recursive: true }); - const envName = process.platform === "win32" ? "env.exe" : "env"; - const envPath = path.join(binDir, envName); - fs.writeFileSync(envPath, process.platform === "win32" ? "" : "#!/bin/sh\n"); - if (process.platform !== "win32") { - fs.chmodSync(envPath, 0o755); - } - const { analysis, allowlistEval } = analyzeEnvWrapperAllowlist({ - argv: [envPath, "-S", 'sh -c "echo pwned"'], - envPath: envPath, - cwd: dir, - }); - - expect(analysis.ok).toBe(true); - expect(analysis.segments[0]?.resolution?.policyBlocked).toBe(true); - expect(allowlistEval.allowlistSatisfied).toBe(false); - expect(allowlistEval.segmentSatisfiedBy).toEqual([null]); - }); - - it("fails closed when transparent env wrappers exceed unwrap depth", () => { - if (process.platform === "win32") { - return; - } - const dir = makeTempDir(); - const binDir = path.join(dir, "bin"); - fs.mkdirSync(binDir, { recursive: true }); - const envPath = path.join(binDir, "env"); - fs.writeFileSync(envPath, "#!/bin/sh\n"); - fs.chmodSync(envPath, 0o755); - const { analysis, allowlistEval } = analyzeEnvWrapperAllowlist({ - argv: buildNestedEnvShellCommand({ - envExecutable: envPath, - depth: 5, - payload: "echo pwned", - }), - envPath, - cwd: dir, - }); - - expect(analysis.ok).toBe(true); - expect(analysis.segments[0]?.resolution?.policyBlocked).toBe(true); - expect(analysis.segments[0]?.resolution?.blockedWrapper).toBe("env"); - expect(allowlistEval.allowlistSatisfied).toBe(false); - expect(allowlistEval.segmentSatisfiedBy).toEqual([null]); - }); - - it("unwraps env wrapper with shell inner executable", () => { - const resolution = resolveCommandResolutionFromArgv(["/usr/bin/env", "bash", "-lc", "echo hi"]); - expect(resolution?.rawExecutable).toBe("bash"); - expect(resolution?.executableName.toLowerCase()).toContain("bash"); - }); - - it("unwraps nice wrapper argv to resolve the effective executable", () => { - const resolution = resolveCommandResolutionFromArgv([ - "/usr/bin/nice", - "bash", - "-lc", - "echo hi", - ]); - expect(resolution?.rawExecutable).toBe("bash"); - expect(resolution?.executableName.toLowerCase()).toContain("bash"); - }); -}); - describe("exec approvals shell parsing", () => { it("parses pipelines and chained commands", () => { const cases = [ @@ -532,26 +335,6 @@ describe("exec approvals shell parsing", () => { expect(res.ok).toBe(true); expect(res.segments[0]?.argv).toEqual(["C:\\Program Files\\Tool\\tool.exe", "--version"]); }); - - it("normalizes short option clusters with attached payloads", () => { - const parsed = parseExecArgvToken("-oblocked.txt"); - expect(parsed.kind).toBe("option"); - if (parsed.kind !== "option" || parsed.style !== "short-cluster") { - throw new Error("expected short-cluster option"); - } - expect(parsed.flags[0]).toBe("-o"); - expect(parsed.cluster).toBe("oblocked.txt"); - }); - - it("normalizes long options with inline payloads", () => { - const parsed = parseExecArgvToken("--output=blocked.txt"); - expect(parsed.kind).toBe("option"); - if (parsed.kind !== "option" || parsed.style !== "long") { - throw new Error("expected long option"); - } - expect(parsed.flag).toBe("--output"); - expect(parsed.inlineValue).toBe("blocked.txt"); - }); }); describe("exec approvals shell allowlist (chained commands)", () => { diff --git a/src/infra/exec-command-resolution.test.ts b/src/infra/exec-command-resolution.test.ts new file mode 100644 index 00000000000..1cb003c077e --- /dev/null +++ b/src/infra/exec-command-resolution.test.ts @@ -0,0 +1,242 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { makePathEnv, makeTempDir } from "./exec-approvals-test-helpers.js"; +import { + evaluateExecAllowlist, + normalizeSafeBins, + parseExecArgvToken, + resolveAllowlistCandidatePath, + resolveCommandResolution, + resolveCommandResolutionFromArgv, +} from "./exec-approvals.js"; + +function buildNestedEnvShellCommand(params: { + envExecutable: string; + depth: number; + payload: string; +}): string[] { + return [...Array(params.depth).fill(params.envExecutable), "/bin/sh", "-c", params.payload]; +} + +function analyzeEnvWrapperAllowlist(params: { argv: string[]; envPath: string; cwd: string }) { + const analysis = { + ok: true as const, + segments: [ + { + argv: params.argv, + resolution: resolveCommandResolutionFromArgv( + params.argv, + params.cwd, + makePathEnv(params.envPath), + ), + }, + ], + }; + const allowlistEval = evaluateExecAllowlist({ + analysis, + allowlist: [{ pattern: params.envPath }], + safeBins: normalizeSafeBins([]), + cwd: params.cwd, + }); + return { analysis, allowlistEval }; +} + +function createPathExecutableFixture(params?: { executable?: string }): { + exeName: string; + exePath: string; + binDir: string; +} { + const dir = makeTempDir(); + const binDir = path.join(dir, "bin"); + fs.mkdirSync(binDir, { recursive: true }); + const baseName = params?.executable ?? "rg"; + const exeName = process.platform === "win32" ? `${baseName}.exe` : baseName; + const exePath = path.join(binDir, exeName); + fs.writeFileSync(exePath, ""); + fs.chmodSync(exePath, 0o755); + return { exeName, exePath, binDir }; +} + +describe("exec-command-resolution", () => { + it("resolves PATH, relative, and quoted executables", () => { + const cases = [ + { + name: "PATH executable", + setup: () => { + const fixture = createPathExecutableFixture(); + return { + command: "rg -n foo", + cwd: undefined as string | undefined, + envPath: makePathEnv(fixture.binDir), + expectedPath: fixture.exePath, + expectedExecutableName: fixture.exeName, + }; + }, + }, + { + name: "relative executable", + setup: () => { + const dir = makeTempDir(); + const cwd = path.join(dir, "project"); + const script = path.join(cwd, "scripts", "run.sh"); + fs.mkdirSync(path.dirname(script), { recursive: true }); + fs.writeFileSync(script, ""); + fs.chmodSync(script, 0o755); + return { + command: "./scripts/run.sh --flag", + cwd, + envPath: undefined as NodeJS.ProcessEnv | undefined, + expectedPath: script, + expectedExecutableName: undefined, + }; + }, + }, + { + name: "quoted executable", + setup: () => { + const dir = makeTempDir(); + const cwd = path.join(dir, "project"); + const script = path.join(cwd, "bin", "tool"); + fs.mkdirSync(path.dirname(script), { recursive: true }); + fs.writeFileSync(script, ""); + fs.chmodSync(script, 0o755); + return { + command: '"./bin/tool" --version', + cwd, + envPath: undefined as NodeJS.ProcessEnv | undefined, + expectedPath: script, + expectedExecutableName: undefined, + }; + }, + }, + ] as const; + + for (const testCase of cases) { + const setup = testCase.setup(); + const res = resolveCommandResolution(setup.command, setup.cwd, setup.envPath); + expect(res?.resolvedPath, testCase.name).toBe(setup.expectedPath); + if (setup.expectedExecutableName) { + expect(res?.executableName, testCase.name).toBe(setup.expectedExecutableName); + } + } + }); + + it("unwraps transparent env and nice wrappers to the effective executable", () => { + const fixture = createPathExecutableFixture(); + + const envResolution = resolveCommandResolutionFromArgv( + ["/usr/bin/env", "rg", "-n", "needle"], + undefined, + makePathEnv(fixture.binDir), + ); + expect(envResolution?.resolvedPath).toBe(fixture.exePath); + expect(envResolution?.executableName).toBe(fixture.exeName); + + const niceResolution = resolveCommandResolutionFromArgv([ + "/usr/bin/nice", + "bash", + "-lc", + "echo hi", + ]); + expect(niceResolution?.rawExecutable).toBe("bash"); + expect(niceResolution?.executableName.toLowerCase()).toContain("bash"); + }); + + it("blocks semantic env wrappers, env -S, and deep transparent-wrapper chains", () => { + const blockedEnv = resolveCommandResolutionFromArgv([ + "/usr/bin/env", + "FOO=bar", + "rg", + "-n", + "needle", + ]); + expect(blockedEnv?.policyBlocked).toBe(true); + expect(blockedEnv?.rawExecutable).toBe("/usr/bin/env"); + + if (process.platform === "win32") { + return; + } + + const dir = makeTempDir(); + const binDir = path.join(dir, "bin"); + fs.mkdirSync(binDir, { recursive: true }); + const envPath = path.join(binDir, "env"); + fs.writeFileSync(envPath, "#!/bin/sh\n"); + fs.chmodSync(envPath, 0o755); + + const envS = analyzeEnvWrapperAllowlist({ + argv: [envPath, "-S", 'sh -c "echo pwned"'], + envPath, + cwd: dir, + }); + expect(envS.analysis.segments[0]?.resolution?.policyBlocked).toBe(true); + expect(envS.allowlistEval.allowlistSatisfied).toBe(false); + + const deep = analyzeEnvWrapperAllowlist({ + argv: buildNestedEnvShellCommand({ + envExecutable: envPath, + depth: 5, + payload: "echo pwned", + }), + envPath, + cwd: dir, + }); + expect(deep.analysis.segments[0]?.resolution?.policyBlocked).toBe(true); + expect(deep.analysis.segments[0]?.resolution?.blockedWrapper).toBe("env"); + expect(deep.allowlistEval.allowlistSatisfied).toBe(false); + }); + + it("resolves allowlist candidate paths from unresolved raw executables", () => { + expect( + resolveAllowlistCandidatePath( + { + rawExecutable: "~/bin/tool", + executableName: "tool", + }, + "/tmp", + ), + ).toContain("/bin/tool"); + + expect( + resolveAllowlistCandidatePath( + { + rawExecutable: "./scripts/run.sh", + executableName: "run.sh", + }, + "/repo", + ), + ).toBe(path.resolve("/repo", "./scripts/run.sh")); + + expect( + resolveAllowlistCandidatePath( + { + rawExecutable: "rg", + executableName: "rg", + }, + "/repo", + ), + ).toBeUndefined(); + }); + + it("normalizes argv tokens for short clusters, long options, and special sentinels", () => { + expect(parseExecArgvToken("")).toEqual({ kind: "empty", raw: "" }); + expect(parseExecArgvToken("--")).toEqual({ kind: "terminator", raw: "--" }); + expect(parseExecArgvToken("-")).toEqual({ kind: "stdin", raw: "-" }); + expect(parseExecArgvToken("echo")).toEqual({ kind: "positional", raw: "echo" }); + + const short = parseExecArgvToken("-oblocked.txt"); + expect(short.kind).toBe("option"); + if (short.kind === "option" && short.style === "short-cluster") { + expect(short.flags[0]).toBe("-o"); + expect(short.cluster).toBe("oblocked.txt"); + } + + const long = parseExecArgvToken("--output=blocked.txt"); + expect(long.kind).toBe("option"); + if (long.kind === "option" && long.style === "long") { + expect(long.flag).toBe("--output"); + expect(long.inlineValue).toBe("blocked.txt"); + } + }); +});