openclaw/src/infra/exec-approvals-allow-always...

445 lines
13 KiB
TypeScript

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 {
evaluateShellAllowlist,
requiresExecApproval,
resolveAllowAlwaysPatterns,
resolveSafeBins,
} from "./exec-approvals.js";
describe("resolveAllowAlwaysPatterns", () => {
function makeExecutable(dir: string, name: string): string {
const fileName = process.platform === "win32" ? `${name}.exe` : name;
const exe = path.join(dir, fileName);
fs.writeFileSync(exe, "");
fs.chmodSync(exe, 0o755);
return exe;
}
function resolvePersistedPatterns(params: {
command: string;
dir: string;
env: Record<string, string | undefined>;
safeBins: ReturnType<typeof resolveSafeBins>;
}) {
const analysis = evaluateShellAllowlist({
command: params.command,
allowlist: [],
safeBins: params.safeBins,
cwd: params.dir,
env: params.env,
platform: process.platform,
});
return {
analysis,
persisted: resolveAllowAlwaysPatterns({
segments: analysis.segments,
cwd: params.dir,
env: params.env,
platform: process.platform,
}),
};
}
function expectAllowAlwaysBypassBlocked(params: {
dir: string;
firstCommand: string;
secondCommand: string;
env: Record<string, string | undefined>;
persistedPattern: string;
}) {
const safeBins = resolveSafeBins(undefined);
const { persisted } = resolvePersistedPatterns({
command: params.firstCommand,
dir: params.dir,
env: params.env,
safeBins,
});
expect(persisted).toEqual([params.persistedPattern]);
const second = evaluateShellAllowlist({
command: params.secondCommand,
allowlist: [{ pattern: params.persistedPattern }],
safeBins,
cwd: params.dir,
env: params.env,
platform: process.platform,
});
expect(second.allowlistSatisfied).toBe(false);
expect(
requiresExecApproval({
ask: "on-miss",
security: "allowlist",
analysisOk: second.analysisOk,
allowlistSatisfied: second.allowlistSatisfied,
}),
).toBe(true);
}
function createShellScriptFixture() {
const dir = makeTempDir();
const scriptsDir = path.join(dir, "scripts");
fs.mkdirSync(scriptsDir, { recursive: true });
const script = path.join(scriptsDir, "save_crystal.sh");
fs.writeFileSync(script, "echo ok\n");
const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` };
const safeBins = resolveSafeBins(undefined);
return { dir, scriptsDir, script, env, safeBins };
}
function expectPersistedShellScriptMatch(params: {
command: string;
script: string;
dir: string;
env: Record<string, string | undefined>;
safeBins: ReturnType<typeof resolveSafeBins>;
}) {
const { persisted } = resolvePersistedPatterns({
command: params.command,
dir: params.dir,
env: params.env,
safeBins: params.safeBins,
});
expect(persisted).toEqual([params.script]);
const second = evaluateShellAllowlist({
command: params.command,
allowlist: [{ pattern: params.script }],
safeBins: params.safeBins,
cwd: params.dir,
env: params.env,
platform: process.platform,
});
expect(second.allowlistSatisfied).toBe(true);
}
it("returns direct executable paths for non-shell segments", () => {
const exe = path.join("/tmp", "openclaw-tool");
const patterns = resolveAllowAlwaysPatterns({
segments: [
{
raw: exe,
argv: [exe],
resolution: { rawExecutable: exe, resolvedPath: exe, executableName: "openclaw-tool" },
},
],
});
expect(patterns).toEqual([exe]);
});
it("unwraps shell wrappers and persists the inner executable instead", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const whoami = makeExecutable(dir, "whoami");
const patterns = resolveAllowAlwaysPatterns({
segments: [
{
raw: "/bin/zsh -lc 'whoami'",
argv: ["/bin/zsh", "-lc", "whoami"],
resolution: {
rawExecutable: "/bin/zsh",
resolvedPath: "/bin/zsh",
executableName: "zsh",
},
},
],
cwd: dir,
env: makePathEnv(dir),
platform: process.platform,
});
expect(patterns).toEqual([whoami]);
expect(patterns).not.toContain("/bin/zsh");
});
it("extracts all inner binaries from shell chains and deduplicates", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const whoami = makeExecutable(dir, "whoami");
const ls = makeExecutable(dir, "ls");
const patterns = resolveAllowAlwaysPatterns({
segments: [
{
raw: "/bin/zsh -lc 'whoami && ls && whoami'",
argv: ["/bin/zsh", "-lc", "whoami && ls && whoami"],
resolution: {
rawExecutable: "/bin/zsh",
resolvedPath: "/bin/zsh",
executableName: "zsh",
},
},
],
cwd: dir,
env: makePathEnv(dir),
platform: process.platform,
});
expect(new Set(patterns)).toEqual(new Set([whoami, ls]));
});
it("persists shell script paths for wrapper invocations without inline commands", () => {
if (process.platform === "win32") {
return;
}
const { dir, scriptsDir, script, env, safeBins } = createShellScriptFixture();
expectPersistedShellScriptMatch({
command: "bash scripts/save_crystal.sh",
script,
dir,
env,
safeBins,
});
const other = path.join(scriptsDir, "other.sh");
fs.writeFileSync(other, "echo other\n");
const third = evaluateShellAllowlist({
command: "bash scripts/other.sh",
allowlist: [{ pattern: script }],
safeBins,
cwd: dir,
env,
platform: process.platform,
});
expect(third.allowlistSatisfied).toBe(false);
});
it("matches persisted shell script paths through dispatch wrappers", () => {
if (process.platform === "win32") {
return;
}
const { dir, script, env, safeBins } = createShellScriptFixture();
expectPersistedShellScriptMatch({
command: "/usr/bin/nice bash scripts/save_crystal.sh",
script,
dir,
env,
safeBins,
});
});
it("does not treat inline shell commands as persisted script paths", () => {
if (process.platform === "win32") {
return;
}
const { dir, script, env } = createShellScriptFixture();
expectAllowAlwaysBypassBlocked({
dir,
firstCommand: "bash scripts/save_crystal.sh",
secondCommand: "bash -lc 'scripts/save_crystal.sh'",
env,
persistedPattern: script,
});
});
it("does not treat stdin shell mode as a persisted script path", () => {
if (process.platform === "win32") {
return;
}
const { dir, script, env } = createShellScriptFixture();
expectAllowAlwaysBypassBlocked({
dir,
firstCommand: "bash scripts/save_crystal.sh",
secondCommand: "bash -s scripts/save_crystal.sh",
env,
persistedPattern: script,
});
});
it("does not persist broad shell binaries when no inner command can be derived", () => {
const patterns = resolveAllowAlwaysPatterns({
segments: [
{
raw: "/bin/zsh -s",
argv: ["/bin/zsh", "-s"],
resolution: {
rawExecutable: "/bin/zsh",
resolvedPath: "/bin/zsh",
executableName: "zsh",
},
},
],
platform: process.platform,
});
expect(patterns).toEqual([]);
});
it("detects shell wrappers even when unresolved executableName is a full path", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const whoami = makeExecutable(dir, "whoami");
const patterns = resolveAllowAlwaysPatterns({
segments: [
{
raw: "/usr/local/bin/zsh -lc whoami",
argv: ["/usr/local/bin/zsh", "-lc", "whoami"],
resolution: {
rawExecutable: "/usr/local/bin/zsh",
resolvedPath: undefined,
executableName: "/usr/local/bin/zsh",
},
},
],
cwd: dir,
env: makePathEnv(dir),
platform: process.platform,
});
expect(patterns).toEqual([whoami]);
});
it("unwraps known dispatch wrappers before shell wrappers", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const whoami = makeExecutable(dir, "whoami");
const patterns = resolveAllowAlwaysPatterns({
segments: [
{
raw: "/usr/bin/nice /bin/zsh -lc whoami",
argv: ["/usr/bin/nice", "/bin/zsh", "-lc", "whoami"],
resolution: {
rawExecutable: "/usr/bin/nice",
resolvedPath: "/usr/bin/nice",
executableName: "nice",
},
},
],
cwd: dir,
env: makePathEnv(dir),
platform: process.platform,
});
expect(patterns).toEqual([whoami]);
expect(patterns).not.toContain("/usr/bin/nice");
});
it("unwraps busybox/toybox shell applets and persists inner executables", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const busybox = makeExecutable(dir, "busybox");
makeExecutable(dir, "toybox");
const whoami = makeExecutable(dir, "whoami");
const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` };
const patterns = resolveAllowAlwaysPatterns({
segments: [
{
raw: `${busybox} sh -lc whoami`,
argv: [busybox, "sh", "-lc", "whoami"],
resolution: {
rawExecutable: busybox,
resolvedPath: busybox,
executableName: "busybox",
},
},
],
cwd: dir,
env,
platform: process.platform,
});
expect(patterns).toEqual([whoami]);
expect(patterns).not.toContain(busybox);
});
it("fails closed for unsupported busybox/toybox applets", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const busybox = makeExecutable(dir, "busybox");
const patterns = resolveAllowAlwaysPatterns({
segments: [
{
raw: `${busybox} sed -n 1p`,
argv: [busybox, "sed", "-n", "1p"],
resolution: {
rawExecutable: busybox,
resolvedPath: busybox,
executableName: "busybox",
},
},
],
cwd: dir,
env: makePathEnv(dir),
platform: process.platform,
});
expect(patterns).toEqual([]);
});
it("fails closed for unresolved dispatch wrappers", () => {
const patterns = resolveAllowAlwaysPatterns({
segments: [
{
raw: "sudo /bin/zsh -lc whoami",
argv: ["sudo", "/bin/zsh", "-lc", "whoami"],
resolution: {
rawExecutable: "sudo",
resolvedPath: "/usr/bin/sudo",
executableName: "sudo",
},
},
],
platform: process.platform,
});
expect(patterns).toEqual([]);
});
it("prevents allow-always bypass for busybox shell applets", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const busybox = makeExecutable(dir, "busybox");
const echo = makeExecutable(dir, "echo");
makeExecutable(dir, "id");
const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` };
expectAllowAlwaysBypassBlocked({
dir,
firstCommand: `${busybox} sh -c 'echo warmup-ok'`,
secondCommand: `${busybox} sh -c 'id > marker'`,
env,
persistedPattern: echo,
});
});
it("prevents allow-always bypass for dispatch-wrapper + shell-wrapper chains", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const echo = makeExecutable(dir, "echo");
makeExecutable(dir, "id");
const env = makePathEnv(dir);
expectAllowAlwaysBypassBlocked({
dir,
firstCommand: "/usr/bin/nice /bin/zsh -lc 'echo warmup-ok'",
secondCommand: "/usr/bin/nice /bin/zsh -lc 'id > marker'",
env,
persistedPattern: echo,
});
});
it("does not persist comment-tailed payload paths that never execute", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const benign = makeExecutable(dir, "benign");
makeExecutable(dir, "payload");
const env = makePathEnv(dir);
expectAllowAlwaysBypassBlocked({
dir,
firstCommand: `${benign} warmup # && payload`,
secondCommand: "payload",
env,
persistedPattern: benign,
});
});
});