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%
11.5 KB · 271 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: tests/permissions/service.test.ts4 * Description: PermissionService tests — fake-asker flows (once/always/deny), persisted grants, event emission, non-interactive denial.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { describe, expect, it } from "vitest";11import type { DurableEventInput } from "../../src/session/events.js";12import { KhaelorError, err, ok, unwrap } from "../../src/shared/index.js";13import type { Result } from "../../src/shared/index.js";14import { mapToolCapabilities } from "../../src/permissions/capabilities.js";15import type { CapabilityMappingContext } from "../../src/permissions/capabilities.js";16import type { PermissionRule } from "../../src/permissions/rules.js";17import { NON_INTERACTIVE_FEEDBACK, PermissionService } from "../../src/permissions/service.js";18import type {19  PermissionAnswer,20  PermissionEventInput,21  PermissionPrompt,22  PermissionServiceOptions,23} from "../../src/permissions/service.js";2425const CTX: CapabilityMappingContext = { projectRoot: "/proj", cwd: "/proj", home: "/Users/x" };2627interface Harness {28  service: PermissionService;29  events: PermissionEventInput[];30  prompts: PermissionPrompt[];31  persisted: PermissionRule[];32}3334function harness(options?: {35  answers?: PermissionAnswer[];36  rules?: PermissionServiceOptions["rules"];37  persistFails?: boolean;38  noAsker?: boolean;39  noPersister?: boolean;40}): Harness {41  const events: PermissionEventInput[] = [];42  const prompts: PermissionPrompt[] = [];43  const persisted: PermissionRule[] = [];44  const answers = [...(options?.answers ?? [])];45  const service = new PermissionService({46    ...(options?.rules !== undefined ? { rules: options.rules } : {}),47    publish: (e) => events.push(e),48    ...(options?.noAsker === true49      ? {}50      : {51          asker: {52            ask: (prompt: PermissionPrompt): Promise<PermissionAnswer> => {53              prompts.push(prompt);54              const answer = answers.shift();55              if (answer === undefined) throw new Error("no scripted answer left");56              return Promise.resolve(answer);57            },58          },59        }),60    ...(options?.noPersister === true61      ? {}62      : {63          persister: {64            persist: (rule: PermissionRule): Promise<Result<void, KhaelorError>> => {65              if (options?.persistFails === true) {66                return Promise.resolve(err(new KhaelorError("config-io", "disk full")));67              }68              persisted.push(rule);69              return Promise.resolve(ok(undefined));70            },71          },72        }),73  });74  return { service, events, prompts, persisted };75}7677function bashCheck(command: string, toolUseId = "tu-1") {78  return {79    toolUseId,80    toolName: "bash",81    requests: unwrap(mapToolCapabilities("bash", { command }, CTX)),82  };83}8485describe("silent policy allow", () => {86  it("allowlisted commands pass with no panel and no events", async () => {87    const h = harness();88    const outcome = await h.service.check(bashCheck("git status"));89    expect(outcome).toMatchObject({ kind: "allow", via: "policy-allow" });90    expect(h.prompts).toHaveLength(0);91    expect(h.events).toHaveLength(0);92  });9394  it("an empty request set (process list) allows silently", async () => {95    const h = harness();96    const outcome = await h.service.check({ toolUseId: "t", toolName: "process", requests: [] });97    expect(outcome).toMatchObject({ kind: "allow", via: "policy-allow" });98  });99100  it("compound commands auto-allow only when EVERY part matches allow (§3.2)", async () => {101    const h = harness({ answers: [{ kind: "allow-once" }] });102    const silent = await h.service.check(bashCheck("git status && git diff"));103    expect(silent.kind).toBe("allow");104    expect(h.prompts).toHaveLength(0);105    const asked = await h.service.check(bashCheck("git status && npm install"));106    expect(asked.kind).toBe("allow");107    expect(h.prompts).toHaveLength(1); // the npm install part forced one panel108  });109});110111describe("ask → allow once", () => {112  it("emits requested + granted(once), allows only this call", async () => {113    const h = harness({ answers: [{ kind: "allow-once" }, { kind: "deny" }] });114    const outcome = await h.service.check(bashCheck("npm install"));115    expect(outcome).toMatchObject({ kind: "allow", via: "user-once" });116    expect(h.events.map((e) => e.type)).toEqual(["permission.requested", "permission.granted"]);117    const granted = h.events[1];118    expect(granted?.type === "permission.granted" && granted.payload.scope).toBe("once");119    // "once" never widens: the same command asks again.120    const second = await h.service.check(bashCheck("npm install", "tu-2"));121    expect(second.kind).toBe("deny");122    expect(h.prompts).toHaveLength(2);123  });124125  it("the requested event carries the top suggestion", async () => {126    const h = harness({ answers: [{ kind: "allow-once" }] });127    await h.service.check(bashCheck("git push origin main"));128    const requested = h.events.find((e) => e.type === "permission.requested");129    expect(requested?.type === "permission.requested" && requested.payload.suggestion).toEqual({130      capability: "process.execute",131      pattern: "git push *",132    });133  });134135  it("one tool call produces ONE panel even with multiple asking requests (§4.4)", async () => {136    const h = harness({ answers: [{ kind: "allow-once" }] });137    await h.service.check(bashCheck("curl https://x"));138    expect(h.prompts).toHaveLength(1);139    expect(h.prompts[0]?.requests.map((r) => r.capability)).toEqual([140      "process.execute",141      "network.access",142    ]);143    expect(h.events.filter((e) => e.type === "permission.requested")).toHaveLength(2);144    expect(h.events.filter((e) => e.type === "permission.granted")).toHaveLength(2);145  });146});147148describe("ask → always allow in this project", () => {149  it("persists the chosen pattern via the injected config-writer and applies it in-session", async () => {150    const h = harness({ answers: [{ kind: "allow-always", pattern: "npm install *" }] });151    const outcome = await h.service.check(bashCheck("npm install left-pad"));152    expect(outcome).toMatchObject({ kind: "allow", via: "user-always" });153    expect(h.persisted).toEqual([154      { capability: "process.execute", pattern: "npm install *", action: "allow", source: "project" },155    ]);156    const granted = h.events.find((e) => e.type === "permission.granted");157    expect(granted?.type === "permission.granted" && granted.payload.scope).toBe("always-project");158    // The grant covers future calls in this session without re-asking.159    const second = await h.service.check(bashCheck("npm install ulid", "tu-2"));160    expect(second).toMatchObject({ kind: "allow", via: "policy-allow" });161    expect(h.prompts).toHaveLength(1);162  });163164  it("a failed persist keeps the in-memory grant and reports loudly (§6.2)", async () => {165    const h = harness({166      answers: [{ kind: "allow-always", pattern: "npm install *" }],167      persistFails: true,168    });169    const outcome = await h.service.check(bashCheck("npm install left-pad"));170    expect(outcome.kind).toBe("allow");171    if (outcome.kind === "allow") {172      expect(outcome.persistWarning).toContain("could not be updated");173    }174    const second = await h.service.check(bashCheck("npm install x", "tu-2"));175    expect(second).toMatchObject({ kind: "allow", via: "policy-allow" });176  });177178  it("REFUSES to persist a pattern that was never offered (fail closed, §3.3)", async () => {179    const h = harness({ answers: [{ kind: "allow-always", pattern: "npm *" }] });180    await expect(h.service.check(bashCheck("npm install"))).rejects.toThrow(KhaelorError);181    expect(h.persisted).toHaveLength(0);182  });183});184185describe("ask → user deny", () => {186  it("without feedback: steering message, denied events with source user", async () => {187    const h = harness({ answers: [{ kind: "deny" }] });188    const outcome = await h.service.check(bashCheck("npm install"));189    expect(outcome).toMatchObject({ kind: "deny", source: "user" });190    if (outcome.kind === "deny") {191      expect(outcome.feedback).toBe(192        "The user declined to allow: npm install. Continue without it, or propose an alternative.",193      );194    }195    const denied = h.events.find((e) => e.type === "permission.denied");196    expect(denied?.type === "permission.denied" && denied.payload.source).toBe("user");197  });198199  it("with feedback: rejection becomes course correction (§5.4)", async () => {200    const h = harness({ answers: [{ kind: "deny", feedback: "use pnpm in this repo" }] });201    const outcome = await h.service.check(bashCheck("npm install"));202    if (outcome.kind === "deny") {203      expect(outcome.feedback).toContain('reason: "use pnpm in this repo"');204      expect(outcome.feedback).toContain("Adapt your approach accordingly.");205    }206  });207});208209describe("policy and hardline denies (no panel, §5.1)", () => {210  it("a deny rule blocks without asking and names the rule", async () => {211    const h = harness({212      rules: {213        project: [214          { capability: "process.execute", pattern: "rm -rf *", action: "deny", source: "project" },215        ],216      },217    });218    const outcome = await h.service.check(bashCheck("rm -rf build"));219    expect(outcome).toMatchObject({ kind: "deny", source: "policy" });220    if (outcome.kind === "deny") {221      expect(outcome.feedback).toContain('process.execute for "rm -rf build" is denied');222      expect(outcome.feedback).toContain('rule: process.execute / "rm -rf *", source: project');223      expect(outcome.feedback).toContain("Do not retry this command");224    }225    expect(h.prompts).toHaveLength(0);226    expect(h.events.map((e) => e.type)).toEqual(["permission.denied"]);227  });228229  it("the hardline floor denies even under allow-everything rules", async () => {230    const h = harness({231      rules: { project: [{ capability: "*", pattern: "*", action: "allow", source: "project" }] },232    });233    const outcome = await h.service.check(bashCheck("rm -rf /"));234    expect(outcome).toMatchObject({ kind: "deny", source: "hardline" });235    if (outcome.kind === "deny") {236      expect(outcome.feedback).toContain("built-in safety floor");237    }238    const denied = h.events[0];239    expect(denied?.type === "permission.denied" && denied.payload.source).toBe("hardline");240  });241});242243describe("non-interactive mode (§5.2)", () => {244  it("resolves every ask as deny with the documented message", async () => {245    const h = harness({ noAsker: true });246    const outcome = await h.service.check(bashCheck("npm install"));247    expect(outcome).toMatchObject({ kind: "deny", source: "policy" });248    if (outcome.kind === "deny") expect(outcome.feedback).toBe(NON_INTERACTIVE_FEEDBACK);249    expect(h.events.map((e) => e.type)).toEqual(["permission.requested", "permission.denied"]);250  });251});252253describe("event compatibility with the session catalog", () => {254  it("PermissionEventInput is assignable to DurableEventInput", async () => {255    const h = harness({ answers: [{ kind: "allow-once" }] });256    await h.service.check(bashCheck("npm install"));257    for (const event of h.events) {258      const durable: DurableEventInput = event; // compile-time structural check259      expect(typeof durable.type).toBe("string");260    }261  });262263  it("permissionRequestId correlates requested → granted", async () => {264    const h = harness({ answers: [{ kind: "allow-once" }] });265    await h.service.check(bashCheck("npm install"));266    const requested = h.events.find((e) => e.type === "permission.requested");267    const granted = h.events.find((e) => e.type === "permission.granted");268    expect(requested?.payload.permissionRequestId).toBe(granted?.payload.permissionRequestId);269  });270});271