import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ResolvedAccessIntent } from "#src/access-intent/access-intent"; import { AccessPath } from "#src/access-intent/access-path"; import { posixPathFlavor } from "#src/path/path-flavor"; import type { ScopedPermissionManager } from "#src/permission-manager"; import { PermissionResolver } from "#src/permission-resolver"; import type { Ruleset } from "#src/rule"; import { SessionApproval } from "#src/session-approval"; import { SessionRules } from "#src/session-rules"; import type { PermissionCheckResult, PermissionState } from "#src/types"; function makePermissionManager() { return { configureForCwd: vi.fn<(cwd: string | undefined | null) => void>(), check: vi .fn< ( intent: ResolvedAccessIntent, sessionRules?: Ruleset, ) => PermissionCheckResult >() .mockReturnValue({ state: "allow", toolName: "read", source: "tool", origin: "builtin", }), getToolPermission: vi .fn<(toolName: string, agentName?: string) => PermissionState>() .mockReturnValue("allow"), getConfigIssues: vi.fn((): string[] => []), }; } function makeResolver( pm?: ScopedPermissionManager, sessionRules?: Pick, ) { const permissionManager = pm ?? makePermissionManager(); const rules = sessionRules ?? new SessionRules(); return { resolver: new PermissionResolver(permissionManager, rules), permissionManager, }; } beforeEach(() => { // no module-level vi.fn() stubs to reset }); describe("PermissionResolver", () => { describe("resolve — tool intent", () => { it("forwards a tool intent with the empty session ruleset", () => { const { resolver, permissionManager } = makeResolver(); resolver.resolve({ kind: "tool", surface: "bash", input: { command: "ls" }, agentName: "agent-x", }); expect(permissionManager.check).toHaveBeenCalledWith( { kind: "tool", surface: "bash", input: { command: "ls" }, agentName: "agent-x", }, [], ); }); it("applies a recorded session approval on the next resolve", () => { const pm = makePermissionManager(); const sessionRules = new SessionRules(); const { resolver } = makeResolver(pm, sessionRules); sessionRules.recordSessionApproval( SessionApproval.single("bash", "git *"), ); resolver.resolve({ kind: "tool", surface: "bash", input: { command: "git status" }, }); const passedRules = vi.mocked(pm.check).mock.calls[0][1]; expect(passedRules).toHaveLength(1); expect(passedRules?.[0]).toMatchObject({ surface: "bash", pattern: "git *", action: "allow", }); }); it("returns the manager's check result", () => { const pm = makePermissionManager(); vi.mocked(pm.check).mockReturnValue({ state: "deny", toolName: "bash", source: "bash", origin: "global", matchedPattern: "rm *", }); const { resolver } = makeResolver(pm); const result = resolver.resolve({ kind: "tool", surface: "bash", input: { command: "rm -rf /" }, }); expect(result).toEqual({ state: "deny", toolName: "bash", source: "bash", origin: "global", matchedPattern: "rm *", }); }); }); describe("resolve — session ruleset threading", () => { it("applies a recorded session approval on the next call", () => { const pm = makePermissionManager(); const sessionRules = new SessionRules(); const { resolver } = makeResolver(pm, sessionRules); sessionRules.recordSessionApproval( SessionApproval.single("path", "src/*"), ); resolver.resolve({ kind: "access-path", surface: "path", path: AccessPath.forPath("src/a.ts", { cwd: "/proj", flavor: posixPathFlavor, }), }); const passedRules = vi.mocked(pm.check).mock.calls[0][1]; expect(passedRules).toHaveLength(1); expect(passedRules?.[0]).toMatchObject({ surface: "path", pattern: "src/*", action: "allow", }); }); }); describe("resolve — access-path intent", () => { it("unwraps the AccessPath via matchValues() into a path-values intent", () => { const { resolver, permissionManager } = makeResolver(); const accessPath = AccessPath.forPath("/tmp/x", { cwd: "/workspace", flavor: posixPathFlavor, }); resolver.resolve({ kind: "access-path", surface: "external_directory", path: accessPath, agentName: "agent-x", }); expect(permissionManager.check).toHaveBeenCalledWith( { kind: "path-values", surface: "external_directory", values: accessPath.matchValues(), agentName: "agent-x", }, [], ); }); it("returns the manager's check result for an access-path intent", () => { const pm = makePermissionManager(); vi.mocked(pm.check).mockReturnValue({ state: "deny", toolName: "external_directory", source: "special", origin: "global", matchedPattern: "/tmp/*", }); const { resolver } = makeResolver(pm); const accessPath = AccessPath.forPath("/tmp/x", { cwd: "/workspace", flavor: posixPathFlavor, }); const result = resolver.resolve({ kind: "access-path", surface: "external_directory", path: accessPath, }); expect(result.state).toBe("deny"); expect(result.matchedPattern).toBe("/tmp/*"); }); }); describe("resolve — pre-fixed path-values intent (forwarded-serving producer)", () => { // #597: the forwarded-serving wire hands the resolver a ResolvedAccessIntent // it built directly from the child-fixed matchValues() — never an // AccessPath. The resolver must pass it through to the manager unchanged // (no matchValues() unwrap, since there is no AccessPath to unwrap). it("passes a path-values intent to the manager unchanged, with the composed session ruleset", () => { const pm = makePermissionManager(); const sessionRules = new SessionRules(); const { resolver } = makeResolver(pm, sessionRules); sessionRules.recordSessionApproval( SessionApproval.single("external_directory", "/tmp/*"), ); resolver.resolve({ kind: "path-values", surface: "external_directory", values: ["/tmp/x", "/real/tmp/x"], agentName: "Explore", }); const [passedIntent, passedRules] = vi.mocked(pm.check).mock.calls[0]; expect(passedIntent).toEqual({ kind: "path-values", surface: "external_directory", values: ["/tmp/x", "/real/tmp/x"], agentName: "Explore", }); expect(passedRules).toHaveLength(1); expect(passedRules?.[0]).toMatchObject({ surface: "external_directory", pattern: "/tmp/*", action: "allow", }); }); it("returns the manager's check result for a path-values intent", () => { const pm = makePermissionManager(); vi.mocked(pm.check).mockReturnValue({ state: "allow", toolName: "path", source: "special", origin: "global", matchedPattern: "src/**", }); const { resolver } = makeResolver(pm); const result = resolver.resolve({ kind: "path-values", surface: "path", values: ["/worktree/issue-42/src/foo.ts", "src/foo.ts"], agentName: "Explore", }); expect(result.state).toBe("allow"); expect(result.matchedPattern).toBe("src/**"); }); }); describe("checkPermission (raw, off-interface)", () => { it("delegates to manager.check as a tool intent without session rules", () => { const { resolver, permissionManager } = makeResolver(); resolver.checkPermission("bash", { command: "ls" }, "agent-1"); expect(permissionManager.check).toHaveBeenCalledWith( { kind: "tool", surface: "bash", input: { command: "ls" }, agentName: "agent-1", }, undefined, ); }); it("passes optional sessionRules as the second arg to check", () => { const { resolver, permissionManager } = makeResolver(); const extraRules: Ruleset = [ { surface: "bash", pattern: "*", action: "allow", origin: "session" }, ]; resolver.checkPermission( "bash", { command: "ls" }, undefined, extraRules, ); expect(permissionManager.check).toHaveBeenCalledWith( { kind: "tool", surface: "bash", input: { command: "ls" }, agentName: undefined, }, extraRules, ); }); }); describe("getToolPermission", () => { it("delegates to permissionManager.getToolPermission", () => { const pm = makePermissionManager(); vi.mocked(pm.getToolPermission).mockReturnValue("deny"); const { resolver } = makeResolver(pm); const result = resolver.getToolPermission("write", "my-agent"); expect(pm.getToolPermission).toHaveBeenCalledWith("write", "my-agent"); expect(result).toBe("deny"); }); }); describe("getConfigIssues", () => { it("delegates to permissionManager.getConfigIssues", () => { const pm = makePermissionManager(); vi.mocked(pm.getConfigIssues).mockReturnValue(["issue-1"]); const { resolver } = makeResolver(pm); const result = resolver.getConfigIssues("agent-1"); expect(pm.getConfigIssues).toHaveBeenCalledWith("agent-1"); expect(result).toEqual(["issue-1"]); }); }); });