Files

912 lines
25 KiB
TypeScript

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");
});
});