/** * Integration tests verifying the unified checkPermission() path. * * Step 5: session rules concatenated into the composed ruleset. * Step 6: all five surfaces produce identical decisions to the old branching code. */ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { describe, expect, it, test } from "vitest"; import type { ResolvedAccessIntent } from "#src/access-intent/access-intent"; import { BashProgram } from "#src/access-intent/bash/program"; import { getPathPolicyValues } from "#src/access-intent/path-normalization"; import { getGlobalConfigPath, getProjectAgentsDir } from "#src/config-paths"; import { posixPathFlavor, win32PathFlavor } from "#src/path/path-flavor"; import { PathNormalizer } from "#src/path-normalizer"; import { PermissionManager, type ScopedPermissionManager, } from "#src/permission-manager"; import type { Ruleset } from "#src/rule"; import { createAgentDirHarness, createInMemoryManager, createManager, createManagerWithConfig, createManagerWithProject, createManagerWithScopes, createMissingConfigManager, sessionRule, } from "#test/helpers/manager-harness"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- describe("PermissionManager — injected platform (#510)", () => { const winAllow: Ruleset = [ sessionRule("external_directory", "C:\\Users\\Foo\\pi\\*"), ]; it("win32 manager folds case for path-surface matching", () => { const manager = new PermissionManager({ globalConfigPath: "/nonexistent/config.json", agentsDir: "/nonexistent/agents", flavor: win32PathFlavor, }); const result = manager.check( { kind: "path-values", surface: "external_directory", values: ["c:\\users\\foo\\pi\\docs\\readme.md"], }, winAllow, ); expect(result.state).toBe("allow"); }); it("posix manager keeps path-surface matching case-sensitive", () => { const manager = new PermissionManager({ globalConfigPath: "/nonexistent/config.json", agentsDir: "/nonexistent/agents", flavor: posixPathFlavor, }); const result = manager.check( { kind: "path-values", surface: "external_directory", values: ["c:\\users\\foo\\pi\\docs\\readme.md"], }, winAllow, ); expect(result.state).not.toBe("allow"); }); it("win32: a /tmp* allow rule suppresses a Git Bash /tmp path (#533)", async () => { // End to end: parse `ls /tmp` under win32, take the token's match values, // and confirm a natural `/tmp*` external_directory allow rule matches them. // The win32 matcher folds separators on both the rule and the value (#653), // so the as-typed literal resolves without a backslash match alias. const program = await BashProgram.parse( "ls /tmp", new PathNormalizer(win32PathFlavor, "C:/projects/app"), ); const values = program.externalPaths()[0].matchValues(); const manager = new PermissionManager({ globalConfigPath: "/nonexistent/config.json", agentsDir: "/nonexistent/agents", flavor: win32PathFlavor, }); const allowed = manager.check( { kind: "path-values", surface: "external_directory", values }, [sessionRule("external_directory", "/tmp*")], ); expect(allowed.state).toBe("allow"); const noRule = manager.check( { kind: "path-values", surface: "external_directory", values }, [], ); expect(noRule.state).not.toBe("allow"); }); it("win32: a /dev/null path allow rule suppresses the Git Bash device prompt (#653)", async () => { // The reported repro: `echo hi > /dev/null` reaches the `path` surface with // the device spelled as typed, so a rule written the same way must win over // a preceding universal ask. const program = await BashProgram.parse( "echo hi > /dev/null", new PathNormalizer(win32PathFlavor, "C:\\projects\\app"), ); const values = program.pathRuleCandidates()[0].path.matchValues(); expect(values).toEqual(["/dev/null"]); const manager = new PermissionManager({ globalConfigPath: "/nonexistent/config.json", agentsDir: "/nonexistent/agents", flavor: win32PathFlavor, }); const allowed = manager.check( { kind: "path-values", surface: "path", values }, [sessionRule("path", "*", "ask"), sessionRule("path", "/dev/null")], ); expect(allowed.state).toBe("allow"); const askedWithoutRule = manager.check( { kind: "path-values", surface: "path", values }, [sessionRule("path", "*", "ask")], ); expect(askedWithoutRule.state).toBe("ask"); }); }); // Adapters that build an AccessIntent and call the unified `check` entry point, // so these tests exercise the single resolution path (#478) without a // production-class wrapper used only by tests. function checkTool( manager: PermissionManager, toolName: string, input: unknown, agentName?: string, sessionRules?: Ruleset, ): PermissionCheckResult { return manager.check( { kind: "tool", surface: toolName, input, agentName }, sessionRules, ); } function checkPathValues( manager: PermissionManager, values: readonly string[], agentName?: string, sessionRules?: Ruleset, surface = "path", ): PermissionCheckResult { return manager.check( { kind: "path-values", surface, values, agentName }, sessionRules, ); } function checkPath( manager: PermissionManager, path: string, opts: { cwd?: string } = {}, surface = "path", agentName?: string, sessionRules?: Ruleset, ): PermissionCheckResult { return checkPathValues( manager, getPathPolicyValues( path, opts.cwd ? { cwd: opts.cwd } : {}, posixPathFlavor, ), agentName, sessionRules, surface, ); } // --------------------------------------------------------------------------- // Step 5: session rules concatenated — wins over config/default // --------------------------------------------------------------------------- describe("checkPermission — session rules", () => { it("session rule wins over the universal default (external_directory)", () => { const manager = createMissingConfigManager(); const sessionRules: Ruleset = [ sessionRule("external_directory", "/other/project"), ]; const result = checkPath( manager, "/other/project", {}, "external_directory", undefined, sessionRules, ); expect(result.state).toBe("allow"); expect(result.source).toBe("session"); expect(result.matchedPattern).toBe("/other/project"); }); it("session rule wins over the universal default (skill)", () => { const manager = createMissingConfigManager(); const sessionRules: Ruleset = [sessionRule("skill", "librarian")]; const result = checkTool( manager, "skill", { name: "librarian" }, undefined, sessionRules, ); expect(result.state).toBe("allow"); expect(result.source).toBe("session"); expect(result.matchedPattern).toBe("librarian"); }); it("session rule wins over the universal default (bash)", () => { const manager = createMissingConfigManager(); const sessionRules: Ruleset = [sessionRule("bash", "git status")]; const result = checkTool( manager, "bash", { command: "git status" }, undefined, sessionRules, ); expect(result.state).toBe("allow"); expect(result.source).toBe("session"); expect(result.matchedPattern).toBe("git status"); }); it("session rule wins over the universal default (tool — read)", () => { const manager = createMissingConfigManager(); const sessionRules: Ruleset = [sessionRule("read", "*")]; const result = checkTool(manager, "read", {}, undefined, sessionRules); expect(result.state).toBe("allow"); expect(result.source).toBe("session"); }); it("session rule wins over the universal default (mcp)", () => { const manager = createMissingConfigManager(); const sessionRules: Ruleset = [sessionRule("mcp", "mcp_status")]; const result = checkTool(manager, "mcp", {}, undefined, sessionRules); expect(result.state).toBe("allow"); expect(result.source).toBe("session"); }); it("no session rules — falls through to default (ask)", () => { const manager = createMissingConfigManager(); const result = checkTool(manager, "read", {}, undefined, []); expect(result.state).toBe("ask"); expect(result.source).not.toBe("session"); }); it("session rule with narrower pattern does not block a broader command not in session", () => { const manager = createMissingConfigManager(); // Only "git status" is session-approved; "git push" should fall through to default. const sessionRules: Ruleset = [sessionRule("bash", "git status")]; const result = checkTool( manager, "bash", { command: "git push origin main" }, undefined, sessionRules, ); expect(result.state).toBe("ask"); expect(result.source).not.toBe("session"); }); it("session wildcard pattern matches multiple commands", () => { const manager = createMissingConfigManager(); const sessionRules: Ruleset = [sessionRule("bash", "git *")]; const push = checkTool( manager, "bash", { command: "git push origin main" }, undefined, sessionRules, ); const status = checkTool( manager, "bash", { command: "git status" }, undefined, sessionRules, ); expect(push.state).toBe("allow"); expect(push.source).toBe("session"); expect(status.state).toBe("allow"); expect(status.source).toBe("session"); }); }); // --------------------------------------------------------------------------- // Step 6: source field and matchedPattern for all five surfaces // --------------------------------------------------------------------------- describe("checkPermission — source derivation and matchedPattern", () => { describe("external_directory (special surface)", () => { it("source is 'special' for a config-matched path", () => { const { manager, cleanup } = createManagerWithConfig({ "*": "ask", external_directory: { "/trusted/*": "allow" }, }); try { const result = checkPath( manager, "/trusted/repo", {}, "external_directory", ); expect(result.state).toBe("allow"); expect(result.source).toBe("special"); expect(result.matchedPattern).toBe("/trusted/*"); } finally { cleanup(); } }); it("source is 'special' even for a default match (no config rule)", () => { const manager = createMissingConfigManager(); const result = checkPath(manager, "/some/path", {}, "external_directory"); expect(result.state).toBe("ask"); expect(result.source).toBe("special"); expect(result.matchedPattern).toBeUndefined(); }); it("matchedPattern is undefined for a default match", () => { const manager = createMissingConfigManager(); const result = checkPath(manager, "/unknown", {}, "external_directory"); expect(result.matchedPattern).toBeUndefined(); }); }); describe("skill surface", () => { it("source is 'skill' for a config-matched skill name", () => { const { manager, cleanup } = createManagerWithConfig({ "*": "ask", skill: { librarian: "allow" }, }); try { const result = checkTool(manager, "skill", { name: "librarian" }); expect(result.state).toBe("allow"); expect(result.source).toBe("skill"); expect(result.matchedPattern).toBe("librarian"); } finally { cleanup(); } }); it("source is 'skill' even for a default match", () => { const manager = createMissingConfigManager(); const result = checkTool(manager, "skill", { name: "unknown" }); expect(result.state).toBe("ask"); expect(result.source).toBe("skill"); }); }); describe("bash surface", () => { it("source is 'bash' and command is included in result", () => { const { manager, cleanup } = createManagerWithConfig({ "*": "ask", bash: { "git *": "allow" }, }); try { const result = checkTool(manager, "bash", { command: "git status", }); expect(result.state).toBe("allow"); expect(result.source).toBe("bash"); expect(result.command).toBe("git status"); expect(result.matchedPattern).toBe("git *"); } finally { cleanup(); } }); it("source is 'bash' even for a default match, command is empty string", () => { const manager = createMissingConfigManager(); const result = checkTool(manager, "bash", {}); expect(result.source).toBe("bash"); expect(result.command).toBe(""); expect(result.matchedPattern).toBeUndefined(); }); }); describe("mcp surface", () => { it("source is 'mcp' for a config-matched target", () => { const { manager, cleanup } = createManagerWithConfig( { "*": "ask", mcp: { exa_search: "allow" } }, ["exa"], ); try { const result = checkTool(manager, "mcp", { tool: "exa:search", server: "exa", }); expect(result.state).toBe("allow"); expect(result.source).toBe("mcp"); expect(result.matchedPattern).toBe("exa_search"); expect(result.target).toBeDefined(); } finally { cleanup(); } }); it("source is 'default' when all targets match only the synthesized default", () => { const manager = createMissingConfigManager(); const result = checkTool(manager, "mcp", { tool: "exa:search" }); expect(result.state).toBe("ask"); expect(result.source).toBe("default"); expect(result.matchedPattern).toBeUndefined(); }); it("target field is set for a matched mcp call", () => { const { manager, cleanup } = createManagerWithConfig( { "*": "ask", mcp: { mcp_status: "allow" } }, [], ); try { const result = checkTool(manager, "mcp", {}); expect(result.target).toBeDefined(); expect(result.source).toBe("mcp"); } finally { cleanup(); } }); }); describe("tool surfaces", () => { it("built-in tool: source is always 'tool' (config match)", () => { const { manager, cleanup } = createManagerWithConfig({ "*": "ask", read: "allow", }); try { const result = checkTool(manager, "read", {}); expect(result.state).toBe("allow"); expect(result.source).toBe("tool"); } finally { cleanup(); } }); it("built-in tool: source is 'tool' even for a default match", () => { const manager = createMissingConfigManager(); const result = checkTool(manager, "read", {}); expect(result.state).toBe("ask"); expect(result.source).toBe("tool"); }); it("extension tool: source is 'default' when no config rule matches", () => { const manager = createMissingConfigManager(); const result = checkTool(manager, "my_custom_tool", {}); expect(result.state).toBe("ask"); expect(result.source).toBe("default"); }); it("extension tool: source is 'tool' when a config rule matches", () => { const { manager, cleanup } = createManagerWithConfig({ "*": "ask", my_custom_tool: "allow", }); try { const result = checkTool(manager, "my_custom_tool", {}); expect(result.state).toBe("allow"); expect(result.source).toBe("tool"); } finally { cleanup(); } }); }); describe("matchedPattern for session rules across surfaces", () => { it("matchedPattern is the session rule pattern for a session match (bash)", () => { const manager = createMissingConfigManager(); const sessionRules: Ruleset = [sessionRule("bash", "git *")]; const result = checkTool( manager, "bash", { command: "git status" }, undefined, sessionRules, ); expect(result.matchedPattern).toBe("git *"); expect(result.source).toBe("session"); }); it("matchedPattern is the session rule pattern for a session match (skill)", () => { const manager = createMissingConfigManager(); const sessionRules: Ruleset = [sessionRule("skill", "librarian")]; const result = checkTool( manager, "skill", { name: "librarian" }, undefined, sessionRules, ); expect(result.matchedPattern).toBe("librarian"); }); }); }); // --------------------------------------------------------------------------- // Home directory expansion in external_directory patterns // --------------------------------------------------------------------------- describe("checkPermission — home path expansion in external_directory rules", () => { it("~/glob pattern allows a path under the real home directory", () => { const { manager, cleanup } = createManagerWithConfig({ "*": "ask", external_directory: { "~/trusted/*": "allow" }, }); try { const result = checkPath( manager, join(homedir(), "trusted/repo"), {}, "external_directory", ); expect(result.state).toBe("allow"); expect(result.source).toBe("special"); expect(result.matchedPattern).toBe("~/trusted/*"); } finally { cleanup(); } }); it("$HOME/glob pattern allows a path under the real home directory", () => { const { manager, cleanup } = createManagerWithConfig({ "*": "ask", external_directory: { "$HOME/trusted/*": "allow" }, }); try { const result = checkPath( manager, join(homedir(), "trusted/repo"), {}, "external_directory", ); expect(result.state).toBe("allow"); expect(result.source).toBe("special"); expect(result.matchedPattern).toBe("$HOME/trusted/*"); } finally { cleanup(); } }); it("~/glob deny rule blocks a path under home", () => { const { manager, cleanup } = createManagerWithConfig({ "*": "allow", external_directory: { "~/private/*": "deny" }, }); try { const result = checkPath( manager, join(homedir(), "private/secrets.txt"), {}, "external_directory", ); expect(result.state).toBe("deny"); expect(result.matchedPattern).toBe("~/private/*"); } finally { cleanup(); } }); it("~/glob pattern does not match a path outside home", () => { const { manager, cleanup } = createManagerWithConfig({ "*": "ask", external_directory: { "~/trusted/*": "allow" }, }); try { const result = checkPath( manager, "/tmp/not-home/file", {}, "external_directory", ); // Falls back to the "*": "ask" default — no allow from the ~/trusted/* rule. expect(result.state).toBe("ask"); expect(result.matchedPattern).toBeUndefined(); } finally { cleanup(); } }); }); // --------------------------------------------------------------------------- // Rule origin provenance // --------------------------------------------------------------------------- describe("checkPermission — rule origin provenance", () => { it("single-scope global: config rule has origin 'global'", () => { const { manager, cleanup } = createManagerWithScopes({ read: "allow" }); try { const result = checkTool(manager, "read", {}); expect(result.state).toBe("allow"); expect(result.origin).toBe("global"); } finally { cleanup(); } }); it("single-scope global with pattern map: origin is 'global'", () => { const { manager, cleanup } = createManagerWithScopes({ bash: { "git *": "allow" }, }); try { const result = checkTool(manager, "bash", { command: "git status" }); expect(result.state).toBe("allow"); expect(result.origin).toBe("global"); } finally { cleanup(); } }); it("project overrides global: winning rule has origin 'project'", () => { const { manager, cleanup } = createManagerWithScopes( { read: "ask" }, { read: "allow" }, ); try { const result = checkTool(manager, "read", {}); expect(result.state).toBe("allow"); expect(result.origin).toBe("project"); } finally { cleanup(); } }); it("both-object merge: patterns retain their own origins", () => { // global defines bash["git *"] = allow; project adds bash["rm *"] = deny. // Both patterns should survive with their own origins. const { manager, cleanup } = createManagerWithScopes( { bash: { "git *": "allow" } }, { bash: { "rm *": "deny" } }, ); try { const gitResult = checkTool(manager, "bash", { command: "git status", }); expect(gitResult.state).toBe("allow"); expect(gitResult.origin).toBe("global"); const rmResult = checkTool(manager, "bash", { command: "rm -rf /", }); expect(rmResult.state).toBe("deny"); expect(rmResult.origin).toBe("project"); } finally { cleanup(); } }); it("both-object merge: project pattern overrides global pattern for same key", () => { // Both scopes define bash["git *"]; project wins for that pattern. const { manager, cleanup } = createManagerWithScopes( { bash: { "git *": "ask" } }, { bash: { "git *": "allow" } }, ); try { const result = checkTool(manager, "bash", { command: "git status", }); expect(result.state).toBe("allow"); expect(result.origin).toBe("project"); } finally { cleanup(); } }); it("string replaces object: all patterns from replacing scope get origin 'project'", () => { // global defines bash as an object; project replaces with string "allow". const { manager, cleanup } = createManagerWithScopes( { bash: { "git *": "ask", "npm *": "ask" } }, { bash: "allow" }, ); try { // The catch-all "*" now comes from the project scope. const result = checkTool(manager, "bash", { command: "anything", }); expect(result.state).toBe("allow"); expect(result.origin).toBe("project"); } finally { cleanup(); } }); it("object replaces string: all patterns from replacing scope get origin 'project'", () => { // global defines read as a string "ask"; project replaces with object. const { manager, cleanup } = createManagerWithScopes( { read: "ask" }, { read: { "*": "allow" } }, ); try { const result = checkTool(manager, "read", {}); expect(result.state).toBe("allow"); expect(result.origin).toBe("project"); } finally { cleanup(); } }); it("no config match: origin is 'builtin' (default layer)", () => { // No config — falls back to synthesized default. const manager = createMissingConfigManager(); const result = checkTool(manager, "read", {}); expect(result.state).toBe("ask"); expect(result.origin).toBe("builtin"); }); it("session rule: origin is 'session'", () => { const manager = createMissingConfigManager(); const sessionRules: Ruleset = [sessionRule("read", "*")]; const result = checkTool(manager, "read", {}, undefined, sessionRules); expect(result.state).toBe("allow"); expect(result.source).toBe("session"); expect(result.origin).toBe("session"); }); it("universal fallback (*) set in global config carries origin 'global'", () => { const { manager, cleanup } = createManagerWithScopes({ "*": "allow" }); try { // No explicit surface rule — hits the synthesized default derived from "*". const result = checkTool(manager, "read", {}); expect(result.state).toBe("allow"); expect(result.origin).toBe("global"); } finally { cleanup(); } }); it("universal fallback (*) overridden by project carries origin 'project'", () => { const { manager, cleanup } = createManagerWithScopes( { "*": "ask" }, { "*": "allow" }, ); try { const result = checkTool(manager, "read", {}); expect(result.state).toBe("allow"); expect(result.origin).toBe("project"); } finally { cleanup(); } }); it("built-in fallback (no * in any config): origin is 'builtin'", () => { // Manager with no config file — built-in "ask" default. const manager = createMissingConfigManager(); const result = checkTool(manager, "read", {}); expect(result.state).toBe("ask"); expect(result.origin).toBe("builtin"); }); }); // --------------------------------------------------------------------------- // In-memory PolicyLoader stub tests — no filesystem required // --------------------------------------------------------------------------- import type { PermissionCheckResult, ScopeConfig } from "#src/types"; describe("PermissionManager with in-memory PolicyLoader", () => { describe("universal fallback", () => { it("defaults to ask when no config is provided", () => { const manager = createInMemoryManager(); const result = checkTool(manager, "read", {}); expect(result.state).toBe("ask"); expect(result.origin).toBe("builtin"); }); it("respects permission['*'] = 'allow' from global config", () => { const manager = createInMemoryManager({ global: { permission: { "*": "allow" } }, }); const result = checkTool(manager, "read", {}); expect(result.state).toBe("allow"); expect(result.origin).toBe("global"); }); it("respects permission['*'] = 'deny' from global config", () => { const manager = createInMemoryManager({ global: { permission: { "*": "deny" } }, }); const result = checkTool(manager, "write", {}); expect(result.state).toBe("deny"); }); }); describe("surface routing", () => { it("bash surface routes correctly", () => { const manager = createInMemoryManager({ global: { permission: { "*": "ask", bash: { "git *": "allow" } }, }, }); const result = checkTool(manager, "bash", { command: "git status", }); expect(result.state).toBe("allow"); expect(result.source).toBe("bash"); expect(result.matchedPattern).toBe("git *"); }); it("tool surface routes correctly for built-in tools", () => { const manager = createInMemoryManager({ global: { permission: { "*": "deny", read: "allow" } }, }); const result = checkTool(manager, "read", {}); expect(result.state).toBe("allow"); expect(result.source).toBe("tool"); }); it("skill surface routes correctly", () => { const manager = createInMemoryManager({ global: { permission: { "*": "ask", skill: { librarian: "allow" } }, }, }); const result = checkTool(manager, "skill", { name: "librarian" }); expect(result.state).toBe("allow"); expect(result.source).toBe("skill"); }); it("mcp surface routes correctly", () => { const manager = createInMemoryManager( { global: { permission: { "*": "ask", mcp: { exa_search: "allow" } }, }, }, ["exa"], ); const result = checkTool(manager, "mcp", { tool: "exa:search", server: "exa", }); expect(result.state).toBe("allow"); expect(result.source).toBe("mcp"); }); it("external_directory surface routes correctly", () => { const manager = createInMemoryManager({ global: { permission: { "*": "ask", external_directory: { "/trusted/*": "allow" }, }, }, }); const result = checkPath( manager, "/trusted/repo", {}, "external_directory", ); expect(result.state).toBe("allow"); expect(result.source).toBe("special"); }); it("extension tools use 'default' source when no config rule matches", () => { const manager = createInMemoryManager({ global: { permission: { "*": "ask" } }, }); const result = checkTool(manager, "my_custom_tool", {}); expect(result.state).toBe("ask"); expect(result.source).toBe("default"); }); }); describe("multi-scope merge", () => { it("project overrides global", () => { const manager = createInMemoryManager({ global: { permission: { read: "ask" } }, project: { permission: { read: "allow" } }, }); const result = checkTool(manager, "read", {}); expect(result.state).toBe("allow"); expect(result.origin).toBe("project"); }); it("agent overrides project", () => { const manager = createInMemoryManager({ global: { permission: { read: "ask" } }, project: { permission: { read: "allow" } }, agent: { coder: { permission: { read: "deny" } } }, }); const result = checkTool(manager, "read", {}, "coder"); expect(result.state).toBe("deny"); expect(result.origin).toBe("agent"); }); it("project-agent overrides agent", () => { const manager = createInMemoryManager({ global: { permission: { read: "deny" } }, agent: { coder: { permission: { read: "deny" } } }, projectAgent: { coder: { permission: { read: "allow" } } }, }); const result = checkTool(manager, "read", {}, "coder"); expect(result.state).toBe("allow"); expect(result.origin).toBe("project-agent"); }); it("deep-shallow merge preserves patterns from different scopes", () => { const manager = createInMemoryManager({ global: { permission: { bash: { "git *": "allow" } } }, project: { permission: { bash: { "rm *": "deny" } } }, }); const gitResult = checkTool(manager, "bash", { command: "git status", }); expect(gitResult.state).toBe("allow"); expect(gitResult.origin).toBe("global"); const rmResult = checkTool(manager, "bash", { command: "rm -rf /", }); expect(rmResult.state).toBe("deny"); expect(rmResult.origin).toBe("project"); }); it("string replaces object in override scope", () => { const manager = createInMemoryManager({ global: { permission: { bash: { "git *": "ask", "npm *": "ask" } }, }, project: { permission: { bash: "allow" } }, }); const result = checkTool(manager, "bash", { command: "anything" }); expect(result.state).toBe("allow"); expect(result.origin).toBe("project"); }); }); describe("session rule composition", () => { it("session rule wins over config", () => { const manager = createInMemoryManager({ global: { permission: { "*": "deny" } }, }); const sessionRules: Ruleset = [sessionRule("read", "*")]; const result = checkTool(manager, "read", {}, undefined, sessionRules); expect(result.state).toBe("allow"); expect(result.source).toBe("session"); }); it("session rule does not bleed across surfaces", () => { const manager = createInMemoryManager({ global: { permission: { "*": "ask" } }, }); const sessionRules: Ruleset = [sessionRule("bash", "git *")]; const bashResult = checkTool( manager, "bash", { command: "git status" }, undefined, sessionRules, ); expect(bashResult.state).toBe("allow"); const readResult = checkTool( manager, "read", {}, undefined, sessionRules, ); expect(readResult.state).toBe("ask"); }); }); describe("origin tracking", () => { it("universal fallback from project carries origin 'project'", () => { const manager = createInMemoryManager({ global: { permission: { "*": "ask" } }, project: { permission: { "*": "allow" } }, }); const result = checkTool(manager, "read", {}); expect(result.state).toBe("allow"); expect(result.origin).toBe("project"); }); it("session origin is 'session'", () => { const manager = createInMemoryManager(); const sessionRules: Ruleset = [sessionRule("read", "*")]; const result = checkTool(manager, "read", {}, undefined, sessionRules); expect(result.origin).toBe("session"); }); }); describe("getToolPermission", () => { it("returns tool-level state for built-in tools", () => { const manager = createInMemoryManager({ global: { permission: { "*": "deny", read: "allow" } }, }); expect(manager.getToolPermission("read")).toBe("allow"); expect(manager.getToolPermission("write")).toBe("deny"); }); it("returns tool-level state for bash surface", () => { const manager = createInMemoryManager({ global: { permission: { "*": "deny", bash: "allow" } }, }); expect(manager.getToolPermission("bash")).toBe("allow"); }); }); describe("getComposedConfigRules", () => { it("returns only config-layer rules", () => { const manager = createInMemoryManager({ global: { permission: { "*": "ask", bash: { "git *": "allow" } }, }, }); const rules = manager.getComposedConfigRules(); expect(rules.every((r) => r.layer === "config")).toBe(true); expect( rules.some((r) => r.surface === "bash" && r.pattern === "git *"), ).toBe(true); }); }); }); // --------------------------------------------------------------------------- // Per-tool path patterns (#147) // --------------------------------------------------------------------------- describe("checkPermission — per-tool path patterns", () => { it("denies read of .env when path pattern matches", () => { const { manager, cleanup } = createManagerWithConfig({ read: { "*": "allow", "*.env": "deny" }, }); try { const result = checkPath(manager, ".env", {}, "read"); expect(result.state).toBe("deny"); expect(result.matchedPattern).toBe("*.env"); } finally { cleanup(); } }); it("allows read of non-.env file when .env is denied", () => { const { manager, cleanup } = createManagerWithConfig({ read: { "*": "allow", "*.env": "deny" }, }); try { const result = checkPath(manager, "src/main.ts", {}, "read"); expect(result.state).toBe("allow"); } finally { cleanup(); } }); it("allows write to src/ when only src/ is allowed", () => { const { manager, cleanup } = createManagerWithConfig({ write: { "*": "deny", "src/*": "allow" }, }); try { const result = checkPath(manager, "src/main.ts", {}, "write"); expect(result.state).toBe("allow"); expect(result.matchedPattern).toBe("src/*"); } finally { cleanup(); } }); it("denies write outside src/ when only src/ is allowed", () => { const { manager, cleanup } = createManagerWithConfig({ write: { "*": "deny", "src/*": "allow" }, }); try { const result = checkPath(manager, "vendor/lib.ts", {}, "write"); expect(result.state).toBe("deny"); } finally { cleanup(); } }); it("backward compat: 'read': 'allow' allows read of any path", () => { const { manager, cleanup } = createManagerWithConfig({ read: "allow", }); try { const result = checkPath(manager, ".env", {}, "read"); expect(result.state).toBe("allow"); } finally { cleanup(); } }); it("backward compat: 'read': 'deny' denies read of any path", () => { const { manager, cleanup } = createManagerWithConfig({ read: "deny", }); try { const result = checkPath(manager, "src/main.ts", {}, "read"); expect(result.state).toBe("deny"); } finally { cleanup(); } }); it("session rule for specific path overrides config deny", () => { const { manager, cleanup } = createManagerWithConfig({ read: { "*": "allow", "*.env": "deny" }, }); try { const sessionRules: Ruleset = [sessionRule("read", ".env")]; const result = checkPath( manager, ".env", {}, "read", undefined, sessionRules, ); expect(result.state).toBe("allow"); expect(result.source).toBe("session"); } finally { cleanup(); } }); it("falls back to '*' when input.path is missing", () => { const { manager, cleanup } = createManagerWithConfig({ read: { "*": "allow", "*.env": "deny" }, }); try { const result = checkTool(manager, "read", {}); expect(result.state).toBe("allow"); } finally { cleanup(); } }); it("getToolPermission still returns surface-level state (not path-specific)", () => { const { manager, cleanup } = createManagerWithConfig({ read: { "*": "allow", "*.env": "deny" }, }); try { const toolState = manager.getToolPermission("read"); expect(toolState).toBe("allow"); } finally { cleanup(); } }); }); // --------------------------------------------------------------------------- // Cross-cutting path surface (#148) // --------------------------------------------------------------------------- describe("cross-cutting path surface", () => { it("denies .env via the path surface", () => { const { manager, cleanup } = createManagerWithConfig({ path: { "*": "allow", "*.env": "deny" }, read: "allow", }); try { const result = checkPath(manager, ".env"); expect(result.state).toBe("deny"); expect(result.matchedPattern).toBe("*.env"); } finally { cleanup(); } }); it("allows non-matching paths via the path surface", () => { const { manager, cleanup } = createManagerWithConfig({ path: { "*": "allow", "*.env": "deny" }, read: "allow", }); try { const result = checkPath(manager, "README.md"); expect(result.state).toBe("allow"); } finally { cleanup(); } }); it("path surface does not interfere with per-tool rules", () => { const { manager, cleanup } = createManagerWithConfig({ path: { "*": "allow" }, read: { "*": "allow", "*.secret": "deny" }, }); try { // path surface allows, per-tool denies const readResult = checkPath(manager, "data.secret", {}, "read"); expect(readResult.state).toBe("deny"); // path surface also allows const pathResult = checkPath(manager, "data.secret"); expect(pathResult.state).toBe("allow"); } finally { cleanup(); } }); it("getToolPermission('path') returns catch-all action", () => { const { manager, cleanup } = createManagerWithConfig({ path: { "*": "allow", "*.env": "deny" }, }); try { const toolState = manager.getToolPermission("path"); expect(toolState).toBe("allow"); } finally { cleanup(); } }); it("session approval on path surface overrides config deny", () => { const { manager, cleanup } = createManagerWithConfig({ path: { "*": "allow", "*.env": "deny" }, }); try { const sessionRules: Ruleset = [sessionRule("path", "/project/.env")]; const result = checkPath( manager, "/project/.env", {}, "path", undefined, sessionRules, ); expect(result.state).toBe("allow"); expect(result.source).toBe("session"); } finally { cleanup(); } }); it("configs without path key behave identically (no path gate fires)", () => { const { manager, cleanup } = createManagerWithConfig({ read: "allow", }); try { // path surface falls through to universal default const result = checkPath(manager, ".env"); expect(result.state).toBe("ask"); } finally { cleanup(); } }); it("universal default produces undefined matchedPattern for gate skip (#58)", () => { const { manager, cleanup } = createManagerWithConfig({ "*": "ask", read: "allow", find: "allow", }); try { // No explicit "path" key → matchedPattern must be undefined so the // path gate skips (describePathGate returns null). const result = checkPath(manager, "src/main.ts"); expect(result.state).toBe("ask"); expect(result.matchedPattern).toBeUndefined(); // Meanwhile the tool-level check should allow read. const readResult = checkPath(manager, "src/main.ts", {}, "read"); expect(readResult.state).toBe("allow"); expect(readResult.matchedPattern).toBe("*"); } finally { cleanup(); } }); // ── Deny-with-reason ──────────────────────────────────────────────────── it("deny-with-reason: reason threads through to PermissionCheckResult", () => { const { manager, cleanup } = createManagerWithConfig({ bash: { "npm *": { action: "deny", reason: "Use pnpm instead" } }, }); try { const result = checkTool(manager, "bash", { command: "npm install", }); expect(result.state).toBe("deny"); expect(result.reason).toBe("Use pnpm instead"); expect(result.matchedPattern).toBe("npm *"); } finally { cleanup(); } }); it("deny-without-reason: reason is undefined in PermissionCheckResult", () => { const { manager, cleanup } = createManagerWithConfig({ bash: { "rm -rf *": "deny" }, }); try { const result = checkTool(manager, "bash", { command: "rm -rf /" }); expect(result.state).toBe("deny"); expect(result.reason).toBeUndefined(); } finally { cleanup(); } }); it("deny-with-reason on a non-bash surface", () => { const { manager, cleanup } = createManagerWithConfig({ read: { "*.env": { action: "deny", reason: "Environment files contain secrets", }, }, }); try { const result = checkPath(manager, ".env", {}, "read"); expect(result.state).toBe("deny"); expect(result.reason).toBe("Environment files contain secrets"); expect(result.matchedPattern).toBe("*.env"); } finally { cleanup(); } }); it("non-string reason falls through to the default (malformed config)", () => { const { manager, cleanup } = createManagerWithConfig({ bash: { "npm *": { action: "deny", reason: 42 } }, }); try { const result = checkTool(manager, "bash", { command: "npm install", }); expect(result.state).toBe("ask"); expect(result.reason).toBeUndefined(); } finally { cleanup(); } }); // ── Last-match-wins ordering ──────────────────────────────────────────── it("last-match-wins: catch-all after deny overrides the deny", () => { // Classic misconfiguration: deny is before allow, so allow wins. const { manager, cleanup } = createManagerWithConfig({ path: { "*.env": "deny", "*": "allow" }, }); try { const result = checkPath(manager, ".env"); // "*" is last and matches .env → allow (the deny is shadowed) expect(result.state).toBe("allow"); } finally { cleanup(); } }); it("last-match-wins: deny after catch-all blocks the path", () => { // Correct ordering: catch-all first, specific deny after. const { manager, cleanup } = createManagerWithConfig({ path: { "*": "allow", "*.env": "deny" }, }); try { const result = checkPath(manager, ".env"); expect(result.state).toBe("deny"); } finally { cleanup(); } }); // ── .env.example override recipe ──────────────────────────────────────── it(".env.example override: denies .env and .env.local, allows .env.example", () => { const { manager, cleanup } = createManagerWithConfig({ path: { "*": "allow", "*.env": "deny", "*.env.*": "deny", "*.env.example": "allow", }, }); try { expect(checkPath(manager, ".env").state).toBe("deny"); expect(checkPath(manager, ".env.local").state).toBe("deny"); expect(checkPath(manager, ".env.production").state).toBe("deny"); expect(checkPath(manager, "src/.env").state).toBe("deny"); expect(checkPath(manager, ".env.example").state).toBe("allow"); expect(checkPath(manager, "README.md").state).toBe("allow"); } finally { cleanup(); } }); // ── Universal fallback interaction ────────────────────────────────────── it("universal '*': 'allow' with no path key makes the path gate transparent", () => { const { manager, cleanup } = createManagerWithConfig({ "*": "allow", }); try { const result = checkPath(manager, ".env"); expect(result.state).toBe("allow"); } finally { cleanup(); } }); it("universal '*': 'deny' with no path key denies via path surface too", () => { const { manager, cleanup } = createManagerWithConfig({ "*": "deny", }); try { const result = checkPath(manager, ".env"); expect(result.state).toBe("deny"); } finally { cleanup(); } }); // ── Composition: path allows, per-tool denies ────────────────────── it("per-tool deny still blocks even when path surface allows", () => { const { manager, cleanup } = createManagerWithConfig({ path: { "*": "allow" }, read: "deny", }); try { // path gate passes (allow), but tool gate denies const pathResult = checkPath(manager, "secret.txt"); expect(pathResult.state).toBe("allow"); const readResult = checkPath(manager, "secret.txt", {}, "read"); expect(readResult.state).toBe("deny"); } finally { cleanup(); } }); }); // --------------------------------------------------------------------------- // Home-expansion in path values (issue #350) // --------------------------------------------------------------------------- describe("cross-cutting path surface — home-expanded values", () => { it("~/... path value is denied by a ~/* rule (reported footgun)", () => { const { manager, cleanup } = createManagerWithConfig({ path: { "*": "allow", "~/.ssh/*": "deny" }, }); try { const result = checkPath(manager, "~/.ssh/config"); expect(result.state).toBe("deny"); expect(result.matchedPattern).toBe("~/.ssh/*"); } finally { cleanup(); } }); it("$HOME/... path value is denied by a ~/* rule", () => { const { manager, cleanup } = createManagerWithConfig({ path: { "*": "allow", "~/.ssh/*": "deny" }, }); try { const result = checkPath(manager, `${homedir()}/.ssh/config`); expect(result.state).toBe("deny"); expect(result.matchedPattern).toBe("~/.ssh/*"); } finally { cleanup(); } }); it("$HOME/... path value matches a $HOME/* pattern rule", () => { const { manager, cleanup } = createManagerWithConfig({ path: { "*": "allow", "$HOME/.ssh/*": "deny" }, }); try { const result = checkPath(manager, "$HOME/.ssh/config"); expect(result.state).toBe("deny"); expect(result.matchedPattern).toBe("$HOME/.ssh/*"); } finally { cleanup(); } }); it("already-absolute home path is still denied by ~/* rule", () => { const { manager, cleanup } = createManagerWithConfig({ path: { "*": "allow", "~/.ssh/*": "deny" }, }); try { const result = checkPath(manager, `${homedir()}/.ssh/config`); expect(result.state).toBe("deny"); } finally { cleanup(); } }); it("non-home value is unchanged — .env still matches *.env", () => { const { manager, cleanup } = createManagerWithConfig({ path: { "*": "allow", "*.env": "deny" }, }); try { const result = checkPath(manager, ".env"); expect(result.state).toBe("deny"); expect(result.matchedPattern).toBe("*.env"); } finally { cleanup(); } }); it("per-tool read surface denies ~/... path with a ~/* rule", () => { const { manager, cleanup } = createManagerWithConfig({ "*": "allow", read: { "*": "allow", "~/.ssh/*": "deny" }, }); try { const result = checkPath(manager, "~/.ssh/config", {}, "read"); expect(result.state).toBe("deny"); expect(result.matchedPattern).toBe("~/.ssh/*"); } finally { cleanup(); } }); }); // --------------------------------------------------------------------------- // configureForCwd and agentDir construction // --------------------------------------------------------------------------- describe("PermissionManager — configureForCwd and agentDir option", () => { it("ScopedPermissionManager is exported and PermissionManager satisfies it", () => { // Type-level assertion: assigning PermissionManager to ScopedPermissionManager compiles. const manager = new PermissionManager({ globalConfigPath: "/nonexistent/config.json", agentsDir: "/nonexistent/agents", }); const scoped: ScopedPermissionManager = manager; expect(typeof scoped.configureForCwd).toBe("function"); expect(typeof scoped.check).toBe("function"); expect(typeof scoped.getToolPermission).toBe("function"); expect(typeof scoped.getConfigIssues).toBe("function"); }); it("construction with { agentDir } reads global config from getGlobalConfigPath(agentDir)", () => { const { agentDir, cleanup } = createAgentDirHarness({ globalPermission: { read: "deny" }, }); try { const manager = new PermissionManager({ agentDir }); const result = checkPath(manager, "foo.txt", {}, "read"); expect(result.state).toBe("deny"); } finally { cleanup(); } }); it("configureForCwd(cwd) applies project config (project overrides global)", () => { const { agentDir, cwd, cleanup } = createAgentDirHarness({ globalPermission: { read: "deny" }, projectPermission: { read: "allow" }, }); try { const manager = new PermissionManager({ agentDir }); // Before configureForCwd: global policy applies expect(checkPath(manager, "foo.txt", {}, "read").state).toBe("deny"); manager.configureForCwd(cwd); // After configureForCwd: project override applies (last-match-wins) expect(checkPath(manager, "foo.txt", {}, "read").state).toBe("allow"); } finally { cleanup(); } }); it("configureForCwd(undefined) reverts to global-only", () => { const { agentDir, cwd, cleanup } = createAgentDirHarness({ globalPermission: { read: "deny" }, projectPermission: { read: "allow" }, }); try { const manager = new PermissionManager({ agentDir }); manager.configureForCwd(cwd); expect(checkPath(manager, "foo.txt", {}, "read").state).toBe("allow"); manager.configureForCwd(undefined); // After reverting: global policy applies again expect(checkPath(manager, "foo.txt", {}, "read").state).toBe("deny"); } finally { cleanup(); } }); it("configureForCwd clears the resolved-permissions cache", () => { const { agentDir, globalConfigPath, cleanup } = createAgentDirHarness({ globalPermission: { read: "allow" }, }); try { const manager = new PermissionManager({ agentDir }); // Warm the cache expect(checkPath(manager, "foo.txt", {}, "read").state).toBe("allow"); // Update global config on disk to deny read writeFileSync( globalConfigPath, JSON.stringify({ permission: { read: "deny" } }, null, 2), ); // configureForCwd clears cache + rebuilds loader manager.configureForCwd(undefined); // Should pick up the changed global config expect(checkPath(manager, "foo.txt", {}, "read").state).toBe("deny"); } finally { cleanup(); } }); it("configureForCwd(cwd) derives projectAgentsDir at /.pi/agents (regression: #428)", () => { // Bug: old code derived /.pi/agent/agents instead of /.pi/agents. // This test pins the correct path and verifies agentsDir is unchanged. const { agentDir, cwd, cleanup } = createAgentDirHarness({ globalPermission: { read: "allow" }, }); try { const manager = new PermissionManager({ agentDir }); manager.configureForCwd(cwd); const paths = manager.getResolvedPolicyPaths(); expect(paths.projectAgentsDir).toBe(getProjectAgentsDir(cwd)); expect(paths.agentsDir).toBe(join(agentDir, "agents")); } finally { cleanup(); } }); it("configureForCwd(cwd) enforces permission: frontmatter from /.pi/agents/.md (regression: #428)", () => { // Bug: wrong directory meant project-agent frontmatter was never loaded. const { agentDir, cwd, cleanup } = createAgentDirHarness({ globalPermission: { read: "allow" }, }); try { // Write a project agent definition with a deny override. const projectAgentsDir = getProjectAgentsDir(cwd); mkdirSync(projectAgentsDir, { recursive: true }); writeFileSync( join(projectAgentsDir, "coder.md"), "---\npermission:\n read: deny\n---\n# Coder\n", ); const manager = new PermissionManager({ agentDir }); manager.configureForCwd(cwd); // Without an agent name: global allow applies. expect(checkPath(manager, "foo.txt", {}, "read").state).toBe("allow"); // With the "coder" agent: project-agent deny overrides global allow. expect(checkPath(manager, "foo.txt", {}, "read", "coder").state).toBe( "deny", ); } finally { cleanup(); } }); }); // --------------------------------------------------------------------------- // Project-level and per-agent config scope — moved from catch-all (#342) // --------------------------------------------------------------------------- test("Project-level config overrides base bash patterns", () => { const { manager, cleanup } = createManagerWithProject( { permission: { "*": "allow", bash: { "*": "ask", "rm -rf *": "deny" }, }, }, {}, { projectConfig: { permission: { bash: { "rm -rf build": "allow" } }, }, }, ); try { const allowed = checkTool(manager, "bash", { command: "rm -rf build", }); expect(allowed.state).toBe("allow"); expect(allowed.matchedPattern).toBe("rm -rf build"); const denied = checkTool(manager, "bash", { command: "rm -rf node_modules", }); expect(denied.state).toBe("deny"); expect(denied.matchedPattern).toBe("rm -rf *"); } finally { cleanup(); } }); test("System-agent config overrides project-level bash patterns", () => { const { manager, cleanup } = createManagerWithProject( { permission: { "*": "allow", bash: "ask" }, }, { reviewer: `--- name: reviewer permission: bash: "git log *": allow --- `, }, { projectConfig: { permission: { bash: { "git *": "deny" } }, }, }, ); try { const allowed = checkTool( manager, "bash", { command: "git log --oneline" }, "reviewer", ); expect(allowed.state).toBe("allow"); expect(allowed.matchedPattern).toBe("git log *"); const denied = checkTool( manager, "bash", { command: "git status" }, "reviewer", ); expect(denied.state).toBe("deny"); expect(denied.matchedPattern).toBe("git *"); } finally { cleanup(); } }); test("Project-agent config overrides system-agent tool rules", () => { const { manager, cleanup } = createManagerWithProject( { permission: { "*": "ask" }, }, { reviewer: `--- name: reviewer permission: read: deny --- `, }, { projectAgentFiles: { reviewer: `--- name: reviewer permission: read: allow --- `, }, }, ); try { const result = checkTool(manager, "read", {}, "reviewer"); expect(result.state).toBe("allow"); expect(result.source).toBe("tool"); } finally { cleanup(); } }); test("Full precedence chain base < project < system-agent < project-agent for universal default", () => { const { manager, cleanup } = createManagerWithProject( { permission: { "*": "deny" }, }, { reviewer: `--- name: reviewer permission: "*": ask --- `, }, { projectConfig: { permission: { "*": "allow" }, }, projectAgentFiles: { reviewer: `--- name: reviewer permission: "*": deny --- `, }, }, ); try { const reviewerResult = checkTool( manager, "custom_extension_tool", {}, "reviewer", ); expect(reviewerResult.state).toBe("deny"); expect(reviewerResult.source).toBe("default"); const globalResult = checkTool(manager, "custom_extension_tool", {}); expect(globalResult.state).toBe("allow"); expect(globalResult.source).toBe("default"); } finally { cleanup(); } }); test("Project-agent applies even without a matching system-agent file", () => { const { manager, cleanup } = createManagerWithProject( { permission: { "*": "allow" }, }, {}, { projectAgentFiles: { reviewer: `--- name: reviewer permission: read: deny --- `, }, }, ); try { const agentResult = checkTool(manager, "read", {}, "reviewer"); expect(agentResult.state).toBe("deny"); expect(agentResult.source).toBe("tool"); const globalResult = checkTool(manager, "read", {}); expect(globalResult.state).toBe("allow"); expect(globalResult.source).toBe("tool"); } finally { cleanup(); } }); // --------------------------------------------------------------------------- // PermissionManager surface resolution — moved from catch-all (#342) // --------------------------------------------------------------------------- test("PermissionManager canonical built-in permission checking", () => { const { manager, cleanup } = createManager({ permission: { "*": "deny", read: "allow" }, }); try { const readResult = checkTool(manager, "read", {}); expect(readResult.state).toBe("allow"); expect(readResult.source).toBe("tool"); const writeResult = checkTool(manager, "write", {}); expect(writeResult.state).toBe("deny"); expect(writeResult.source).toBe("tool"); } finally { cleanup(); } }); test("multiline bash command resolves to allow via universal fallback", () => { // Regression test for #73: node -e "..." with embedded newlines was // falling through to the hard-coded 'ask' default because wildcardMatch // used /^.*$/ (no dotAll), which does not match '\n'. const { manager, cleanup } = createManager({ permission: { "*": "allow", bash: { "rm -rf *": "deny", "sudo *": "ask" }, }, }); try { const command = "node -e \"\nimport('x').then(() => {\n console.log('done');\n});\n\""; const result = checkTool(manager, "bash", { command }); expect(result.state).toBe("allow"); } finally { cleanup(); } }); test("Bash specific deny patterns override catch-all within the same config", () => { // In the flat format, patterns within a surface map are ordered by insertion. // Last-match-wins means specific patterns placed AFTER the catch-all override it. const { manager, cleanup } = createManager({ permission: { "*": "ask", bash: { "*": "allow", "rm -rf *": "deny" }, }, }); try { const denied = checkTool(manager, "bash", { command: "rm -rf build", }); expect(denied.state).toBe("deny"); expect(denied.source).toBe("bash"); expect(denied.matchedPattern).toBe("rm -rf *"); const allowed = checkTool(manager, "bash", { command: "echo hello" }); expect(allowed.state).toBe("allow"); expect(allowed.source).toBe("bash"); expect(allowed.matchedPattern).toBe("*"); } finally { cleanup(); } }); test("MCP wildcard matching uses the registered mcp tool", () => { const { manager, cleanup } = createManager({ permission: { "*": "ask", mcp: { "*": "deny", "research_*": "ask", "research_query-*": "allow" }, }, }); try { const queryDocs = checkTool(manager, "mcp", { tool: "research:query-docs", }); expect(queryDocs.state).toBe("allow"); expect(queryDocs.source).toBe("mcp"); expect(queryDocs.matchedPattern).toBe("research_query-*"); expect(queryDocs.target).toBe("research_query-docs"); const resolve2 = checkTool(manager, "mcp", { tool: "research:resolve-context", }); expect(resolve2.state).toBe("ask"); expect(resolve2.matchedPattern).toBe("research_*"); expect(resolve2.target).toBe("research_resolve-context"); const unknown = checkTool(manager, "mcp", { tool: "search:provider", }); expect(unknown.state).toBe("deny"); expect(unknown.matchedPattern).toBe("*"); expect(unknown.target).toBe("search_provider"); } finally { cleanup(); } }); test("Arbitrary extension tools use exact-name tool permissions instead of MCP fallback", () => { const { manager, cleanup } = createManager({ permission: { "*": "deny", third_party_tool: "allow", mcp: { "*": "deny" }, }, }); try { const allowed = checkTool(manager, "third_party_tool", {}); expect(allowed.state).toBe("allow"); expect(allowed.source).toBe("tool"); // another_extension_tool has no explicit rule — falls through to the // universal default (permission["*"] = "deny") with source "default". const fallback = checkTool(manager, "another_extension_tool", {}); expect(fallback.state).toBe("deny"); expect(fallback.source).toBe("default"); } finally { cleanup(); } }); test("Skill permission matching", () => { const { manager, cleanup } = createManager({ permission: { "*": "ask", skill: { "*": "ask", "web-*": "deny", "requesting-code-review": "allow", }, }, }); try { const allowed = checkTool(manager, "skill", { name: "requesting-code-review", }); expect(allowed.state).toBe("allow"); expect(allowed.matchedPattern).toBe("requesting-code-review"); expect(allowed.source).toBe("skill"); const denied = checkTool(manager, "skill", { name: "web-design-guidelines", }); expect(denied.state).toBe("deny"); expect(denied.matchedPattern).toBe("web-*"); const fallback = checkTool(manager, "skill", { name: "unknown-skill", }); expect(fallback.state).toBe("ask"); expect(fallback.matchedPattern).toBe("*"); } finally { cleanup(); } }); test("MCP proxy tool infers server-prefixed aliases from configured server names", () => { const { manager, cleanup } = createManager( { permission: { "*": "ask", mcp: { "exa_*": "deny", exa_get_code_context_exa: "allow" }, }, }, {}, { mcpServerNames: ["exa"] }, ); try { const result = checkTool(manager, "mcp", { tool: "get_code_context_exa", }); expect(result.state).toBe("allow"); expect(result.source).toBe("mcp"); expect(result.matchedPattern).toBe("exa_get_code_context_exa"); expect(result.target).toBe("exa_get_code_context_exa"); } finally { cleanup(); } }); test("MCP server names in settings.json are not used — only mcp.json is consulted", () => { const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-test-")); const globalConfigPath = join(baseDir, "pi-permissions.jsonc"); const mcpConfigPath = join(baseDir, "mcp.json"); const settingsJsonPath = join(baseDir, "settings.json"); const agentsDir = join(baseDir, "agents"); mkdirSync(agentsDir, { recursive: true }); const config: ScopeConfig = { permission: { "*": "ask", mcp: { "legacy-server_*": "allow" } }, }; writeFileSync( globalConfigPath, `${JSON.stringify(config, null, 2)}\n`, "utf8", ); writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers: {} }), "utf8"); writeFileSync( settingsJsonPath, JSON.stringify({ mcpServers: { "legacy-server": {} } }), "utf8", ); const manager = new PermissionManager({ globalConfigPath, agentsDir, globalMcpConfigPath: mcpConfigPath, }); try { const result = checkTool(manager, "mcp", { tool: "some_tool_legacy-server", }); expect(result.state).toBe("ask"); } finally { rmSync(baseDir, { recursive: true, force: true }); } }); test("MCP describe mode normalizes qualified tool names without duplicating server prefixes", () => { const { manager, cleanup } = createManager( { permission: { "*": "ask", mcp: { "exa_*": "deny", exa_web_search_exa: "allow" }, }, }, {}, { mcpServerNames: ["exa"] }, ); try { const result = checkTool(manager, "mcp", { describe: "exa:web_search_exa", server: "exa", }); expect(result.state).toBe("allow"); expect(result.source).toBe("mcp"); expect(result.matchedPattern).toBe("exa_web_search_exa"); expect(result.target).toBe("exa_web_search_exa"); } finally { cleanup(); } }); test("Canonical tools map directly without legacy aliases", () => { const { manager, cleanup } = createManager({ permission: { "*": "ask", find: "allow", ls: "deny" }, }); try { const findResult = checkTool(manager, "find", {}); expect(findResult.state).toBe("allow"); expect(findResult.source).toBe("tool"); const lsResult = checkTool(manager, "ls", {}); expect(lsResult.state).toBe("deny"); expect(lsResult.source).toBe("tool"); } finally { cleanup(); } }); test("mcp catch-all acts as fallback for unmatched MCP targets", () => { const { manager, cleanup } = createManager( { permission: { "*": "ask" }, }, { reviewer: `--- name: reviewer permission: mcp: allow --- `, }, ); try { const result = checkTool( manager, "mcp", { tool: "exa:web_search_exa" }, "reviewer", ); expect(result.state).toBe("allow"); expect(result.source).toBe("mcp"); expect(result.target).toBe("exa_web_search_exa"); } finally { cleanup(); } }); test("specific MCP rules override mcp catch-all", () => { const { manager, cleanup } = createManager( { permission: { "*": "ask" }, }, { reviewer: `--- name: reviewer permission: mcp: "*": allow exa_web_search_exa: deny --- `, }, { mcpServerNames: ["exa"] }, ); try { const result = checkTool( manager, "mcp", { tool: "web_search_exa" }, "reviewer", ); expect(result.state).toBe("deny"); expect(result.source).toBe("mcp"); expect(result.matchedPattern).toBe("exa_web_search_exa"); expect(result.target).toBe("exa_web_search_exa"); } finally { cleanup(); } }); test("specific MCP rules still win when mcp catch-all is deny", () => { const { manager, cleanup } = createManager( { permission: { "*": "ask" }, }, { reviewer: `--- name: reviewer permission: mcp: "*": deny exa_web_search_exa: allow --- `, }, { mcpServerNames: ["exa"] }, ); try { const allowed = checkTool( manager, "mcp", { tool: "web_search_exa" }, "reviewer", ); expect(allowed.state).toBe("allow"); expect(allowed.source).toBe("mcp"); expect(allowed.matchedPattern).toBe("exa_web_search_exa"); expect(allowed.target).toBe("exa_web_search_exa"); const fallback = checkTool( manager, "mcp", { tool: "other_exa" }, "reviewer", ); expect(fallback.state).toBe("deny"); expect(fallback.source).toBe("mcp"); expect(fallback.target).toBe("exa_other_exa"); } finally { cleanup(); } }); test("mcp catch-all in agent frontmatter overrides global default", () => { const { manager, cleanup } = createManager( { permission: { "*": "deny" }, }, { reviewer: `--- name: reviewer permission: mcp: allow --- `, }, ); try { const readResult = checkTool(manager, "read", {}, "reviewer"); expect(readResult.state).toBe("deny"); expect(readResult.source).toBe("tool"); const mcpResult = checkTool( manager, "mcp", { tool: "exa:web_search_exa" }, "reviewer", ); expect(mcpResult.state).toBe("allow"); expect(mcpResult.source).toBe("mcp"); } finally { cleanup(); } }); test("Agent frontmatter canonical tools resolve correctly", () => { const { manager, cleanup } = createManager( { permission: { "*": "deny" }, }, { reviewer: `--- name: reviewer permission: find: allow ls: deny --- `, }, ); try { const findResult = checkTool(manager, "find", {}, "reviewer"); expect(findResult.state).toBe("allow"); expect(findResult.source).toBe("tool"); const lsResult = checkTool(manager, "ls", {}, "reviewer"); expect(lsResult.state).toBe("deny"); expect(lsResult.source).toBe("tool"); } finally { cleanup(); } }); test("All surface names work in agent frontmatter flat permission format", () => { const { manager, cleanup } = createManager( { permission: { "*": "deny" }, }, { reviewer: `--- name: reviewer permission: find: allow task: allow mcp: allow --- `, }, ); try { const findResult = checkTool(manager, "find", {}, "reviewer"); expect(findResult.state).toBe("allow"); expect(findResult.source).toBe("tool"); const taskResult = checkTool(manager, "task", {}, "reviewer"); expect(taskResult.state).toBe("allow"); expect(taskResult.source).toBe("tool"); const mcpResult = checkTool( manager, "mcp", { tool: "exa:web_search_exa" }, "reviewer", ); expect(mcpResult.state).toBe("allow"); } finally { cleanup(); } }); test("task uses exact-name tool permissions like any registered extension tool", () => { const { manager, cleanup } = createManager({ permission: { "*": "deny", task: "allow" }, }); try { const taskResult = checkTool(manager, "task", {}); expect(taskResult.state).toBe("allow"); expect(taskResult.source).toBe("tool"); } finally { cleanup(); } }); test("getToolPermission returns tool-level policy for canonical and extension tools", () => { const { manager, cleanup } = createManager( { permission: { "*": "ask" }, }, { reviewer: `--- name: reviewer permission: bash: deny read: deny task: allow --- `, }, ); try { const bashPermission = manager.getToolPermission("bash", "reviewer"); expect(bashPermission).toBe("deny"); const taskPermission = manager.getToolPermission("task", "reviewer"); expect(taskPermission).toBe("allow"); const readPermission = manager.getToolPermission("read", "reviewer"); expect(readPermission).toBe("deny"); const defaultBashPermission = manager.getToolPermission("bash"); expect(defaultBashPermission).toBe("ask"); const { manager: manager2, cleanup: cleanup2 } = createManager({ permission: { "*": "deny", bash: "allow" }, }); try { const globalBashPermission = manager2.getToolPermission("bash"); expect(globalBashPermission).toBe("allow"); } finally { cleanup2(); } } finally { cleanup(); } }); test("getToolPermission supports arbitrary extension tool names", () => { const { manager, cleanup } = createManager({ permission: { "*": "deny", third_party_tool: "allow" }, }); try { const explicitPermission = manager.getToolPermission("third_party_tool"); expect(explicitPermission).toBe("allow"); const fallbackPermission = manager.getToolPermission( "missing_extension_tool", ); expect(fallbackPermission).toBe("deny"); } finally { cleanup(); } }); // --------------------------------------------------------------------------- // external_directory config resolution and pattern maps — moved from catch-all (#342) // --------------------------------------------------------------------------- test("external_directory permission falls back to universal default when not explicitly configured", () => { const { manager, cleanup } = createManager({ permission: {} }); try { const result = checkTool(manager, "external_directory", {}); expect(result.state).toBe("ask"); expect(result.source).toBe("special"); expect(result.matchedPattern).toBe(undefined); } finally { cleanup(); } }); test("external_directory permission respects explicit deny", () => { const { manager, cleanup } = createManager({ permission: { "*": "allow", external_directory: "deny" }, }); try { const result = checkTool(manager, "external_directory", {}); expect(result.state).toBe("deny"); expect(result.source).toBe("special"); expect(result.matchedPattern).toBe("*"); } finally { cleanup(); } }); test("external_directory permission can be explicitly allowed", () => { const { manager, cleanup } = createManager({ permission: { "*": "allow", external_directory: "allow" }, }); try { const result = checkTool(manager, "external_directory", {}); expect(result.state).toBe("allow"); expect(result.source).toBe("special"); expect(result.matchedPattern).toBe("*"); } finally { cleanup(); } }); test("external_directory permission respects per-agent override", () => { const { manager, cleanup } = createManager( { permission: { "*": "allow", external_directory: "deny" }, }, { trusted: `--- name: trusted permission: external_directory: allow --- `, }, ); try { const globalResult = checkTool(manager, "external_directory", {}); expect(globalResult.state).toBe("deny"); const agentResult = checkTool(manager, "external_directory", {}, "trusted"); expect(agentResult.state).toBe("allow"); expect(agentResult.source).toBe("special"); } finally { cleanup(); } }); test("external_directory permission is not affected by unrelated surface keys", () => { const { manager, cleanup } = createManager({ permission: { "*": "allow", external_directory: "allow" }, }); try { const extResult = checkTool(manager, "external_directory", {}); expect(extResult.state).toBe("allow"); expect(extResult.matchedPattern).toBe("*"); } finally { cleanup(); } }); test("skill pattern map in agent frontmatter overrides global skill policy", () => { const { manager, cleanup } = createManager( { permission: { "*": "deny", skill: "deny" }, }, { reviewer: `--- name: reviewer permission: skill: "*": ask "pi-*": allow --- `, }, ); try { const allowed = checkTool( manager, "skill", { name: "pi-code-review" }, "reviewer", ); expect(allowed.state).toBe("allow"); expect(allowed.matchedPattern).toBe("pi-*"); expect(allowed.source).toBe("skill"); const asked = checkTool( manager, "skill", { name: "other-skill" }, "reviewer", ); expect(asked.state).toBe("ask"); expect(asked.matchedPattern).toBe("*"); const denied = checkTool(manager, "skill", { name: "pi-code-review" }); expect(denied.state).toBe("deny"); expect(denied.source).toBe("skill"); } finally { cleanup(); } }); test("external_directory pattern map in agent frontmatter overrides global policy", () => { const { manager, cleanup } = createManager( { permission: { "*": "allow", external_directory: "deny" }, }, { trusted: `--- name: trusted permission: external_directory: "*": deny "~/Downloads/*": allow --- `, }, ); try { const allowed = checkPath( manager, `${homedir()}/Downloads/file.txt`, {}, "external_directory", "trusted", ); expect(allowed.state).toBe("allow"); expect(allowed.matchedPattern).toBe("~/Downloads/*"); expect(allowed.source).toBe("special"); const denied = checkPath( manager, `${homedir()}/Documents/secret.txt`, {}, "external_directory", "trusted", ); expect(denied.state).toBe("deny"); expect(denied.matchedPattern).toBe("*"); const globalDenied = checkTool(manager, "external_directory", {}); expect(globalDenied.state).toBe("deny"); expect(globalDenied.source).toBe("special"); } finally { cleanup(); } }); test("project-agent frontmatter skill rules override global-agent frontmatter skill rules", () => { const { manager, cleanup } = createManagerWithProject( { permission: { "*": "deny" }, }, { analyst: `--- name: analyst permission: skill: "*": ask --- `, }, { projectAgentFiles: { analyst: `--- name: analyst permission: skill: "pi-*": allow "*": deny --- `, }, }, ); try { const allowed = checkTool( manager, "skill", { name: "pi-code-review" }, "analyst", ); expect(allowed.state).toBe("allow"); expect(allowed.matchedPattern).toBe("pi-*"); const denied = checkTool( manager, "skill", { name: "other-skill" }, "analyst", ); expect(denied.state).toBe("deny"); expect(denied.matchedPattern).toBe("*"); } finally { cleanup(); } }); test("project-agent frontmatter external_directory rules override global-agent frontmatter rules", () => { const { manager, cleanup } = createManagerWithProject( { permission: { "*": "allow", external_directory: "deny" }, }, { analyst: `--- name: analyst permission: external_directory: ask --- `, }, { projectAgentFiles: { analyst: `--- name: analyst permission: external_directory: allow --- `, }, }, ); try { const result = checkTool(manager, "external_directory", {}, "analyst"); expect(result.state).toBe("allow"); expect(result.source).toBe("special"); const globalResult = checkTool(manager, "external_directory", {}); expect(globalResult.state).toBe("deny"); } finally { cleanup(); } }); // --------------------------------------------------------------------------- // PI_CODING_AGENT_DIR support — moved from catch-all (#342) // --------------------------------------------------------------------------- test("PermissionManager reads config from PI_CODING_AGENT_DIR when set", () => { const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-envdir-")); const agentsDir = join(baseDir, "agents"); const newConfigPath = getGlobalConfigPath(baseDir); mkdirSync(agentsDir, { recursive: true }); mkdirSync(dirname(newConfigPath), { recursive: true }); const config: ScopeConfig = { permission: { "*": "deny", read: "allow" }, }; writeFileSync(newConfigPath, JSON.stringify(config), "utf8"); const original = process.env.PI_CODING_AGENT_DIR; process.env.PI_CODING_AGENT_DIR = baseDir; try { const manager = new PermissionManager(); const result = checkTool(manager, "read", {}); expect(result.state).toBe("allow"); const result2 = checkTool(manager, "write", {}); expect(result2.state).toBe("deny"); } finally { if (original !== undefined) { process.env.PI_CODING_AGENT_DIR = original; } else { delete process.env.PI_CODING_AGENT_DIR; } rmSync(baseDir, { recursive: true, force: true }); } }); // --------------------------------------------------------------------------- // getConfigIssues — moved from catch-all (#342) // --------------------------------------------------------------------------- test("PermissionManager.getConfigIssues returns empty array for clean config", () => { const config: ScopeConfig = { permission: { "*": "ask", external_directory: "ask" }, }; const { manager, cleanup } = createManager(config); try { const issues = manager.getConfigIssues(); expect(issues.length).toBe(0); } finally { cleanup(); } }); test("PermissionManager.getConfigIssues returns empty array for empty config", () => { const { manager, cleanup } = createManager({}); try { const issues = manager.getConfigIssues(); expect(issues.length).toBe(0); } finally { cleanup(); } }); // --------------------------------------------------------------------------- // Session-aware checkPermission() — moved from catch-all (#342) // --------------------------------------------------------------------------- test("checkPermission returns source 'session' when session rules cover the external_directory path", () => { const { manager, cleanup } = createManager({ permission: { "*": "allow" }, }); try { const sessionRules = [ sessionRule("external_directory", "/other/project/*"), ]; const result = checkPath( manager, "/other/project/src/foo.ts", {}, "external_directory", undefined, sessionRules, ); expect(result.state).toBe("allow"); expect(result.source).toBe("session"); expect(result.matchedPattern).toBe("/other/project/*"); } finally { cleanup(); } }); test("checkPermission falls back to config policy when session rules do not cover the path", () => { const { manager, cleanup } = createManager({ permission: { "*": "allow", external_directory: "deny" }, }); try { const sessionRules = [ sessionRule("external_directory", "/other/project/*"), ]; const result = checkTool( manager, "external_directory", { path: "/completely/different/path.ts" }, undefined, sessionRules, ); expect(result.state).toBe("deny"); expect(result.source).toBe("special"); } finally { cleanup(); } }); test("checkPermission with empty session rules is identical to call without sessionRules arg", () => { const { manager, cleanup } = createManager({ permission: { "*": "allow", external_directory: "deny" }, }); try { const withEmpty = checkPath( manager, "/other/project/foo.ts", {}, "external_directory", undefined, [], ); const withoutArg = checkPath( manager, "/other/project/foo.ts", {}, "external_directory", ); const expected: PermissionCheckResult = { toolName: "external_directory", state: "deny", matchedPattern: "*", source: "special", origin: "global", }; expect(withEmpty).toEqual(expected); expect(withoutArg).toEqual(expected); } finally { cleanup(); } }); test("session rules for one surface do not affect checks on other surfaces", () => { const { manager, cleanup } = createManager({ permission: {} }); try { const sessionRules = [ sessionRule("external_directory", "/other/project/*"), ]; const bashResult = checkTool( manager, "bash", { command: "git status" }, undefined, sessionRules, ); expect(bashResult.state).toBe("ask"); expect(bashResult.source).toBe("bash"); const mcpResult = checkTool( manager, "mcp", { tool: "exa:search" }, undefined, sessionRules, ); expect(mcpResult.state).toBe("ask"); expect(mcpResult.source).toBe("default"); } finally { cleanup(); } }); test("session rules override config deny for external_directory", () => { const { manager, cleanup } = createManager({ permission: { "*": "allow", external_directory: "deny" }, }); try { const sessionRules = [ sessionRule("external_directory", "/other/project/*"), ]; const result = checkPath( manager, "/other/project/src/foo.ts", {}, "external_directory", undefined, sessionRules, ); expect(result.state).toBe("allow"); expect(result.source).toBe("session"); } finally { cleanup(); } }); test("checkPermission returns source 'session' for bash when session rules match", () => { const { manager, cleanup } = createManager({ permission: {} }); try { const sessionRules = [sessionRule("bash", "git *")]; const result = checkTool( manager, "bash", { command: "git status --short" }, undefined, sessionRules, ); expect(result.state).toBe("allow"); expect(result.source).toBe("session"); expect(result.matchedPattern).toBe("git *"); } finally { cleanup(); } }); test("checkPermission returns source 'session' for bash when session rule is exact match", () => { const { manager, cleanup } = createManager({ permission: {} }); try { const sessionRules = [sessionRule("bash", "ls")]; const result = checkTool( manager, "bash", { command: "ls" }, undefined, sessionRules, ); expect(result.state).toBe("allow"); expect(result.source).toBe("session"); } finally { cleanup(); } }); test("checkPermission falls back to config for bash when session rules do not match the command", () => { const { manager, cleanup } = createManager({ permission: { bash: "deny" } }); try { const sessionRules = [sessionRule("bash", "git *")]; const result = checkTool( manager, "bash", { command: "npm run build" }, undefined, sessionRules, ); expect(result.state).toBe("deny"); expect(result.source).toBe("bash"); } finally { cleanup(); } }); test("checkPermission returns source 'session' for mcp when session rules match the target", () => { const { manager, cleanup } = createManager({ permission: {} }); try { const sessionRules = [sessionRule("mcp", "exa:*")]; const result = checkTool( manager, "mcp", { tool: "exa:search" }, undefined, sessionRules, ); expect(result.state).toBe("allow"); expect(result.source).toBe("session"); } finally { cleanup(); } }); test("checkPermission returns source 'session' for skill when session rules match", () => { const { manager, cleanup } = createManager({ permission: {} }); try { const sessionRules = [sessionRule("skill", "librarian")]; const result = checkTool( manager, "skill", { name: "librarian" }, undefined, sessionRules, ); expect(result.state).toBe("allow"); expect(result.source).toBe("session"); expect(result.matchedPattern).toBe("librarian"); } finally { cleanup(); } }); test("checkPermission returns source 'session' for tool surface when session rules match", () => { const { manager, cleanup } = createManager({ permission: {} }); try { const sessionRules = [sessionRule("read", "*")]; const result = checkTool(manager, "read", {}, undefined, sessionRules); expect(result.state).toBe("allow"); expect(result.source).toBe("session"); } finally { cleanup(); } }); test("bash session rules do not bleed into mcp checks", () => { const { manager, cleanup } = createManager({ permission: {} }); try { const sessionRules = [sessionRule("bash", "git *")]; const result = checkTool( manager, "mcp", { tool: "exa:search" }, undefined, sessionRules, ); expect(result.source).not.toBe("session"); } finally { cleanup(); } }); // --------------------------------------------------------------------------- // getResolvedPolicyPaths — moved from catch-all (#342) // --------------------------------------------------------------------------- test("getResolvedPolicyPaths returns correct paths and existence when files exist", () => { const tempDir = mkdtempSync(join(tmpdir(), "policy-paths-exist-")); try { const globalConfigPath = join(tempDir, "pi-permissions.jsonc"); const agentsDir = join(tempDir, "agents"); const projectConfigPath = join(tempDir, "project", "pi-permissions.jsonc"); const projectAgentsDir = join(tempDir, "project", "agents"); writeFileSync(globalConfigPath, "{}", "utf-8"); mkdirSync(agentsDir, { recursive: true }); mkdirSync(join(tempDir, "project"), { recursive: true }); writeFileSync(projectConfigPath, "{}", "utf-8"); mkdirSync(projectAgentsDir, { recursive: true }); const pm = new PermissionManager({ globalConfigPath, agentsDir, projectGlobalConfigPath: projectConfigPath, projectAgentsDir, }); const result = pm.getResolvedPolicyPaths(); expect(result.globalConfigPath).toBe(globalConfigPath); expect(result.globalConfigExists).toBe(true); expect(result.projectConfigPath).toBe(projectConfigPath); expect(result.projectConfigExists).toBe(true); expect(result.agentsDir).toBe(agentsDir); expect(result.agentsDirExists).toBe(true); expect(result.projectAgentsDir).toBe(projectAgentsDir); expect(result.projectAgentsDirExists).toBe(true); } finally { rmSync(tempDir, { recursive: true, force: true }); } }); test("getResolvedPolicyPaths returns false for missing files and null for absent project paths", () => { const tempDir = mkdtempSync(join(tmpdir(), "policy-paths-missing-")); try { const globalConfigPath = join(tempDir, "does-not-exist.jsonc"); const agentsDir = join(tempDir, "no-agents"); const pm = new PermissionManager({ globalConfigPath, agentsDir, }); const result = pm.getResolvedPolicyPaths(); expect(result.globalConfigPath).toBe(globalConfigPath); expect(result.globalConfigExists).toBe(false); expect(result.projectConfigPath).toBe(null); expect(result.projectConfigExists).toBe(false); expect(result.agentsDir).toBe(agentsDir); expect(result.agentsDirExists).toBe(false); expect(result.projectAgentsDir).toBe(null); expect(result.projectAgentsDirExists).toBe(false); } finally { rmSync(tempDir, { recursive: true, force: true }); } }); describe("checkPermission — cwd-aware path policy values", () => { const cwd = "/workspace/project"; it("matches a relative read input against an absolute allowlist", () => { const { manager, cleanup } = createManagerWithConfig({ read: { "*": "ask", [`${cwd}/*`]: "allow" }, }); try { manager.configureForCwd(cwd); const result = checkPath(manager, "src/App.jsx", { cwd }, "read"); expect(result.state).toBe("allow"); expect(result.matchedPattern).toBe(`${cwd}/*`); } finally { cleanup(); } }); it("keeps legacy relative path rules working after configureForCwd", () => { const { manager, cleanup } = createManagerWithConfig({ read: { "*": "allow", "src/*": "deny" }, }); try { manager.configureForCwd(cwd); const result = checkPath(manager, "src/App.jsx", { cwd }, "read"); expect(result.state).toBe("deny"); expect(result.matchedPattern).toBe("src/*"); } finally { cleanup(); } }); it("preserves last-match-wins across absolute and relative aliases", () => { const { manager, cleanup } = createManagerWithConfig({ read: { "*": "ask", [`${cwd}/*`]: "allow", "src/*": "deny", }, }); try { manager.configureForCwd(cwd); const result = checkPath(manager, "src/App.jsx", { cwd }, "read"); // The later "src/*" deny wins over the earlier absolute allow. expect(result.state).toBe("deny"); expect(result.matchedPattern).toBe("src/*"); } finally { cleanup(); } }); it("matches the cross-cutting path surface against absolute allowlists", () => { const { manager, cleanup } = createManagerWithConfig({ path: { "*": "ask", [`${cwd}/*`]: "allow" }, }); try { manager.configureForCwd(cwd); const result = checkPath(manager, "src/App.jsx", { cwd }); expect(result.state).toBe("allow"); expect(result.matchedPattern).toBe(`${cwd}/*`); } finally { cleanup(); } }); }); describe("checkPathPolicy", () => { const cwd = "/workspace/project"; it("evaluates precomputed policy values against the path surface", () => { const { manager, cleanup } = createManagerWithConfig({ path: { "*": "ask", [`${cwd}/*`]: "allow" }, }); try { const result = checkPathValues(manager, [ `${cwd}/src/App.jsx`, "src/App.jsx", ]); expect(result.state).toBe("allow"); expect(result.matchedPattern).toBe(`${cwd}/*`); expect(result.source).toBe("special"); expect(result.toolName).toBe("path"); } finally { cleanup(); } }); it("preserves last-match-wins across the provided aliases", () => { const { manager, cleanup } = createManagerWithConfig({ path: { "*": "ask", [`${cwd}/*`]: "allow", "src/*": "deny" }, }); try { const result = checkPathValues(manager, [ `${cwd}/src/App.jsx`, "src/App.jsx", ]); expect(result.state).toBe("deny"); expect(result.matchedPattern).toBe("src/*"); } finally { cleanup(); } }); it("applies session rules over config", () => { const { manager, cleanup } = createManagerWithConfig({ path: { "*": "ask", "src/*": "deny" }, }); try { const sessionRules: Ruleset = [sessionRule("path", "src/*")]; const result = checkPathValues( manager, ["src/App.jsx"], undefined, sessionRules, ); expect(result.state).toBe("allow"); expect(result.source).toBe("session"); } finally { cleanup(); } }); it("falls back to the catch-all for an empty value list", () => { const { manager, cleanup } = createManagerWithConfig({ path: { "*": "deny" }, }); try { const result = checkPathValues(manager, []); expect(result.state).toBe("deny"); expect(result.matchedPattern).toBe("*"); } finally { cleanup(); } }); it("evaluates against the external_directory surface when one is provided", () => { const { manager, cleanup } = createManagerWithConfig({ external_directory: { "*": "ask", "/tmp/*": "allow" }, }); try { const result = checkPathValues( manager, ["/tmp/x"], undefined, undefined, "external_directory", ); expect(result.state).toBe("allow"); expect(result.matchedPattern).toBe("/tmp/*"); expect(result.source).toBe("special"); expect(result.toolName).toBe("external_directory"); } finally { cleanup(); } }); it("defaults to the path surface when no surface is provided", () => { const { manager, cleanup } = createManagerWithConfig({ external_directory: { "*": "ask", "/tmp/*": "allow" }, path: { "*": "allow" }, }); try { // No path rule denies; the external_directory allow must NOT apply here. const result = checkPathValues(manager, ["/tmp/x"]); expect(result.toolName).toBe("path"); expect(result.state).toBe("allow"); expect(result.matchedPattern).toBe("*"); } finally { cleanup(); } }); }); // --------------------------------------------------------------------------- // check(intent) — unified entry point (Step 1 of #478) // --------------------------------------------------------------------------- describe("check — tool intent", () => { it("resolves a tool call on the bash surface", () => { const { manager, cleanup } = createManagerWithConfig({ bash: { "*": "allow", "git push": "deny" }, }); try { const intent: ResolvedAccessIntent = { kind: "tool", surface: "bash", input: { command: "git push" }, }; const result = manager.check(intent); expect(result.state).toBe("deny"); expect(result.toolName).toBe("bash"); expect(result.source).toBe("bash"); } finally { cleanup(); } }); it("resolves a tool call on the read surface", () => { const { manager, cleanup } = createManagerWithConfig({ read: "deny" }); try { const intent: ResolvedAccessIntent = { kind: "tool", surface: "read", input: { path: "/some/file.txt" }, }; const result = manager.check(intent); expect(result.state).toBe("deny"); expect(result.toolName).toBe("read"); expect(result.source).toBe("tool"); } finally { cleanup(); } }); it("applies session rules via the tool intent", () => { const { manager, cleanup } = createManagerWithConfig({ bash: { "*": "deny" }, }); try { const sessionRules: Ruleset = [sessionRule("bash", "echo *")]; const intent: ResolvedAccessIntent = { kind: "tool", surface: "bash", input: { command: "echo hello" }, }; const result = manager.check(intent, sessionRules); expect(result.state).toBe("allow"); expect(result.source).toBe("session"); } finally { cleanup(); } }); it("threads agentName through the tool intent", () => { const { manager, cleanup } = createManager( { permission: { bash: "deny" } }, { "agent-a": `---\nname: agent-a\npermission:\n bash: allow\n---\n`, }, ); try { const intent: ResolvedAccessIntent = { kind: "tool", surface: "bash", input: { command: "echo hi" }, agentName: "agent-a", }; const result = manager.check(intent); expect(result.state).toBe("allow"); } finally { cleanup(); } }); }); describe("check — path-values intent", () => { const cwd = "/workspace/project"; it("evaluates precomputed policy values against the path surface", () => { const { manager, cleanup } = createManagerWithConfig({ path: { "*": "ask", [`${cwd}/*`]: "allow" }, }); try { const intent: ResolvedAccessIntent = { kind: "path-values", surface: "path", values: [`${cwd}/src/App.jsx`, "src/App.jsx"], }; const result = manager.check(intent); expect(result.state).toBe("allow"); expect(result.matchedPattern).toBe(`${cwd}/*`); expect(result.source).toBe("special"); expect(result.toolName).toBe("path"); } finally { cleanup(); } }); it("evaluates against the external_directory surface", () => { const { manager, cleanup } = createManagerWithConfig({ external_directory: { "*": "ask", "/tmp/*": "allow" }, }); try { const intent: ResolvedAccessIntent = { kind: "path-values", surface: "external_directory", values: ["/tmp/x"], }; const result = manager.check(intent); expect(result.state).toBe("allow"); expect(result.matchedPattern).toBe("/tmp/*"); expect(result.source).toBe("special"); expect(result.toolName).toBe("external_directory"); } finally { cleanup(); } }); it("falls back to the catch-all for an empty value list", () => { const { manager, cleanup } = createManagerWithConfig({ path: { "*": "deny" }, }); try { const intent: ResolvedAccessIntent = { kind: "path-values", surface: "path", values: [], }; const result = manager.check(intent); expect(result.state).toBe("deny"); expect(result.matchedPattern).toBe("*"); } finally { cleanup(); } }); it("applies session rules via the path-values intent", () => { const { manager, cleanup } = createManagerWithConfig({ path: { "*": "ask", "src/*": "deny" }, }); try { const sessionRules: Ruleset = [sessionRule("path", "src/*")]; const intent: ResolvedAccessIntent = { kind: "path-values", surface: "path", values: ["src/App.jsx"], }; const result = manager.check(intent, sessionRules); expect(result.state).toBe("allow"); expect(result.source).toBe("session"); } finally { cleanup(); } }); it("last-match-wins across the provided aliases", () => { const { manager, cleanup } = createManagerWithConfig({ path: { "*": "ask", [`${cwd}/*`]: "allow", "src/*": "deny" }, }); try { const intent: ResolvedAccessIntent = { kind: "path-values", surface: "path", values: [`${cwd}/src/App.jsx`, "src/App.jsx"], }; const result = manager.check(intent); expect(result.state).toBe("deny"); expect(result.matchedPattern).toBe("src/*"); } finally { cleanup(); } }); });