multica/src/agent/tools/exec-allowlist.test.ts
yushen e67682cfa0 feat(agent): add exec approval type definitions and safety evaluation engine
Introduces the core exec approval system with:
- Type definitions: ExecSecurity, ExecAsk, ApprovalDecision, ExecApprovalConfig
- Command safety evaluation: shell syntax analysis, safe binary detection,
  dangerous pattern detection, allowlist matching
- Persistent allowlist management: glob pattern matching, dedup, usage tracking
- Comprehensive test coverage (76 tests)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 17:06:58 +08:00

164 lines
5.5 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
matchAllowlist,
addAllowlistEntry,
recordAllowlistUse,
removeAllowlistEntry,
normalizeAllowlist,
} from "./exec-allowlist.js";
import type { ExecAllowlistEntry } from "./exec-approval-types.js";
describe("matchAllowlist", () => {
const entries: ExecAllowlistEntry[] = [
{ id: "1", pattern: "git *" },
{ id: "2", pattern: "pnpm test" },
{ id: "3", pattern: "ls **" },
{ id: "4", pattern: "node --version" },
];
it("matches wildcard patterns", () => {
expect(matchAllowlist(entries, "git status")).toBeTruthy();
expect(matchAllowlist(entries, "git push origin main")).toBeNull(); // * doesn't match spaces
expect(matchAllowlist(entries, "git log")).toBeTruthy();
});
it("matches exact patterns", () => {
expect(matchAllowlist(entries, "pnpm test")).toBeTruthy();
expect(matchAllowlist(entries, "node --version")).toBeTruthy();
});
it("matches double-star patterns", () => {
expect(matchAllowlist(entries, "ls -la /tmp/some/path")).toBeTruthy();
});
it("is case-insensitive", () => {
expect(matchAllowlist(entries, "GIT status")).toBeTruthy();
expect(matchAllowlist(entries, "PNPM TEST")).toBeTruthy();
});
it("returns null for non-matching commands", () => {
expect(matchAllowlist(entries, "rm -rf /")).toBeNull();
expect(matchAllowlist(entries, "curl http://evil.com")).toBeNull();
expect(matchAllowlist(entries, "pnpm build")).toBeNull();
});
it("returns null for empty inputs", () => {
expect(matchAllowlist([], "git status")).toBeNull();
expect(matchAllowlist(entries, "")).toBeNull();
expect(matchAllowlist(entries, " ")).toBeNull();
});
});
describe("addAllowlistEntry", () => {
it("adds new entry with UUID", () => {
const entries: ExecAllowlistEntry[] = [];
const result = addAllowlistEntry(entries, "git *");
expect(result).toHaveLength(1);
expect(result[0]!.pattern).toBe("git *");
expect(result[0]!.id).toBeTruthy();
expect(result[0]!.lastUsedAt).toBeTruthy();
});
it("deduplicates by pattern", () => {
const entries: ExecAllowlistEntry[] = [{ id: "1", pattern: "git *" }];
const result = addAllowlistEntry(entries, "git *");
expect(result).toHaveLength(1); // no new entry
});
it("deduplicates case-insensitively", () => {
const entries: ExecAllowlistEntry[] = [{ id: "1", pattern: "Git *" }];
const result = addAllowlistEntry(entries, "git *");
expect(result).toHaveLength(1);
});
it("trims pattern", () => {
const entries: ExecAllowlistEntry[] = [];
const result = addAllowlistEntry(entries, " git * ");
expect(result[0]!.pattern).toBe("git *");
});
it("preserves existing entries", () => {
const entries: ExecAllowlistEntry[] = [{ id: "1", pattern: "ls *" }];
const result = addAllowlistEntry(entries, "git *");
expect(result).toHaveLength(2);
expect(result[0]!.pattern).toBe("ls *");
});
});
describe("recordAllowlistUse", () => {
it("updates lastUsedAt and lastUsedCommand", () => {
const entry: ExecAllowlistEntry = { id: "1", pattern: "git *" };
const entries = [entry];
const result = recordAllowlistUse(entries, entry, "git status");
expect(result[0]!.lastUsedAt).toBeTruthy();
expect(result[0]!.lastUsedCommand).toBe("git status");
});
it("matches by ID", () => {
const entries: ExecAllowlistEntry[] = [
{ id: "1", pattern: "git *" },
{ id: "2", pattern: "ls *" },
];
const result = recordAllowlistUse(entries, { id: "2", pattern: "ls *" }, "ls -la");
expect(result[0]!.lastUsedCommand).toBeUndefined();
expect(result[1]!.lastUsedCommand).toBe("ls -la");
});
it("matches by pattern when no ID", () => {
const entries: ExecAllowlistEntry[] = [{ pattern: "git *" }];
const result = recordAllowlistUse(entries, { pattern: "git *" }, "git log");
expect(result[0]!.lastUsedCommand).toBe("git log");
});
});
describe("removeAllowlistEntry", () => {
it("removes by pattern", () => {
const entries: ExecAllowlistEntry[] = [
{ id: "1", pattern: "git *" },
{ id: "2", pattern: "ls *" },
];
const result = removeAllowlistEntry(entries, "git *");
expect(result).toHaveLength(1);
expect(result[0]!.pattern).toBe("ls *");
});
it("removes by ID", () => {
const entries: ExecAllowlistEntry[] = [
{ id: "1", pattern: "git *" },
{ id: "2", pattern: "ls *" },
];
const result = removeAllowlistEntry(entries, "1");
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe("2");
});
it("is case-insensitive for patterns", () => {
const entries: ExecAllowlistEntry[] = [{ id: "1", pattern: "Git *" }];
const result = removeAllowlistEntry(entries, "git *");
expect(result).toHaveLength(0);
});
});
describe("normalizeAllowlist", () => {
it("assigns IDs to entries without them", () => {
const entries: ExecAllowlistEntry[] = [{ pattern: "git *" }];
const result = normalizeAllowlist(entries);
expect(result[0]!.id).toBeTruthy();
});
it("preserves existing IDs", () => {
const entries: ExecAllowlistEntry[] = [{ id: "my-id", pattern: "git *" }];
const result = normalizeAllowlist(entries);
expect(result[0]!.id).toBe("my-id");
});
it("deduplicates by pattern", () => {
const entries: ExecAllowlistEntry[] = [
{ id: "1", pattern: "git *" },
{ id: "2", pattern: "Git *" }, // duplicate (case-insensitive)
];
const result = normalizeAllowlist(entries);
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe("1"); // first one wins
});
});