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%
7.5 KB · 184 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: tests/permissions/capabilities.test.ts4 * Description: Per-tool capability mapping goldens — path resolution tricks, bash derivation, process actions.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { describe, expect, it } from "vitest";11import { unwrap } from "../../src/shared/index.js";12import { mapToolCapabilities } from "../../src/permissions/capabilities.js";13import type {14  CapabilityMappingContext,15  CapabilityRequest,16} from "../../src/permissions/capabilities.js";1718const CTX: CapabilityMappingContext = { projectRoot: "/proj", cwd: "/proj", home: "/Users/x" };1920function map(tool: string, input: unknown, ctx: CapabilityMappingContext = CTX): CapabilityRequest[] {21  return unwrap(mapToolCapabilities(tool, input, ctx));22}2324describe("read / grep / glob → file.read", () => {25  it("read maps to file.read with the resolved path", () => {26    const [req] = map("read", { file_path: "src/a.ts" });27    expect(req).toMatchObject({ capability: "file.read", subject: "/proj/src/a.ts" });28  });2930  it("reads outside the project stay file.read (pattern rules can still gate them)", () => {31    const [req] = map("read", { file_path: "/etc/hosts" });32    expect(req?.capability).toBe("file.read");33    expect(req?.subject).toBe("/etc/hosts");34  });3536  it("grep and glob default their subject to the cwd", () => {37    expect(map("grep", {})[0]).toMatchObject({ capability: "file.read", subject: "/proj" });38    expect(map("glob", { path: "src" })[0]).toMatchObject({39      capability: "file.read",40      subject: "/proj/src",41    });42  });43});4445describe("write / edit → file.write.* with full path resolution", () => {46  it("write under the project root is file.write.project", () => {47    const [req] = map("write", { file_path: "src/new.ts" });48    expect(req?.capability).toBe("file.write.project");49    expect(req?.subject).toBe("/proj/src/new.ts");50  });5152  it("../ traversal flips to file.write.outsideProject", () => {53    const [req] = map("edit", { file_path: "../outside.txt" });54    expect(req?.capability).toBe("file.write.outsideProject");55    expect(req?.subject).toBe("/outside.txt");56    expect(req?.riskNotes).toContain("Path is outside the project root.");57  });5859  it("./x/../../etc/hosts is resolved BEFORE classification (spec §1 example)", () => {60    const [req] = map("write", { file_path: "./x/../../etc/hosts", }, { ...CTX, cwd: "/proj" });61    expect(req?.capability).toBe("file.write.outsideProject");62    expect(req?.subject).toBe("/etc/hosts");63  });6465  it("symlinks are canonicalized via the injected resolver", () => {66    const ctx: CapabilityMappingContext = {67      ...CTX,68      resolvePath: (p) => (p === "/proj/link.txt" ? "/etc/target" : p),69    };70    const [req] = map("write", { file_path: "link.txt" }, ctx);71    expect(req?.capability).toBe("file.write.outsideProject");72    expect(req?.subject).toBe("/etc/target");73  });7475  it("a prefix-sharing sibling directory is NOT inside the project", () => {76    const [req] = map("write", { file_path: "/proj-evil/x.ts" });77    expect(req?.capability).toBe("file.write.outsideProject");78  });79});8081describe("bash → process.execute plus derived requests", () => {82  it("a simple command yields one process.execute request with suggestions", () => {83    const reqs = map("bash", { command: "npm  install" });84    expect(reqs).toHaveLength(1);85    expect(reqs[0]).toMatchObject({86      capability: "process.execute",87      subject: "npm install", // collapsed whitespace88      alwaysPatterns: ["npm install"],89    });90  });9192  it("derives network.access for net tools", () => {93    const reqs = map("bash", { command: "curl https://api.example.com" });94    expect(reqs.map((r) => r.capability)).toEqual(["process.execute", "network.access"]);95  });9697  it("derives git.modify for mutating git commands", () => {98    const reqs = map("bash", { command: "git push origin main" });99    expect(reqs.map((r) => r.capability)).toEqual(["process.execute", "git.modify"]);100    expect(reqs[0]?.alwaysPatterns).toEqual(["git push *"]);101  });102103  it("derives file.write.outsideProject from filesystem verbs", () => {104    const reqs = map("bash", { command: "tee /etc/hosts" });105    const outside = reqs.find((r) => r.capability === "file.write.outsideProject");106    expect(outside?.subject).toBe("/etc/hosts");107  });108109  it("compound commands yield one process.execute request per part, no suggestions", () => {110    const reqs = map("bash", { command: "git status && git diff" });111    const exec = reqs.filter((r) => r.capability === "process.execute");112    expect(exec.map((r) => r.subject)).toEqual(["git status", "git diff"]);113    expect(exec.every((r) => r.alwaysPatterns.length === 0)).toBe(true);114  });115116  it("a redirect adds an exact-only whole-command request plus the outside write", () => {117    const reqs = map("bash", { command: "cat README.md > /tmp/out" });118    const whole = reqs.find((r) => r.exactOnly === true);119    expect(whole?.subject).toBe("cat README.md > /tmp/out");120    const outside = reqs.find((r) => r.capability === "file.write.outsideProject");121    expect(outside?.subject).toBe("/tmp/out");122  });123124  it("obfuscated commands yield one exact-only request with the substitution note", () => {125    const reqs = map("bash", { command: "echo $(whoami)" });126    expect(reqs).toHaveLength(1);127    expect(reqs[0]).toMatchObject({ capability: "process.execute", exactOnly: true });128    expect(reqs[0]?.alwaysPatterns).toEqual([]);129  });130131  it("obfuscated commands with net-tool names conservatively carry network.access", () => {132    const reqs = map("bash", { command: "curl $(cat url.txt)" });133    expect(reqs.map((r) => r.capability)).toEqual(["process.execute", "network.access"]);134  });135});136137describe("process tool", () => {138  it("start maps to process.background plus derived analysis", () => {139    const reqs = map("process", { action: "start", command: "npm run dev" });140    expect(reqs).toHaveLength(1);141    expect(reqs[0]).toMatchObject({142      capability: "process.background",143      subject: "npm run dev",144      alwaysPatterns: ["npm run dev", "npm run *"],145    });146  });147148  it("start derives network.access too", () => {149    const reqs = map("process", { action: "start", command: "ssh -N -L 8080:x:80 host" });150    expect(reqs.map((r) => r.capability)).toEqual(["process.background", "network.access"]);151  });152153  it("list / read / stop produce NO requests (pure observation)", () => {154    expect(map("process", { action: "list" })).toEqual([]);155    expect(map("process", { action: "read", id: "p1" })).toEqual([]);156    expect(map("process", { action: "stop", id: "p1" })).toEqual([]);157  });158159  it("write maps to process.background with a stdin: subject", () => {160    const ctx: CapabilityMappingContext = {161      ...CTX,162      processCommandLookup: (id) => (id === "p1" ? "npm run dev" : undefined),163    };164    const [req] = map("process", { action: "write", id: "p1", input: "y\n" }, ctx);165    expect(req).toMatchObject({ capability: "process.background", subject: "stdin:npm run dev" });166  });167168  it("unknown actions fail closed", () => {169    expect(mapToolCapabilities("process", { action: "hack" }, CTX).ok).toBe(false);170  });171});172173describe("fail-closed mapping errors", () => {174  it("unknown tools are a design error, never a silent bypass", () => {175    expect(mapToolCapabilities("teleport", {}, CTX).ok).toBe(false);176  });177178  it("missing required fields fail", () => {179    expect(mapToolCapabilities("read", {}, CTX).ok).toBe(false);180    expect(mapToolCapabilities("bash", { command: "" }, CTX).ok).toBe(false);181    expect(mapToolCapabilities("write", null, CTX).ok).toBe(false);182  });183});184