mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-01 10:40:21 +00:00
refactor: separate exec policy and execution targets
This commit is contained in:
@@ -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: "--" });
|
||||
|
||||
Reference in New Issue
Block a user