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

2472 lines
70 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 } from "#src/config-paths";
import { posixPathFlavor, win32PathFlavor } from "#src/path/path-flavor";
import { PathNormalizer } from "#src/path-normalizer";
import { PermissionManager } 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)", () => {
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 (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("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");
});
});
});
// ---------------------------------------------------------------------------
// 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("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("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("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-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");
});
});
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("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();
}
});
// ── Pattern specificity ordering ───────────────────────────────────────
it("specific deny wins even when a catch-all is declared later", () => {
const { manager, cleanup } = createManagerWithConfig({
path: { "*.env": "deny", "*": "allow" },
});
try {
const result = checkPath(manager, ".env");
expect(result.state).toBe("deny");
expect(result.matchedPattern).toBe("*.env");
} finally {
cleanup();
}
});
it("specific deny wins when declared after a catch-all", () => {
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();
}
});
});
// ---------------------------------------------------------------------------
// Global-only policy loading
// ---------------------------------------------------------------------------
test("project policy files are ignored", () => {
const { agentDir, cwd, cleanup } = createAgentDirHarness({
globalPermission: { "*": "ask", bash: "deny" },
projectPermission: { bash: "allow" },
});
try {
const manager = new PermissionManager({ agentDir });
manager.configureForCwd(cwd);
expect(checkTool(manager, "bash", { command: "echo hi" }).state).toBe(
"deny",
);
} 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("Precedence is base < system-agent < project-agent and ignores project policy", () => {
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("deny");
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", () => {
// Specific patterns override catch-alls regardless of declaration order.
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();
}
});
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("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();
}
});
// ---------------------------------------------------------------------------
// 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 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' 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,
projectAgentsDir,
});
const result = pm.getResolvedPolicyPaths();
expect(result.globalConfigPath).toBe(globalConfigPath);
expect(result.globalConfigExists).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.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("chooses the most specific pattern 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");
expect(result.state).toBe("allow");
expect(result.matchedPattern).toBe(`${cwd}/*`);
} 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("chooses the most specific pattern 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("allow");
expect(result.matchedPattern).toBe(`${cwd}/*`);
} 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();
}
});
});
// ---------------------------------------------------------------------------
// 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("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("uses pattern specificity 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("allow");
expect(result.matchedPattern).toBe(`${cwd}/*`);
} finally {
cleanup();
}
});
});