SPB Git

spb/khaelor Public

KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.

TypeScript 82.9% HTML 14.9% CSS 1.1% JavaScript 0.7%
9.9 KB · 237 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: tests/permissions/rules.test.ts4 * Description: Evaluator tests — wildcard matching, last-match-wins, layer precedence, hardline floor, normalization, defaults.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { describe, expect, it } from "vitest";11import type { CapabilityRequest } from "../../src/permissions/capabilities.js";12import {13  DEFAULT_RULES,14  HARDLINE_RULE,15  combineDecisions,16  evaluate,17  mergeRuleLayers,18  normalizePermissionsSection,19  tagRules,20  wildcardMatch,21} from "../../src/permissions/rules.js";22import type { PermissionRule } from "../../src/permissions/rules.js";23import { unwrap } from "../../src/shared/index.js";2425function req(capability: CapabilityRequest["capability"], subject: string, extra?: Partial<CapabilityRequest>): CapabilityRequest {26  return {27    capability,28    subject,29    display: subject,30    alwaysPatterns: [],31    riskNotes: [],32    ...extra,33  };34}3536describe("wildcardMatch", () => {37  it("* matches any run of characters, including / in paths", () => {38    expect(wildcardMatch("*", "anything at all")).toBe(true);39    expect(wildcardMatch("file.write.*", "file.write.project")).toBe(true);40    expect(wildcardMatch("git push *", "git push origin main")).toBe(true);41    expect(wildcardMatch("/Users/x/notes/*", "/Users/x/notes/a/b.md")).toBe(true);42    expect(wildcardMatch("*.env", "/proj/.env")).toBe(true);43    expect(wildcardMatch("*/.ssh/*", "/Users/x/.ssh/id_rsa")).toBe(true);44  });4546  it("a pattern without * must match exactly, case-sensitively", () => {47    expect(wildcardMatch("git status", "git status")).toBe(true);48    expect(wildcardMatch("git status", "git status --short")).toBe(false);49    expect(wildcardMatch("Git status", "git status")).toBe(false);50  });5152  it("escapes regex specials in patterns", () => {53    expect(wildcardMatch("npm run build (prod)*", "npm run build (prod) --x")).toBe(true);54    expect(wildcardMatch("a.b", "aXb")).toBe(false);55  });56});5758describe("evaluate — last match wins (§4.3)", () => {59  const request = req("process.execute", "git push origin main");6061  it("the LAST matching rule wins", () => {62    const allowThenDeny: PermissionRule[] = [63      { capability: "process.execute", pattern: "*", action: "allow" },64      { capability: "process.execute", pattern: "git push *", action: "deny" },65    ];66    expect(evaluate(allowThenDeny, request).action).toBe("deny");67    expect(evaluate([...allowThenDeny].reverse(), request).action).toBe("allow");68  });6970  it("both fields must match", () => {71    const rules: PermissionRule[] = [72      { capability: "network.access", pattern: "git push *", action: "deny" },73    ];74    expect(evaluate(rules, request).action).toBe("ask"); // capability mismatch → unmatched75  });7677  it("unmatched requests default to ask (safe default)", () => {78    expect(evaluate([], request)).toEqual({ action: "ask" });79  });8081  it("missing pattern defaults to *", () => {82    const rules: PermissionRule[] = [{ capability: "process.execute", action: "allow" }];83    expect(evaluate(rules, request).action).toBe("allow");84  });8586  it("returns the matched rule for provenance", () => {87    const rule: PermissionRule = {88      capability: "process.execute",89      pattern: "git push *",90      action: "allow",91      source: "project",92    };93    expect(evaluate([rule], request).rule).toBe(rule);94  });9596  it("command subjects are matched with collapsed whitespace", () => {97    const rules: PermissionRule[] = [98      { capability: "process.execute", pattern: "npm install", action: "allow" },99    ];100    expect(evaluate(rules, req("process.execute", "npm   install")).action).toBe("allow");101  });102103  it("exact-only requests: allow rules need an exact pattern, deny still matches wildcards", () => {104    const subject = "cat README.md > /tmp/out";105    const exactReq = req("process.execute", subject, { exactOnly: true });106    const wildcardAllow: PermissionRule[] = [107      { capability: "process.execute", pattern: "cat *", action: "allow" },108    ];109    expect(evaluate(wildcardAllow, exactReq).action).toBe("ask"); // cat * > file asks (§4.5)110    const exactAllow: PermissionRule[] = [111      { capability: "process.execute", pattern: subject, action: "allow" },112    ];113    expect(evaluate(exactAllow, exactReq).action).toBe("allow");114    const wildcardDeny: PermissionRule[] = [115      { capability: "process.execute", pattern: "cat *", action: "deny" },116    ];117    expect(evaluate(wildcardDeny, exactReq).action).toBe("deny");118  });119});120121describe("hardline floor — beneath the rule system, unoverridable (§4.2)", () => {122  const allowEverything: PermissionRule[] = [{ capability: "*", pattern: "*", action: "allow" }];123124  it("no allow rule can override the floor", () => {125    const decision = evaluate(allowEverything, req("process.execute", "rm -rf /"));126    expect(decision.action).toBe("deny");127    expect(decision.rule).toBe(HARDLINE_RULE);128  });129130  it("fires on de-obfuscated variants", () => {131    expect(evaluate(allowEverything, req("process.execute", 'rm -rf "/"')).action).toBe("deny");132    expect(evaluate(allowEverything, req("process.background", "shutdown -h now")).action).toBe(133      "deny",134    );135  });136137  it("path capabilities are not routed through the command floor", () => {138    const decision = evaluate(allowEverything, req("file.read", "/proj/rm -rf slash"));139    expect(decision.action).toBe("allow");140  });141});142143describe("layering — defaults < user < project < session (§4.2)", () => {144  it("later layers win by position", () => {145    const defaults: PermissionRule[] = [146      { capability: "process.execute", pattern: "*", action: "ask", source: "default" },147    ];148    const user: PermissionRule[] = [149      { capability: "process.execute", pattern: "npm test", action: "deny", source: "user" },150    ];151    const project: PermissionRule[] = [152      { capability: "process.execute", pattern: "npm test", action: "allow", source: "project" },153    ];154    const session: PermissionRule[] = [155      { capability: "process.execute", pattern: "npm test", action: "deny", source: "session" },156    ];157    const request = req("process.execute", "npm test");158    expect(evaluate(mergeRuleLayers(defaults, user), request).action).toBe("deny");159    expect(evaluate(mergeRuleLayers(defaults, user, project), request).action).toBe("allow");160    expect(evaluate(mergeRuleLayers(defaults, user, project, session), request).action).toBe("deny");161  });162163  it("skips undefined layers and tags provenance", () => {164    const merged = mergeRuleLayers(undefined, tagRules([{ capability: "*", action: "ask" }], "user"));165    expect(merged).toEqual([{ capability: "*", action: "ask", source: "user" }]);166  });167});168169describe("normalizePermissionsSection (§4.1)", () => {170  it("expands shorthand and nested forms in source key order, then appends rules", () => {171    const rules = unwrap(172      normalizePermissionsSection(173        {174          "file.read": "allow",175          "process.execute": { "git status": "allow", "*": "ask" },176          rules: [177            { capability: "file.write.outsideProject", pattern: "/Users/x/notes/*", action: "allow" },178          ],179        },180        "project",181      ),182    );183    expect(rules).toEqual([184      { capability: "file.read", action: "allow", source: "project" },185      { capability: "process.execute", pattern: "git status", action: "allow", source: "project" },186      { capability: "process.execute", pattern: "*", action: "ask", source: "project" },187      {188        capability: "file.write.outsideProject",189        pattern: "/Users/x/notes/*",190        action: "allow",191        source: "project",192      },193    ]);194  });195196  it("rejects invalid shapes", () => {197    expect(normalizePermissionsSection(null, "user").ok).toBe(false);198    expect(normalizePermissionsSection({ "file.read": "maybe" }, "user").ok).toBe(false);199    expect(normalizePermissionsSection({ "file.read": 3 }, "user").ok).toBe(false);200    expect(normalizePermissionsSection({ rules: [{ action: "allow" }] }, "user").ok).toBe(false);201    expect(normalizePermissionsSection({ rules: [{ capability: "x", action: "later" }] }, "user").ok).toBe(false);202  });203});204205describe("shipped defaults (§4.5)", () => {206  const rules = [...DEFAULT_RULES];207208  it("ordinary project work is friction-free", () => {209    expect(evaluate(rules, req("file.read", "/proj/src/a.ts")).action).toBe("allow");210    expect(evaluate(rules, req("file.write.project", "/proj/src/a.ts")).action).toBe("allow");211    expect(evaluate(rules, req("process.execute", "git status --short")).action).toBe("allow");212    expect(evaluate(rules, req("process.execute", "ls -la")).action).toBe("allow");213    expect(evaluate(rules, req("process.background", "stdin:npm run dev")).action).toBe("allow");214  });215216  it("secrets-shaped reads and broad actions ask", () => {217    expect(evaluate(rules, req("file.read", "/proj/.env")).action).toBe("ask");218    expect(evaluate(rules, req("file.read", "/proj/.env.local")).action).toBe("ask");219    expect(evaluate(rules, req("file.read", "/proj/.env.example")).action).toBe("allow"); // last match wins220    expect(evaluate(rules, req("file.read", "/Users/x/.ssh/id_rsa")).action).toBe("ask");221    expect(evaluate(rules, req("file.write.outsideProject", "/etc/hosts")).action).toBe("ask");222    expect(evaluate(rules, req("process.execute", "npm install")).action).toBe("ask");223    expect(evaluate(rules, req("network.access", "curl https://x")).action).toBe("ask");224    expect(evaluate(rules, req("git.modify", "git push origin main")).action).toBe("ask");225    expect(evaluate(rules, req("process.background", "npm run dev")).action).toBe("ask");226  });227});228229describe("combineDecisions — deny > ask > allow (§4.4)", () => {230  it("combines correctly", () => {231    expect(combineDecisions([{ action: "allow" }, { action: "allow" }])).toBe("allow");232    expect(combineDecisions([{ action: "allow" }, { action: "ask" }])).toBe("ask");233    expect(combineDecisions([{ action: "ask" }, { action: "deny" }])).toBe("deny");234    expect(combineDecisions([])).toBe("allow"); // no requests = pure observation235  });236});237