import { describe, expect, test } from "vitest"; import { posixPathFlavor, win32PathFlavor } from "#src/path/path-flavor"; import type { Rule, RuleOrigin, Ruleset } from "#src/rule"; import { evaluate, evaluateAnyValue, evaluateFirst, evaluateMostRestrictive, floorAllowsToAsk, rewriteAsksToYolo, } from "#src/rule"; describe("evaluate", () => { const allowBashGit: Rule = { surface: "bash", pattern: "git *", action: "allow", origin: "global", }; const denyBashGitPush: Rule = { surface: "bash", pattern: "git push *", action: "deny", origin: "global", }; const allowRead: Rule = { surface: "read", pattern: "*", action: "allow", origin: "global", }; const askMcp: Rule = { surface: "mcp", pattern: "*", action: "ask", origin: "global", }; const allowSkillLibrarian: Rule = { surface: "skill", pattern: "librarian", action: "allow", origin: "global", }; const askSpecialExtDir: Rule = { surface: "special", pattern: "external_directory", action: "ask", origin: "global", }; test("returns matching rule when a rule matches", () => { const ruleset: Ruleset = [allowBashGit]; const result = evaluate("bash", "git status", ruleset, posixPathFlavor); expect(result).toEqual(allowBashGit); }); test("returns synthetic rule with 'ask' when no rules match and no defaultAction", () => { const result = evaluate( "bash", "npm install", [allowBashGit], posixPathFlavor, ); expect(result.surface).toBe("bash"); expect(result.pattern).toBe("npm install"); expect(result.action).toBe("ask"); }); test("returns synthetic rule with custom defaultAction when no rules match", () => { const result = evaluate( "bash", "npm install", [allowBashGit], posixPathFlavor, "deny", ); expect(result.surface).toBe("bash"); expect(result.pattern).toBe("npm install"); expect(result.action).toBe("deny"); }); test("defaultAction does not affect matched rules", () => { const result = evaluate( "bash", "git status", [allowBashGit], posixPathFlavor, "deny", ); expect(result).toEqual(allowBashGit); }); test("returns synthetic rule for empty ruleset", () => { const result = evaluate("mcp", "exa_search", [], posixPathFlavor); expect(result.surface).toBe("mcp"); expect(result.pattern).toBe("exa_search"); expect(result.action).toBe("ask"); }); test("matches rules for all permission surfaces", () => { expect( evaluate("read", "src/foo.ts", [allowRead], posixPathFlavor).action, ).toBe("allow"); expect( evaluate("mcp", "exa_search", [askMcp], posixPathFlavor).action, ).toBe("ask"); expect( evaluate("skill", "librarian", [allowSkillLibrarian], posixPathFlavor) .action, ).toBe("allow"); expect( evaluate( "special", "external_directory", [askSpecialExtDir], posixPathFlavor, ).action, ).toBe("ask"); }); test("last-match-wins: later conflicting rule overrides earlier", () => { const ruleset: Ruleset = [allowBashGit, denyBashGitPush]; const result = evaluate( "bash", "git push origin main", ruleset, posixPathFlavor, ); expect(result).toEqual(denyBashGitPush); }); test("last-match-wins: broad deny followed by specific allow", () => { const denyAll: Rule = { surface: "bash", pattern: "*", action: "deny", origin: "global", }; const allowStatus: Rule = { surface: "bash", pattern: "git status", action: "allow", origin: "global", }; const result = evaluate( "bash", "git status", [denyAll, allowStatus], posixPathFlavor, ); expect(result).toEqual(allowStatus); }); test("wildcard surface in rule matches any surface value", () => { const universalAllow: Rule = { surface: "*", pattern: "*", action: "allow", origin: "global", }; expect( evaluate("bash", "anything", [universalAllow], posixPathFlavor).action, ).toBe("allow"); expect( evaluate("mcp", "something", [universalAllow], posixPathFlavor).action, ).toBe("allow"); expect( evaluate("skill", "librarian", [universalAllow], posixPathFlavor).action, ).toBe("allow"); }); test("specific surface rule does not match a different surface", () => { const ruleset: Ruleset = [allowBashGit]; // bash rule should not match mcp surface const result = evaluate("mcp", "git status", ruleset, posixPathFlavor); expect(result.action).toBe("ask"); // falls back to default }); test("merged rulesets: rules from later scope take priority", () => { const globalRules: Ruleset = [ { surface: "bash", pattern: "git *", action: "ask", origin: "global" }, ]; const agentRules: Ruleset = [ { surface: "bash", pattern: "git *", action: "allow", origin: "agent" }, ]; const merged = [...globalRules, ...agentRules]; const result = evaluate("bash", "git status", merged, posixPathFlavor); expect(result.action).toBe("allow"); // agent rule wins }); test("merged rulesets: earlier scope used when later scope has no match", () => { const globalRules: Ruleset = [ { surface: "bash", pattern: "git *", action: "allow", origin: "global" }, ]; const agentRules: Ruleset = [ { surface: "bash", pattern: "npm *", action: "deny", origin: "agent" }, ]; // git status matches global but not agent rule const merged = [...globalRules, ...agentRules]; const result = evaluate("bash", "git status", merged, posixPathFlavor); expect(result.action).toBe("allow"); // global rule is the last match for this pattern }); test("empty ruleset returns synthetic default", () => { const result = evaluate("bash", "git status", [], posixPathFlavor); expect(result.surface).toBe("bash"); expect(result.pattern).toBe("git status"); expect(result.action).toBe("ask"); }); test("rule.layer is ignored by evaluate() — matching is identical with or without it", () => { const withLayer: Rule = { surface: "bash", pattern: "git *", action: "allow", layer: "config", origin: "global", }; const withoutLayer: Rule = { surface: "bash", pattern: "git *", action: "allow", origin: "global", }; const withDefault: Rule = { surface: "bash", pattern: "*", action: "ask", layer: "default", origin: "builtin", }; // Both rules with and without layer field produce the same match. expect( evaluate("bash", "git status", [withLayer], posixPathFlavor).action, ).toBe("allow"); expect( evaluate("bash", "git status", [withoutLayer], posixPathFlavor).action, ).toBe("allow"); // Layer metadata does not affect last-match-wins ordering. const ruleset: Rule[] = [withDefault, withLayer]; expect(evaluate("bash", "git status", ruleset, posixPathFlavor)).toEqual( withLayer, ); // A rule with layer: "default" still wins if it is last in the array. const reversedRuleset: Rule[] = [withLayer, withDefault]; expect( evaluate("bash", "git status", reversedRuleset, posixPathFlavor), ).toEqual(withDefault); }); test("evaluate() preserves origin on a matched rule", () => { const origin: RuleOrigin = "project"; const rule: Rule = { surface: "bash", pattern: "git *", action: "allow", layer: "config", origin, }; const result = evaluate("bash", "git status", [rule], posixPathFlavor); expect(result.origin).toBe("project"); }); test("evaluate() synthetic fallback rule has origin 'builtin'", () => { const result = evaluate("bash", "npm install", [], posixPathFlavor); expect(result.origin).toBe("builtin"); }); test("evaluate() propagates reason from the matched deny rule", () => { const rule: Rule = { surface: "bash", pattern: "npm *", action: "deny", reason: "Use pnpm instead", layer: "config", origin: "global", }; const result = evaluate("bash", "npm install", [rule], posixPathFlavor); expect(result.action).toBe("deny"); expect(result.reason).toBe("Use pnpm instead"); }); test("evaluate() carries reason through last-match-wins when deny wins", () => { const allowAll: Rule = { surface: "bash", pattern: "*", action: "allow", layer: "config", origin: "global", }; const denyNpm: Rule = { surface: "bash", pattern: "npm *", action: "deny", reason: "Use pnpm", layer: "config", origin: "global", }; const result = evaluate( "bash", "npm install", [allowAll, denyNpm], posixPathFlavor, ); expect(result.action).toBe("deny"); expect(result.reason).toBe("Use pnpm"); }); test("evaluate() drops reason when a later allow overrides the deny", () => { const denyNpm: Rule = { surface: "bash", pattern: "npm *", action: "deny", reason: "Use pnpm", layer: "config", origin: "global", }; const allowInstall: Rule = { surface: "bash", pattern: "npm install", action: "allow", layer: "config", origin: "global", }; const result = evaluate( "bash", "npm install", [denyNpm, allowInstall], posixPathFlavor, ); expect(result.action).toBe("allow"); expect(result.reason).toBeUndefined(); }); test("evaluate() synthetic fallback rule has no reason", () => { const result = evaluate("bash", "npm install", [], posixPathFlavor); expect(result.reason).toBeUndefined(); }); test("RuleOrigin covers all seven provenance values", () => { const origins: RuleOrigin[] = [ "global", "project", "agent", "project-agent", "builtin", "baseline", "session", ]; for (const origin of origins) { const rule: Rule = { surface: "read", pattern: "*", action: "allow", layer: "config", origin, }; expect(evaluate("read", "*", [rule], posixPathFlavor).origin).toBe( origin, ); } }); // ── Windows: path-surface patterns fold case (last-match-wins) ────────── const denyExternalAll: Rule = { surface: "external_directory", pattern: "*", action: "deny", layer: "config", origin: "global", }; const allowExternalPi: Rule = { surface: "external_directory", pattern: "C:\\Users\\Foo\\pi\\*", action: "allow", layer: "config", origin: "global", }; test("win32: external_directory allow override matches a lowercased path over a preceding deny", () => { const result = evaluate( "external_directory", "c:\\users\\foo\\pi\\docs\\readme.md", [denyExternalAll, allowExternalPi], win32PathFlavor, ); expect(result.action).toBe("allow"); }); test("posix: the same mixed-case override stays case-sensitive (falls through to deny)", () => { const result = evaluate( "external_directory", "c:\\users\\foo\\pi\\docs\\readme.md", [denyExternalAll, allowExternalPi], posixPathFlavor, ); expect(result.action).toBe("deny"); }); test("win32: a forward-slash external_directory pattern matches a backslash value", () => { const allowForwardSlash: Rule = { surface: "external_directory", pattern: "C:/Users/Foo/pi/*", action: "allow", layer: "config", origin: "global", }; const result = evaluate( "external_directory", "c:\\users\\foo\\pi\\docs\\readme.md", [denyExternalAll, allowForwardSlash], win32PathFlavor, ); expect(result.action).toBe("allow"); }); test("win32: a forward-slash path pattern matches a forward-slash value (#653)", () => { // A Git Bash device token reaches the `path` surface spelled as typed, so // the fold has to normalize the value as well as the rule pattern. const askAll: Rule = { surface: "path", pattern: "*", action: "ask", layer: "config", origin: "global", }; const allowDevice: Rule = { surface: "path", pattern: "/dev/null", action: "allow", layer: "config", origin: "global", }; const result = evaluate( "path", "/dev/null", [askAll, allowDevice], win32PathFlavor, ); expect(result.action).toBe("allow"); }); test("win32: bash surface keeps its separators unfolded (not a path surface)", () => { const result = evaluate( "bash", "cat \\tmp\\x", [ { surface: "bash", pattern: "cat /tmp/x", action: "allow", origin: "global", }, ], win32PathFlavor, ); expect(result.action).toBe("ask"); }); test("win32: bash surface stays case-sensitive (not a path surface)", () => { const result = evaluate( "bash", "GIT push", [ { surface: "bash", pattern: "git *", action: "allow", origin: "global", }, ], win32PathFlavor, ); expect(result.action).toBe("ask"); }); }); describe("evaluateFirst", () => { const defaultRule: Rule = { surface: "*", pattern: "*", action: "ask", layer: "default", origin: "builtin", }; const allowBash: Rule = { surface: "bash", pattern: "git *", action: "allow", layer: "config", origin: "global", }; const denyMcp: Rule = { surface: "mcp", pattern: "exa_search", action: "deny", layer: "config", origin: "global", }; test("returns the first candidate that matches a non-default rule", () => { const rules: Ruleset = [defaultRule, allowBash]; const result = evaluateFirst( "bash", ["git status", "*"], rules, posixPathFlavor, ); expect(result.rule).toEqual(allowBash); expect(result.value).toBe("git status"); }); test("skips candidates that only match the default rule", () => { // "npm install" matches only the default; "*" also matches only the // default — falls back to first candidate. const rules: Ruleset = [defaultRule]; const result = evaluateFirst( "bash", ["npm install", "*"], rules, posixPathFlavor, ); expect(result.rule.layer).toBe("default"); expect(result.value).toBe("npm install"); }); test("falls back to first candidate when all candidates match only the default", () => { const rules: Ruleset = [defaultRule]; const result = evaluateFirst( "bash", ["a", "b", "c"], rules, posixPathFlavor, ); expect(result.value).toBe("a"); }); test("stops at first non-default match, does not continue to remaining candidates", () => { // "exa_search" matches denyMcp (non-default). The loop stops there; // "mcp" is never evaluated even though it would match a different rule. const allowMcpCatchAll: Rule = { surface: "mcp", pattern: "mcp", action: "allow", layer: "config", origin: "global", }; const rules: Ruleset = [defaultRule, denyMcp, allowMcpCatchAll]; const result = evaluateFirst( "mcp", ["exa_search", "mcp"], rules, posixPathFlavor, ); expect(result.rule).toEqual(denyMcp); expect(result.value).toBe("exa_search"); }); test("skips candidates that match only the default and continues to next", () => { // "unknown_tool" matches only the universal default; // "exa_search" matches denyMcp (non-default) — that is the result. const rules: Ruleset = [defaultRule, denyMcp]; const result = evaluateFirst( "mcp", ["unknown_tool", "exa_search"], rules, posixPathFlavor, ); expect(result.rule).toEqual(denyMcp); expect(result.value).toBe("exa_search"); }); test("single-candidate array behaves like evaluate()", () => { const rules: Ruleset = [defaultRule, allowBash]; const result = evaluateFirst( "bash", ["git status"], rules, posixPathFlavor, ); expect(result.rule).toEqual(allowBash); expect(result.value).toBe("git status"); }); test("uses '*' as fallback value when values array is empty", () => { const rules: Ruleset = [defaultRule]; const result = evaluateFirst("bash", [], rules, posixPathFlavor); expect(result.value).toBe("*"); }); }); describe("evaluateAnyValue", () => { const catchAllAllow: Rule = { surface: "path", pattern: "*", action: "allow", layer: "config", origin: "global", }; const catchAllAsk: Rule = { surface: "path", pattern: "*", action: "ask", layer: "config", origin: "global", }; const relativeDeny: Rule = { surface: "path", pattern: "src/*", action: "deny", layer: "config", origin: "global", }; const absoluteAllow: Rule = { surface: "path", pattern: "/proj/*", action: "allow", layer: "config", origin: "global", }; test("a later relative rule wins over a catch-all matched by another alias", () => { const rules: Ruleset = [catchAllAllow, relativeDeny]; const result = evaluateAnyValue( "path", ["/proj/src/foo.ts", "src/foo.ts"], rules, posixPathFlavor, ); expect(result.rule).toEqual(relativeDeny); expect(result.value).toBe("src/foo.ts"); }); test("uses an absolute alias when no later relative rule matches", () => { const rules: Ruleset = [catchAllAsk, absoluteAllow]; const result = evaluateAnyValue( "path", ["/proj/src/foo.ts", "src/foo.ts"], rules, posixPathFlavor, ); expect(result.rule).toEqual(absoluteAllow); expect(result.value).toBe("/proj/src/foo.ts"); }); test("falls back to the first value's default when no rule matches", () => { const result = evaluateAnyValue( "path", ["/proj/src/foo.ts", "src/foo.ts"], [], posixPathFlavor, ); expect(result.rule.action).toBe("ask"); expect(result.value).toBe("/proj/src/foo.ts"); }); test("uses '*' as fallback value when values array is empty", () => { const result = evaluateAnyValue("path", [], [], posixPathFlavor); expect(result.value).toBe("*"); }); }); describe("evaluateMostRestrictive", () => { const denyEnv: Rule = { surface: "path", pattern: "*.env", action: "deny", layer: "config", origin: "global", }; const askSsh: Rule = { surface: "path", pattern: "/home/user/.ssh/*", action: "ask", layer: "config", origin: "global", }; const allowAll: Rule = { surface: "path", pattern: "*", action: "allow", layer: "config", origin: "global", }; test("deny short-circuits: returns immediately without evaluating remaining values", () => { const rules: Ruleset = [allowAll, denyEnv]; const result = evaluateMostRestrictive( "path", [".env", "README.md"], rules, posixPathFlavor, ); expect(result).not.toBeNull(); expect(result!.rule.action).toBe("deny"); expect(result!.value).toBe(".env"); }); test("ask accumulates: returns first ask when no deny found", () => { const rules: Ruleset = [allowAll, askSsh]; const result = evaluateMostRestrictive( "path", ["/home/user/.ssh/id_rsa", "README.md"], rules, posixPathFlavor, ); expect(result).not.toBeNull(); expect(result!.rule.action).toBe("ask"); expect(result!.value).toBe("/home/user/.ssh/id_rsa"); }); test("all allow: returns null", () => { const rules: Ruleset = [allowAll]; const result = evaluateMostRestrictive( "path", ["README.md", "src/index.ts"], rules, posixPathFlavor, ); expect(result).toBeNull(); }); test("empty values: returns null", () => { const rules: Ruleset = [allowAll, denyEnv]; const result = evaluateMostRestrictive("path", [], rules, posixPathFlavor); expect(result).toBeNull(); }); test("deny wins over ask", () => { const rules: Ruleset = [allowAll, askSsh, denyEnv]; const result = evaluateMostRestrictive( "path", ["/home/user/.ssh/id_rsa", ".env"], rules, posixPathFlavor, ); expect(result).not.toBeNull(); expect(result!.rule.action).toBe("deny"); expect(result!.value).toBe(".env"); }); }); // Shared Rule fixtures for the composition-stage overlay blocks // (rewriteAsksToYolo and floorAllowsToAsk), which mirror each other. const overlayAskBash: Rule = { surface: "bash", pattern: "*", action: "ask", layer: "config", origin: "global", }; const overlayDenyEnv: Rule = { surface: "path", pattern: ".env", action: "deny", layer: "config", origin: "project", }; const overlayAllowRead: Rule = { surface: "read", pattern: "*", action: "allow", layer: "config", origin: "agent", }; const overlayAskDefault: Rule = { surface: "*", pattern: "*", action: "ask", layer: "default", origin: "builtin", }; describe("rewriteAsksToYolo", () => { test("rewrites an ask rule to allow tagged origin 'yolo'", () => { const result = rewriteAsksToYolo([overlayAskBash]); expect(result).toEqual([ { surface: "bash", pattern: "*", action: "allow", layer: "config", origin: "yolo", }, ]); }); test("preserves surface, pattern, and layer while flipping ask", () => { const [rewritten] = rewriteAsksToYolo([overlayAskBash]); expect(rewritten.surface).toBe("bash"); expect(rewritten.pattern).toBe("*"); expect(rewritten.layer).toBe("config"); expect(rewritten.action).toBe("allow"); expect(rewritten.origin).toBe("yolo"); }); test("rewrites the synthesized universal default ask rule", () => { const result = rewriteAsksToYolo([overlayAskDefault]); expect(result[0]?.action).toBe("allow"); expect(result[0]?.origin).toBe("yolo"); expect(result[0]?.layer).toBe("default"); }); test("passes deny rules through untouched (preserves hard denies)", () => { const result = rewriteAsksToYolo([overlayDenyEnv]); expect(result).toEqual([overlayDenyEnv]); }); test("passes allow rules through untouched", () => { const result = rewriteAsksToYolo([overlayAllowRead]); expect(result).toEqual([overlayAllowRead]); }); test("rewrites only ask rules in a mixed ruleset, preserving order", () => { const ruleset: Ruleset = [ overlayAskDefault, overlayAllowRead, overlayAskBash, overlayDenyEnv, ]; const result = rewriteAsksToYolo(ruleset); expect(result.map((r) => r.action)).toEqual([ "allow", "allow", "allow", "deny", ]); expect(result.map((r) => r.origin)).toEqual([ "yolo", "agent", "yolo", "project", ]); }); test("does not mutate the input ruleset", () => { const ruleset: Ruleset = [overlayAskBash]; rewriteAsksToYolo(ruleset); expect(ruleset[0]?.action).toBe("ask"); expect(ruleset[0]?.origin).toBe("global"); }); test("'yolo' is a valid RuleOrigin", () => { const origin: RuleOrigin = "yolo"; expect(origin).toBe("yolo"); }); }); describe("floorAllowsToAsk", () => { test("floors an allow rule to ask tagged origin 'fail-closed'", () => { const result = floorAllowsToAsk([overlayAllowRead]); expect(result).toEqual([ { surface: "read", pattern: "*", action: "ask", layer: "config", origin: "fail-closed", }, ]); }); test("preserves surface, pattern, and layer while flooring allow", () => { const [floored] = floorAllowsToAsk([overlayAllowRead]); expect(floored.surface).toBe("read"); expect(floored.pattern).toBe("*"); expect(floored.layer).toBe("config"); expect(floored.action).toBe("ask"); expect(floored.origin).toBe("fail-closed"); }); test("passes deny rules through untouched (preserves hard denies)", () => { const result = floorAllowsToAsk([overlayDenyEnv]); expect(result).toEqual([overlayDenyEnv]); }); test("passes ask rules through untouched", () => { const result = floorAllowsToAsk([overlayAskBash]); expect(result).toEqual([overlayAskBash]); }); test("floors only allow rules in a mixed ruleset, preserving order", () => { const ruleset: Ruleset = [ overlayAskDefault, overlayAllowRead, overlayAskBash, overlayDenyEnv, ]; const result = floorAllowsToAsk(ruleset); expect(result.map((r) => r.action)).toEqual(["ask", "ask", "ask", "deny"]); expect(result.map((r) => r.origin)).toEqual([ "builtin", "fail-closed", "global", "project", ]); }); test("does not mutate the input ruleset", () => { const ruleset: Ruleset = [overlayAllowRead]; floorAllowsToAsk(ruleset); expect(ruleset[0]?.action).toBe("allow"); expect(ruleset[0]?.origin).toBe("agent"); }); test("'fail-closed' is a valid RuleOrigin", () => { const origin: RuleOrigin = "fail-closed"; expect(origin).toBe("fail-closed"); }); });