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: [ { raw: params.argv.join(" "), 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 scriptName = process.platform === "win32" ? "run.cmd" : "run.sh"; const script = path.join(cwd, "scripts", scriptName); fs.mkdirSync(path.dirname(script), { recursive: true }); fs.writeFileSync(script, ""); fs.chmodSync(script, 0o755); return { command: `./scripts/${scriptName} --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 scriptName = process.platform === "win32" ? "tool.cmd" : "tool"; const script = path.join(cwd, "bin", scriptName); fs.mkdirSync(path.dirname(script), { recursive: true }); fs.writeFileSync(script, ""); fs.chmodSync(script, 0o755); return { command: `"./bin/${scriptName}" --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"); } }); });