refactor: separate exec policy and execution targets

This commit is contained in:
Peter Steinberger
2026-03-23 19:33:29 -07:00
parent a96eded4a0
commit 7f373823b0
14 changed files with 444 additions and 170 deletions

View File

@@ -4,12 +4,13 @@ import { describe, expect, it } from "vitest";
import { makePathEnv, makeTempDir } from "./exec-approvals-test-helpers.js";
import {
evaluateExecAllowlist,
resolvePlannedSegmentArgv,
normalizeSafeBins,
parseExecArgvToken,
resolveAllowlistCandidatePath,
resolveCommandResolution,
resolveCommandResolutionFromArgv,
resolvePolicyAllowlistCandidatePath,
resolveExecutionTargetCandidatePath,
resolvePolicyTargetCandidatePath,
} from "./exec-approvals.js";
function buildNestedEnvShellCommand(params: {
@@ -119,9 +120,9 @@ describe("exec-command-resolution", () => {
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);
expect(res?.execution.resolvedPath, testCase.name).toBe(setup.expectedPath);
if (setup.expectedExecutableName) {
expect(res?.executableName, testCase.name).toBe(setup.expectedExecutableName);
expect(res?.execution.executableName, testCase.name).toBe(setup.expectedExecutableName);
}
}
});
@@ -134,8 +135,8 @@ describe("exec-command-resolution", () => {
undefined,
makePathEnv(fixture.binDir),
);
expect(envResolution?.resolvedPath).toBe(fixture.exePath);
expect(envResolution?.executableName).toBe(fixture.exeName);
expect(envResolution?.execution.resolvedPath).toBe(fixture.exePath);
expect(envResolution?.execution.executableName).toBe(fixture.exeName);
const niceResolution = resolveCommandResolutionFromArgv([
"/usr/bin/nice",
@@ -143,16 +144,16 @@ describe("exec-command-resolution", () => {
"-lc",
"echo hi",
]);
expect(niceResolution?.rawExecutable).toBe("bash");
expect(niceResolution?.executableName.toLowerCase()).toContain("bash");
expect(niceResolution?.execution.rawExecutable).toBe("bash");
expect(niceResolution?.execution.executableName.toLowerCase()).toContain("bash");
const timeResolution = resolveCommandResolutionFromArgv(
["/usr/bin/time", "-p", "rg", "-n", "needle"],
undefined,
makePathEnv(fixture.binDir),
);
expect(timeResolution?.resolvedPath).toBe(fixture.exePath);
expect(timeResolution?.executableName).toBe(fixture.exeName);
expect(timeResolution?.execution.resolvedPath).toBe(fixture.exePath);
expect(timeResolution?.execution.executableName).toBe(fixture.exeName);
});
it("keeps shell multiplexer wrappers as a separate policy target", () => {
@@ -165,13 +166,13 @@ describe("exec-command-resolution", () => {
fs.chmodSync(busybox, 0o755);
const resolution = resolveCommandResolutionFromArgv([busybox, "sh", "-lc", "echo hi"]);
expect(resolution?.rawExecutable).toBe("sh");
expect(resolution?.execution.rawExecutable).toBe("sh");
expect(resolution?.effectiveArgv).toEqual(["sh", "-lc", "echo hi"]);
expect(resolution?.wrapperChain).toEqual(["busybox"]);
expect(resolution?.policyResolution?.rawExecutable).toBe(busybox);
expect(resolution?.policyResolution?.resolvedPath).toBe(busybox);
expect(resolvePolicyAllowlistCandidatePath(resolution ?? null, dir)).toBe(busybox);
expect(resolution?.executableName.toLowerCase()).toContain("sh");
expect(resolution?.policy.rawExecutable).toBe(busybox);
expect(resolution?.policy.resolvedPath).toBe(busybox);
expect(resolvePolicyTargetCandidatePath(resolution ?? null, dir)).toBe(busybox);
expect(resolution?.execution.executableName.toLowerCase()).toContain("sh");
});
it("does not satisfy inner-shell allowlists when invoked through busybox wrappers", () => {
@@ -184,7 +185,7 @@ describe("exec-command-resolution", () => {
fs.chmodSync(busybox, 0o755);
const shellResolution = resolveCommandResolutionFromArgv(["sh", "-lc", "echo hi"]);
expect(shellResolution?.resolvedPath).toBeTruthy();
expect(shellResolution?.execution.resolvedPath).toBeTruthy();
const wrappedResolution = resolveCommandResolutionFromArgv([busybox, "sh", "-lc", "echo hi"]);
const evalResult = evaluateExecAllowlist({
@@ -198,7 +199,7 @@ describe("exec-command-resolution", () => {
},
],
},
allowlist: [{ pattern: shellResolution?.resolvedPath ?? "" }],
allowlist: [{ pattern: shellResolution?.execution.resolvedPath ?? "" }],
safeBins: normalizeSafeBins([]),
cwd: dir,
});
@@ -215,7 +216,7 @@ describe("exec-command-resolution", () => {
"needle",
]);
expect(blockedEnv?.policyBlocked).toBe(true);
expect(blockedEnv?.rawExecutable).toBe("/usr/bin/env");
expect(blockedEnv?.execution.rawExecutable).toBe("/usr/bin/env");
if (process.platform === "win32") {
return;
@@ -252,7 +253,7 @@ describe("exec-command-resolution", () => {
it("resolves allowlist candidate paths from unresolved raw executables", () => {
expect(
resolveAllowlistCandidatePath(
resolveExecutionTargetCandidatePath(
{
rawExecutable: "~/bin/tool",
executableName: "tool",
@@ -262,7 +263,7 @@ describe("exec-command-resolution", () => {
).toContain("/bin/tool");
expect(
resolveAllowlistCandidatePath(
resolveExecutionTargetCandidatePath(
{
rawExecutable: "./scripts/run.sh",
executableName: "run.sh",
@@ -272,7 +273,7 @@ describe("exec-command-resolution", () => {
).toBe(path.resolve("/repo", "./scripts/run.sh"));
expect(
resolveAllowlistCandidatePath(
resolveExecutionTargetCandidatePath(
{
rawExecutable: "rg",
executableName: "rg",
@@ -282,6 +283,101 @@ describe("exec-command-resolution", () => {
).toBeUndefined();
});
it("keeps execution and policy targets coherent across wrapper classes", () => {
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");
const rgPath = path.join(binDir, "rg");
const busybox = path.join(dir, "busybox");
for (const file of [envPath, rgPath, busybox]) {
fs.writeFileSync(file, "");
fs.chmodSync(file, 0o755);
}
const cases = [
{
name: "transparent env wrapper",
argv: [envPath, "rg", "-n", "needle"],
env: makePathEnv(binDir),
expectedExecutionPath: rgPath,
expectedPolicyPath: rgPath,
expectedPlannedArgv: [fs.realpathSync(rgPath), "-n", "needle"],
allowlistPattern: rgPath,
allowlistSatisfied: true,
},
{
name: "busybox shell multiplexer",
argv: [busybox, "sh", "-lc", "echo hi"],
env: { PATH: `${binDir}${path.delimiter}/bin:/usr/bin` },
expectedExecutionPath: "/bin/sh",
expectedPolicyPath: busybox,
expectedPlannedArgv: ["/bin/sh", "-lc", "echo hi"],
allowlistPattern: busybox,
allowlistSatisfied: true,
},
{
name: "semantic env wrapper",
argv: [envPath, "FOO=bar", "rg", "-n", "needle"],
env: makePathEnv(binDir),
expectedExecutionPath: envPath,
expectedPolicyPath: envPath,
expectedPlannedArgv: null,
allowlistPattern: envPath,
allowlistSatisfied: false,
},
{
name: "wrapper depth overflow",
argv: buildNestedEnvShellCommand({
envExecutable: envPath,
depth: 5,
payload: "echo hi",
}),
env: makePathEnv(binDir),
expectedExecutionPath: envPath,
expectedPolicyPath: envPath,
expectedPlannedArgv: null,
allowlistPattern: envPath,
allowlistSatisfied: false,
},
] as const;
for (const testCase of cases) {
const argv = [...testCase.argv];
const resolution = resolveCommandResolutionFromArgv(argv, dir, testCase.env);
const segment = {
raw: argv.join(" "),
argv,
resolution,
};
expect(
resolveExecutionTargetCandidatePath(resolution ?? null, dir),
`${testCase.name} execution`,
).toBe(testCase.expectedExecutionPath);
expect(
resolvePolicyTargetCandidatePath(resolution ?? null, dir),
`${testCase.name} policy`,
).toBe(testCase.expectedPolicyPath);
expect(resolvePlannedSegmentArgv(segment), `${testCase.name} planned argv`).toEqual(
testCase.expectedPlannedArgv,
);
const evaluation = evaluateExecAllowlist({
analysis: { ok: true, segments: [segment] },
allowlist: [{ pattern: testCase.allowlistPattern }],
safeBins: normalizeSafeBins([]),
cwd: dir,
env: testCase.env,
});
expect(evaluation.allowlistSatisfied, `${testCase.name} allowlist`).toBe(
testCase.allowlistSatisfied,
);
}
});
it("normalizes argv tokens for short clusters, long options, and special sentinels", () => {
expect(parseExecArgvToken("")).toEqual({ kind: "empty", raw: "" });
expect(parseExecArgvToken("--")).toEqual({ kind: "terminator", raw: "--" });