Files
my-pi/pi-permission-system/test/permission-manager-unified.test.ts

3435 lines
99 KiB
TypeScript

/**
* 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 <cwd>/.pi/agents (regression: #428)", () => {
// Bug: old code derived <cwd>/.pi/agent/agents instead of <cwd>/.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 <cwd>/.pi/agents/<agent>.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();
}
});
});