import { beforeEach, describe, expect, it, type Mock, vi } from "vitest"; import type { AccessIntent } from "#src/access-intent/access-intent"; import type { AuthorizerRegistrar } from "#src/authority/authorizer-registry"; import { posixPathFlavor } from "#src/path/path-flavor"; import { PathNormalizer } from "#src/path-normalizer"; import { LocalPermissionsService } from "#src/permissions-service"; import type { ToolAccessExtractorRegistrar } from "#src/tool-access-extractor-registry"; import type { ToolInputFormatter, ToolInputFormatterRegistrar, } from "#src/tool-input-formatter-registry"; import type { PermissionCheckResult, PermissionState } from "#src/types"; import { makeCheckResult } from "#test/helpers/handler-fixtures"; // Mock node:fs so realpathSync (the canonical alias) is controllable. const realpathSync = vi.hoisted(() => vi.fn<(path: string) => string>((p) => p), ); vi.mock("node:fs", () => ({ realpathSync, default: { realpathSync }, })); // Mock the advisory bash resolver so the service test asserts delegation; the // decomposition behavior itself is covered in bash-advisory-check.test.ts. const resolveBashAdvisoryCheck = vi.hoisted(() => vi.fn< ( command: string, agentName: string | undefined, resolver: unknown, ) => PermissionCheckResult >(), ); vi.mock("#src/bash-advisory-check", () => ({ resolveBashAdvisoryCheck })); // ── helpers ──────────────────────────────────────────────────────────────── interface FakeResolver { resolve: Mock<(intent: AccessIntent) => PermissionCheckResult>; getToolPermission: Mock< (toolName: string, agentName?: string) => PermissionState >; } function makeResolver(): FakeResolver { return { resolve: vi .fn<(intent: AccessIntent) => PermissionCheckResult>() .mockReturnValue(makeCheckResult()), getToolPermission: vi .fn<(toolName: string, agentName?: string) => PermissionState>() .mockReturnValue("ask"), }; } function makeFormatterRegistry(): ToolInputFormatterRegistrar { return { register: vi .fn() .mockReturnValue(vi.fn()), }; } function makeAccessExtractorRegistry(): ToolAccessExtractorRegistrar { return { register: vi .fn() .mockReturnValue(vi.fn()), }; } function makeAuthorizerRegistry(): AuthorizerRegistrar { return { register: vi.fn().mockReturnValue(vi.fn()), }; } function makeService(overrides?: { resolver?: FakeResolver; formatterRegistry?: ToolInputFormatterRegistrar; accessExtractorRegistry?: ToolAccessExtractorRegistrar; authorizerRegistry?: AuthorizerRegistrar; }) { const resolver = overrides?.resolver ?? makeResolver(); // The published service always answers against the parent session's cwd. const session = { getPathNormalizer: () => normalizer }; const formatterRegistry = overrides?.formatterRegistry ?? makeFormatterRegistry(); const accessExtractorRegistry = overrides?.accessExtractorRegistry ?? makeAccessExtractorRegistry(); const authorizerRegistry = overrides?.authorizerRegistry ?? makeAuthorizerRegistry(); const service = new LocalPermissionsService( resolver, session, formatterRegistry, accessExtractorRegistry, authorizerRegistry, ); return { service, resolver, formatterRegistry, accessExtractorRegistry, authorizerRegistry, }; } const normalizer = new PathNormalizer(posixPathFlavor, "/test/project"); // ── tests ────────────────────────────────────────────────────────────────── beforeEach(() => { realpathSync.mockReset(); realpathSync.mockImplementation((p: string) => p); resolveBashAdvisoryCheck.mockReset(); resolveBashAdvisoryCheck.mockReturnValue( makeCheckResult({ toolName: "bash" }), ); }); describe("checkPermission", () => { it("resolves a non-path surface through a tool intent", () => { const { service, resolver } = makeService(); service.checkPermission("skill", "my-skill", "my-agent"); expect(resolver.resolve).toHaveBeenCalledWith({ kind: "tool", surface: "skill", input: { name: "my-skill" }, agentName: "my-agent", }); }); it("routes a bash query through the advisory decomposition resolver", () => { const { service, resolver } = makeService(); const expected = makeCheckResult({ state: "deny", toolName: "bash" }); resolveBashAdvisoryCheck.mockReturnValue(expected); const result = service.checkPermission( "bash", "cd /repo && npm install x", "my-agent", ); expect(resolveBashAdvisoryCheck).toHaveBeenCalledWith( "cd /repo && npm install x", "my-agent", resolver, ); expect(result).toBe(expected); }); it("passes an empty string to the advisory resolver for a value-less bash query", () => { const { service, resolver } = makeService(); service.checkPermission("bash"); expect(resolveBashAdvisoryCheck).toHaveBeenCalledWith( "", undefined, resolver, ); }); it("resolves an external_directory path query through an access-path intent matching the canonical alias", () => { realpathSync.mockImplementation((p: string) => p === "/test/project/link" ? "/test/project/real" : p, ); const { service, resolver } = makeService(); service.checkPermission("external_directory", "link"); expect(resolver.resolve).toHaveBeenCalledTimes(1); const intent = resolver.resolve.mock.calls[0][0]; expect(intent.kind).toBe("access-path"); if (intent.kind === "access-path") { expect(intent.surface).toBe("external_directory"); expect(intent.path.matchValues()).toContain("/test/project/real"); } }); it("resolves a path-bearing tool query (read) through an access-path intent", () => { const { service, resolver } = makeService(); service.checkPermission("read", "/test/project/.env"); const intent = resolver.resolve.mock.calls[0][0]; expect(intent.kind).toBe("access-path"); if (intent.kind === "access-path") { expect(intent.surface).toBe("read"); expect(intent.path.value()).toBe("/test/project/.env"); } }); it("falls back to a tool intent for a value-less path query", () => { const { service, resolver } = makeService(); service.checkPermission("path"); const intent = resolver.resolve.mock.calls[0][0]; expect(intent.kind).toBe("tool"); }); it("returns the result from resolver.resolve", () => { const expected = makeCheckResult({ state: "deny", toolName: "skill" }); const resolver = makeResolver(); resolver.resolve.mockReturnValue(expected); const { service } = makeService({ resolver }); const result = service.checkPermission("skill", "my-skill"); expect(result).toBe(expected); }); }); describe("getToolPermission", () => { it("delegates to resolver.getToolPermission", () => { const resolver = makeResolver(); resolver.getToolPermission.mockReturnValue("deny"); const { service } = makeService({ resolver }); const result = service.getToolPermission("write", "my-agent"); expect(resolver.getToolPermission).toHaveBeenCalledWith( "write", "my-agent", ); expect(result).toBe("deny"); }); it("omits agentName when not provided", () => { const { service, resolver } = makeService(); service.getToolPermission("read"); expect(resolver.getToolPermission).toHaveBeenCalledWith("read", undefined); }); }); describe("registerToolInputFormatter", () => { it("delegates to formatterRegistry.register and returns the unsubscribe function", () => { const unsub = vi.fn(); const { service, formatterRegistry } = makeService(); vi.mocked(formatterRegistry.register).mockReturnValue(unsub); const formatter: ToolInputFormatter = vi.fn(); const result = service.registerToolInputFormatter("my-tool", formatter); expect(formatterRegistry.register).toHaveBeenCalledWith( "my-tool", formatter, ); expect(result).toBe(unsub); }); }); describe("registerToolAccessExtractor", () => { it("delegates to accessExtractorRegistry.register and returns the unsubscribe function", () => { const unsub = vi.fn(); const { service, accessExtractorRegistry } = makeService(); vi.mocked(accessExtractorRegistry.register).mockReturnValue(unsub); const extractor = vi.fn(); const result = service.registerToolAccessExtractor("ffgrep", extractor); expect(accessExtractorRegistry.register).toHaveBeenCalledWith( "ffgrep", extractor, ); expect(result).toBe(unsub); }); }); describe("registerAuthorizer", () => { it("delegates to authorizerRegistry.register and returns the unsubscribe function", () => { const unsub = vi.fn(); const { service, authorizerRegistry } = makeService(); vi.mocked(authorizerRegistry.register).mockReturnValue(unsub); const authorize = vi.fn(); const result = service.registerAuthorizer("model-judge", authorize); expect(authorizerRegistry.register).toHaveBeenCalledWith( "model-judge", authorize, ); expect(result).toBe(unsub); }); });