/** * KHAELOR * File: tests/permissions/service.test.ts * Description: PermissionService tests — fake-asker flows (once/always/deny), persisted grants, event emission, non-interactive denial. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { describe, expect, it } from "vitest"; import type { DurableEventInput } from "../../src/session/events.js"; import { KhaelorError, err, ok, unwrap } from "../../src/shared/index.js"; import type { Result } from "../../src/shared/index.js"; import { mapToolCapabilities } from "../../src/permissions/capabilities.js"; import type { CapabilityMappingContext } from "../../src/permissions/capabilities.js"; import type { PermissionRule } from "../../src/permissions/rules.js"; import { NON_INTERACTIVE_FEEDBACK, PermissionService } from "../../src/permissions/service.js"; import type { PermissionAnswer, PermissionEventInput, PermissionPrompt, PermissionServiceOptions, } from "../../src/permissions/service.js"; const CTX: CapabilityMappingContext = { projectRoot: "/proj", cwd: "/proj", home: "/Users/x" }; interface Harness { service: PermissionService; events: PermissionEventInput[]; prompts: PermissionPrompt[]; persisted: PermissionRule[]; } function harness(options?: { answers?: PermissionAnswer[]; rules?: PermissionServiceOptions["rules"]; persistFails?: boolean; noAsker?: boolean; noPersister?: boolean; }): Harness { const events: PermissionEventInput[] = []; const prompts: PermissionPrompt[] = []; const persisted: PermissionRule[] = []; const answers = [...(options?.answers ?? [])]; const service = new PermissionService({ ...(options?.rules !== undefined ? { rules: options.rules } : {}), publish: (e) => events.push(e), ...(options?.noAsker === true ? {} : { asker: { ask: (prompt: PermissionPrompt): Promise => { prompts.push(prompt); const answer = answers.shift(); if (answer === undefined) throw new Error("no scripted answer left"); return Promise.resolve(answer); }, }, }), ...(options?.noPersister === true ? {} : { persister: { persist: (rule: PermissionRule): Promise> => { if (options?.persistFails === true) { return Promise.resolve(err(new KhaelorError("config-io", "disk full"))); } persisted.push(rule); return Promise.resolve(ok(undefined)); }, }, }), }); return { service, events, prompts, persisted }; } function bashCheck(command: string, toolUseId = "tu-1") { return { toolUseId, toolName: "bash", requests: unwrap(mapToolCapabilities("bash", { command }, CTX)), }; } describe("silent policy allow", () => { it("allowlisted commands pass with no panel and no events", async () => { const h = harness(); const outcome = await h.service.check(bashCheck("git status")); expect(outcome).toMatchObject({ kind: "allow", via: "policy-allow" }); expect(h.prompts).toHaveLength(0); expect(h.events).toHaveLength(0); }); it("an empty request set (process list) allows silently", async () => { const h = harness(); const outcome = await h.service.check({ toolUseId: "t", toolName: "process", requests: [] }); expect(outcome).toMatchObject({ kind: "allow", via: "policy-allow" }); }); it("compound commands auto-allow only when EVERY part matches allow (§3.2)", async () => { const h = harness({ answers: [{ kind: "allow-once" }] }); const silent = await h.service.check(bashCheck("git status && git diff")); expect(silent.kind).toBe("allow"); expect(h.prompts).toHaveLength(0); const asked = await h.service.check(bashCheck("git status && npm install")); expect(asked.kind).toBe("allow"); expect(h.prompts).toHaveLength(1); // the npm install part forced one panel }); }); describe("ask → allow once", () => { it("emits requested + granted(once), allows only this call", async () => { const h = harness({ answers: [{ kind: "allow-once" }, { kind: "deny" }] }); const outcome = await h.service.check(bashCheck("npm install")); expect(outcome).toMatchObject({ kind: "allow", via: "user-once" }); expect(h.events.map((e) => e.type)).toEqual(["permission.requested", "permission.granted"]); const granted = h.events[1]; expect(granted?.type === "permission.granted" && granted.payload.scope).toBe("once"); // "once" never widens: the same command asks again. const second = await h.service.check(bashCheck("npm install", "tu-2")); expect(second.kind).toBe("deny"); expect(h.prompts).toHaveLength(2); }); it("the requested event carries the top suggestion", async () => { const h = harness({ answers: [{ kind: "allow-once" }] }); await h.service.check(bashCheck("git push origin main")); const requested = h.events.find((e) => e.type === "permission.requested"); expect(requested?.type === "permission.requested" && requested.payload.suggestion).toEqual({ capability: "process.execute", pattern: "git push *", }); }); it("one tool call produces ONE panel even with multiple asking requests (§4.4)", async () => { const h = harness({ answers: [{ kind: "allow-once" }] }); await h.service.check(bashCheck("curl https://x")); expect(h.prompts).toHaveLength(1); expect(h.prompts[0]?.requests.map((r) => r.capability)).toEqual([ "process.execute", "network.access", ]); expect(h.events.filter((e) => e.type === "permission.requested")).toHaveLength(2); expect(h.events.filter((e) => e.type === "permission.granted")).toHaveLength(2); }); }); describe("ask → always allow in this project", () => { it("persists the chosen pattern via the injected config-writer and applies it in-session", async () => { const h = harness({ answers: [{ kind: "allow-always", pattern: "npm install *" }] }); const outcome = await h.service.check(bashCheck("npm install left-pad")); expect(outcome).toMatchObject({ kind: "allow", via: "user-always" }); expect(h.persisted).toEqual([ { capability: "process.execute", pattern: "npm install *", action: "allow", source: "project" }, ]); const granted = h.events.find((e) => e.type === "permission.granted"); expect(granted?.type === "permission.granted" && granted.payload.scope).toBe("always-project"); // The grant covers future calls in this session without re-asking. const second = await h.service.check(bashCheck("npm install ulid", "tu-2")); expect(second).toMatchObject({ kind: "allow", via: "policy-allow" }); expect(h.prompts).toHaveLength(1); }); it("a failed persist keeps the in-memory grant and reports loudly (§6.2)", async () => { const h = harness({ answers: [{ kind: "allow-always", pattern: "npm install *" }], persistFails: true, }); const outcome = await h.service.check(bashCheck("npm install left-pad")); expect(outcome.kind).toBe("allow"); if (outcome.kind === "allow") { expect(outcome.persistWarning).toContain("could not be updated"); } const second = await h.service.check(bashCheck("npm install x", "tu-2")); expect(second).toMatchObject({ kind: "allow", via: "policy-allow" }); }); it("REFUSES to persist a pattern that was never offered (fail closed, §3.3)", async () => { const h = harness({ answers: [{ kind: "allow-always", pattern: "npm *" }] }); await expect(h.service.check(bashCheck("npm install"))).rejects.toThrow(KhaelorError); expect(h.persisted).toHaveLength(0); }); }); describe("ask → user deny", () => { it("without feedback: steering message, denied events with source user", async () => { const h = harness({ answers: [{ kind: "deny" }] }); const outcome = await h.service.check(bashCheck("npm install")); expect(outcome).toMatchObject({ kind: "deny", source: "user" }); if (outcome.kind === "deny") { expect(outcome.feedback).toBe( "The user declined to allow: npm install. Continue without it, or propose an alternative.", ); } const denied = h.events.find((e) => e.type === "permission.denied"); expect(denied?.type === "permission.denied" && denied.payload.source).toBe("user"); }); it("with feedback: rejection becomes course correction (§5.4)", async () => { const h = harness({ answers: [{ kind: "deny", feedback: "use pnpm in this repo" }] }); const outcome = await h.service.check(bashCheck("npm install")); if (outcome.kind === "deny") { expect(outcome.feedback).toContain('reason: "use pnpm in this repo"'); expect(outcome.feedback).toContain("Adapt your approach accordingly."); } }); }); describe("policy and hardline denies (no panel, §5.1)", () => { it("a deny rule blocks without asking and names the rule", async () => { const h = harness({ rules: { project: [ { capability: "process.execute", pattern: "rm -rf *", action: "deny", source: "project" }, ], }, }); const outcome = await h.service.check(bashCheck("rm -rf build")); expect(outcome).toMatchObject({ kind: "deny", source: "policy" }); if (outcome.kind === "deny") { expect(outcome.feedback).toContain('process.execute for "rm -rf build" is denied'); expect(outcome.feedback).toContain('rule: process.execute / "rm -rf *", source: project'); expect(outcome.feedback).toContain("Do not retry this command"); } expect(h.prompts).toHaveLength(0); expect(h.events.map((e) => e.type)).toEqual(["permission.denied"]); }); it("the hardline floor denies even under allow-everything rules", async () => { const h = harness({ rules: { project: [{ capability: "*", pattern: "*", action: "allow", source: "project" }] }, }); const outcome = await h.service.check(bashCheck("rm -rf /")); expect(outcome).toMatchObject({ kind: "deny", source: "hardline" }); if (outcome.kind === "deny") { expect(outcome.feedback).toContain("built-in safety floor"); } const denied = h.events[0]; expect(denied?.type === "permission.denied" && denied.payload.source).toBe("hardline"); }); }); describe("non-interactive mode (§5.2)", () => { it("resolves every ask as deny with the documented message", async () => { const h = harness({ noAsker: true }); const outcome = await h.service.check(bashCheck("npm install")); expect(outcome).toMatchObject({ kind: "deny", source: "policy" }); if (outcome.kind === "deny") expect(outcome.feedback).toBe(NON_INTERACTIVE_FEEDBACK); expect(h.events.map((e) => e.type)).toEqual(["permission.requested", "permission.denied"]); }); }); describe("event compatibility with the session catalog", () => { it("PermissionEventInput is assignable to DurableEventInput", async () => { const h = harness({ answers: [{ kind: "allow-once" }] }); await h.service.check(bashCheck("npm install")); for (const event of h.events) { const durable: DurableEventInput = event; // compile-time structural check expect(typeof durable.type).toBe("string"); } }); it("permissionRequestId correlates requested → granted", async () => { const h = harness({ answers: [{ kind: "allow-once" }] }); await h.service.check(bashCheck("npm install")); const requested = h.events.find((e) => e.type === "permission.requested"); const granted = h.events.find((e) => e.type === "permission.granted"); expect(requested?.payload.permissionRequestId).toBe(granted?.payload.permissionRequestId); }); });