|
@@ -0,0 +1,165 @@
|
|
|
|
|
+import { mock, describe, expect, test } from "bun:test";
|
|
|
|
|
+
|
|
|
|
|
+// Mock log.ts to cut the heavy dependency chain
|
|
|
|
|
+mock.module("src/utils/log.ts", () => ({
|
|
|
|
|
+ logError: () => {},
|
|
|
|
|
+ logToFile: () => {},
|
|
|
|
|
+ getLogDisplayTitle: () => "",
|
|
|
|
|
+ logEvent: () => {},
|
|
|
|
|
+ logMCPError: () => {},
|
|
|
|
|
+ logMCPDebug: () => {},
|
|
|
|
|
+ dateToFilename: (d: Date) => d.toISOString().replace(/[:.]/g, "-"),
|
|
|
|
|
+ getLogFilePath: () => "/tmp/mock-log",
|
|
|
|
|
+ attachErrorLogSink: () => {},
|
|
|
|
|
+ getInMemoryErrors: () => [],
|
|
|
|
|
+ loadErrorLogs: async () => [],
|
|
|
|
|
+ getErrorLogByIndex: async () => null,
|
|
|
|
|
+ captureAPIRequest: () => {},
|
|
|
|
|
+ _resetErrorLogForTesting: () => {},
|
|
|
|
|
+}));
|
|
|
|
|
+
|
|
|
|
|
+// Mock slowOperations to avoid bun:bundle
|
|
|
|
|
+mock.module("src/utils/slowOperations.ts", () => ({
|
|
|
|
|
+ jsonStringify: JSON.stringify,
|
|
|
|
|
+ jsonParse: JSON.parse,
|
|
|
|
|
+ slowLogging: { enabled: false },
|
|
|
|
|
+ clone: (v: any) => structuredClone(v),
|
|
|
|
|
+ cloneDeep: (v: any) => structuredClone(v),
|
|
|
|
|
+ callerFrame: () => "",
|
|
|
|
|
+ SLOW_OPERATION_THRESHOLD_MS: 100,
|
|
|
|
|
+ writeFileSync_DEPRECATED: () => {},
|
|
|
|
|
+}));
|
|
|
|
|
+
|
|
|
|
|
+const {
|
|
|
|
|
+ getDenyRuleForTool,
|
|
|
|
|
+ getAskRuleForTool,
|
|
|
|
|
+ getDenyRuleForAgent,
|
|
|
|
|
+ filterDeniedAgents,
|
|
|
|
|
+} = await import("../permissions");
|
|
|
|
|
+
|
|
|
|
|
+import { getEmptyToolPermissionContext } from "../../../Tool";
|
|
|
|
|
+
|
|
|
|
|
+// ─── Helper ─────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+function makeContext(opts: {
|
|
|
|
|
+ denyRules?: string[];
|
|
|
|
|
+ askRules?: string[];
|
|
|
|
|
+}) {
|
|
|
|
|
+ const ctx = getEmptyToolPermissionContext();
|
|
|
|
|
+ const deny: Record<string, string[]> = {};
|
|
|
|
|
+ const ask: Record<string, string[]> = {};
|
|
|
|
|
+
|
|
|
|
|
+ // alwaysDenyRules stores raw rule strings — getDenyRules() calls
|
|
|
|
|
+ // permissionRuleValueFromString internally
|
|
|
|
|
+ if (opts.denyRules?.length) {
|
|
|
|
|
+ deny["localSettings"] = opts.denyRules;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (opts.askRules?.length) {
|
|
|
|
|
+ ask["localSettings"] = opts.askRules;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...ctx,
|
|
|
|
|
+ alwaysDenyRules: deny,
|
|
|
|
|
+ alwaysAskRules: ask,
|
|
|
|
|
+ } as any;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function makeTool(name: string, mcpInfo?: { serverName: string; toolName: string }) {
|
|
|
|
|
+ return { name, mcpInfo };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ─── getDenyRuleForTool ─────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+describe("getDenyRuleForTool", () => {
|
|
|
|
|
+ test("returns null when no deny rules", () => {
|
|
|
|
|
+ const ctx = makeContext({});
|
|
|
|
|
+ expect(getDenyRuleForTool(ctx, makeTool("Bash"))).toBeNull();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test("returns matching deny rule for tool", () => {
|
|
|
|
|
+ const ctx = makeContext({ denyRules: ["Bash"] });
|
|
|
|
|
+ const result = getDenyRuleForTool(ctx, makeTool("Bash"));
|
|
|
|
|
+ expect(result).not.toBeNull();
|
|
|
|
|
+ expect(result!.ruleValue.toolName).toBe("Bash");
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test("returns null for non-matching tool", () => {
|
|
|
|
|
+ const ctx = makeContext({ denyRules: ["Bash"] });
|
|
|
|
|
+ expect(getDenyRuleForTool(ctx, makeTool("Read"))).toBeNull();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test("rule with content does not match whole-tool deny", () => {
|
|
|
|
|
+ // getDenyRuleForTool uses toolMatchesRule which requires ruleContent === undefined
|
|
|
|
|
+ // Rules like "Bash(rm -rf)" only match specific invocations, not the entire tool
|
|
|
|
|
+ const ctx = makeContext({ denyRules: ["Bash(rm -rf)"] });
|
|
|
|
|
+ const result = getDenyRuleForTool(ctx, makeTool("Bash"));
|
|
|
|
|
+ expect(result).toBeNull();
|
|
|
|
|
+ });
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// ─── getAskRuleForTool ──────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+describe("getAskRuleForTool", () => {
|
|
|
|
|
+ test("returns null when no ask rules", () => {
|
|
|
|
|
+ const ctx = makeContext({});
|
|
|
|
|
+ expect(getAskRuleForTool(ctx, makeTool("Bash"))).toBeNull();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test("returns matching ask rule", () => {
|
|
|
|
|
+ const ctx = makeContext({ askRules: ["Write"] });
|
|
|
|
|
+ const result = getAskRuleForTool(ctx, makeTool("Write"));
|
|
|
|
|
+ expect(result).not.toBeNull();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test("returns null for non-matching tool", () => {
|
|
|
|
|
+ const ctx = makeContext({ askRules: ["Write"] });
|
|
|
|
|
+ expect(getAskRuleForTool(ctx, makeTool("Bash"))).toBeNull();
|
|
|
|
|
+ });
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// ─── getDenyRuleForAgent ────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+describe("getDenyRuleForAgent", () => {
|
|
|
|
|
+ test("returns null when no deny rules", () => {
|
|
|
|
|
+ const ctx = makeContext({});
|
|
|
|
|
+ expect(getDenyRuleForAgent(ctx, "Agent", "Explore")).toBeNull();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test("returns matching deny rule for agent type", () => {
|
|
|
|
|
+ const ctx = makeContext({ denyRules: ["Agent(Explore)"] });
|
|
|
|
|
+ const result = getDenyRuleForAgent(ctx, "Agent", "Explore");
|
|
|
|
|
+ expect(result).not.toBeNull();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test("returns null for non-matching agent type", () => {
|
|
|
|
|
+ const ctx = makeContext({ denyRules: ["Agent(Explore)"] });
|
|
|
|
|
+ expect(getDenyRuleForAgent(ctx, "Agent", "Research")).toBeNull();
|
|
|
|
|
+ });
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// ─── filterDeniedAgents ─────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+describe("filterDeniedAgents", () => {
|
|
|
|
|
+ test("returns all agents when no deny rules", () => {
|
|
|
|
|
+ const ctx = makeContext({});
|
|
|
|
|
+ const agents = [{ agentType: "Explore" }, { agentType: "Research" }];
|
|
|
|
|
+ expect(filterDeniedAgents(agents, ctx, "Agent")).toEqual(agents);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test("filters out denied agent type", () => {
|
|
|
|
|
+ const ctx = makeContext({ denyRules: ["Agent(Explore)"] });
|
|
|
|
|
+ const agents = [{ agentType: "Explore" }, { agentType: "Research" }];
|
|
|
|
|
+ const result = filterDeniedAgents(agents, ctx, "Agent");
|
|
|
|
|
+ expect(result).toHaveLength(1);
|
|
|
|
|
+ expect(result[0]!.agentType).toBe("Research");
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test("returns empty array when all agents denied", () => {
|
|
|
|
|
+ const ctx = makeContext({
|
|
|
|
|
+ denyRules: ["Agent(Explore)", "Agent(Research)"],
|
|
|
|
|
+ });
|
|
|
|
|
+ const agents = [{ agentType: "Explore" }, { agentType: "Research" }];
|
|
|
|
|
+ expect(filterDeniedAgents(agents, ctx, "Agent")).toEqual([]);
|
|
|
|
|
+ });
|
|
|
|
|
+});
|