refactor(permission-system): simplify policy and autoreview flow

This commit is contained in:
云服务部-叶林立
2026-09-03 02:01:06 +08:00
parent 180da95ab7
commit 6e7effa792
112 changed files with 911 additions and 10346 deletions
@@ -1,10 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import {
AgentPrepHandler,
shouldExposeTool,
} from "#src/handlers/before-agent-start";
import type { ToolRegistry } from "#src/tool-registry";
import { AgentPrepHandler } from "#src/handlers/before-agent-start";
import { makeCheckResult, makeCtx } from "#test/helpers/handler-fixtures";
import {
@@ -12,325 +8,51 @@ import {
makeRealSession,
} from "#test/helpers/session-fixtures";
// ── SDK stubs ──────────────────────────────────────────────────────────────
vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
const original =
await importOriginal<typeof import("@earendil-works/pi-coding-agent")>();
return {
...original,
isToolCallEventType: vi.fn().mockReturnValue(false),
};
});
// ── helpers ────────────────────────────────────────────────────────────────
function makeEvent(systemPrompt = "You are an assistant.") {
return { systemPrompt };
}
function makeToolRegistry(overrides: Partial<ToolRegistry> = {}): ToolRegistry {
return {
getAll: vi.fn().mockReturnValue([]),
getActive: vi.fn().mockReturnValue([]),
setActive: vi.fn(),
...overrides,
};
}
function makeSetup(opts?: {
toolPermission?: "allow" | "deny" | "ask";
toolRegistry?: Partial<ToolRegistry>;
}) {
function makeSetup() {
const { session, permissionManager, sessionRules, configStore, forwarding } =
makeRealSession();
const { resolver } = makeRealResolver(permissionManager, sessionRules);
if (opts?.toolPermission !== undefined) {
vi.mocked(permissionManager.getToolPermission).mockReturnValue(
opts.toolPermission,
);
}
// Default check returns allow (for skill-prompt sanitizer via resolver.checkPermission)
vi.mocked(permissionManager.check).mockReturnValue(makeCheckResult());
const toolRegistry = makeToolRegistry(opts?.toolRegistry);
const warmParser = vi.fn();
const handler = new AgentPrepHandler(
session,
resolver,
toolRegistry,
warmParser,
);
const handler = new AgentPrepHandler(session, resolver, warmParser);
return {
handler,
session,
resolver,
permissionManager,
configStore,
forwarding,
toolRegistry,
warmParser,
};
}
// ── shouldExposeTool (pure helper) ─────────────────────────────────────────
describe("shouldExposeTool", () => {
it("returns true when tool permission is allow", () => {
const getter = vi.fn().mockReturnValue("allow");
expect(shouldExposeTool("read", null, getter)).toBe(true);
});
it("returns true when tool permission is ask", () => {
const getter = vi.fn().mockReturnValue("ask");
expect(shouldExposeTool("bash", "agent-x", getter)).toBe(true);
});
it("returns false when tool permission is deny", () => {
const getter = vi.fn().mockReturnValue("deny");
expect(shouldExposeTool("write", null, getter)).toBe(false);
});
it("passes agentName through to getToolPermission", () => {
const getter = vi.fn().mockReturnValue("allow");
shouldExposeTool("read", "my-agent", getter);
expect(getter).toHaveBeenCalledWith("read", "my-agent");
});
it("converts null agentName to undefined for getToolPermission", () => {
const getter = vi.fn().mockReturnValue("allow");
shouldExposeTool("read", null, getter);
expect(getter).toHaveBeenCalledWith("read", undefined);
});
});
// ── AgentPrepHandler.handle ────────────────────────────────────────────────
describe("AgentPrepHandler.handle", () => {
it("activates the session with ctx", async () => {
describe("AgentPrepHandler", () => {
it("warms the parser and activates the permission session", async () => {
const { handler, forwarding, warmParser } = makeSetup();
const ctx = makeCtx();
const { handler, forwarding } = makeSetup();
await handler.handle(makeEvent(), ctx);
// Real session.activate calls forwarding.start
await handler.handle({ systemPrompt: "You are an assistant." }, ctx);
expect(warmParser).toHaveBeenCalledTimes(1);
expect(forwarding.start).toHaveBeenCalledWith(ctx);
});
it("triggers the bash-parser warm-up", async () => {
const { handler, warmParser } = makeSetup();
await handler.handle(makeEvent(), makeCtx());
expect(warmParser).toHaveBeenCalledTimes(1);
});
it("refreshes config with ctx, gated on project trust", async () => {
const ctx = makeCtx();
it("refreshes global permission configuration", async () => {
const { handler, configStore } = makeSetup();
await handler.handle(makeEvent(), ctx);
expect(configStore.refresh).toHaveBeenCalledWith(ctx, true);
});
const ctx = makeCtx({ isProjectTrusted: () => false });
await handler.handle({ systemPrompt: "You are an assistant." }, ctx);
it("withholds the project scope when the project is untrusted", async () => {
const ctx = makeCtx({
isProjectTrusted: vi.fn<() => boolean>().mockReturnValue(false),
});
const { handler, configStore } = makeSetup();
await handler.handle(makeEvent(), ctx);
expect(configStore.refresh).toHaveBeenCalledWith(ctx, false);
});
it("resolves agent name using systemPrompt", async () => {
const ctx = makeCtx();
it("does not rewrite a prompt without skill entries", async () => {
const { handler, session } = makeSetup();
const spy = vi.spyOn(session, "resolveAgentName");
await handler.handle(makeEvent("<active_agent name='x'>"), ctx);
expect(spy).toHaveBeenCalledWith(ctx, "<active_agent name='x'>");
});
it("filters out denied tools from allowed list", async () => {
const { handler, toolRegistry } = makeSetup({
toolPermission: "deny",
toolRegistry: {
getActive: vi.fn().mockReturnValue(["write", "read"]),
},
});
await handler.handle(makeEvent(), makeCtx());
expect(toolRegistry.setActive).toHaveBeenCalledWith([]);
});
it("includes allowed and ask tools in the active list", async () => {
const { handler, toolRegistry } = makeSetup({
toolRegistry: {
getActive: vi.fn().mockReturnValue(["read", "write"]),
},
});
await handler.handle(makeEvent(), makeCtx());
expect(toolRegistry.setActive).toHaveBeenCalledWith(["read", "write"]);
});
it("does not activate registered tools pi left inactive (find/grep/ls)", async () => {
// Regression for #385: the active set is the base, not the full registry.
const { handler, toolRegistry } = makeSetup({
toolRegistry: {
getActive: vi.fn().mockReturnValue(["read", "bash", "edit", "write"]),
getAll: vi
.fn()
.mockReturnValue([
{ name: "read" },
{ name: "bash" },
{ name: "edit" },
{ name: "write" },
{ name: "find" },
{ name: "grep" },
{ name: "ls" },
]),
},
});
await handler.handle(makeEvent(), makeCtx());
expect(toolRegistry.setActive).toHaveBeenCalledWith([
"read",
"bash",
"edit",
"write",
]);
});
it("calls setActive on every turn (no dedup gate)", async () => {
const { handler, toolRegistry } = makeSetup({
toolRegistry: {
getActive: vi.fn().mockReturnValue(["read"]),
},
});
await handler.handle(makeEvent(), makeCtx());
await handler.handle(makeEvent(), makeCtx());
expect(toolRegistry.setActive).toHaveBeenCalledTimes(2);
});
it("filters a denied skill from the systemPrompt on every turn, not just the first", async () => {
const systemPrompt = [
"You are an assistant.",
"",
"<available_skills>",
" <skill>",
" <name>secret</name>",
" <description>A denied skill</description>",
" <location>/skills/secret/SKILL.md</location>",
" </skill>",
"</available_skills>",
].join("\n");
const { handler, permissionManager } = makeSetup();
vi.mocked(permissionManager.check).mockImplementation((intent) =>
intent.surface === "skill"
? makeCheckResult({ state: "deny" })
: makeCheckResult(),
const result = await handler.handle(
{ systemPrompt: "You are an assistant." },
makeCtx(),
);
const first = await handler.handle(makeEvent(systemPrompt), makeCtx());
const second = await handler.handle(makeEvent(systemPrompt), makeCtx());
expect(first).toHaveProperty("systemPrompt");
expect((first as { systemPrompt: string }).systemPrompt).not.toContain(
"secret",
);
expect(second).toHaveProperty("systemPrompt");
expect((second as { systemPrompt: string }).systemPrompt).not.toContain(
"secret",
);
});
it("returns empty object on repeated calls with unchanged inputs", async () => {
const { handler } = makeSetup();
await handler.handle(makeEvent(), makeCtx());
const result = await handler.handle(makeEvent(), makeCtx());
expect(result).toEqual({});
});
it("stores resolved skill entries on the session", async () => {
const { handler, session } = makeSetup();
const spy = vi.spyOn(session, "setActiveSkillEntries");
await handler.handle(makeEvent(), makeCtx());
expect(spy).toHaveBeenCalledWith(expect.any(Array));
});
it("returns modified systemPrompt when prompt changes", async () => {
const systemPrompt = `You are an assistant.\n\nAvailable tools:\n- read\n- write\n`;
const { handler } = makeSetup();
const result = await handler.handle(makeEvent(systemPrompt), makeCtx());
expect(result).toHaveProperty("systemPrompt");
});
it("returns empty object when systemPrompt is unchanged", async () => {
const prompt = "No tools section here.";
const { handler } = makeSetup();
const result = await handler.handle(makeEvent(prompt), makeCtx());
expect(result).toEqual({});
});
it("narrows a denied tool out of the Available tools listing without removing the section", async () => {
const systemPrompt = [
"Available tools:",
"- read: Read file contents",
"- bash: Run shell commands",
].join("\n");
const { handler, permissionManager } = makeSetup({
toolRegistry: {
getActive: vi.fn().mockReturnValue(["read", "bash"]),
},
});
vi.mocked(permissionManager.getToolPermission).mockImplementation((tool) =>
tool === "bash" ? "deny" : "allow",
);
const result = await handler.handle(makeEvent(systemPrompt), makeCtx());
expect(result.systemPrompt).toBeDefined();
const out = result.systemPrompt ?? "";
expect(out).toContain("Available tools:");
expect(out).toContain("- read: Read file contents");
expect(out).not.toContain("- bash");
});
it("keeps the wire system prompt byte-stable across the tool-listing drift between turns", async () => {
const fullProse = [
"You are an assistant.",
"",
"Available tools:",
"- bash: Run shell commands",
"- read: Read file contents",
"- edit: Edit a file",
"- write: Write a file",
"",
"Guidelines:",
"- use bash for file operations like ls, rg, find",
"- use read to examine files instead of cat or sed.",
"- Be concise in your responses",
].join("\n");
const narrowedProse = [
"You are an assistant.",
"",
"Available tools:",
"- read: Read file contents",
"- edit: Edit a file",
"- write: Write a file",
"",
"Guidelines:",
"- use read to examine files instead of cat or sed.",
"- Be concise in your responses",
].join("\n");
const { handler, permissionManager } = makeSetup({
toolRegistry: {
getActive: vi.fn().mockReturnValue(["bash", "read", "edit", "write"]),
},
});
vi.mocked(permissionManager.getToolPermission).mockImplementation((tool) =>
tool === "bash" ? "deny" : "allow",
);
// Turn 1: Pi feeds the full default listing.
const first = await handler.handle(makeEvent(fullProse), makeCtx());
// Turn 2: Pi's setActive rebuild means the event now carries the narrowed
// listing, so the override the handler returns must still match turn 1.
const second = await handler.handle(makeEvent(narrowedProse), makeCtx());
const wire1 = first.systemPrompt ?? fullProse;
const wire2 = second.systemPrompt ?? narrowedProse;
expect(wire1).toBe(narrowedProse);
expect(wire2).toBe(narrowedProse);
expect(session.getActiveSkillEntries()).toEqual([]);
});
});
@@ -1,501 +0,0 @@
/**
* Integration tests for external_directory tool_call enforcement.
*
* These tests exercise PermissionGateHandler.handleToolCall with the
* external-directory gate, verifying the full descriptor→runner pipeline
* while mocking only the PermissionSession boundary.
*
* Regression guard: importing the four external-directory message helpers
* ensures the test file fails to load if any helper is removed.
*/
import { describe, expect, it, vi } from "vitest";
import { EXTENSION_TAG } from "#src/presentation/agent-renderer";
import { buildExternalDirectoryAskPayload } from "#src/presentation/path-ask-payload";
import type { PermissionCheckResult } from "#src/types";
import {
ALL_PATH_BEARING_TOOLS,
ALL_TOOLS,
blockReviewEntries,
EXT_DIR_CWD,
EXTERNAL_PATH,
findExtDirDecision,
makeApprovingPrompter,
makeDenyingPrompter,
makeExtDirCheck,
makeUnavailablePrompter,
OPTIONAL_PATH_TOOLS,
} from "#test/helpers/external-directory-fixtures";
import {
getDecisionEvents,
makeCtx,
makeHandler,
makeToolCallEvent,
} from "#test/helpers/handler-fixtures";
// ── SDK stubs ──────────────────────────────────────────────────────────────
vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
const original =
await importOriginal<typeof import("@earendil-works/pi-coding-agent")>();
return { ...original };
});
// ── Regression guard: helper presence ──────────────────────────────────────
describe("external_directory helper regression guard", () => {
it("the external-directory ask names the path it gates", () => {
expect(
buildExternalDirectoryAskPayload({
toolName: "read",
pathValue: "/outside/file",
cwd: "/project",
agentName: null,
}).request.value,
).toBe("/outside/file");
});
it("EXTENSION_TAG is the expected value", () => {
expect(EXTENSION_TAG).toBe("[pi-permission-system]");
});
// formatExternalDirectoryDenyReason, formatExternalDirectoryUserDeniedReason,
// and formatExternalDirectoryHardStopHint are now renders over the prompt
// payload. Their behavior is tested in presentation/agent-renderer.test.ts.
});
// ── Path scope: gate applicability ────────────────────────────────────────
describe("external_directory path scope", () => {
it("skips external_directory check when path is inside CWD", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("deny") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", {
input: { path: `${EXT_DIR_CWD}/src/index.ts` },
});
const result = await handler.handleToolCall(event, makeCtx());
// Should not be blocked — the external_directory gate is skipped,
// and the tool gate sees "allow" (default toolState in makeExtDirCheck)
expect(result).toEqual({ action: "allow" });
});
it("fires external_directory check when path is outside CWD", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("deny") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toMatchObject({ action: "block" });
});
it("skips external_directory check for non-path-bearing tool (bash)", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("deny", "allow") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("bash", {
input: { command: `cat ${EXTERNAL_PATH}` },
});
// bash is not in PATH_BEARING_TOOLS, so the external_directory gate
// for tool path does not fire (bash-external-directory gate is separate)
const result = await handler.handleToolCall(event, makeCtx());
// bash-external-directory gate MAY fire separately, but the tool-path
// external_directory gate does NOT fire for bash
// We verify the checkPermission was not called with "external_directory"
// from the tool-path gate by checking the result is not blocked by it
expect(result).toBeDefined();
});
it.each(
ALL_PATH_BEARING_TOOLS,
)("blocks %s with an out-of-cwd path when external_directory is deny", async (toolName) => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("deny") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent(toolName, {
input: { path: EXTERNAL_PATH },
});
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toMatchObject({ action: "block" });
});
it.each(
OPTIONAL_PATH_TOOLS,
)("skips external_directory check for %s when path is omitted", async (toolName) => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("deny") },
tools: ALL_TOOLS,
});
// No path in input — external_directory gate should not fire
const event = makeToolCallEvent(toolName);
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toEqual({ action: "allow" });
});
});
// ── Policy state matrix: allow and deny ────────────────────────────────────
describe("external_directory policy state — allow", () => {
it("falls through to tool gate when external_directory is allow", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("allow") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toEqual({ action: "allow" });
});
it("emits decision event with policy_allow on external_directory surface", async () => {
const { handler, events } = makeHandler({
session: { checkPermission: makeExtDirCheck("allow") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
await handler.handleToolCall(event, makeCtx());
expect(findExtDirDecision(events)).toMatchObject({
surface: "external_directory",
result: "allow",
resolution: "policy_allow",
});
});
it("does not write a block review-log entry when external_directory is allow", async () => {
const { handler, logger } = makeHandler({
session: { checkPermission: makeExtDirCheck("allow") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
await handler.handleToolCall(event, makeCtx());
expect(blockReviewEntries(logger)).toHaveLength(0);
});
});
// #144: allow external reads, gate external writes
describe("external_directory — allow external reads, gate external writes (#144)", () => {
it("allows read of external path when external_directory and read are both allow", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("allow", "allow") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toEqual({ action: "allow" });
});
it("prompts for write to external path when external_directory allows but write is ask", async () => {
const { handler, prompter } = makeHandler({
session: { checkPermission: makeExtDirCheck("allow", "ask") },
prompter: makeApprovingPrompter(),
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("write", {
input: { path: EXTERNAL_PATH },
});
const result = await handler.handleToolCall(event, makeCtx());
// external_directory passes; write gate prompts and user approves
expect(result).toEqual({ action: "allow" });
expect(prompter.escalate).toHaveBeenCalledOnce();
});
it("blocks write to external path when external_directory allows but write is deny", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("allow", "deny") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("write", {
input: { path: EXTERNAL_PATH },
});
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toMatchObject({ action: "block" });
});
it("emits separate decision events for external_directory and write surfaces", async () => {
const { handler, events } = makeHandler({
session: { checkPermission: makeExtDirCheck("allow", "deny") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("write", {
input: { path: EXTERNAL_PATH },
});
await handler.handleToolCall(event, makeCtx());
const decisions = getDecisionEvents(events);
const writeDecision = decisions.find((d) => d.surface === "write");
expect(findExtDirDecision(events)).toMatchObject({
surface: "external_directory",
result: "allow",
resolution: "policy_allow",
});
expect(writeDecision).toMatchObject({
surface: "write",
result: "deny",
resolution: "policy_deny",
});
});
});
describe("external_directory policy state — deny", () => {
it("blocks with reason containing the external path", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("deny") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toMatchObject({ action: "block" });
expect((result as { reason?: string }).reason).toContain(EXTERNAL_PATH);
});
it("block reason contains extension attribution", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("deny") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
const result = await handler.handleToolCall(event, makeCtx());
expect((result as { reason?: string }).reason).toContain(
"[pi-permission-system]",
);
expect((result as { reason?: string }).reason).not.toContain("Hard stop");
});
it("writes review-log entry with resolution policy_denied", async () => {
const { handler, logger } = makeHandler({
session: { checkPermission: makeExtDirCheck("deny") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
await handler.handleToolCall(event, makeCtx());
const entries = blockReviewEntries(logger);
expect(entries.length).toBeGreaterThanOrEqual(1);
expect(entries[0][1]).toMatchObject({
resolution: "policy_denied",
});
});
it("emits decision event with policy_deny on external_directory surface", async () => {
const { handler, events } = makeHandler({
session: { checkPermission: makeExtDirCheck("deny") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
await handler.handleToolCall(event, makeCtx());
expect(findExtDirDecision(events)).toMatchObject({
surface: "external_directory",
result: "deny",
resolution: "policy_deny",
});
});
});
// ── Policy state matrix: ask ────────────────────────────────────────────────
describe("external_directory policy state — ask", () => {
it("does not block when user approves", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("ask") },
prompter: makeApprovingPrompter(),
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toEqual({ action: "allow" });
});
it("emits user_approved decision when user approves", async () => {
const { handler, events } = makeHandler({
session: { checkPermission: makeExtDirCheck("ask") },
prompter: makeApprovingPrompter(),
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
await handler.handleToolCall(event, makeCtx());
expect(findExtDirDecision(events)).toMatchObject({
surface: "external_directory",
result: "allow",
resolution: "user_approved",
});
});
it("blocks when user denies", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("ask") },
prompter: makeDenyingPrompter(),
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toMatchObject({ action: "block" });
});
it("emits user_denied decision when user denies", async () => {
const { handler, events } = makeHandler({
session: { checkPermission: makeExtDirCheck("ask") },
prompter: makeDenyingPrompter(),
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
await handler.handleToolCall(event, makeCtx());
expect(findExtDirDecision(events)).toMatchObject({
surface: "external_directory",
result: "deny",
resolution: "user_denied",
});
});
it("block reason includes denialReason when user provides one", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("ask") },
prompter: makeDenyingPrompter("not needed"),
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toMatchObject({ action: "block" });
expect((result as { reason?: string }).reason).toContain("not needed");
});
it("blocks with confirmation_unavailable when no UI is available", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("ask") },
prompter: makeUnavailablePrompter(),
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
const result = await handler.handleToolCall(
event,
makeCtx({ hasUI: false }),
);
expect(result).toMatchObject({ action: "block" });
// The gate surface names the boundary; an unavailable verdict states only
// that approval was unreachable, since no retry shape changes that.
expect((result as { reason?: string }).reason).toBe(
`${EXTENSION_TAG} This 'external_directory' call for tool 'read' for path '${EXTERNAL_PATH}' requires approval, but no interactive UI is available.`,
);
});
it("emits confirmation_unavailable decision when no UI", async () => {
const { handler, events } = makeHandler({
session: { checkPermission: makeExtDirCheck("ask") },
prompter: makeUnavailablePrompter(),
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
await handler.handleToolCall(event, makeCtx({ hasUI: false }));
expect(findExtDirDecision(events)).toMatchObject({
surface: "external_directory",
result: "deny",
resolution: "confirmation_unavailable",
});
});
});
// ── Per-agent override ─────────────────────────────────────────────────────
describe("external_directory per-agent override", () => {
it("honors per-agent override of external_directory policy", async () => {
// checkPermission varies by agentName: allow for "special-agent", deny otherwise
const agentAwareCheck = vi
.fn()
.mockImplementation(
(
surface: string,
_input: unknown,
agentName?: string,
): PermissionCheckResult => {
if (surface === "external_directory") {
const state =
agentName === "special-agent" ? "allow" : ("deny" as const);
return {
state,
toolName: surface,
source: "tool",
origin: agentName === "special-agent" ? "agent" : "global",
};
}
return {
state: "allow",
toolName: surface,
source: "tool",
origin: "builtin",
};
},
);
// With agent override → allowed
const { handler: handler1, events: events1 } = makeHandler({
session: {
checkPermission: agentAwareCheck,
resolveAgentName: vi.fn().mockReturnValue("special-agent"),
},
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
const result1 = await handler1.handleToolCall(event, makeCtx());
expect(result1).toEqual({ action: "allow" });
expect(findExtDirDecision(events1)).toMatchObject({
result: "allow",
resolution: "policy_allow",
agentName: "special-agent",
});
// Without agent override → denied
const { handler: handler2 } = makeHandler({
session: {
checkPermission: agentAwareCheck,
resolveAgentName: vi.fn().mockReturnValue(null),
},
tools: ALL_TOOLS,
});
const result2 = await handler2.handleToolCall(event, makeCtx());
expect(result2).toMatchObject({ action: "block" });
});
});
// ── Decision event surface and value ──────────────────────────────────────
describe("external_directory decision event fields", () => {
it("decision event value is the external path", async () => {
const { handler, events } = makeHandler({
session: { checkPermission: makeExtDirCheck("deny") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
await handler.handleToolCall(event, makeCtx());
const extDirDecision = findExtDirDecision(events);
expect(extDirDecision).toBeDefined();
expect(extDirDecision!.value).toBe(EXTERNAL_PATH);
});
it("decision event includes agentName when present", async () => {
const { handler, events } = makeHandler({
session: {
checkPermission: makeExtDirCheck("allow"),
resolveAgentName: vi.fn().mockReturnValue("my-agent"),
},
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
await handler.handleToolCall(event, makeCtx());
expect(findExtDirDecision(events)).toMatchObject({
agentName: "my-agent",
});
});
it("decision event agentName is null when no agent", async () => {
const { handler, events } = makeHandler({
session: { checkPermission: makeExtDirCheck("allow") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
await handler.handleToolCall(event, makeCtx());
expect(findExtDirDecision(events)).toMatchObject({
agentName: null,
});
});
});
@@ -1,175 +0,0 @@
/**
* Integration tests verifying that sequential tool calls to the same
* external path only prompt once — the session-approval recorded by the
* first call covers the second.
*
* Uses real PermissionSession + PermissionResolver + SessionRules so the
* stateful approval-tracking path is exercised end-to-end.
*/
import { describe, expect, it, vi } from "vitest";
import {
makeApprovingPrompter,
makeDeduplicatingHandler,
makeDedupWiring,
makeExtDirBashEvent,
makeExtDirToolEvent,
} from "#test/helpers/external-directory-fixtures";
import { makeCtx } from "#test/helpers/handler-fixtures";
// ── SDK stub ───────────────────────────────────────────────────────────────
vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
const original =
await importOriginal<typeof import("@earendil-works/pi-coding-agent")>();
return { ...original };
});
// ── tests ──────────────────────────────────────────────────────────────────
describe("external-directory session dedup", () => {
describe("path-bearing tools (read, write, edit)", () => {
it("does not re-prompt for the same external path after session approval", async () => {
const { handler, prompter } = makeDeduplicatingHandler();
const ctx = makeCtx();
const externalPath = "/outside/project/data.txt";
// First call — should prompt
const event1 = makeExtDirToolEvent("read", externalPath, "tc-1");
const result1 = await handler.handleToolCall(event1, ctx);
expect(result1).toEqual({ action: "allow" });
expect(prompter.escalate).toHaveBeenCalledTimes(1);
// Second call — same path, should hit session rule, no prompt
const event2 = makeExtDirToolEvent("read", externalPath, "tc-2");
const result2 = await handler.handleToolCall(event2, ctx);
expect(result2).toEqual({ action: "allow" });
expect(prompter.escalate).toHaveBeenCalledTimes(1);
});
it("does not re-prompt for a different file in the same external directory", async () => {
const { handler, prompter } = makeDeduplicatingHandler();
const ctx = makeCtx();
// First call — prompt for /outside/project/a.txt
const event1 = makeExtDirToolEvent(
"read",
"/outside/project/a.txt",
"tc-1",
);
await handler.handleToolCall(event1, ctx);
expect(prompter.escalate).toHaveBeenCalledTimes(1);
// Second call — /outside/project/b.txt is in the same directory
const event2 = makeExtDirToolEvent(
"read",
"/outside/project/b.txt",
"tc-2",
);
await handler.handleToolCall(event2, ctx);
expect(prompter.escalate).toHaveBeenCalledTimes(1);
});
it("does prompt for a file in a different external directory", async () => {
const { handler, prompter } = makeDeduplicatingHandler();
const ctx = makeCtx();
// First call — /outside/alpha/file.txt
const event1 = makeExtDirToolEvent(
"read",
"/outside/alpha/file.txt",
"tc-1",
);
await handler.handleToolCall(event1, ctx);
expect(prompter.escalate).toHaveBeenCalledTimes(1);
// Second call — /outside/beta/file.txt is a different directory
const event2 = makeExtDirToolEvent(
"read",
"/outside/beta/file.txt",
"tc-2",
);
await handler.handleToolCall(event2, ctx);
expect(prompter.escalate).toHaveBeenCalledTimes(2);
});
it("re-prompts when user approved once (not for session)", async () => {
const approveOnce = makeApprovingPrompter();
const { handler, prompter } = makeDeduplicatingHandler(approveOnce);
const ctx = makeCtx();
const externalPath = "/outside/project/data.txt";
// First call — prompt, approved once
const event1 = makeExtDirToolEvent("read", externalPath, "tc-1");
await handler.handleToolCall(event1, ctx);
expect(prompter.escalate).toHaveBeenCalledTimes(1);
// Second call — no session rule recorded, should prompt again
const event2 = makeExtDirToolEvent("read", externalPath, "tc-2");
await handler.handleToolCall(event2, ctx);
expect(prompter.escalate).toHaveBeenCalledTimes(2);
});
});
describe("bash commands with external paths", () => {
it("does not re-prompt for a bash command referencing the same external path after session approval", async () => {
const { handler, prompter } = makeDeduplicatingHandler();
const ctx = makeCtx();
// First call — bash referencing /tmp/out.txt
const event1 = makeExtDirBashEvent("echo hello > /tmp/out.txt", "tc-1");
const result1 = await handler.handleToolCall(event1, ctx);
expect(result1).toEqual({ action: "allow" });
expect(prompter.escalate).toHaveBeenCalledTimes(1);
// Second call — different bash command, same external path
const event2 = makeExtDirBashEvent("cat /tmp/out.txt", "tc-2");
const result2 = await handler.handleToolCall(event2, ctx);
expect(result2).toEqual({ action: "allow" });
expect(prompter.escalate).toHaveBeenCalledTimes(1);
});
it("does not re-prompt for read after bash already approved the same directory", async () => {
const { handler, prompter } = makeDeduplicatingHandler();
const ctx = makeCtx();
// First call — bash writes to /tmp/out.txt
const event1 = makeExtDirBashEvent("echo hello > /tmp/out.txt", "tc-1");
await handler.handleToolCall(event1, ctx);
expect(prompter.escalate).toHaveBeenCalledTimes(1);
// Second call — read from /tmp/out.txt (same directory, different tool)
const event2 = makeExtDirToolEvent("read", "/tmp/out.txt", "tc-2");
await handler.handleToolCall(event2, ctx);
expect(prompter.escalate).toHaveBeenCalledTimes(1);
});
});
});
// ---------------------------------------------------------------------------
// Moved from permission-system.test.ts catch-all (#342)
// ---------------------------------------------------------------------------
describe("session shutdown clears external-directory approvals", () => {
it("re-prompts for the same path after session shutdown", async () => {
const { handler, prompter, session } = makeDedupWiring();
const externalPath = "/tmp/sibling/foo.ts";
const ctx = makeCtx();
const event = makeExtDirToolEvent("read", externalPath, "tc-1");
// First access: prompt fires and records session approval.
await handler.handleToolCall(event, ctx);
expect(vi.mocked(prompter.escalate)).toHaveBeenCalledTimes(1);
// Second access: covered by session approval — no re-prompt.
await handler.handleToolCall({ ...event, toolCallId: "tc-2" }, ctx);
expect(vi.mocked(prompter.escalate)).toHaveBeenCalledTimes(1);
// Shutdown clears session approvals.
session.shutdown();
// Third access: session rules cleared — must re-prompt.
await handler.handleToolCall({ ...event, toolCallId: "tc-3" }, ctx);
expect(vi.mocked(prompter.escalate)).toHaveBeenCalledTimes(2);
});
});
@@ -1,168 +0,0 @@
/**
* Acceptance test for issue #418.
*
* Reproduces the reported bug with a real symlink (no `realpathSync` mock):
* an `external_directory` allow configured for the path as the user types it
* (`<link>/*`) must allow access even though the OS resolves `<link>` to a
* different canonical directory. Exercised end-to-end through the real
* `PermissionManager` + `PermissionResolver` for both a path-bearing tool and
* a bash command, and for an allow keyed on the symlink-resolved form too.
*/
import { mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { BashProgram } from "#src/access-intent/bash/program";
import { describeBashExternalDirectoryGate } from "#src/handlers/gates/bash-external-directory";
import {
type GateDescriptor,
isGateBypass,
isGateDescriptor,
} from "#src/handlers/gates/descriptor";
import { describeExternalDirectoryGate } from "#src/handlers/gates/external-directory";
import type { ToolCallContext } from "#src/handlers/gates/types";
import { pathFlavorForPlatform } from "#src/path/path-flavor";
import { PathNormalizer } from "#src/path-normalizer";
import { PermissionResolver } from "#src/permission-resolver";
import { SessionRules } from "#src/session-rules";
import type { ScopeConfig } from "#src/types";
import { createManager } from "#test/helpers/manager-harness";
// ── real symlink fixture ─────────────────────────────────────────────────────
let realDir: string;
let linkDir: string;
let cwd: string;
const tempRoots: string[] = [];
function mkTemp(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix));
tempRoots.push(dir);
return dir;
}
beforeEach(() => {
realDir = mkTemp("ext-real-");
const linkParent = mkTemp("ext-link-");
linkDir = join(linkParent, "link");
symlinkSync(realDir, linkDir);
cwd = mkTemp("ext-cwd-");
});
afterEach(() => {
while (tempRoots.length > 0) {
const dir = tempRoots.pop();
if (dir) rmSync(dir, { recursive: true, force: true });
}
});
function makeResolver(config: ScopeConfig) {
const { manager, cleanup } = createManager(config);
manager.configureForCwd(cwd);
const resolver = new PermissionResolver(manager, new SessionRules());
return { resolver, cleanup };
}
function readTcc(): ToolCallContext {
return {
toolName: "read",
agentName: null,
input: { path: join(linkDir, "file.ts") },
toolCallId: "tc-1",
cwd,
};
}
// ── tests ────────────────────────────────────────────────────────────────────
describe("external_directory symlink acceptance (#418)", () => {
it("allows a path-bearing tool when the allow is keyed on the typed (symlinked) path", () => {
const { resolver, cleanup } = makeResolver({
permission: {
external_directory: { "*": "ask", [`${linkDir}/*`]: "allow" },
},
});
try {
const result = describeExternalDirectoryGate(
readTcc(),
[],
resolver,
new PathNormalizer(pathFlavorForPlatform(process.platform), cwd),
);
expect(isGateDescriptor(result)).toBe(true);
expect((result as GateDescriptor).preCheck?.state).toBe("allow");
} finally {
cleanup();
}
});
it("allows a path-bearing tool when the allow is keyed on the resolved path", () => {
// Key the allow on the fully symlink-resolved directory (on macOS the
// tmpdir root itself is a symlink, e.g. /var -> /private/var).
const resolved = realpathSync(realDir);
const { resolver, cleanup } = makeResolver({
permission: {
external_directory: { "*": "ask", [`${resolved}/*`]: "allow" },
},
});
try {
const result = describeExternalDirectoryGate(
readTcc(),
[],
resolver,
new PathNormalizer(pathFlavorForPlatform(process.platform), cwd),
);
expect(isGateDescriptor(result)).toBe(true);
expect((result as GateDescriptor).preCheck?.state).toBe("allow");
} finally {
cleanup();
}
});
it("still prompts (ask) when no external_directory allow matches", () => {
const { resolver, cleanup } = makeResolver({
permission: { external_directory: { "*": "ask" } },
});
try {
const result = describeExternalDirectoryGate(
readTcc(),
[],
resolver,
new PathNormalizer(pathFlavorForPlatform(process.platform), cwd),
);
expect(isGateDescriptor(result)).toBe(true);
expect((result as GateDescriptor).preCheck?.state).toBe("ask");
} finally {
cleanup();
}
});
it("allows a bash command referencing the typed (symlinked) path", async () => {
const { resolver, cleanup } = makeResolver({
permission: {
external_directory: { "*": "ask", [`${linkDir}/*`]: "allow" },
},
});
try {
const command = `cat ${join(linkDir, "file.ts")}`;
const tcc: ToolCallContext = {
toolName: "bash",
agentName: null,
input: { command },
toolCallId: "tc-2",
cwd,
};
const program = await BashProgram.parse(
command,
new PathNormalizer(pathFlavorForPlatform(process.platform), cwd),
);
const result = describeBashExternalDirectoryGate(tcc, program, resolver);
// All external paths are covered by the allow → bypass, no prompt.
expect(isGateBypass(result)).toBe(true);
} finally {
cleanup();
}
});
});
@@ -1,395 +0,0 @@
import { homedir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import type { AccessIntent } from "#src/access-intent/access-intent";
import { BashProgram } from "#src/access-intent/bash/program";
import { describeBashExternalDirectoryGate } from "#src/handlers/gates/bash-external-directory";
import type {
GateBypass,
GateDescriptor,
GateResult,
} from "#src/handlers/gates/descriptor";
import { isGateBypass, isGateDescriptor } from "#src/handlers/gates/descriptor";
import type { ToolCallContext } from "#src/handlers/gates/types";
import { pathFlavorForPlatform, win32PathFlavor } from "#src/path/path-flavor";
import { PathNormalizer } from "#src/path-normalizer";
import type { ScopedPermissionResolver } from "#src/permission-resolver";
import {
allEvidence,
findEvidence,
type PromptPayload,
} from "#src/presentation/prompt-payload";
import type { PermissionCheckResult } from "#src/types";
import { getNonEmptyString, toRecord } from "#src/value-guards";
import { makeResolver } from "#test/helpers/gate-fixtures";
// ── helpers ────────────────────────────────────────────────────────────────
/** Every escaping path the payload lists, in payload order. */
function externalPaths(payload: PromptPayload): string[] {
return allEvidence(payload, "external path").map((entry) => entry.text);
}
function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
return {
toolName: "bash",
agentName: null,
input: { command: "cat /outside/project/file.ts" },
toolCallId: "tc-1",
cwd: "/test/project",
...overrides,
};
}
function makeCheckResult(
state: "allow" | "deny" | "ask",
overrides: Partial<PermissionCheckResult> = {},
): PermissionCheckResult {
return {
state,
toolName: "external_directory",
source: "special",
origin: "builtin",
...overrides,
};
}
/** Extract the policy match values a resolve(intent) call carries. */
function intentValues(intent: AccessIntent): readonly string[] {
if (intent.kind === "access-path") return intent.path.matchValues();
return [];
}
/**
* Mirror the handler's parse-once derivation: parse the bash command into a
* shared `BashProgram` and inject it, exactly as `permission-gate-handler.ts`
* does, so the gate is exercised through the production wiring.
*/
async function describeGate(
tcc: ToolCallContext,
resolver: ScopedPermissionResolver,
): Promise<GateResult> {
const command = getNonEmptyString(toRecord(tcc.input).command);
const bashProgram =
tcc.toolName === "bash" && command
? await BashProgram.parse(
command,
new PathNormalizer(pathFlavorForPlatform(process.platform), tcc.cwd),
)
: null;
return describeBashExternalDirectoryGate(tcc, bashProgram, resolver);
}
// ── tests ──────────────────────────────────────────────────────────────────
describe("describeBashExternalDirectoryGate", () => {
it("returns null when tool is not bash", async () => {
const result = await describeGate(
makeTcc({ toolName: "read" }),
makeResolver(makeCheckResult("ask")),
);
expect(result).toBeNull();
});
it("returns null when command has no external paths", async () => {
const result = await describeGate(
makeTcc({ input: { command: "ls -la" } }),
makeResolver(makeCheckResult("ask")),
);
expect(result).toBeNull();
});
describe("resolved shell expansions (#694)", () => {
it("prompts for a $HOME write target that does not exist yet", async () => {
const resolver = makeResolver(makeCheckResult("ask"));
const result = await describeGate(
makeTcc({
input: {
command: 'touch "$HOME/pi-permission-system-repro-new"',
},
}),
resolver,
);
expect(isGateDescriptor(result)).toBe(true);
expect(externalPaths((result as GateDescriptor).payload)).toEqual([
join(homedir(), "pi-permission-system-repro-new"),
]);
});
// biome-ignore lint/suspicious/noTemplateCurlyInString: intentional literal — a braced shell expansion, not a template string
it("prompts for a braced ${HOME} reference", async () => {
const result = await describeGate(
// biome-ignore lint/suspicious/noTemplateCurlyInString: intentional literal — a braced shell expansion, not a template string
makeTcc({ input: { command: 'ls "${HOME}/somewhere"' } }),
makeResolver(makeCheckResult("ask")),
);
expect(isGateDescriptor(result)).toBe(true);
expect(externalPaths((result as GateDescriptor).payload)).toEqual([
join(homedir(), "somewhere"),
]);
});
it("does not prompt for a variable it cannot resolve", async () => {
const result = await describeGate(
makeTcc({ input: { command: 'CURRENT="$HOME"; ls "$CURRENT"' } }),
makeResolver(makeCheckResult("ask")),
);
expect(result).toBeNull();
});
});
it("resolves each external path on the external_directory surface via an access-path intent (#418)", async () => {
const resolver = makeResolver(makeCheckResult("ask"));
await describeGate(
makeTcc({ input: { command: "cat /outside/a.ts" } }),
resolver,
);
const intent = resolver.resolve.mock.calls[0][0];
expect(intent).toMatchObject({
kind: "access-path",
surface: "external_directory",
agentName: undefined,
});
expect(intentValues(intent)).toEqual(["/outside/a.ts"]);
});
it("carries the deciding path's access facts on promptDetails (bash external_directory surface)", async () => {
const resolver = makeResolver(makeCheckResult("ask"));
const result = (await describeGate(
makeTcc({ input: { command: "cat /outside/a.ts" } }),
resolver,
)) as GateDescriptor;
const intent = resolver.resolve.mock.calls[0][0];
const path = intent.kind === "access-path" ? intent.path : undefined;
expect(path).toBeDefined();
expect(result.promptDetails.accessIntent).toEqual({
surface: "external_directory",
matchValues: path?.matchValues(),
boundaryValue: path?.boundaryValue(),
});
});
it("emits a bash_external_directory payload listing every escaping path", async () => {
const resolver = makeResolver(makeCheckResult("ask"));
const result = (await describeGate(
makeTcc({ input: { command: "cat /outside/a.ts" } }),
resolver,
)) as GateDescriptor;
expect(result.payload.kind).toBe("bash_external_directory");
// The command is the decision value; the paths it reaches are evidence.
expect(result.payload.request.value).toBe("cat /outside/a.ts");
expect(result.payload.evidence).toContainEqual({
label: "external path",
text: "/outside/a.ts",
detail: null,
});
});
it("returns GateBypass when all external paths are session-covered", async () => {
const resolver = makeResolver(
makeCheckResult("allow", { source: "session" }),
);
const result = await describeGate(makeTcc(), resolver);
expect(result).not.toBeNull();
expect(isGateBypass(result)).toBe(true);
const bypass = result as GateBypass;
expect(bypass.action).toBe("allow");
expect(bypass.log).toMatchObject({
event: "permission_request.session_approved",
details: expect.objectContaining({ resolution: "session_approved" }),
});
expect(bypass.decidedBy).toEqual({
kind: "session_approval",
surface: "external_directory",
pattern: null,
});
});
it("returns GateDescriptor with multi-pattern sessionApproval for uncovered paths", async () => {
const result = await describeGate(
makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
makeResolver(makeCheckResult("ask")),
);
expect(isGateDescriptor(result)).toBe(true);
const desc = result as GateDescriptor;
expect(desc.sessionApproval).toBeDefined();
if (!desc.sessionApproval) return;
expect(desc.sessionApproval.patterns.length).toBeGreaterThan(0);
});
it("returns GateBypass when all external paths are config-level allowed", async () => {
// Config-level allow (source: "special") should suppress the prompt,
// not just session-level allow. This was the bug: source !== "session"
// kept config-allowed paths in the uncovered set.
const resolver = makeResolver();
resolver.resolve.mockImplementation((intent) =>
intentValues(intent).length > 0
? makeCheckResult("allow", { source: "special" })
: makeCheckResult("ask"),
);
const result = await describeGate(makeTcc(), resolver);
expect(result).not.toBeNull();
expect(isGateBypass(result)).toBe(true);
});
it("uses worst-check state from uncovered paths for preCheck (config deny > catch-all ask)", async () => {
// The path-less extCheck used to always return the "*" catch-all (ask),
// silently downgrading a config-level deny to ask. After the fix, the
// descriptor's preCheck is derived from the actual path check result.
const resolver = makeResolver();
resolver.resolve.mockImplementation((intent) =>
intentValues(intent).length > 0
? makeCheckResult("deny", { source: "special" })
: makeCheckResult("ask"),
);
const result = await describeGate(makeTcc(), resolver);
expect(isGateDescriptor(result)).toBe(true);
const desc = result as GateDescriptor;
expect(desc.preCheck?.state).toBe("deny");
});
it("descriptor surface is 'external_directory'", async () => {
const result = await describeGate(
makeTcc(),
makeResolver(makeCheckResult("ask")),
);
const desc = result as GateDescriptor;
expect(desc.surface).toBe("external_directory");
});
it("descriptor decision surface is 'external_directory'", async () => {
const result = await describeGate(
makeTcc(),
makeResolver(makeCheckResult("ask")),
);
const desc = result as GateDescriptor;
expect(desc.decision.surface).toBe("external_directory");
});
it("payload carries the command and the boundary it escaped", async () => {
const result = await describeGate(
makeTcc({ input: { command: "cat /outside/file.ts" } }),
makeResolver(makeCheckResult("ask")),
);
const { payload } = result as GateDescriptor;
expect(payload.kind).toBe("bash_external_directory");
expect(payload.request.value).toBe("cat /outside/file.ts");
expect(findEvidence(payload, "working directory")?.text).toBe(
"/test/project",
);
});
it("promptDetails includes command and tool_call source", async () => {
const result = await describeGate(
makeTcc({ agentName: "agent-1", toolCallId: "tc-5" }),
makeResolver(makeCheckResult("ask")),
);
const desc = result as GateDescriptor;
expect(desc.promptDetails).toMatchObject({
source: "tool_call",
agentName: "agent-1",
toolCallId: "tc-5",
toolName: "bash",
command: "cat /outside/project/file.ts",
});
});
it("config-allowed path is excluded; remaining ask path produces a descriptor", async () => {
// One path config-allowed, one config-ask → descriptor with only the ask path.
const resolver = makeResolver();
resolver.resolve.mockImplementation((intent) =>
intentValues(intent).includes("/outside/a.ts")
? makeCheckResult("allow", { source: "special" })
: makeCheckResult("ask"),
);
const result = await describeGate(
makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
resolver,
);
expect(isGateDescriptor(result)).toBe(true);
const desc = result as GateDescriptor;
expect(desc.sessionApproval).toBeDefined();
if (!desc.sessionApproval) return;
expect(desc.sessionApproval.patterns.length).toBe(1);
expect(desc.preCheck?.state).toBe("ask");
});
it("config-denied path makes worstCheck deny even when another path is ask", async () => {
// One path config-denied, one config-ask → descriptor with preCheck.state === "deny".
const resolver = makeResolver();
resolver.resolve.mockImplementation((intent) =>
intentValues(intent).includes("/outside/a.ts")
? makeCheckResult("deny", { source: "special" })
: makeCheckResult("ask"),
);
const result = await describeGate(
makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
resolver,
);
expect(isGateDescriptor(result)).toBe(true);
const desc = result as GateDescriptor;
expect(desc.preCheck?.state).toBe("deny");
// Both paths are uncovered (neither is allow), so both patterns are included.
expect(desc.sessionApproval).toBeDefined();
if (!desc.sessionApproval) return;
expect(desc.sessionApproval.patterns.length).toBe(2);
});
it("only includes uncovered paths when some are session-covered", async () => {
const resolver = makeResolver();
resolver.resolve.mockImplementation((intent) =>
intentValues(intent).includes("/outside/a.ts")
? makeCheckResult("allow", { source: "session" })
: makeCheckResult("ask"),
);
const result = await describeGate(
makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
resolver,
);
expect(isGateDescriptor(result)).toBe(true);
const desc = result as GateDescriptor;
// Should have patterns only for the uncovered path
expect(desc.sessionApproval).toBeDefined();
if (!desc.sessionApproval) return;
expect(desc.sessionApproval.patterns.length).toBe(1);
});
});
describe("describeBashExternalDirectoryGate — Git Bash semantics (win32)", () => {
async function describeGateWin32(
tcc: ToolCallContext,
resolver: ScopedPermissionResolver,
): Promise<GateResult> {
const command = getNonEmptyString(toRecord(tcc.input).command);
const bashProgram =
tcc.toolName === "bash" && command
? await BashProgram.parse(
command,
new PathNormalizer(win32PathFlavor, tcc.cwd),
)
: null;
return describeBashExternalDirectoryGate(tcc, bashProgram, resolver);
}
const winTcc = (command: string): ToolCallContext =>
makeTcc({ cwd: "C:/projects/app", input: { command } });
it("does not prompt for a /dev/null redirect target", async () => {
const result = await describeGateWin32(
winTcc("echo hi > /dev/null"),
makeResolver(makeCheckResult("ask")),
);
expect(result).toBeNull();
});
it("prompts for a /tmp path displayed as typed, not as C:\\tmp", async () => {
const result = await describeGateWin32(
winTcc("ls /tmp"),
makeResolver(makeCheckResult("ask")),
);
expect(isGateDescriptor(result)).toBe(true);
expect(externalPaths((result as GateDescriptor).payload)).toEqual(["/tmp"]);
});
});
@@ -1,135 +0,0 @@
import { describe, expect, it } from "vitest";
import { AccessPath } from "#src/access-intent/access-path";
import {
resolveExternalDirectoryPolicy,
selectUncoveredExternalPaths,
} from "#src/handlers/gates/external-directory-policy";
import { posixPathFlavor } from "#src/path/path-flavor";
import type { PermissionCheckResult } from "#src/types";
import { makeResolver } from "#test/helpers/gate-fixtures";
const cwd = "/test/project";
function makeCheckResult(
state: "allow" | "deny" | "ask",
overrides: Partial<PermissionCheckResult> = {},
): PermissionCheckResult {
return {
state,
toolName: "external_directory",
source: "special",
origin: "builtin",
...overrides,
};
}
describe("resolveExternalDirectoryPolicy", () => {
it("resolves the path's match aliases on the external_directory surface (#418)", () => {
const path = AccessPath.forPath("/outside/a.ts", {
cwd,
flavor: posixPathFlavor,
});
const resolver = makeResolver(makeCheckResult("ask"));
const result = resolveExternalDirectoryPolicy(path, resolver, undefined);
expect(resolver.resolve).toHaveBeenCalledWith({
kind: "access-path",
surface: "external_directory",
path,
agentName: undefined,
});
expect(result).toEqual(makeCheckResult("ask"));
});
it("threads the agent name through to the resolver", () => {
const path = AccessPath.forPath("/outside/a.ts", {
cwd,
flavor: posixPathFlavor,
});
const resolver = makeResolver(makeCheckResult("allow"));
resolveExternalDirectoryPolicy(path, resolver, "reviewer");
expect(resolver.resolve).toHaveBeenCalledWith({
kind: "access-path",
surface: "external_directory",
path,
agentName: "reviewer",
});
});
});
describe("selectUncoveredExternalPaths", () => {
it("returns no uncovered paths when every path resolves to allow", () => {
const paths = [
AccessPath.forPath("/outside/a.ts", { cwd, flavor: posixPathFlavor }),
AccessPath.forPath("/outside/b.ts", { cwd, flavor: posixPathFlavor }),
];
const resolver = makeResolver(makeCheckResult("allow"));
const { uncovered, worstCheck } = selectUncoveredExternalPaths(
paths,
resolver,
undefined,
);
expect(uncovered).toEqual([]);
expect(worstCheck).toBeUndefined();
});
it("collects only paths whose resolved state is not allow", () => {
const allowed = AccessPath.forPath("/outside/ok.ts", {
cwd,
flavor: posixPathFlavor,
});
const asked = AccessPath.forPath("/outside/ask.ts", {
cwd,
flavor: posixPathFlavor,
});
const resolver = makeResolver();
resolver.resolve.mockImplementation((intent) => {
const values =
intent.kind === "access-path" ? intent.path.matchValues() : [];
return values.includes("/outside/ok.ts")
? makeCheckResult("allow")
: makeCheckResult("ask");
});
const { uncovered } = selectUncoveredExternalPaths(
[allowed, asked],
resolver,
undefined,
);
expect(uncovered.map(({ path }) => path.value())).toEqual([asked.value()]);
});
it("returns the most restrictive uncovered check as worstCheck (deny > ask)", () => {
const asked = AccessPath.forPath("/outside/ask.ts", {
cwd,
flavor: posixPathFlavor,
});
const denied = AccessPath.forPath("/outside/deny.ts", {
cwd,
flavor: posixPathFlavor,
});
const resolver = makeResolver();
resolver.resolve.mockImplementation((intent) => {
const values =
intent.kind === "access-path" ? intent.path.matchValues() : [];
return values.includes("/outside/deny.ts")
? makeCheckResult("deny")
: makeCheckResult("ask");
});
const { worstCheck } = selectUncoveredExternalPaths(
[asked, denied],
resolver,
undefined,
);
expect(worstCheck?.state).toBe("deny");
});
});
@@ -1,304 +0,0 @@
import { describe, expect, it } from "vitest";
import type {
GateBypass,
GateDescriptor,
} from "#src/handlers/gates/descriptor";
import { isGateBypass, isGateDescriptor } from "#src/handlers/gates/descriptor";
import { describeExternalDirectoryGate } from "#src/handlers/gates/external-directory";
import type { ToolCallContext } from "#src/handlers/gates/types";
import { pathFlavorForPlatform } from "#src/path/path-flavor";
import { PathNormalizer } from "#src/path-normalizer";
import type { ScopedPermissionResolver } from "#src/permission-resolver";
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
import { makeResolver } from "#test/helpers/gate-fixtures";
import { makeCheckResult } from "#test/helpers/handler-fixtures";
// ── helpers ───────────────────────────��────────────────────────────��───────
function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
return {
toolName: "read",
agentName: null,
input: { path: "/outside/project/file.ts" },
toolCallId: "tc-1",
cwd: "/test/project",
...overrides,
};
}
// Default resolver for descriptor-shape tests that do not assert the resolved
// state: returns `ask` for the external_directory surface so a descriptor is
// produced. Tests that assert the typed+resolved matching pass an explicit
// resolver to `describeExternalDirectoryGate` directly.
function gateUnderTest(
tcc: ToolCallContext,
infraDirs: string[],
extractors?: ToolAccessExtractorLookup,
resolver: ScopedPermissionResolver = makeResolver(
makeCheckResult({ state: "ask", toolName: "external_directory" }),
),
) {
return describeExternalDirectoryGate(
tcc,
infraDirs,
resolver,
new PathNormalizer(pathFlavorForPlatform(process.platform), tcc.cwd),
extractors,
);
}
// ── tests ────────────────────��────────────────────────────────────��────────
describe("describeExternalDirectoryGate", () => {
it("returns null when tool is not path-bearing", () => {
const result = gateUnderTest(
makeTcc({ toolName: "bash", input: { command: "ls" } }),
["/test/agent"],
);
expect(result).toBeNull();
});
it("returns null when path is inside CWD", () => {
const result = gateUnderTest(
makeTcc({ input: { path: "/test/project/src/index.ts" } }),
["/test/agent"],
);
expect(result).toBeNull();
});
// ── Pi infrastructure read bypass ─────────────────���────────────────────
it("returns GateBypass for read targeting an infra dir", () => {
const result = gateUnderTest(
makeTcc({
toolName: "read",
input: { path: "/test/agent/git/some-package/SKILL.md" },
}),
["/test/agent", "/test/agent/git"],
);
expect(result).not.toBeNull();
expect(isGateBypass(result)).toBe(true);
const bypass = result as GateBypass;
expect(bypass.action).toBe("allow");
expect(bypass.decision).toMatchObject({
resolution: "infrastructure_auto_allowed",
result: "allow",
});
expect(bypass.log).toMatchObject({
event: "permission_request.infrastructure_auto_allowed",
});
// Containment allowed this, not a rule the operator wrote.
expect(bypass.decidedBy).toEqual({ kind: "infrastructure_read" });
});
it("returns GateBypass respecting custom infraDirs", () => {
const result = gateUnderTest(
makeTcc({
toolName: "read",
input: { path: "/custom/infra/SKILL.md" },
}),
["/custom/infra"],
);
expect(isGateBypass(result)).toBe(true);
});
it("does NOT bypass for write tools targeting infra dirs", () => {
const result = gateUnderTest(
makeTcc({
toolName: "write",
input: { path: "/test/agent/git/some-file.ts", content: "x" },
}),
["/test/agent", "/test/agent/git"],
);
// Should be a GateDescriptor (needs permission check), not a bypass
expect(result).not.toBeNull();
expect(isGateDescriptor(result)).toBe(true);
});
// ── GateDescriptor for external paths ─────────────────────────────────��
it("returns GateDescriptor with surface 'external_directory'", () => {
const result = gateUnderTest(makeTcc(), ["/test/agent"]);
expect(isGateDescriptor(result)).toBe(true);
const desc = result as GateDescriptor;
expect(desc.surface).toBe("external_directory");
});
it("decision value is the external path", () => {
const result = gateUnderTest(
makeTcc({ input: { path: "/outside/project/file.ts" } }),
["/test/agent"],
) as GateDescriptor;
expect(result.decision.value).toBe("/outside/project/file.ts");
expect(result.decision.surface).toBe("external_directory");
});
it("carries the child-fixed access facts on promptDetails (external_directory surface)", () => {
const path = "/outside/project/file.ts";
const result = gateUnderTest(makeTcc({ input: { path } }), [
"/test/agent",
]) as GateDescriptor;
const accessPath = new PathNormalizer(
pathFlavorForPlatform(process.platform),
"/test/project",
).forPath(path);
expect(result.promptDetails.accessIntent).toEqual({
surface: "external_directory",
matchValues: accessPath.matchValues(),
boundaryValue: accessPath.boundaryValue(),
});
});
it("emits an external_directory payload carrying the escaped boundary", () => {
const result = gateUnderTest(
makeTcc({ input: { path: "/outside/project/file.ts" } }),
["/test/agent"],
) as GateDescriptor;
expect(result.payload.kind).toBe("external_directory");
expect(result.payload.request.value).toBe("/outside/project/file.ts");
expect(result.payload.evidence).toContainEqual({
label: "working directory",
text: "/test/project",
detail: null,
});
});
it("carries a precomputed preCheck and an empty input (matching is done by the gate)", () => {
const result = gateUnderTest(
makeTcc({ input: { path: "/outside/project/file.ts" } }),
["/test/agent"],
) as GateDescriptor;
expect(result.input).toEqual({});
expect(result.preCheck).toBeDefined();
expect(result.preCheck?.state).toBe("ask");
});
it("resolves the typed and symlink-resolved aliases on the external_directory surface (#418)", () => {
const resolver = makeResolver(
makeCheckResult({ state: "ask", toolName: "external_directory" }),
);
gateUnderTest(
makeTcc({ input: { path: "/outside/project/file.ts" } }),
["/test/agent"],
undefined,
resolver,
);
expect(resolver.resolve).toHaveBeenCalledWith(
expect.objectContaining({
kind: "access-path",
surface: "external_directory",
agentName: undefined,
}),
);
const intent = resolver.resolve.mock.calls[0][0];
expect(intent.kind).toBe("access-path");
if (intent.kind === "access-path") {
expect(intent.path.matchValues()).toEqual(["/outside/project/file.ts"]);
}
});
it("sessionApproval uses deriveApprovalPattern", () => {
const result = gateUnderTest(
makeTcc({ input: { path: "/outside/project/file.ts" } }),
["/test/agent"],
) as GateDescriptor;
expect(result.sessionApproval).toBeDefined();
expect(result.sessionApproval?.surface).toBe("external_directory");
expect(result.sessionApproval?.representativePattern).toBeDefined();
});
it("payload contains the external path and the boundary it escaped", () => {
const result = gateUnderTest(
makeTcc({ input: { path: "/outside/project/file.ts" } }),
["/test/agent"],
) as GateDescriptor;
expect(result.payload.kind).toBe("external_directory");
expect(result.payload.request.toolName).toBe("read");
expect(result.payload.request.value).toBe("/outside/project/file.ts");
expect(result.payload.evidence).toContainEqual({
label: "working directory",
text: "/test/project",
detail: null,
});
});
it("promptDetails includes path and tool_call source", () => {
const result = gateUnderTest(
makeTcc({ toolName: "read", agentName: "agent-1", toolCallId: "tc-5" }),
["/test/agent"],
) as GateDescriptor;
expect(result.promptDetails).toMatchObject({
source: "tool_call",
agentName: "agent-1",
toolCallId: "tc-5",
toolName: "read",
path: "/outside/project/file.ts",
});
});
it("logContext includes the path, and no prompt wording", () => {
const result = gateUnderTest(makeTcc(), ["/test/agent"]) as GateDescriptor;
expect(result.logContext).toMatchObject({
source: "tool_call",
path: "/outside/project/file.ts",
});
// The payload's request facts are stamped by the runner, not the gate.
expect(result.logContext).not.toHaveProperty("message");
});
});
// Extension and MCP tools are now external-directory gated (#352) ───────────
describe("describeExternalDirectoryGate — extension and MCP tools (#352)", () => {
it("gates an extension tool with an external input.path", () => {
const result = gateUnderTest(
makeTcc({
toolName: "my-ext",
input: { path: "/outside/project/file.ts" },
}),
["/test/agent"],
);
expect(isGateDescriptor(result)).toBe(true);
expect((result as GateDescriptor).surface).toBe("external_directory");
});
it("gates an MCP tool with an external arguments.path", () => {
const result = gateUnderTest(
makeTcc({
toolName: "mcp",
input: { arguments: { path: "/outside/project/file.ts" } },
}),
["/test/agent"],
);
expect(isGateDescriptor(result)).toBe(true);
});
it("uses a registered extractor's external path for a custom-shaped tool", () => {
const extractors = {
get: (name: string) =>
name === "ffgrep"
? (input: Record<string, unknown>) =>
typeof input.target === "string" ? input.target : undefined
: undefined,
};
const result = gateUnderTest(
makeTcc({ toolName: "ffgrep", input: { target: "/outside/project/x" } }),
["/test/agent"],
extractors,
);
expect(isGateDescriptor(result)).toBe(true);
});
it("returns null for an extension tool whose path is inside cwd", () => {
const result = gateUnderTest(
makeTcc({
toolName: "my-ext",
input: { path: "/test/project/src/x.ts" },
}),
["/test/agent"],
);
expect(result).toBeNull();
});
});
@@ -847,29 +847,6 @@ describe("GateRunner — request identity", () => {
expect(decisions[0].requestId).toBe(reviewWrites[0].details.requestId);
});
it("stamps a bypass's log entry and decision event with one id", async () => {
const { runner, reviewWrites, decisions } = makeRecordingRunner();
const bypass: GateBypass = {
action: "allow",
decidedBy: { kind: "infrastructure_read" },
log: {
event: "permission_request.infrastructure_auto_allowed",
details: { path: "/x" },
},
decision: {
surface: "read",
value: "/x",
result: "allow",
resolution: "infrastructure_auto_allowed",
origin: null,
agentName: null,
matchedPattern: null,
},
};
await runner.run(bypass, null);
expect(decisions[0].requestId).toMatch(/^perm-/);
expect(decisions[0].requestId).toBe(reviewWrites[0].details.requestId);
});
it("stamps an allow decision event even when nothing is written to the log", async () => {
const { runner, decisions } = makeRecordingRunner();
@@ -118,17 +118,6 @@ describe("ToolCallGatePipeline", () => {
expect(getToolPreviewLimits).toHaveBeenCalled();
});
it("calls getInfrastructureReadDirs() during evaluate", async () => {
const getInfrastructureReadDirs = vi.fn<() => string[]>(() => []);
const resolver = makeResolver(makeCheckResult());
const inputs = makeGateInputs({ getInfrastructureReadDirs });
const { runner } = makeGateRunner();
const pipeline = new ToolCallGatePipeline(resolver, inputs);
await pipeline.evaluate(makeTcc({ toolName: "read", input: {} }), runner);
expect(getInfrastructureReadDirs).toHaveBeenCalled();
});
it("calls getActiveSkillEntries() during evaluate", async () => {
const getActiveSkillEntries = vi.fn<() => []>(() => []);
@@ -154,35 +154,6 @@ describe("shell-tool alias gating (#574)", () => {
);
});
it("gates an aliased tool's workdir and its relative tokens via external_directory", async () => {
const { handler, events } = makeHandler({
shellTools: execShellTools,
tools: ["exec_command"],
session: {
checkPermission: makeSurfaceCheck(
{ external_directory: { state: "deny", matchedPattern: "*" } },
{ state: "allow" },
),
},
});
// workdir /etc is outside the cwd; the relative token resolves against it.
await handler.handleToolCall(
makeToolCallEvent("exec_command", {
input: { cmd: "cat ../secret.txt", workdir: "/etc" },
}),
makeCtx(),
);
const decisions = getDecisionEvents(events);
expect(decisions).toContainEqual(
expect.objectContaining({
surface: "external_directory",
result: "deny",
resolution: "policy_deny",
}),
);
});
it("floors an indirection wrapper (sudo) in an aliased command to ask (#490)", async () => {
const { handler, events } = makeHandler({
@@ -223,34 +223,6 @@ describe("handleToolCall decision events — confirmation_unavailable", () => {
});
});
// ── infrastructure_auto_allowed path ──────────────────────────────────────
describe("handleToolCall decision events — infrastructure_auto_allowed", () => {
it("emits allow with infrastructure_auto_allowed for Pi infra reads", async () => {
const infraDir = "/test/agent";
const { handler, events } = makeHandler({
session: {
checkPermission: vi.fn().mockReturnValue(makeCheckResult()),
getInfrastructureReadDirs: vi.fn().mockReturnValue([infraDir]),
},
});
const event = makeToolCallEvent("read", {
input: { path: `${infraDir}/some-file.json` },
});
await handler.handleToolCall(event, makeCtx());
const decisions = getDecisionEvents(events);
const infraEvents = decisions.filter(
(e) => e.resolution === "infrastructure_auto_allowed",
);
expect(infraEvents).toHaveLength(1);
expect(infraEvents[0]).toMatchObject({
result: "allow",
resolution: "infrastructure_auto_allowed",
});
});
});
// ── auto_approved path (yolo mode) ───────────────────────────────────
@@ -292,86 +292,6 @@ describe("handleToolCall — bash command chain gate", () => {
});
});
// ---------------------------------------------------------------------------
// Moved from permission-system.test.ts catch-all (#342)
// ---------------------------------------------------------------------------
describe("handleToolCall — bash external-directory policy states", () => {
it("allows bash command with only internal paths when external_directory is denied", async () => {
const { handler } = makeHandler({ tools: ["bash"] });
const event = makeToolCallEvent("bash", {
input: { command: "cat src/index.ts" },
});
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toEqual({ action: "allow" });
});
it("blocks bash command with external path when external_directory is ask and no UI", async () => {
const { handler } = makeHandler({
session: {
checkPermission: makeSurfaceCheck({
external_directory: { state: "ask", source: "special" },
}),
},
tools: ["bash"],
prompter: {
escalate: vi.fn().mockResolvedValue({
approved: false,
state: "denied",
confirmationUnavailable: true,
}),
},
});
const event = makeToolCallEvent("bash", {
input: { command: "cat /etc/hosts" },
});
const result = await handler.handleToolCall(
event,
makeCtx({ hasUI: false }),
);
expect(result).toMatchObject({ action: "block" });
expect(String((result as { reason?: unknown }).reason)).toMatch(
/no interactive UI/i,
);
});
it("allows bash command with external path when external_directory is allow", async () => {
const { handler } = makeHandler({
session: {
checkPermission: makeSurfaceCheck({
external_directory: { state: "allow", source: "special" },
}),
},
tools: ["bash"],
});
const event = makeToolCallEvent("bash", {
input: { command: "cat /etc/hosts" },
});
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toEqual({ action: "allow" });
});
it("applies bash pattern deny after external_directory allow", async () => {
const { handler } = makeHandler({
session: {
checkPermission: makeSurfaceCheck(
{
external_directory: { state: "allow", source: "special" },
bash: { state: "deny", source: "bash" },
},
{ state: "allow" },
),
},
tools: ["bash"],
});
const event = makeToolCallEvent("bash", {
input: { command: "cat /etc/hosts" },
});
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toMatchObject({ action: "block" });
});
});
describe("handleToolCall — generic ask prompt content", () => {
it("ask prompt includes serialized tool input for informed approval", async () => {
const { handler, prompter } = makeHandler({