mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
refactor(permission-system): simplify policy and autoreview flow
This commit is contained in:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user