230 lines
7.0 KiB
TypeScript
230 lines
7.0 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
analyzeArgvCommand,
|
|
analyzeShellCommand,
|
|
isSafeBinUsage,
|
|
matchAllowlist,
|
|
maxAsk,
|
|
minSecurity,
|
|
normalizeSafeBins,
|
|
resolveCommandResolution,
|
|
resolveExecApprovals,
|
|
type ExecAllowlistEntry,
|
|
} from "./exec-approvals.js";
|
|
|
|
function makePathEnv(binDir: string): NodeJS.ProcessEnv {
|
|
if (process.platform !== "win32") {
|
|
return { PATH: binDir };
|
|
}
|
|
return { PATH: binDir, PATHEXT: ".EXE;.CMD;.BAT;.COM" };
|
|
}
|
|
|
|
function makeTempDir() {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-exec-approvals-"));
|
|
}
|
|
|
|
describe("exec approvals allowlist matching", () => {
|
|
it("ignores basename-only patterns", () => {
|
|
const resolution = {
|
|
rawExecutable: "rg",
|
|
resolvedPath: "/opt/homebrew/bin/rg",
|
|
executableName: "rg",
|
|
};
|
|
const entries: ExecAllowlistEntry[] = [{ pattern: "RG" }];
|
|
const match = matchAllowlist(entries, resolution);
|
|
expect(match).toBeNull();
|
|
});
|
|
|
|
it("matches by resolved path with **", () => {
|
|
const resolution = {
|
|
rawExecutable: "rg",
|
|
resolvedPath: "/opt/homebrew/bin/rg",
|
|
executableName: "rg",
|
|
};
|
|
const entries: ExecAllowlistEntry[] = [{ pattern: "/opt/**/rg" }];
|
|
const match = matchAllowlist(entries, resolution);
|
|
expect(match?.pattern).toBe("/opt/**/rg");
|
|
});
|
|
|
|
it("does not let * cross path separators", () => {
|
|
const resolution = {
|
|
rawExecutable: "rg",
|
|
resolvedPath: "/opt/homebrew/bin/rg",
|
|
executableName: "rg",
|
|
};
|
|
const entries: ExecAllowlistEntry[] = [{ pattern: "/opt/*/rg" }];
|
|
const match = matchAllowlist(entries, resolution);
|
|
expect(match).toBeNull();
|
|
});
|
|
|
|
it("requires a resolved path", () => {
|
|
const resolution = {
|
|
rawExecutable: "bin/rg",
|
|
resolvedPath: undefined,
|
|
executableName: "rg",
|
|
};
|
|
const entries: ExecAllowlistEntry[] = [{ pattern: "bin/rg" }];
|
|
const match = matchAllowlist(entries, resolution);
|
|
expect(match).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("exec approvals command resolution", () => {
|
|
it("resolves PATH executables", () => {
|
|
const dir = makeTempDir();
|
|
const binDir = path.join(dir, "bin");
|
|
fs.mkdirSync(binDir, { recursive: true });
|
|
const exeName = process.platform === "win32" ? "rg.exe" : "rg";
|
|
const exe = path.join(binDir, exeName);
|
|
fs.writeFileSync(exe, "");
|
|
fs.chmodSync(exe, 0o755);
|
|
const res = resolveCommandResolution("rg -n foo", undefined, makePathEnv(binDir));
|
|
expect(res?.resolvedPath).toBe(exe);
|
|
expect(res?.executableName).toBe(exeName);
|
|
});
|
|
|
|
it("resolves relative paths against cwd", () => {
|
|
const dir = makeTempDir();
|
|
const cwd = path.join(dir, "project");
|
|
const script = path.join(cwd, "scripts", "run.sh");
|
|
fs.mkdirSync(path.dirname(script), { recursive: true });
|
|
fs.writeFileSync(script, "");
|
|
fs.chmodSync(script, 0o755);
|
|
const res = resolveCommandResolution("./scripts/run.sh --flag", cwd, undefined);
|
|
expect(res?.resolvedPath).toBe(script);
|
|
});
|
|
|
|
it("parses quoted executables", () => {
|
|
const dir = makeTempDir();
|
|
const cwd = path.join(dir, "project");
|
|
const script = path.join(cwd, "bin", "tool");
|
|
fs.mkdirSync(path.dirname(script), { recursive: true });
|
|
fs.writeFileSync(script, "");
|
|
fs.chmodSync(script, 0o755);
|
|
const res = resolveCommandResolution('"./bin/tool" --version', cwd, undefined);
|
|
expect(res?.resolvedPath).toBe(script);
|
|
});
|
|
});
|
|
|
|
describe("exec approvals shell parsing", () => {
|
|
it("parses simple pipelines", () => {
|
|
const res = analyzeShellCommand({ command: "echo ok | jq .foo" });
|
|
expect(res.ok).toBe(true);
|
|
expect(res.segments.map((seg) => seg.argv[0])).toEqual(["echo", "jq"]);
|
|
});
|
|
|
|
it("rejects chained commands", () => {
|
|
const res = analyzeShellCommand({ command: "ls && rm -rf /" });
|
|
expect(res.ok).toBe(false);
|
|
});
|
|
|
|
it("parses argv commands", () => {
|
|
const res = analyzeArgvCommand({ argv: ["/bin/echo", "ok"] });
|
|
expect(res.ok).toBe(true);
|
|
expect(res.segments[0]?.argv).toEqual(["/bin/echo", "ok"]);
|
|
});
|
|
});
|
|
|
|
describe("exec approvals safe bins", () => {
|
|
it("allows safe bins with non-path args", () => {
|
|
const dir = makeTempDir();
|
|
const binDir = path.join(dir, "bin");
|
|
fs.mkdirSync(binDir, { recursive: true });
|
|
const exeName = process.platform === "win32" ? "jq.exe" : "jq";
|
|
const exe = path.join(binDir, exeName);
|
|
fs.writeFileSync(exe, "");
|
|
fs.chmodSync(exe, 0o755);
|
|
const res = analyzeShellCommand({
|
|
command: "jq .foo",
|
|
cwd: dir,
|
|
env: makePathEnv(binDir),
|
|
});
|
|
expect(res.ok).toBe(true);
|
|
const segment = res.segments[0];
|
|
const ok = isSafeBinUsage({
|
|
argv: segment.argv,
|
|
resolution: segment.resolution,
|
|
safeBins: normalizeSafeBins(["jq"]),
|
|
cwd: dir,
|
|
});
|
|
expect(ok).toBe(true);
|
|
});
|
|
|
|
it("blocks safe bins with file args", () => {
|
|
const dir = makeTempDir();
|
|
const binDir = path.join(dir, "bin");
|
|
fs.mkdirSync(binDir, { recursive: true });
|
|
const exeName = process.platform === "win32" ? "jq.exe" : "jq";
|
|
const exe = path.join(binDir, exeName);
|
|
fs.writeFileSync(exe, "");
|
|
fs.chmodSync(exe, 0o755);
|
|
const file = path.join(dir, "secret.json");
|
|
fs.writeFileSync(file, "{}");
|
|
const res = analyzeShellCommand({
|
|
command: "jq .foo secret.json",
|
|
cwd: dir,
|
|
env: makePathEnv(binDir),
|
|
});
|
|
expect(res.ok).toBe(true);
|
|
const segment = res.segments[0];
|
|
const ok = isSafeBinUsage({
|
|
argv: segment.argv,
|
|
resolution: segment.resolution,
|
|
safeBins: normalizeSafeBins(["jq"]),
|
|
cwd: dir,
|
|
});
|
|
expect(ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("exec approvals policy helpers", () => {
|
|
it("minSecurity returns the more restrictive value", () => {
|
|
expect(minSecurity("deny", "full")).toBe("deny");
|
|
expect(minSecurity("allowlist", "full")).toBe("allowlist");
|
|
});
|
|
|
|
it("maxAsk returns the more aggressive ask mode", () => {
|
|
expect(maxAsk("off", "always")).toBe("always");
|
|
expect(maxAsk("on-miss", "off")).toBe("on-miss");
|
|
});
|
|
});
|
|
|
|
describe("exec approvals wildcard agent", () => {
|
|
it("merges wildcard allowlist entries with agent entries", () => {
|
|
const dir = makeTempDir();
|
|
const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(dir);
|
|
|
|
try {
|
|
const approvalsPath = path.join(dir, ".clawdbot", "exec-approvals.json");
|
|
fs.mkdirSync(path.dirname(approvalsPath), { recursive: true });
|
|
fs.writeFileSync(
|
|
approvalsPath,
|
|
JSON.stringify(
|
|
{
|
|
version: 1,
|
|
agents: {
|
|
"*": { allowlist: [{ pattern: "/bin/hostname" }] },
|
|
main: { allowlist: [{ pattern: "/usr/bin/uname" }] },
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
|
|
const resolved = resolveExecApprovals("main");
|
|
expect(resolved.allowlist.map((entry) => entry.pattern)).toEqual([
|
|
"/bin/hostname",
|
|
"/usr/bin/uname",
|
|
]);
|
|
} finally {
|
|
homedirSpy.mockRestore();
|
|
}
|
|
});
|
|
});
|