mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 08:27:39 +00:00
refactor(exec): unify wrapper resolution and split approvals tests
This commit is contained in:
409
src/infra/exec-approvals-safe-bins.test.ts
Normal file
409
src/infra/exec-approvals-safe-bins.test.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
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,
|
||||
evaluateShellAllowlist,
|
||||
isSafeBinUsage,
|
||||
normalizeSafeBins,
|
||||
resolveSafeBins,
|
||||
} from "./exec-approvals.js";
|
||||
import {
|
||||
SAFE_BIN_PROFILE_FIXTURES,
|
||||
SAFE_BIN_PROFILES,
|
||||
resolveSafeBinProfiles,
|
||||
} from "./exec-safe-bin-policy.js";
|
||||
|
||||
describe("exec approvals safe bins", () => {
|
||||
type SafeBinCase = {
|
||||
name: string;
|
||||
argv: string[];
|
||||
resolvedPath: string;
|
||||
expected: boolean;
|
||||
safeBins?: string[];
|
||||
executableName?: string;
|
||||
rawExecutable?: string;
|
||||
cwd?: string;
|
||||
setup?: (cwd: string) => void;
|
||||
};
|
||||
|
||||
function buildDeniedFlagVariantCases(params: {
|
||||
executableName: string;
|
||||
resolvedPath: string;
|
||||
safeBins?: string[];
|
||||
flag: string;
|
||||
takesValue: boolean;
|
||||
label: string;
|
||||
}): SafeBinCase[] {
|
||||
const value = "blocked";
|
||||
const argvVariants: string[][] = [];
|
||||
if (!params.takesValue) {
|
||||
argvVariants.push([params.executableName, params.flag]);
|
||||
} else if (params.flag.startsWith("--")) {
|
||||
argvVariants.push([params.executableName, `${params.flag}=${value}`]);
|
||||
argvVariants.push([params.executableName, params.flag, value]);
|
||||
} else if (params.flag.startsWith("-")) {
|
||||
argvVariants.push([params.executableName, `${params.flag}${value}`]);
|
||||
argvVariants.push([params.executableName, params.flag, value]);
|
||||
} else {
|
||||
argvVariants.push([params.executableName, params.flag, value]);
|
||||
}
|
||||
return argvVariants.map((argv) => ({
|
||||
name: `${params.label} (${argv.slice(1).join(" ")})`,
|
||||
argv,
|
||||
resolvedPath: params.resolvedPath,
|
||||
expected: false,
|
||||
safeBins: params.safeBins ?? [params.executableName],
|
||||
executableName: params.executableName,
|
||||
}));
|
||||
}
|
||||
|
||||
const deniedFlagCases: SafeBinCase[] = [
|
||||
...buildDeniedFlagVariantCases({
|
||||
executableName: "sort",
|
||||
resolvedPath: "/usr/bin/sort",
|
||||
flag: "-o",
|
||||
takesValue: true,
|
||||
label: "blocks sort output flag",
|
||||
}),
|
||||
...buildDeniedFlagVariantCases({
|
||||
executableName: "sort",
|
||||
resolvedPath: "/usr/bin/sort",
|
||||
flag: "--output",
|
||||
takesValue: true,
|
||||
label: "blocks sort output flag",
|
||||
}),
|
||||
...buildDeniedFlagVariantCases({
|
||||
executableName: "sort",
|
||||
resolvedPath: "/usr/bin/sort",
|
||||
flag: "--compress-program",
|
||||
takesValue: true,
|
||||
label: "blocks sort external program flag",
|
||||
}),
|
||||
...buildDeniedFlagVariantCases({
|
||||
executableName: "grep",
|
||||
resolvedPath: "/usr/bin/grep",
|
||||
flag: "-R",
|
||||
takesValue: false,
|
||||
label: "blocks grep recursive flag",
|
||||
}),
|
||||
...buildDeniedFlagVariantCases({
|
||||
executableName: "grep",
|
||||
resolvedPath: "/usr/bin/grep",
|
||||
flag: "--recursive",
|
||||
takesValue: false,
|
||||
label: "blocks grep recursive flag",
|
||||
}),
|
||||
...buildDeniedFlagVariantCases({
|
||||
executableName: "grep",
|
||||
resolvedPath: "/usr/bin/grep",
|
||||
flag: "--file",
|
||||
takesValue: true,
|
||||
label: "blocks grep file-pattern flag",
|
||||
}),
|
||||
...buildDeniedFlagVariantCases({
|
||||
executableName: "jq",
|
||||
resolvedPath: "/usr/bin/jq",
|
||||
flag: "-f",
|
||||
takesValue: true,
|
||||
label: "blocks jq file-program flag",
|
||||
}),
|
||||
...buildDeniedFlagVariantCases({
|
||||
executableName: "jq",
|
||||
resolvedPath: "/usr/bin/jq",
|
||||
flag: "--from-file",
|
||||
takesValue: true,
|
||||
label: "blocks jq file-program flag",
|
||||
}),
|
||||
...buildDeniedFlagVariantCases({
|
||||
executableName: "wc",
|
||||
resolvedPath: "/usr/bin/wc",
|
||||
flag: "--files0-from",
|
||||
takesValue: true,
|
||||
label: "blocks wc file-list flag",
|
||||
}),
|
||||
];
|
||||
|
||||
const cases: SafeBinCase[] = [
|
||||
{
|
||||
name: "allows safe bins with non-path args",
|
||||
argv: ["jq", ".foo"],
|
||||
resolvedPath: "/usr/bin/jq",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "blocks safe bins with file args",
|
||||
argv: ["jq", ".foo", "secret.json"],
|
||||
resolvedPath: "/usr/bin/jq",
|
||||
expected: false,
|
||||
setup: (cwd) => fs.writeFileSync(path.join(cwd, "secret.json"), "{}"),
|
||||
},
|
||||
{
|
||||
name: "blocks safe bins resolved from untrusted directories",
|
||||
argv: ["jq", ".foo"],
|
||||
resolvedPath: "/tmp/evil-bin/jq",
|
||||
expected: false,
|
||||
cwd: "/tmp",
|
||||
},
|
||||
...deniedFlagCases,
|
||||
{
|
||||
name: "blocks grep file positional when pattern uses -e",
|
||||
argv: ["grep", "-e", "needle", ".env"],
|
||||
resolvedPath: "/usr/bin/grep",
|
||||
expected: false,
|
||||
safeBins: ["grep"],
|
||||
executableName: "grep",
|
||||
},
|
||||
{
|
||||
name: "blocks grep file positional after -- terminator",
|
||||
argv: ["grep", "-e", "needle", "--", ".env"],
|
||||
resolvedPath: "/usr/bin/grep",
|
||||
expected: false,
|
||||
safeBins: ["grep"],
|
||||
executableName: "grep",
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
it(testCase.name, () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const cwd = testCase.cwd ?? makeTempDir();
|
||||
testCase.setup?.(cwd);
|
||||
const executableName = testCase.executableName ?? "jq";
|
||||
const rawExecutable = testCase.rawExecutable ?? executableName;
|
||||
const ok = isSafeBinUsage({
|
||||
argv: testCase.argv,
|
||||
resolution: {
|
||||
rawExecutable,
|
||||
resolvedPath: testCase.resolvedPath,
|
||||
executableName,
|
||||
},
|
||||
safeBins: normalizeSafeBins(testCase.safeBins ?? [executableName]),
|
||||
});
|
||||
expect(ok).toBe(testCase.expected);
|
||||
});
|
||||
}
|
||||
|
||||
it("supports injected trusted safe-bin dirs for tests/callers", () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const ok = isSafeBinUsage({
|
||||
argv: ["jq", ".foo"],
|
||||
resolution: {
|
||||
rawExecutable: "jq",
|
||||
resolvedPath: "/custom/bin/jq",
|
||||
executableName: "jq",
|
||||
},
|
||||
safeBins: normalizeSafeBins(["jq"]),
|
||||
trustedSafeBinDirs: new Set(["/custom/bin"]),
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
it("supports injected platform for deterministic safe-bin checks", () => {
|
||||
const ok = isSafeBinUsage({
|
||||
argv: ["jq", ".foo"],
|
||||
resolution: {
|
||||
rawExecutable: "jq",
|
||||
resolvedPath: "/usr/bin/jq",
|
||||
executableName: "jq",
|
||||
},
|
||||
safeBins: normalizeSafeBins(["jq"]),
|
||||
platform: "win32",
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
it("supports injected trusted path checker for deterministic callers", () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const baseParams = {
|
||||
argv: ["jq", ".foo"],
|
||||
resolution: {
|
||||
rawExecutable: "jq",
|
||||
resolvedPath: "/tmp/custom/jq",
|
||||
executableName: "jq",
|
||||
},
|
||||
safeBins: normalizeSafeBins(["jq"]),
|
||||
};
|
||||
expect(
|
||||
isSafeBinUsage({
|
||||
...baseParams,
|
||||
isTrustedSafeBinPathFn: () => true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSafeBinUsage({
|
||||
...baseParams,
|
||||
isTrustedSafeBinPathFn: () => false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps safe-bin profile fixtures aligned with compiled profiles", () => {
|
||||
for (const [name, fixture] of Object.entries(SAFE_BIN_PROFILE_FIXTURES)) {
|
||||
const profile = SAFE_BIN_PROFILES[name];
|
||||
expect(profile).toBeDefined();
|
||||
const fixtureDeniedFlags = fixture.deniedFlags ?? [];
|
||||
const compiledDeniedFlags = profile?.deniedFlags ?? new Set<string>();
|
||||
for (const deniedFlag of fixtureDeniedFlags) {
|
||||
expect(compiledDeniedFlags.has(deniedFlag)).toBe(true);
|
||||
}
|
||||
expect(Array.from(compiledDeniedFlags).toSorted()).toEqual(
|
||||
[...fixtureDeniedFlags].toSorted(),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not include sort/grep in default safeBins", () => {
|
||||
const defaults = resolveSafeBins(undefined);
|
||||
expect(defaults.has("jq")).toBe(true);
|
||||
expect(defaults.has("sort")).toBe(false);
|
||||
expect(defaults.has("grep")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not auto-allow unprofiled safe-bin entries", () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const result = evaluateShellAllowlist({
|
||||
command: "python3 -c \"print('owned')\"",
|
||||
allowlist: [],
|
||||
safeBins: normalizeSafeBins(["python3"]),
|
||||
cwd: "/tmp",
|
||||
});
|
||||
expect(result.analysisOk).toBe(true);
|
||||
expect(result.allowlistSatisfied).toBe(false);
|
||||
});
|
||||
|
||||
it("allows caller-defined custom safe-bin profiles", () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const safeBinProfiles = resolveSafeBinProfiles({
|
||||
echo: {
|
||||
maxPositional: 1,
|
||||
},
|
||||
});
|
||||
const allow = isSafeBinUsage({
|
||||
argv: ["echo", "hello"],
|
||||
resolution: {
|
||||
rawExecutable: "echo",
|
||||
resolvedPath: "/bin/echo",
|
||||
executableName: "echo",
|
||||
},
|
||||
safeBins: normalizeSafeBins(["echo"]),
|
||||
safeBinProfiles,
|
||||
});
|
||||
const deny = isSafeBinUsage({
|
||||
argv: ["echo", "hello", "world"],
|
||||
resolution: {
|
||||
rawExecutable: "echo",
|
||||
resolvedPath: "/bin/echo",
|
||||
executableName: "echo",
|
||||
},
|
||||
safeBins: normalizeSafeBins(["echo"]),
|
||||
safeBinProfiles,
|
||||
});
|
||||
expect(allow).toBe(true);
|
||||
expect(deny).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks sort output flags independent of file existence", () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const cwd = makeTempDir();
|
||||
fs.writeFileSync(path.join(cwd, "existing.txt"), "x");
|
||||
const resolution = {
|
||||
rawExecutable: "sort",
|
||||
resolvedPath: "/usr/bin/sort",
|
||||
executableName: "sort",
|
||||
};
|
||||
const safeBins = normalizeSafeBins(["sort"]);
|
||||
const existing = isSafeBinUsage({
|
||||
argv: ["sort", "-o", "existing.txt"],
|
||||
resolution,
|
||||
safeBins,
|
||||
});
|
||||
const missing = isSafeBinUsage({
|
||||
argv: ["sort", "-o", "missing.txt"],
|
||||
resolution,
|
||||
safeBins,
|
||||
});
|
||||
const longFlag = isSafeBinUsage({
|
||||
argv: ["sort", "--output=missing.txt"],
|
||||
resolution,
|
||||
safeBins,
|
||||
});
|
||||
expect(existing).toBe(false);
|
||||
expect(missing).toBe(false);
|
||||
expect(longFlag).toBe(false);
|
||||
});
|
||||
|
||||
it("threads trusted safe-bin dirs through allowlist evaluation", () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const analysis = {
|
||||
ok: true as const,
|
||||
segments: [
|
||||
{
|
||||
raw: "jq .foo",
|
||||
argv: ["jq", ".foo"],
|
||||
resolution: {
|
||||
rawExecutable: "jq",
|
||||
resolvedPath: "/custom/bin/jq",
|
||||
executableName: "jq",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const denied = evaluateExecAllowlist({
|
||||
analysis,
|
||||
allowlist: [],
|
||||
safeBins: normalizeSafeBins(["jq"]),
|
||||
trustedSafeBinDirs: new Set(["/usr/bin"]),
|
||||
cwd: "/tmp",
|
||||
});
|
||||
expect(denied.allowlistSatisfied).toBe(false);
|
||||
|
||||
const allowed = evaluateExecAllowlist({
|
||||
analysis,
|
||||
allowlist: [],
|
||||
safeBins: normalizeSafeBins(["jq"]),
|
||||
trustedSafeBinDirs: new Set(["/custom/bin"]),
|
||||
cwd: "/tmp",
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user