mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 18:55:22 +00:00
2472 lines
70 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|