/** * KHAELOR * File: tests/permissions/rules.test.ts * Description: Evaluator tests — wildcard matching, last-match-wins, layer precedence, hardline floor, normalization, defaults. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { describe, expect, it } from "vitest"; import type { CapabilityRequest } from "../../src/permissions/capabilities.js"; import { DEFAULT_RULES, HARDLINE_RULE, combineDecisions, evaluate, mergeRuleLayers, normalizePermissionsSection, tagRules, wildcardMatch, } from "../../src/permissions/rules.js"; import type { PermissionRule } from "../../src/permissions/rules.js"; import { unwrap } from "../../src/shared/index.js"; function req(capability: CapabilityRequest["capability"], subject: string, extra?: Partial): CapabilityRequest { return { capability, subject, display: subject, alwaysPatterns: [], riskNotes: [], ...extra, }; } describe("wildcardMatch", () => { it("* matches any run of characters, including / in paths", () => { expect(wildcardMatch("*", "anything at all")).toBe(true); expect(wildcardMatch("file.write.*", "file.write.project")).toBe(true); expect(wildcardMatch("git push *", "git push origin main")).toBe(true); expect(wildcardMatch("/Users/x/notes/*", "/Users/x/notes/a/b.md")).toBe(true); expect(wildcardMatch("*.env", "/proj/.env")).toBe(true); expect(wildcardMatch("*/.ssh/*", "/Users/x/.ssh/id_rsa")).toBe(true); }); it("a pattern without * must match exactly, case-sensitively", () => { expect(wildcardMatch("git status", "git status")).toBe(true); expect(wildcardMatch("git status", "git status --short")).toBe(false); expect(wildcardMatch("Git status", "git status")).toBe(false); }); it("escapes regex specials in patterns", () => { expect(wildcardMatch("npm run build (prod)*", "npm run build (prod) --x")).toBe(true); expect(wildcardMatch("a.b", "aXb")).toBe(false); }); }); describe("evaluate — last match wins (§4.3)", () => { const request = req("process.execute", "git push origin main"); it("the LAST matching rule wins", () => { const allowThenDeny: PermissionRule[] = [ { capability: "process.execute", pattern: "*", action: "allow" }, { capability: "process.execute", pattern: "git push *", action: "deny" }, ]; expect(evaluate(allowThenDeny, request).action).toBe("deny"); expect(evaluate([...allowThenDeny].reverse(), request).action).toBe("allow"); }); it("both fields must match", () => { const rules: PermissionRule[] = [ { capability: "network.access", pattern: "git push *", action: "deny" }, ]; expect(evaluate(rules, request).action).toBe("ask"); // capability mismatch → unmatched }); it("unmatched requests default to ask (safe default)", () => { expect(evaluate([], request)).toEqual({ action: "ask" }); }); it("missing pattern defaults to *", () => { const rules: PermissionRule[] = [{ capability: "process.execute", action: "allow" }]; expect(evaluate(rules, request).action).toBe("allow"); }); it("returns the matched rule for provenance", () => { const rule: PermissionRule = { capability: "process.execute", pattern: "git push *", action: "allow", source: "project", }; expect(evaluate([rule], request).rule).toBe(rule); }); it("command subjects are matched with collapsed whitespace", () => { const rules: PermissionRule[] = [ { capability: "process.execute", pattern: "npm install", action: "allow" }, ]; expect(evaluate(rules, req("process.execute", "npm install")).action).toBe("allow"); }); it("exact-only requests: allow rules need an exact pattern, deny still matches wildcards", () => { const subject = "cat README.md > /tmp/out"; const exactReq = req("process.execute", subject, { exactOnly: true }); const wildcardAllow: PermissionRule[] = [ { capability: "process.execute", pattern: "cat *", action: "allow" }, ]; expect(evaluate(wildcardAllow, exactReq).action).toBe("ask"); // cat * > file asks (§4.5) const exactAllow: PermissionRule[] = [ { capability: "process.execute", pattern: subject, action: "allow" }, ]; expect(evaluate(exactAllow, exactReq).action).toBe("allow"); const wildcardDeny: PermissionRule[] = [ { capability: "process.execute", pattern: "cat *", action: "deny" }, ]; expect(evaluate(wildcardDeny, exactReq).action).toBe("deny"); }); }); describe("hardline floor — beneath the rule system, unoverridable (§4.2)", () => { const allowEverything: PermissionRule[] = [{ capability: "*", pattern: "*", action: "allow" }]; it("no allow rule can override the floor", () => { const decision = evaluate(allowEverything, req("process.execute", "rm -rf /")); expect(decision.action).toBe("deny"); expect(decision.rule).toBe(HARDLINE_RULE); }); it("fires on de-obfuscated variants", () => { expect(evaluate(allowEverything, req("process.execute", 'rm -rf "/"')).action).toBe("deny"); expect(evaluate(allowEverything, req("process.background", "shutdown -h now")).action).toBe( "deny", ); }); it("path capabilities are not routed through the command floor", () => { const decision = evaluate(allowEverything, req("file.read", "/proj/rm -rf slash")); expect(decision.action).toBe("allow"); }); }); describe("layering — defaults < user < project < session (§4.2)", () => { it("later layers win by position", () => { const defaults: PermissionRule[] = [ { capability: "process.execute", pattern: "*", action: "ask", source: "default" }, ]; const user: PermissionRule[] = [ { capability: "process.execute", pattern: "npm test", action: "deny", source: "user" }, ]; const project: PermissionRule[] = [ { capability: "process.execute", pattern: "npm test", action: "allow", source: "project" }, ]; const session: PermissionRule[] = [ { capability: "process.execute", pattern: "npm test", action: "deny", source: "session" }, ]; const request = req("process.execute", "npm test"); expect(evaluate(mergeRuleLayers(defaults, user), request).action).toBe("deny"); expect(evaluate(mergeRuleLayers(defaults, user, project), request).action).toBe("allow"); expect(evaluate(mergeRuleLayers(defaults, user, project, session), request).action).toBe("deny"); }); it("skips undefined layers and tags provenance", () => { const merged = mergeRuleLayers(undefined, tagRules([{ capability: "*", action: "ask" }], "user")); expect(merged).toEqual([{ capability: "*", action: "ask", source: "user" }]); }); }); describe("normalizePermissionsSection (§4.1)", () => { it("expands shorthand and nested forms in source key order, then appends rules", () => { const rules = unwrap( normalizePermissionsSection( { "file.read": "allow", "process.execute": { "git status": "allow", "*": "ask" }, rules: [ { capability: "file.write.outsideProject", pattern: "/Users/x/notes/*", action: "allow" }, ], }, "project", ), ); expect(rules).toEqual([ { capability: "file.read", action: "allow", source: "project" }, { capability: "process.execute", pattern: "git status", action: "allow", source: "project" }, { capability: "process.execute", pattern: "*", action: "ask", source: "project" }, { capability: "file.write.outsideProject", pattern: "/Users/x/notes/*", action: "allow", source: "project", }, ]); }); it("rejects invalid shapes", () => { expect(normalizePermissionsSection(null, "user").ok).toBe(false); expect(normalizePermissionsSection({ "file.read": "maybe" }, "user").ok).toBe(false); expect(normalizePermissionsSection({ "file.read": 3 }, "user").ok).toBe(false); expect(normalizePermissionsSection({ rules: [{ action: "allow" }] }, "user").ok).toBe(false); expect(normalizePermissionsSection({ rules: [{ capability: "x", action: "later" }] }, "user").ok).toBe(false); }); }); describe("shipped defaults (§4.5)", () => { const rules = [...DEFAULT_RULES]; it("ordinary project work is friction-free", () => { expect(evaluate(rules, req("file.read", "/proj/src/a.ts")).action).toBe("allow"); expect(evaluate(rules, req("file.write.project", "/proj/src/a.ts")).action).toBe("allow"); expect(evaluate(rules, req("process.execute", "git status --short")).action).toBe("allow"); expect(evaluate(rules, req("process.execute", "ls -la")).action).toBe("allow"); expect(evaluate(rules, req("process.background", "stdin:npm run dev")).action).toBe("allow"); }); it("secrets-shaped reads and broad actions ask", () => { expect(evaluate(rules, req("file.read", "/proj/.env")).action).toBe("ask"); expect(evaluate(rules, req("file.read", "/proj/.env.local")).action).toBe("ask"); expect(evaluate(rules, req("file.read", "/proj/.env.example")).action).toBe("allow"); // last match wins expect(evaluate(rules, req("file.read", "/Users/x/.ssh/id_rsa")).action).toBe("ask"); expect(evaluate(rules, req("file.write.outsideProject", "/etc/hosts")).action).toBe("ask"); expect(evaluate(rules, req("process.execute", "npm install")).action).toBe("ask"); expect(evaluate(rules, req("network.access", "curl https://x")).action).toBe("ask"); expect(evaluate(rules, req("git.modify", "git push origin main")).action).toBe("ask"); expect(evaluate(rules, req("process.background", "npm run dev")).action).toBe("ask"); }); }); describe("combineDecisions — deny > ask > allow (§4.4)", () => { it("combines correctly", () => { expect(combineDecisions([{ action: "allow" }, { action: "allow" }])).toBe("allow"); expect(combineDecisions([{ action: "allow" }, { action: "ask" }])).toBe("ask"); expect(combineDecisions([{ action: "ask" }, { action: "deny" }])).toBe("deny"); expect(combineDecisions([])).toBe("allow"); // no requests = pure observation }); });