import { describe, expect, test, vi } from "vitest"; import { CapabilityDeniedError, createCapabilityGateway } from "./capability-gateway.js"; import type { PluginManifest } from "./manifest.js"; const DEFERRED_HOST_BINDINGS = [ "http.outbound", "secret.read", "db.read", "db.write", "storage.read", "storage.write", "tenant.info", ] as const; function fixture(capabilities: PluginManifest["capabilities"] = []): PluginManifest { return { schemaVersion: 1, id: "de.toolctrl.test", version: "1.0.0", name: "Test", entry: "plugin.wasm", wasmSha256: "0".repeat(64), signature: "0".repeat(128), signedBy: "0".repeat(64), allowedHosts: [], capabilities, hooks: [{ name: "x", export: "x" }], }; } describe("createCapabilityGateway", () => { test("invokes a host-fn whose capability is declared", async () => { const gateway = createCapabilityGateway({ manifest: fixture([{ name: "log" }]), hostFns: { log: (msg: unknown) => `logged:${String(msg)}`, }, }); const result = await gateway.invoke("log", "hi"); expect(result).toBe("logged:hi"); }); test("denies undeclared capabilities with not_declared_in_manifest", async () => { const gateway = createCapabilityGateway({ manifest: fixture([{ name: "log" }]), hostFns: { "http.outbound": async () => "should not run", }, }); await expect(gateway.invoke("http.outbound")).rejects.toThrow(CapabilityDeniedError); await expect(gateway.invoke("http.outbound")).rejects.toMatchObject({ reason: "not_declared_in_manifest", }); }); test("denies declared-but-unimplemented capabilities", async () => { const gateway = createCapabilityGateway({ manifest: fixture([{ name: "secret.read" }]), hostFns: {}, }); await expect(gateway.invoke("secret.read")).rejects.toMatchObject({ reason: "no_host_implementation", }); }); test.each(DEFERRED_HOST_BINDINGS)("fails closed for deferred %s bindings with no_host_implementation", async (capability) => { const hostFn = vi.fn(async () => "should not run"); const gateway = createCapabilityGateway({ manifest: fixture([{ name: capability }]), approvedGrants: [{ capabilityName: capability, scopeJson: null }], hostFns: { log: () => "ok", audit: () => "ok", [capability]: hostFn, }, }); await expect(gateway.invoke(capability)).rejects.toMatchObject({ reason: "no_host_implementation", }); expect(hostFn).not.toHaveBeenCalled(); }); test("denies manifest-declared capabilities without tenant-approved grants", async () => { const gateway = createCapabilityGateway({ manifest: fixture([{ name: "log" }, { name: "audit" }]), approvedGrants: [{ capabilityName: "log", scopeJson: null }], hostFns: { log: () => "ok", audit: () => "should not run", }, }); await expect(gateway.invoke("log")).resolves.toBe("ok"); await expect(gateway.invoke("audit")).rejects.toMatchObject({ reason: "not_granted_for_tenant", }); }); test("denies tenant grants whose scope exceeds the manifest scope", async () => { const hostFn = vi.fn(async () => "should not run"); const gateway = createCapabilityGateway({ manifest: fixture([{ name: "http.outbound", scope: { allowedHosts: ["api.example.com"] } }]), approvedGrants: [{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api.example.com", "internal.example"] } }], hostFns: { "http.outbound": hostFn, }, }); await expect(gateway.invoke("http.outbound", "https://api.example.com")).rejects.toMatchObject({ reason: "not_granted_for_tenant", }); expect(hostFn).not.toHaveBeenCalled(); }); test("charges fuel per call (defaults to 100)", async () => { const gateway = createCapabilityGateway({ manifest: fixture([{ name: "log" }]), hostFns: { log: () => "ok" }, }); expect(gateway.fuelCharged()).toBe(0); await gateway.invoke("log"); expect(gateway.fuelCharged()).toBe(100); await gateway.invoke("log"); expect(gateway.fuelCharged()).toBe(200); }); test("respects fuelCostPerCall override and resetFuel", async () => { const gateway = createCapabilityGateway({ manifest: fixture([{ name: "log" }]), hostFns: { log: () => "ok" }, fuelCostPerCall: 25, }); await gateway.invoke("log"); await gateway.invoke("log"); expect(gateway.fuelCharged()).toBe(50); gateway.resetFuel(); expect(gateway.fuelCharged()).toBe(0); }); test("calls onDenied with the deny reason for both deny paths", async () => { const denials: Array<{ capability: string; reason: string }> = []; const gateway = createCapabilityGateway({ manifest: fixture([{ name: "secret.read" }]), hostFns: {}, onDenied: (info) => denials.push(info), }); await expect(gateway.invoke("http.outbound")).rejects.toThrow(); await expect(gateway.invoke("secret.read")).rejects.toThrow(); expect(denials.map((d) => d.reason)).toEqual([ "not_declared_in_manifest", "no_host_implementation", ]); }); test("records onCall outcomes for success, denial, and host-fn errors", async () => { const calls: Array<{ capability: string; outcome: string; errorCode?: string }> = []; const gateway = createCapabilityGateway({ manifest: fixture([{ name: "log" }, { name: "audit" }]), hostFns: { log: () => "ok", audit: () => { throw new TypeError("audit sink unavailable"); }, }, onCall: (info) => calls.push(info), }); await expect(gateway.invoke("log")).resolves.toBe("ok"); await expect(gateway.invoke("http.outbound")).rejects.toMatchObject({ reason: "not_declared_in_manifest", }); await expect(gateway.invoke("db.read")).rejects.toMatchObject({ reason: "not_declared_in_manifest", }); await expect(gateway.invoke("audit")).rejects.toThrow("audit sink unavailable"); expect(calls).toMatchObject([ { capability: "log", outcome: "success" }, { capability: "http.outbound", outcome: "denied", errorCode: "not_declared_in_manifest" }, { capability: "db.read", outcome: "denied", errorCode: "not_declared_in_manifest" }, { capability: "audit", outcome: "error", errorCode: "TypeError" }, ]); }); test("records deterministic payload hashes without exposing raw args", async () => { const calls: Array> = []; const gateway = createCapabilityGateway({ manifest: fixture([{ name: "log" }]), hostFns: { log: () => "ok", }, onCall: (info) => calls.push(info), }); await gateway.invoke("log", "same-value"); await gateway.invoke("log", "same-value"); await gateway.invoke("log", "other-value"); expect(calls[0]?.payloadHash).toMatch(/^[a-f0-9]{64}$/u); expect(calls[1]?.payloadHash).toBe(calls[0]?.payloadHash); expect(calls[2]?.payloadHash).not.toBe(calls[0]?.payloadHash); expect(JSON.stringify(calls)).not.toContain("same-value"); expect(JSON.stringify(calls)).not.toContain("other-value"); }); test("records no_host_implementation through onCall", async () => { const calls: Array<{ capability: string; outcome: string; errorCode?: string }> = []; const gateway = createCapabilityGateway({ manifest: fixture([{ name: "secret.read" }]), hostFns: {}, onCall: (info) => calls.push(info), }); await expect(gateway.invoke("secret.read")).rejects.toMatchObject({ reason: "no_host_implementation", }); expect(calls).toEqual([ expect.objectContaining({ capability: "secret.read", outcome: "denied", errorCode: "no_host_implementation", }), ]); }); test("keeps future scoped host bindings unavailable before dispatch", async () => { const hostFn = vi.fn(async () => "should not run"); const denials: Array<{ capability: string; reason: string }> = []; const calls: Array<{ capability: string; outcome: string; errorCode?: string }> = []; const gateway = createCapabilityGateway({ manifest: fixture([{ name: "http.outbound", scope: { allowedHosts: ["api.example.com"] } }]), approvedGrants: [{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api.example.com"] } }], hostFns: { "http.outbound": hostFn, }, onDenied: (info) => denials.push(info), onCall: (info) => calls.push(info), }); await expect(gateway.invoke("http.outbound", { url: "https://evil.example.com/v1" })).rejects.toMatchObject({ reason: "no_host_implementation", }); expect(hostFn).not.toHaveBeenCalled(); expect(denials).toEqual([{ capability: "http.outbound", reason: "no_host_implementation" }]); expect(calls).toEqual([ expect.objectContaining({ capability: "http.outbound", outcome: "denied", errorCode: "no_host_implementation", }), ]); }); test("ignores onCall recorder failures", async () => { const gateway = createCapabilityGateway({ manifest: fixture([{ name: "log" }]), hostFns: { log: () => "ok" }, onCall: () => { throw new Error("audit sink down"); }, }); await expect(gateway.invoke("log")).resolves.toBe("ok"); }); test("propagates async reviewed host-fn results unchanged", async () => { const gateway = createCapabilityGateway({ manifest: fixture([{ name: "audit" }]), hostFns: { audit: async () => ({ recorded: true }), }, }); const out = await gateway.invoke<{ recorded: boolean }>("audit"); expect(out).toEqual({ recorded: true }); }); test.each(DEFERRED_HOST_BINDINGS)("invokeSync fails closed for deferred %s bindings even when wired", (capability) => { const hostFn = vi.fn(() => "should not run"); const gateway = createCapabilityGateway({ manifest: fixture([{ name: capability }]), approvedGrants: [{ capabilityName: capability, scopeJson: null }], hostFns: { [capability]: hostFn, }, }); expect(() => gateway.invokeSync(capability)).toThrow(CapabilityDeniedError); expect(() => gateway.invokeSync(capability)).toThrow(`capability denied: ${capability} (no_host_implementation)`); expect(hostFn).not.toHaveBeenCalled(); }); test("invokeSync gates synchronous host imports and records calls", () => { const calls: Array<{ capability: string; outcome: string; errorCode?: string }> = []; const gateway = createCapabilityGateway({ manifest: fixture([{ name: "log" }]), hostFns: { log: (msg: unknown) => `logged:${String(msg)}`, }, onCall: (info) => calls.push(info), }); expect(gateway.invokeSync("log", "hi")).toBe("logged:hi"); expect(gateway.fuelCharged()).toBe(100); expect(() => gateway.invokeSync("audit", { action: "x" })).toThrow(CapabilityDeniedError); expect(calls).toMatchObject([ { capability: "log", outcome: "success" }, { capability: "audit", outcome: "denied", errorCode: "not_declared_in_manifest" }, ]); }); test("invokeSync rejects async host functions before reporting success", () => { const calls: Array<{ capability: string; outcome: string; errorCode?: string }> = []; const gateway = createCapabilityGateway({ manifest: fixture([{ name: "audit" }]), hostFns: { audit: async () => "later", }, onCall: (info) => calls.push(info), }); expect(() => gateway.invokeSync("audit")).toThrow("async_host_fn_not_supported"); expect(calls).toEqual([ expect.objectContaining({ capability: "audit", outcome: "error", errorCode: "Error", }), ]); }); });