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
File diff suppressed because it is too large Load Diff
@@ -47,16 +47,6 @@ describe("normalizeInput — non-MCP surfaces", () => {
expect(result.resultExtras).toEqual({});
});
it("external_directory surface ignores input.path and returns ['*']", () => {
const result = normalizeInput(
"external_directory",
{ path: "/other/project" },
[],
);
expect(result.surface).toBe("external_directory");
expect(result.values).toEqual(["*"]);
expect(result.resultExtras).toEqual({});
});
it("read surface ignores input.path and returns ['*']", () => {
const result = normalizeInput("read", { path: ".env" }, []);
@@ -68,7 +58,6 @@ describe("normalizeInput — non-MCP surfaces", () => {
it("missing path also returns ['*'] (unchanged fallback)", () => {
for (const surface of [
"path",
"external_directory",
"read",
"write",
"edit",
@@ -249,19 +238,6 @@ describe("buildAccessIntentForSurface", () => {
}
});
it("emits an access-path intent for the external_directory surface", () => {
const intent = buildAccessIntentForSurface(
"external_directory",
"/outside/dir",
normalizer,
undefined,
);
expect(intent.kind).toBe("access-path");
if (intent.kind === "access-path") {
expect(intent.surface).toBe("external_directory");
expect(intent.path.value()).toBe("/outside/dir");
}
});
it("emits an access-path intent for a path-bearing tool surface (read)", () => {
const intent = buildAccessIntentForSurface(
@@ -315,15 +291,6 @@ describe("buildAccessIntentForSurface", () => {
}
});
it("falls back to a tool intent for a whitespace-only path value", () => {
const intent = buildAccessIntentForSurface(
"external_directory",
" ",
normalizer,
undefined,
);
expect(intent.kind).toBe("tool");
});
});
describe("buildResolvedIntentFromMatchValues", () => {
@@ -348,19 +315,6 @@ describe("buildResolvedIntentFromMatchValues", () => {
});
});
it("emits a path-values intent for the external_directory surface", () => {
const intent = buildResolvedIntentFromMatchValues(
"external_directory",
["/tmp/x", "/real/tmp/x"],
"Explore",
);
expect(intent).toEqual({
kind: "path-values",
surface: "external_directory",
values: ["/tmp/x", "/real/tmp/x"],
agentName: "Explore",
});
});
it("emits a tool intent from the single portable value for a non-path surface (bash)", () => {
const intent = buildResolvedIntentFromMatchValues(
@@ -3,7 +3,6 @@ import { describe, expect, test } from "vitest";
import {
PATH_BEARING_TOOLS,
PATH_SURFACES,
READ_ONLY_PATH_BEARING_TOOLS,
} from "#src/access-intent/path-surfaces";
describe("PATH_BEARING_TOOLS", () => {
@@ -19,21 +18,9 @@ describe("PATH_BEARING_TOOLS", () => {
});
});
describe("READ_ONLY_PATH_BEARING_TOOLS", () => {
test("contains read, find, grep, ls", () => {
for (const tool of ["read", "find", "grep", "ls"]) {
expect(READ_ONLY_PATH_BEARING_TOOLS.has(tool)).toBe(true);
}
});
test("does not contain write or edit", () => {
expect(READ_ONLY_PATH_BEARING_TOOLS.has("write")).toBe(false);
expect(READ_ONLY_PATH_BEARING_TOOLS.has("edit")).toBe(false);
});
});
describe("PATH_SURFACES", () => {
test("contains the path-bearing tools plus the cross-cutting gates", () => {
test("contains the path-bearing tools plus the path gate", () => {
for (const surface of [
"read",
"write",
@@ -41,7 +28,6 @@ describe("PATH_SURFACES", () => {
"find",
"grep",
"ls",
"external_directory",
"path",
]) {
expect(PATH_SURFACES.has(surface)).toBe(true);
@@ -48,7 +48,7 @@ function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
} as unknown as ExtensionContext;
}
/** Details whose gate-computed surface drives the delegation envelope. */
/** Details carrying the gate-computed surface supplied to authorizers. */
function makeDetailsOn(surface: string): PromptPermissionDetails {
return makeDetails({
accessIntent: { surface, matchValues: ["/v"], boundaryValue: null },
@@ -340,29 +340,8 @@ describe("AuthorizerSelection", () => {
);
});
it("caps a link's allow on an excluded surface, falling through to the terminal", async () => {
const registry = new AuthorizerRegistry();
register(registry, "judge", { kind: "allow" });
const selection = new AuthorizerSelection(
makeDeps({
prompter: makeInvokingPrompter(),
authorizerRegistry: registry,
getAuthorizerChain: () => ["judge"],
}),
);
// No UI, not a subagent → the terminal is DenyingAuthorizer.
selection.activate(makeCtx({ hasUI: false }));
const decision = await selection.escalate(
makeDetailsOn("external_directory"),
);
// The envelope downgraded the link's allow to defer, so the terminal
// (denying) owns the decision — the allow did not leak through.
expect(decision.approved).toBe(false);
});
it("lets a link's allow through on a non-excluded surface", async () => {
it("lets a configured authorizer approve the path surface", async () => {
const registry = new AuthorizerRegistry();
register(registry, "judge", { kind: "allow" });
const selection = new AuthorizerSelection(
@@ -374,10 +353,10 @@ describe("AuthorizerSelection", () => {
);
selection.activate(makeCtx({ hasUI: false }));
const decision = await selection.escalate(makeDetailsOn("bash"));
const decision = await selection.escalate(makeDetailsOn("path"));
// bash is not excluded, so the link's allow stands (a non-persistent
// approved grant) — the denying terminal is never reached.
// A configured authorizer's allow is final for this ask and does not
// create a persistent session grant. The terminal is never reached.
expect(decision).toEqual({
approved: true,
state: "approved",
@@ -1,147 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { Authorizer, AuthorizerVerdict } from "#src/authority/authorizer";
import { encloseInDelegationEnvelope } from "#src/authority/delegation-envelope";
import type { PromptPermissionDetails } from "#src/authority/permission-prompter";
import type { PermissionQuery } from "#src/service";
import { makeAuthorizerLog } from "#test/helpers/authorizer-log-fixtures";
import { makePromptDetails } from "#test/helpers/prompt-details-fixtures";
function makeQuery(): PermissionQuery {
return { checkPermission: vi.fn(), getToolPermission: vi.fn() };
}
/** Build details whose gate-computed surface is `accessIntentSurface`. */
function makeDetails(
accessIntentSurface: string | undefined,
displaySurface?: string | null,
toolName?: string,
): PromptPermissionDetails {
return makePromptDetails({
surface: displaySurface,
toolName,
accessIntent:
accessIntentSurface === undefined
? undefined
: {
surface: accessIntentSurface,
matchValues: ["/some/value"],
boundaryValue: null,
},
});
}
/** A link whose fixed verdict the envelope may cap. */
function makeLink(verdict: AuthorizerVerdict): Authorizer["authorize"] {
return vi.fn<Authorizer["authorize"]>().mockResolvedValue(verdict);
}
describe("encloseInDelegationEnvelope", () => {
const query = makeQuery();
const log = makeAuthorizerLog();
describe("caps an allow verdict on an excluded surface to defer", () => {
it("downgrades an allow on external_directory", async () => {
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "allow" }));
const verdict = await enclosed(
makeDetails("external_directory"),
query,
log,
);
expect(verdict).toEqual({ kind: "defer" });
});
it.each(["write", "edit", "bash", "custom-tool"] as const)(
"downgrades an allow on external_directory for %s",
async (toolName) => {
const enclosed = encloseInDelegationEnvelope(
makeLink({ kind: "allow" }),
);
const verdict = await enclosed(
makeDetails("external_directory", undefined, toolName),
query,
log,
);
expect(verdict).toEqual({ kind: "defer" });
},
);
it("downgrades an allow on the path surface", async () => {
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "allow" }));
const verdict = await enclosed(makeDetails("path"), query, log);
expect(verdict).toEqual({ kind: "defer" });
});
it("downgrades an allow when the surface is undetermined (fail-safe)", async () => {
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "allow" }));
const verdict = await enclosed(makeDetails(undefined, null), query, log);
expect(verdict).toEqual({ kind: "defer" });
});
});
describe("passes verdicts through unchanged", () => {
it("keeps an allow on a non-excluded surface (bash)", async () => {
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "allow" }));
const verdict = await enclosed(makeDetails("bash"), query, log);
expect(verdict).toEqual({ kind: "allow" });
});
it("keeps an allow on a per-tool surface (read)", async () => {
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "allow" }));
const verdict = await enclosed(makeDetails("read"), query, log);
expect(verdict).toEqual({ kind: "allow" });
});
it.each(["read", "find", "grep", "ls"] as const)(
"keeps an allow on external_directory for the built-in read-only tool %s",
async (toolName) => {
const enclosed = encloseInDelegationEnvelope(
makeLink({ kind: "allow" }),
);
const verdict = await enclosed(
makeDetails("external_directory", undefined, toolName),
query,
log,
);
expect(verdict).toEqual({ kind: "allow" });
},
);
it("never caps a deny, even on an excluded surface", async () => {
const enclosed = encloseInDelegationEnvelope(
makeLink({ kind: "deny", reason: "wrong path" }),
);
const verdict = await enclosed(
makeDetails("external_directory"),
query,
log,
);
expect(verdict).toEqual({ kind: "deny", reason: "wrong path" });
});
it("never caps a defer", async () => {
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "defer" }));
const verdict = await enclosed(makeDetails("path"), query, log);
expect(verdict).toEqual({ kind: "defer" });
});
});
it("prefers the gate-computed accessIntent surface over the display surface", async () => {
// accessIntent.surface (external_directory) is authoritative even when the
// display-surface override says otherwise.
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "allow" }));
const verdict = await enclosed(
makeDetails("external_directory", "bash"),
query,
log,
);
expect(verdict).toEqual({ kind: "defer" });
});
it("forwards details, the injected query, and the review-log seam to the wrapped link", async () => {
const link = makeLink({ kind: "defer" });
const enclosed = encloseInDelegationEnvelope(link);
const details = makeDetails("bash");
await enclosed(details, query, log);
expect(link).toHaveBeenCalledWith(details, query, log);
});
});
@@ -2,10 +2,8 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { Authorizer } from "#src/authority/authorizer";
import { AuthorizerRegistry } from "#src/authority/authorizer-registry";
import { AuthorizerSelection } from "#src/authority/authorizer-selection";
import { encloseInDelegationEnvelope } from "#src/authority/delegation-envelope";
import { ForwardedRequestServer } from "#src/authority/forwarded-request-server";
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
import type {
@@ -16,7 +14,6 @@ import {
PermissionPrompter,
type PromptPermissionDetails,
} from "#src/authority/permission-prompter";
import type { PermissionQuery } from "#src/service";
import {
makeAuthorizerSelectionDeps,
registerLink,
@@ -57,9 +54,8 @@ function readResponse(
* An approving `AskEscalator` that records the details it was handed.
*
* The reconstructed `PromptPermissionDetails` is itself the subject of the
* access-facts and bounded-delegation cases below: they assert its exact shape,
* and hand it to the real delegation envelope — a collaborator the server never
* touches, but one that reads the details the server builds.
* access-facts cases below: they assert the exact evidence passed to the
* serving node's configured authorizer chain.
*/
function makeCapturingEscalator() {
const escalated: PromptPermissionDetails[] = [];
@@ -550,9 +546,8 @@ describe("processInbox — child-fixed access facts on the escalated ask", () =>
value: "git push",
});
// Absence, not an explicit `undefined`: the delegation envelope's
// `accessIntent?.surface ?? surface` fallback reads the display surface only
// when the key is genuinely absent.
// Version-skew requests can omit access intent entirely; preserve that
// absence instead of manufacturing evidence.
expect(details).not.toHaveProperty("accessIntent");
});
});
@@ -640,49 +635,6 @@ describe("processInbox — the child's payload on the escalated ask", () => {
});
});
describe("processInbox — bounded delegation over forwarded asks", () => {
const query: PermissionQuery = {
checkPermission: vi.fn(),
getToolPermission: vi.fn(),
};
const log = makeAuthorizerLog();
const allowingLink: Authorizer["authorize"] = () =>
Promise.resolve({ kind: "allow" });
test("caps a link's allow on a forwarded path ask to defer", async () => {
const details = await escalateForwardedAsk({
id: "req-envelope-path",
source: "tool_call",
surface: "write",
value: "/worktree/issue-42/.ssh/config",
accessIntent: makeForwardedAccessIntent({
surface: "path",
matchValues: ["/worktree/issue-42/.ssh/config"],
boundaryValue: "/worktree/issue-42/.ssh/config",
}),
});
const enclosed = encloseInDelegationEnvelope(allowingLink);
// The gate surface, not the displayed tool name, decides exclusion — so a
// forwarded path ask is capped exactly like the same ask made locally.
expect(await enclosed(details, query, log)).toEqual({ kind: "defer" });
});
test("passes a link's allow on a forwarded bash ask through", async () => {
const details = await escalateForwardedAsk({
id: "req-envelope-bash",
source: "tool_call",
surface: "bash",
value: "npm test",
accessIntent: makeForwardedAccessIntent({ matchValues: ["npm test"] }),
});
const enclosed = encloseInDelegationEnvelope(allowingLink);
expect(await enclosed(details, query, log)).toEqual({ kind: "allow" });
});
});
describe("processInbox — the serving node's chain adjudicates a forwarded ask", () => {
/** A serving node with UI: its terminal is the human prompt. */
File diff suppressed because it is too large Load Diff
@@ -14,8 +14,6 @@
import {
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
@@ -28,11 +26,6 @@ import {
type ExtensionAPI,
} from "@earendil-works/pi-coding-agent";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createPermissionForwardingLocation,
type ForwardedPermissionRequest,
} from "#src/authority/permission-forwarding";
import { getServingSessionRegistry } from "#src/authority/serving-registry";
import { SUBAGENT_CHILD_SESSION_CREATED } from "#src/authority/subagent-lifecycle-events";
import { getSubagentSessionRegistry } from "#src/authority/subagent-registry";
import { getGlobalConfigPath } from "#src/config-paths";
@@ -40,7 +33,6 @@ import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
import piPermissionSystemExtension from "#src/index";
import { PERMISSIONS_READY_CHANNEL } from "#src/permission-events";
import { getPermissionsService } from "#src/service";
import { publishServingHeartbeat } from "#test/helpers/forwarding-fixtures";
import { makeFakePi } from "#test/helpers/make-fake-pi";
const SERVICE_KEY = Symbol.for("@gotgenes/pi-permission-system:service");
@@ -171,8 +163,6 @@ function makeUiCtx(cwd: string, capturedTitles: string[]): { ctx: unknown } {
return { ctx };
}
const sleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
/** Drive the registered `session_start` handler with a ctx. */
function fireSessionStart(
@@ -182,53 +172,6 @@ function fireSessionStart(
return pi.fire("session_start", { reason: "start" }, ctx);
}
/**
* Simulate the parent UI session responding to a forwarded permission request.
*
* Polls the parent's requests directory for the child's request file, then
* writes an approval response so the child's forwarding poll resolves quickly
* instead of waiting out the 10-minute timeout.
*/
async function approveForwardedRequest(
forwardingDir: string,
parentSessionId: string,
): Promise<ForwardedPermissionRequest> {
const location = createPermissionForwardingLocation(
forwardingDir,
parentSessionId,
);
const deadline = Date.now() + 2000;
while (Date.now() < deadline) {
let files: string[] = [];
try {
files = readdirSync(location.requestsDir).filter((f) =>
f.endsWith(".json"),
);
} catch {
files = [];
}
const requestFile = files[0];
if (requestFile) {
const request = JSON.parse(
readFileSync(join(location.requestsDir, requestFile), "utf8"),
) as ForwardedPermissionRequest;
mkdirSync(location.responsesDir, { recursive: true });
writeFileSync(
join(location.responsesDir, `${request.id}.json`),
JSON.stringify({
approved: true,
state: "approved",
responderSessionId: parentSessionId,
respondedAt: Date.now(),
}),
"utf8",
);
return request;
}
await sleep(5);
}
throw new Error("Timed out waiting for the forwarded permission request");
}
describe("event-handler registration completeness", () => {
it("registers a handler for every required event exactly once", () => {
@@ -239,208 +182,7 @@ describe("event-handler registration completeness", () => {
});
});
describe("subagent registry sharing across factory instances", () => {
// The #296 regression class: two factory invocations on *different* event
// buses must still resolve the same process-global SubagentSessionRegistry,
// so a child registered via the parent's bus detects itself as a subagent and
// forwards (rather than blocking) an external-directory `ask`.
it("lets a child instance forward an ask it received via the parent's bus", async () => {
writeGlobalConfig({
permission: { "*": "allow", external_directory: "ask" },
});
const childCwd = mkdtempSync(join(tmpdir(), "pi-perm-child-cwd-"));
const externalDir = mkdtempSync(join(tmpdir(), "pi-perm-external-"));
const forwardingDir = join(agentDir, "sessions", "permission-forwarding");
const parentSessionId = "parent-session-1";
const childSessionId = "child-session-1";
// Two factory instances, each wired to its own event bus (as in production:
// every session's ResourceLoader creates a separate bus).
const parentBus = createEventBus();
const childBus = createEventBus();
piPermissionSystemExtension(
makeFakePi({ events: parentBus }) as unknown as ExtensionAPI,
);
const childPi = makeFakePi({
events: childBus,
toolNames: ["read"],
});
piPermissionSystemExtension(childPi as unknown as ExtensionAPI);
// The child session is announced on the *parent's* bus only; the parent's
// lifecycle subscription writes it into the shared global registry.
parentBus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
sessionId: childSessionId,
parentSessionId,
});
// This test answers the forwarded request by hand instead of running the
// parent's poll timer, so state what that timer would have published:
// the parent is draining its inbox. Without it the child correctly
// abandons the request as unserved (#719).
getServingSessionRegistry().markServing(parentSessionId);
// The child fires an external-directory read with no UI. With the shared
// registry it detects itself as a subagent and forwards; the simulated
// parent approves.
const firePromise = childPi.fire(
"tool_call",
{
toolName: "read",
toolCallId: "child-external-read",
input: { path: join(externalDir, "secret.txt") },
},
makeChildCtx(childCwd, childSessionId),
);
const request = await approveForwardedRequest(
forwardingDir,
parentSessionId,
);
expect(request.targetSessionId).toBe(parentSessionId);
expect(request.requesterSessionId).toBe(childSessionId);
// The child persists the original display fields so the parent emits a
// non-degraded `permissions:ui_prompt` event (forwarded non-degradation).
expect(request.source).toBe("tool_call");
expect(request.surface).toBe("read");
expect(request.value).toBe(join(externalDir, "secret.txt"));
const result = (await firePromise) as { block?: true };
expect(result.block).toBeUndefined();
rmSync(childCwd, { recursive: true, force: true });
rmSync(externalDir, { recursive: true, force: true });
});
// The #719 failure mode: the child forwards correctly, but nothing drains
// the parent's inbox. Before the serving registry it waited out the full
// ten-minute timeout and reported the block as a user denial.
it("blocks promptly when no session is draining the parent's inbox", async () => {
writeGlobalConfig({
permission: { "*": "allow", external_directory: "ask" },
});
const childCwd = mkdtempSync(join(tmpdir(), "pi-perm-child-cwd-"));
const externalDir = mkdtempSync(join(tmpdir(), "pi-perm-external-"));
const parentSessionId = "parent-session-2";
const childSessionId = "child-session-2";
const parentBus = createEventBus();
piPermissionSystemExtension(
makeFakePi({ events: parentBus }) as unknown as ExtensionAPI,
);
const childPi = makeFakePi({
events: createEventBus(),
toolNames: ["read"],
});
piPermissionSystemExtension(childPi as unknown as ExtensionAPI);
parentBus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
sessionId: childSessionId,
parentSessionId,
});
// Deliberately no markServing: nobody is polling the parent's inbox.
const result = (await childPi.fire(
"tool_call",
{
toolName: "read",
toolCallId: "child-external-read",
input: { path: join(externalDir, "secret.txt") },
},
makeChildCtx(childCwd, childSessionId),
)) as { block?: true; reason?: string };
expect(result.block).toBe(true);
expect(result.reason).toContain("no interactive UI is available");
expect(result.reason).toContain(
`Session '${parentSessionId}' is not serving forwarded permission requests.`,
);
expect(result.reason).not.toContain("User denied");
rmSync(childCwd, { recursive: true, force: true });
rmSync(externalDir, { recursive: true, force: true });
});
});
describe("out-of-process forwarding liveness", () => {
// A child spawned as its own `pi` process resolves its parent from the
// environment and shares no `globalThis` with it, so the serving registry the
// tests above rely on is invisible to it. The filesystem heartbeat is the
// only signal it has (#721, #735 scenario 1).
const externalAsk = {
permission: { "*": "allow", external_directory: "ask" },
};
function fireChildRead(childCwd: string, externalDir: string) {
const childPi = makeFakePi({
events: createEventBus(),
toolNames: ["read"],
});
piPermissionSystemExtension(childPi as unknown as ExtensionAPI);
return childPi.fire(
"tool_call",
{
toolName: "read",
toolCallId: "child-external-read",
input: { path: join(externalDir, "secret.txt") },
},
makeChildCtx(childCwd, "child-session-oop"),
);
}
it("blocks promptly when the out-of-process parent published no heartbeat", async () => {
writeGlobalConfig(externalAsk);
const childCwd = mkdtempSync(join(tmpdir(), "pi-perm-child-cwd-"));
const externalDir = mkdtempSync(join(tmpdir(), "pi-perm-external-"));
const parentSessionId = "parent-session-oop-gone";
// The env-var path a process-based subagent extension uses: the hint makes
// the child detect itself, and the parent id names a session that exited.
vi.stubEnv("PI_SUBAGENT_CHILD", "1");
vi.stubEnv("PI_SUBAGENT_PARENT_SESSION", parentSessionId);
const result = (await fireChildRead(childCwd, externalDir)) as {
block?: true;
reason?: string;
};
expect(result.block).toBe(true);
expect(result.reason).toContain(
`Session '${parentSessionId}' is not serving forwarded permission requests.`,
);
expect(result.reason).not.toContain("User denied");
rmSync(childCwd, { recursive: true, force: true });
rmSync(externalDir, { recursive: true, force: true });
});
it("waits for an out-of-process parent whose heartbeat is fresh", async () => {
writeGlobalConfig(externalAsk);
const childCwd = mkdtempSync(join(tmpdir(), "pi-perm-child-cwd-"));
const externalDir = mkdtempSync(join(tmpdir(), "pi-perm-external-"));
const forwardingDir = join(agentDir, "sessions", "permission-forwarding");
const parentSessionId = "parent-session-oop-live";
vi.stubEnv("PI_SUBAGENT_CHILD", "1");
vi.stubEnv("PI_SUBAGENT_PARENT_SESSION", parentSessionId);
// What the parent's poll timer would have published; this test answers the
// request by hand instead of running that timer.
publishServingHeartbeat(forwardingDir, parentSessionId);
const firePromise = fireChildRead(childCwd, externalDir);
const request = await approveForwardedRequest(
forwardingDir,
parentSessionId,
);
expect(request.targetSessionId).toBe(parentSessionId);
const result = (await firePromise) as { block?: true };
expect(result.block).toBeUndefined();
rmSync(childCwd, { recursive: true, force: true });
rmSync(externalDir, { recursive: true, force: true });
});
});
describe("shutdown teardown chain", () => {
it("unpublishes the service and unsubscribes the lifecycle on shutdown", async () => {
@@ -727,7 +469,7 @@ describe("project trust gates project-scoped config (#644)", () => {
rmSync(cwd, { recursive: true, force: true });
});
it("lets a trusted project's `bash: allow` override global `bash: deny`", async () => {
it("ignores project policy even when the project is trusted", async () => {
writeGlobalConfig({ permission: { "*": "ask", bash: "deny" } });
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-trusted-cwd-"));
writeProjectConfig(cwd, { permission: { bash: "allow" } });
@@ -739,10 +481,10 @@ describe("project trust gates project-scoped config (#644)", () => {
makeBaseCtx(cwd, "trusted-session", { isProjectTrusted: true }),
);
// The trusted project override applies (last-match-wins).
// Project policy files are not part of the permission configuration.
expect(
getPermissionsService()!.checkPermission("bash", "echo hi").state,
).toBe("allow");
).toBe("deny");
rmSync(cwd, { recursive: true, force: true });
});
@@ -883,65 +625,6 @@ describe("bash bare-token path gating (#509, #645)", () => {
rmSync(cwd, { recursive: true, force: true });
});
it("denies a bare symlink escaping the working tree under external_directory deny (#645)", async () => {
// The issue's headline repro:
// printf 'test' > /tmp/…-secret ; ln -s /tmp/…-secret outside-link
// cat outside-link (under a permissive `cat *` bash rule)
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-escape-cwd-"));
const outside = mkdtempSync(join(tmpdir(), "pi-perm-escape-target-"));
const secret = join(outside, "pi-permission-test-secret");
writeFileSync(secret, "test");
symlinkSync(secret, join(cwd, "outside-link"));
writeGlobalConfig({
permission: {
"*": "allow",
bash: { "cat *": "allow" },
external_directory: "deny",
},
});
const pi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
const ctx = makeChildCtx(cwd, "bare-escape-session-deny");
await fireSessionStart(pi, ctx);
const result = await fireBashToolCall(pi, ctx, "cat outside-link");
expect(result.block).toBe(true);
rmSync(cwd, { recursive: true, force: true });
rmSync(outside, { recursive: true, force: true });
});
it("denies a path embedded in a long option under external_directory deny (#645)", async () => {
// The issue's second repro: `grep --file=/tmp/…` under an allowing
// `grep *` bash rule. The flag token never reached the path surfaces.
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-flagpath-cwd-"));
const outside = mkdtempSync(join(tmpdir(), "pi-perm-flagpath-target-"));
const patterns = join(outside, "pi-permission-patterns");
writeFileSync(patterns, "secret\n");
writeGlobalConfig({
permission: {
"*": "allow",
bash: { "grep *": "allow" },
external_directory: "deny",
},
});
const pi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
const ctx = makeChildCtx(cwd, "flag-path-session-deny");
await fireSessionStart(pi, ctx);
const result = await fireBashToolCall(
pi,
ctx,
`grep --file=${patterns} target`,
);
expect(result.block).toBe(true);
rmSync(cwd, { recursive: true, force: true });
rmSync(outside, { recursive: true, force: true });
});
});
describe("multi-instance global service interplay", () => {
+11 -142
View File
@@ -245,7 +245,6 @@ describe("loadUnifiedConfig", () => {
"*": "ask",
read: "allow",
bash: { "*": "ask", "git *": "allow" },
external_directory: "ask",
},
}),
);
@@ -256,7 +255,6 @@ describe("loadUnifiedConfig", () => {
"*": "ask",
read: "allow",
bash: { "*": "ask", "git *": "allow" },
external_directory: "ask",
});
});
@@ -401,32 +399,6 @@ describe("loadUnifiedConfig", () => {
expect(result.issues.length).toBeGreaterThan(0);
});
it("parses piInfrastructureReadPaths when a valid string array is present", () => {
const configPath = join(tempDir, "config.json");
writeFileSync(
configPath,
JSON.stringify({ piInfrastructureReadPaths: ["/extra/path"] }),
);
const result = loadUnifiedConfig(configPath);
expect(result.config.piInfrastructureReadPaths).toEqual(["/extra/path"]);
});
it("parses piInfrastructureReadPaths as empty array when set to []", () => {
const configPath = join(tempDir, "config.json");
writeFileSync(
configPath,
JSON.stringify({ piInfrastructureReadPaths: [] }),
);
const result = loadUnifiedConfig(configPath);
expect(result.config.piInfrastructureReadPaths).toEqual([]);
});
it("omits piInfrastructureReadPaths when absent", () => {
const configPath = join(tempDir, "config.json");
writeFileSync(configPath, JSON.stringify({ debugLog: false }));
const result = loadUnifiedConfig(configPath);
expect(result.config).not.toHaveProperty("piInfrastructureReadPaths");
});
it.each([
["string", "not-an-array"],
@@ -446,7 +418,7 @@ describe("loadUnifiedConfig", () => {
});
describe("mergeUnifiedConfigs", () => {
it("deep-merges permission objects so project overrides global per-key", () => {
it("deep-merges permission objects so the later config overrides per key", () => {
const merged = mergeUnifiedConfigs(
{
permission: {
@@ -496,7 +468,7 @@ describe("mergeUnifiedConfigs", () => {
expect(merged.permission).toEqual({ bash: "deny" });
});
it("replaces scalar runtime knobs (project wins)", () => {
it("replaces scalar runtime knobs with later values", () => {
const merged = mergeUnifiedConfigs(
{
debugLog: true,
@@ -513,7 +485,7 @@ describe("mergeUnifiedConfigs", () => {
expect(merged.doublePressToConfirm).toBe(false);
});
it("replaces the prompt-budget scalars (project wins)", () => {
it("replaces prompt-budget scalars with later values", () => {
const merged = mergeUnifiedConfigs(
{ promptMaxRows: 24, promptFieldMaxWidth: 400 },
{ promptMaxRows: 10 },
@@ -523,7 +495,7 @@ describe("mergeUnifiedConfigs", () => {
expect(merged.promptFieldMaxWidth).toBe(400);
});
it("replaces the review-log field width (project wins)", () => {
it("replaces the review-log field width with a later value", () => {
expect(
mergeUnifiedConfigs(
{ reviewLogFieldMaxWidth: 1000 },
@@ -612,34 +584,6 @@ describe("mergeUnifiedConfigs", () => {
expect(merged).not.toHaveProperty("toolTextSummaryMaxLength");
});
it("override piInfrastructureReadPaths replaces base array", () => {
const merged = mergeUnifiedConfigs(
{ piInfrastructureReadPaths: ["/base/path"] },
{ piInfrastructureReadPaths: ["/override/path"] },
);
expect(merged.piInfrastructureReadPaths).toEqual(["/override/path"]);
});
it("base piInfrastructureReadPaths survives when override omits it", () => {
const merged = mergeUnifiedConfigs(
{ piInfrastructureReadPaths: ["/kept/path"] },
{ debugLog: true },
);
expect(merged.piInfrastructureReadPaths).toEqual(["/kept/path"]);
});
it("piInfrastructureReadPaths is absent when both base and override omit it", () => {
const merged = mergeUnifiedConfigs({ debugLog: true }, { yoloMode: false });
expect(merged).not.toHaveProperty("piInfrastructureReadPaths");
});
it("override piInfrastructureReadPaths as empty array replaces non-empty base", () => {
const merged = mergeUnifiedConfigs(
{ piInfrastructureReadPaths: ["/base/path"] },
{ piInfrastructureReadPaths: [] },
);
expect(merged.piInfrastructureReadPaths).toEqual([]);
});
it("override authorizerChain replaces base array", () => {
const merged = mergeUnifiedConfigs(
@@ -764,27 +708,6 @@ describe("loadAndMergeConfigs", () => {
writeFileSync(join(extensionRoot, "config.json"), JSON.stringify(content));
}
it("merges global and project new-layout configs", () => {
writeGlobal({
debugLog: true,
permission: { "*": "ask", read: "allow" },
});
writeProject({
permission: { "*": "allow", write: "deny" },
});
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
// The merged config leaves a permissive top-level '*' with no bash '*' policy,
// so the bash-fallback footgun warning is expected.
expect(result.issues).toHaveLength(1);
expect(result.issues[0]).toContain("bash");
expect(result.merged.debugLog).toBe(true);
expect(result.merged.permission).toEqual({
"*": "allow",
read: "allow",
write: "deny",
});
});
it("detects legacy global policy and emits migration issue", () => {
writeLegacyGlobalPolicy({
@@ -800,18 +723,6 @@ describe("loadAndMergeConfigs", () => {
expect(result.merged.permission).toBeUndefined();
});
it("detects legacy project policy and emits migration issue", () => {
writeLegacyProjectPolicy({
bash: { "git status": "allow" },
});
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
expect(result.issues).toHaveLength(1);
expect(result.issues[0]).toContain(".pi/agent/pi-permissions.jsonc");
expect(result.issues[0]).toContain(".pi/extensions/pi-permission-system");
// Legacy file has no flat-format permission key — no rules extracted
expect(result.merged.permission).toBeUndefined();
});
it("detects legacy extension runtime config and emits migration issue", () => {
writeLegacyExtensionConfig({
@@ -878,57 +789,15 @@ describe("loadAndMergeConfigs", () => {
expect(result.issues).toEqual([]);
});
describe("includeProjectScope", () => {
it("omits the new project config when includeProjectScope is false", () => {
writeGlobal({
permission: { "*": "ask", bash: "deny" },
});
writeProject({
permission: { bash: "allow" },
});
it("ignores new and legacy project policy files", () => {
writeGlobal({ permission: { "*": "ask", bash: "deny" } });
writeProject({ permission: { bash: "allow" } });
writeLegacyProjectPolicy({ permission: { "*": "allow" } });
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot, {
includeProjectScope: false,
});
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
// The untrusted project's `bash: allow` must not override global `deny`.
expect(result.merged.permission).toEqual({ "*": "ask", bash: "deny" });
expect(result.project).toEqual({});
});
it("omits the legacy project policy when includeProjectScope is false", () => {
writeGlobal({ permission: { "*": "ask" } });
writeLegacyProjectPolicy({ permission: { "*": "allow" } });
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot, {
includeProjectScope: false,
});
expect(result.merged.permission).toEqual({ "*": "ask" });
expect(
result.issues.some((i) => i.includes("pi-permissions.jsonc")),
).toBe(false);
});
it("includes the project config when includeProjectScope is true", () => {
writeGlobal({ permission: { "*": "ask", bash: "deny" } });
writeProject({ permission: { bash: "allow" } });
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot, {
includeProjectScope: true,
});
expect(result.merged.permission).toEqual({ "*": "ask", bash: "allow" });
});
it("includes the project config by default (option omitted)", () => {
writeGlobal({ permission: { "*": "ask", bash: "deny" } });
writeProject({ permission: { bash: "allow" } });
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
expect(result.merged.permission).toEqual({ "*": "ask", bash: "allow" });
});
expect(result.merged.permission).toEqual({ "*": "ask", bash: "deny" });
expect(result.issues).toEqual([]);
});
describe("deprecated tool-preview caps", () => {
@@ -8,9 +8,7 @@ import {
getGlobalLogsDir,
getLegacyExtensionConfigPath,
getLegacyGlobalPolicyPath,
getLegacyProjectPolicyPath,
getProjectAgentsDir,
getProjectConfigPath,
REVIEW_LOG_FILENAME,
} from "#src/config-paths";
@@ -38,11 +36,6 @@ describe("config-paths", () => {
);
});
it("getProjectConfigPath returns .pi/extensions/pi-permission-system/policy.jsonc under cwd", () => {
expect(getProjectConfigPath(cwd)).toBe(
join(cwd, ".pi", "extensions", "pi-permission-system", "policy.jsonc"),
);
});
it("getProjectAgentsDir returns .pi/agents under cwd", () => {
expect(getProjectAgentsDir(cwd)).toBe(join(cwd, ".pi", "agents"));
@@ -56,11 +49,6 @@ describe("config-paths", () => {
);
});
it("getLegacyProjectPolicyPath returns .pi/agent/pi-permissions.jsonc under cwd", () => {
expect(getLegacyProjectPolicyPath(cwd)).toBe(
join(cwd, ".pi", "agent", "pi-permissions.jsonc"),
);
});
it("getLegacyExtensionConfigPath returns config.json under extensionRoot", () => {
expect(getLegacyExtensionConfigPath(extensionRoot)).toBe(
@@ -94,19 +94,6 @@ describe("config pipeline seam", () => {
expect(config).not.toHaveProperty("toolTextSummaryMaxLength");
});
it("project config still overrides a global deprecated cap in the merge", () => {
writeGlobal({ toolInputPreviewMaxLength: 200 });
const projectDir = join(cwd, ".pi", "extensions", "pi-permission-system");
mkdirSync(projectDir, { recursive: true });
writeFileSync(
join(projectDir, "policy.jsonc"),
JSON.stringify({ toolInputPreviewMaxLength: 500 }),
);
const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
expect(mergeResult.merged.toolInputPreviewMaxLength).toBe(500);
});
it("defaults apply when config file is absent", () => {
// No config files written — agentDir and cwd directories don't exist.
@@ -13,82 +13,23 @@ import { createPermissionSystemLogger } from "#src/logging";
import type { ResolvedPolicyPaths } from "#src/permission-manager";
import { PermissionManager } from "#src/permission-manager";
test("buildResolvedConfigLogEntry includes policy paths and legacy detection flags", () => {
test("buildResolvedConfigLogEntry reports global and agent policy paths", () => {
const policyPaths: ResolvedPolicyPaths = {
globalConfigPath:
"/home/user/.pi/agent/extensions/pi-permission-system/config.json",
globalConfigPath: "/home/user/.pi/agent/extensions/pi-permission-system/policy.jsonc",
globalConfigExists: true,
projectConfigPath:
"/projects/my-app/.pi/extensions/pi-permission-system/config.json",
projectConfigExists: false,
agentsDir: "/home/user/.pi/agent/agents",
agentsDirExists: true,
projectAgentsDir: "/projects/my-app/.pi/agent/agents",
projectAgentsDirExists: false,
};
const result = buildResolvedConfigLogEntry({ policyPaths });
expect(result.globalConfigPath).toBe(
"/home/user/.pi/agent/extensions/pi-permission-system/config.json",
);
expect(result.globalConfigExists).toBe(true);
expect(result.projectConfigPath).toBe(
"/projects/my-app/.pi/extensions/pi-permission-system/config.json",
);
expect(result.projectConfigExists).toBe(false);
expect(result.agentsDir).toBe("/home/user/.pi/agent/agents");
expect(result.agentsDirExists).toBe(true);
expect(result.projectAgentsDir).toBe("/projects/my-app/.pi/agent/agents");
expect(result.projectAgentsDirExists).toBe(false);
expect(result.legacyGlobalPolicyDetected).toBe(false);
expect(result.legacyProjectPolicyDetected).toBe(false);
expect(result.legacyExtensionConfigDetected).toBe(false);
});
test("buildResolvedConfigLogEntry handles null project paths", () => {
const policyPaths: ResolvedPolicyPaths = {
globalConfigPath:
"/home/user/.pi/agent/extensions/pi-permission-system/config.json",
globalConfigExists: false,
projectConfigPath: null,
projectConfigExists: false,
agentsDir: "/home/user/.pi/agent/agents",
agentsDirExists: false,
projectAgentsDir: null,
projectAgentsDirExists: false,
};
const result = buildResolvedConfigLogEntry({ policyPaths });
expect(result.projectConfigPath).toBe(null);
expect(result.projectConfigExists).toBe(false);
expect(result.projectAgentsDir).toBe(null);
expect(result.projectAgentsDirExists).toBe(false);
});
test("buildResolvedConfigLogEntry surfaces legacy detection flags", () => {
const policyPaths: ResolvedPolicyPaths = {
globalConfigPath:
"/home/user/.pi/agent/extensions/pi-permission-system/config.json",
globalConfigExists: true,
projectConfigPath: null,
projectConfigExists: false,
agentsDir: "/home/user/.pi/agent/agents",
agentsDirExists: false,
projectAgentsDir: null,
projectAgentsDirExists: false,
};
const result = buildResolvedConfigLogEntry({
policyPaths,
legacyGlobalPolicyDetected: true,
legacyExtensionConfigDetected: true,
expect(result).toEqual({
...policyPaths,
legacyGlobalPolicyDetected: false,
legacyExtensionConfigDetected: false,
});
expect(result.legacyGlobalPolicyDetected).toBe(true);
expect(result.legacyProjectPolicyDetected).toBe(false);
expect(result.legacyExtensionConfigDetected).toBe(true);
});
test("config.resolved entry appears in review log via logger", () => {
@@ -135,12 +76,9 @@ test("config.resolved entry appears in review log via logger", () => {
expect(parsed.globalConfigExists).toBe(true);
expect(parsed.agentsDir).toBe(agentsDir);
expect(parsed.agentsDirExists).toBe(false);
expect(parsed.projectConfigPath).toBe(null);
expect(parsed.projectConfigExists).toBe(false);
expect(parsed.projectAgentsDir).toBe(null);
expect(parsed.projectAgentsDirExists).toBe(false);
expect(parsed.legacyGlobalPolicyDetected).toBe(false);
expect(parsed.legacyProjectPolicyDetected).toBe(false);
expect(parsed.legacyExtensionConfigDetected).toBe(false);
} finally {
rmSync(tempDir, { recursive: true, force: true });
@@ -29,7 +29,6 @@ describe("unifiedConfigSchema", () => {
yoloMode: true,
toolInputPreviewMaxLength: 1000,
toolTextSummaryMaxLength: 120,
piInfrastructureReadPaths: ["/extra/path"],
permission: {
"*": "ask",
read: "allow",
+3 -20
View File
@@ -76,8 +76,6 @@ function makePolicyPathProvider(
(): ResolvedPolicyPaths => ({
globalConfigPath: "/agent/config.json",
globalConfigExists: false,
projectConfigPath: null,
projectConfigExists: false,
agentsDir: "/agent/agents",
agentsDirExists: false,
projectAgentsDir: null,
@@ -161,25 +159,23 @@ describe("ConfigStore", () => {
// ── refresh() ─────────────────────────────────────────────────────────
describe("refresh()", () => {
it("uses the passed ctx cwd for loadAndMergeConfigs and includes the project scope when trusted", () => {
it("uses the passed ctx cwd for global-only config loading", () => {
const { store } = makeStore();
store.refresh(makeCtx({ cwd: "/my/project" }), true);
expect(mockLoadAndMergeConfigs).toHaveBeenCalledWith(
"/test/agent",
"/my/project",
expect.any(String),
{ includeProjectScope: true },
);
});
it("withholds the project scope when the project is untrusted", () => {
it("does not add a project config scope when the project is untrusted", () => {
const { store } = makeStore();
store.refresh(makeCtx({ cwd: "/my/project" }), false);
expect(mockLoadAndMergeConfigs).toHaveBeenCalledWith(
"/test/agent",
"/my/project",
expect.any(String),
{ includeProjectScope: false },
);
});
@@ -190,7 +186,6 @@ describe("ConfigStore", () => {
"/test/agent",
"",
expect.any(String),
{ includeProjectScope: true },
);
});
@@ -307,17 +302,6 @@ describe("ConfigStore", () => {
expect(mockSyncPermissionSystemStatus).not.toHaveBeenCalled();
});
it("carries piInfrastructureReadPaths from merged config into current()", () => {
const { store } = makeStore();
mockLoadAndMergeConfigs.mockReturnValue({
merged: { piInfrastructureReadPaths: ["/extra/path"] },
issues: [],
});
store.refresh(undefined, true);
expect(store.current().piInfrastructureReadPaths).toEqual([
"/extra/path",
]);
});
});
// ── save() ─────────────────────────────────────────────────────────────
@@ -459,13 +443,12 @@ describe("ConfigStore", () => {
expect(mockBuildResolvedConfigLogEntry).toHaveBeenCalledWith(
expect.objectContaining({
legacyGlobalPolicyDetected: expect.any(Boolean),
legacyProjectPolicyDetected: expect.any(Boolean),
legacyExtensionConfigDetected: expect.any(Boolean),
}),
);
});
it("does not check project legacy path when no cwd is provided", () => {
it("checks only supported global legacy paths", () => {
const { store } = makeStore();
store.logResolvedPaths(); // no cwd
// existsSync called for global and ext-config legacy paths only (not project)
@@ -49,7 +49,6 @@ describe("detectMisplacedPermissionKeys", () => {
mcp: {},
skills: {},
special: {},
external_directory: {},
});
expect(result).toEqual([
"defaultPolicy",
@@ -58,7 +57,6 @@ describe("detectMisplacedPermissionKeys", () => {
"mcp",
"skills",
"special",
"external_directory",
]);
});
@@ -1,107 +1,26 @@
import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockDiscoverGlobalNodeModulesRoot } = vi.hoisted(() => ({
mockDiscoverGlobalNodeModulesRoot: vi.fn<() => string | null>(),
}));
vi.mock("../src/node-modules-discovery", () => ({
discoverGlobalNodeModulesRoot: mockDiscoverGlobalNodeModulesRoot,
}));
import { describe, expect, it } from "vitest";
import { getGlobalLogsDir } from "#src/config-paths";
import { computeExtensionPaths } from "#src/extension-paths";
describe("computeExtensionPaths", () => {
beforeEach(() => {
mockDiscoverGlobalNodeModulesRoot.mockReset();
mockDiscoverGlobalNodeModulesRoot.mockReturnValue(
"/mock/global/node_modules",
);
it("derives only extension-owned runtime paths from agentDir", () => {
const paths = computeExtensionPaths("/agent");
expect(paths).toEqual({
agentDir: "/agent",
sessionsDir: "/agent/sessions",
subagentSessionsDir: "/agent/sessions/subagents",
forwardingDir: "/agent/sessions/permission-forwarding",
globalLogsDir: getGlobalLogsDir("/agent"),
});
expect(paths).not.toHaveProperty("piInfrastructureDirs");
});
it("sets agentDir from argument", () => {
const paths = computeExtensionPaths("/test/agent");
expect(paths.agentDir).toBe("/test/agent");
});
it("derives sessionsDir as agentDir/sessions", () => {
const paths = computeExtensionPaths("/test/agent");
expect(paths.sessionsDir).toBe("/test/agent/sessions");
});
it("derives subagentSessionsDir as agentDir/subagent-sessions", () => {
const paths = computeExtensionPaths("/test/agent");
expect(paths.subagentSessionsDir).toBe("/test/agent/subagent-sessions");
});
it("derives forwardingDir as sessionsDir/permission-forwarding", () => {
const paths = computeExtensionPaths("/test/agent");
expect(paths.forwardingDir).toBe(
join("/test/agent/sessions", "permission-forwarding"),
);
});
it("derives globalLogsDir via getGlobalLogsDir(agentDir)", () => {
const paths = computeExtensionPaths("/test/agent");
expect(paths.globalLogsDir).toBe(getGlobalLogsDir("/test/agent"));
});
it("includes agentDir in piInfrastructureDirs", () => {
const paths = computeExtensionPaths("/test/agent");
expect(paths.piInfrastructureDirs).toContain("/test/agent");
});
it("includes agentDir/git in piInfrastructureDirs", () => {
const paths = computeExtensionPaths("/test/agent");
expect(paths.piInfrastructureDirs).toContain("/test/agent/git");
});
it("includes discovered global node_modules root in piInfrastructureDirs", () => {
const paths = computeExtensionPaths("/test/agent");
expect(paths.piInfrastructureDirs).toContain("/mock/global/node_modules");
});
it("omits global node_modules from piInfrastructureDirs when discovery returns null", () => {
mockDiscoverGlobalNodeModulesRoot.mockReturnValue(null);
const paths = computeExtensionPaths("/test/agent");
expect(paths.piInfrastructureDirs).toHaveLength(2);
expect(paths.piInfrastructureDirs).toContain("/test/agent");
expect(paths.piInfrastructureDirs).toContain("/test/agent/git");
});
it("all entries in piInfrastructureDirs are strings (no null)", () => {
mockDiscoverGlobalNodeModulesRoot.mockReturnValue(null);
const paths = computeExtensionPaths("/test/agent");
for (const dir of paths.piInfrastructureDirs) {
expect(typeof dir).toBe("string");
}
});
it("includes piPackageDir in piInfrastructureDirs when provided", () => {
const paths = computeExtensionPaths("/test/agent", "/pi/install");
expect(paths.piInfrastructureDirs).toContain("/pi/install");
});
it("omits piPackageDir when not provided (current behavior preserved)", () => {
const paths = computeExtensionPaths("/test/agent");
expect(paths.piInfrastructureDirs).toEqual([
"/test/agent",
"/test/agent/git",
"/mock/global/node_modules",
]);
});
it("omits piPackageDir when given an empty string", () => {
const paths = computeExtensionPaths("/test/agent", "");
expect(paths.piInfrastructureDirs).not.toContain("");
});
it("two calls with different agentDirs produce independent results", () => {
it("does not retain values between agent directories", () => {
const a = computeExtensionPaths("/agent/a");
const b = computeExtensionPaths("/agent/b");
expect(a.agentDir).toBe("/agent/a");
expect(b.agentDir).toBe("/agent/b");
expect(a.sessionsDir).toBe("/agent/a/sessions");
expect(b.sessionsDir).toBe("/agent/b/sessions");
});
@@ -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({
@@ -35,7 +35,6 @@ describe("HardGuardPolicyLoader", () => {
"~/.ssh/**": "deny",
"~/.ssh/*.example": "allow",
});
expect(loader.loadProjectConfig()).toEqual({});
expect(loader.getConfigIssues()).toEqual([]);
});
@@ -1,281 +0,0 @@
/**
* Shared fixtures for the external-directory handler-pipeline tests.
*
* Targets the collapsed external-directory gate (Phase 6 Step 5, #477).
* Consumed by external-directory-integration.test.ts and
* external-directory-session-dedup.test.ts.
*/
import { vi } from "vitest";
import type { AskEscalator } from "#src/authority/authorizer-selection";
import { GateDecisionReporter } from "#src/decision-reporter";
import { GateRunner } from "#src/handlers/gates/runner";
import { SkillInputGatePipeline } from "#src/handlers/gates/skill-input-gate-pipeline";
import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline";
import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
import type { ScopedPermissionManager } from "#src/permission-manager";
import type { SessionLogger } from "#src/session-logger";
import type { PermissionCheckResult, PermissionState } from "#src/types";
import { wildcardMatch } from "#src/wildcard-matcher";
import { DECIDED_BY_HUMAN } from "#test/helpers/decision-fixtures";
import {
getDecisionEvents,
makeEvents,
makeSurfaceCheck,
makeToolRegistry,
} from "#test/helpers/handler-fixtures";
import {
makeRealResolver,
makeRealSession,
} from "#test/helpers/session-fixtures";
// ── Shared constants ───────────────────────────────────────────────────────
/** Working-directory used by the external-directory handler-pipeline tests. */
export const EXT_DIR_CWD = "/test/project";
/** An external path (outside {@link EXT_DIR_CWD}) used across the test suite. */
export const EXTERNAL_PATH = "/outside/project/file.ts";
/** All path-bearing tools subject to the external-directory gate. */
export const ALL_PATH_BEARING_TOOLS = [
"read",
"write",
"edit",
"find",
"grep",
"ls",
];
/** Path-bearing tools where the path is optional (no input → gate is skipped). */
export const OPTIONAL_PATH_TOOLS = ["find", "grep", "ls"];
/** Full tool set used as the default registry in external-directory tests. */
export const ALL_TOOLS = [...ALL_PATH_BEARING_TOOLS, "bash"];
// ── Setup builders ─────────────────────────────────────────────────────────
/**
* Builds a `checkPermission` mock for external-directory tests.
*
* Routes `external_directory` to `externalDirectoryState`, `path` to allow
* with `source: "special"` (so the cross-cutting path gate is transparent),
* and every other surface to `toolState` (default: allow).
*/
export function makeExtDirCheck(
externalDirectoryState: PermissionState,
toolState: PermissionState = "allow",
) {
return makeSurfaceCheck(
{
external_directory: { state: externalDirectoryState },
path: { state: "allow", source: "special" },
},
{ state: toolState },
);
}
/** AskEscalator stub that approves with `state: "approved"`. */
export function makeApprovingPrompter(): AskEscalator {
return {
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue({
approved: true,
state: "approved",
decidedBy: DECIDED_BY_HUMAN,
}),
};
}
/**
* AskEscalator stub that denies.
*
* Pass `denialReason` to simulate a user who explains the refusal.
*/
export function makeDenyingPrompter(denialReason?: string): AskEscalator {
return {
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue(
denialReason !== undefined
? {
approved: false,
state: "denied",
denialReason,
decidedBy: DECIDED_BY_HUMAN,
}
: { approved: false, state: "denied", decidedBy: DECIDED_BY_HUMAN },
),
};
}
/**
* AskEscalator stub for a session with no reachable authority: the
* DenyingAuthorizer denies with the `confirmationUnavailable` marker.
*/
export function makeUnavailablePrompter(): AskEscalator {
return {
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue({
approved: false,
state: "denied",
confirmationUnavailable: true,
decidedBy: DECIDED_BY_HUMAN,
}),
};
}
// ── Query helpers ──────────────────────────────────────────────────────────
/** Find the `external_directory` decision event from the events mock. */
export function findExtDirDecision(events: ReturnType<typeof makeEvents>) {
return getDecisionEvents(events).find(
(d) => d.surface === "external_directory",
);
}
/** Return the `permission_request.blocked` review-log entries from the logger mock. */
export function blockReviewEntries(logger: SessionLogger) {
return (logger.review as ReturnType<typeof vi.fn>).mock.calls.filter(
([eventName]: string[]) => eventName === "permission_request.blocked",
);
}
// ── Session-dedup wiring ──────────────────────────────────────────────────
/**
* Installs the session-aware `check(intent)` mock on the permission manager.
*
* Returns `ask` for `external_directory` on first access; re-checks recorded
* session rules on subsequent calls and returns `allow` (source: "session")
* when a `wildcardMatch` covers the path.
*/
export function makeExtDirDedupCheck(
permissionManager: ScopedPermissionManager,
): void {
vi.mocked(permissionManager.check).mockImplementation(
(intent, rules): PermissionCheckResult => {
const { surface } = intent;
const pathValue =
intent.kind === "path-values" ? (intent.values[0] ?? null) : null;
if (surface === "external_directory") {
if (pathValue && rules && rules.length > 0) {
const match = rules.findLast(
(r) =>
r.surface === "external_directory" &&
wildcardMatch(r.pattern, pathValue),
);
if (match) {
return {
state: "allow",
toolName: surface,
source: "session",
origin: "session",
matchedPattern: match.pattern,
};
}
}
return {
state: "ask",
toolName: surface,
source: "special",
origin: "global",
};
}
return {
state: "allow",
toolName: surface,
source: "tool",
origin: "builtin",
};
},
);
}
/** AskEscalator stub that approves for the session (`state: "approved_for_session"`). */
function makeSessionApprovingPrompter(): AskEscalator {
return {
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue({
approved: true,
state: "approved_for_session",
decidedBy: DECIDED_BY_HUMAN,
}),
};
}
/**
* Builds the fully-wired session-dedup handler with real collaborators.
*
* Unlike `makeHandler`, this wires `makeRealSession` + `makeRealResolver`
* manually so the caller can access the raw `session` for shutdown tests.
*
* Returns `{ handler, prompter, session }`.
*/
export function makeDedupWiring(prompter?: AskEscalator) {
const { session, permissionManager, sessionRules, logger } =
makeRealSession();
const { resolver } = makeRealResolver(permissionManager, sessionRules);
makeExtDirDedupCheck(permissionManager);
const events = makeEvents();
const reporter = new GateDecisionReporter(logger, events);
const resolvedPrompter: AskEscalator =
prompter ?? makeSessionApprovingPrompter();
const runner = new GateRunner(
resolver,
sessionRules,
resolvedPrompter,
reporter,
() => false,
);
const handler = new PermissionGateHandler(
session,
makeToolRegistry({
getAll: vi
.fn()
.mockReturnValue([
{ name: "read" },
{ name: "write" },
{ name: "edit" },
{ name: "bash" },
]),
}),
new ToolCallGatePipeline(resolver, session),
new SkillInputGatePipeline(resolver),
runner,
);
return { handler, prompter: resolvedPrompter, session };
}
/**
* Builds the session-dedup handler without exposing the raw session.
*
* Wraps `makeDedupWiring`; returns `{ handler, prompter }`.
* Use `makeDedupWiring` when the test also needs `session.shutdown()`.
*/
export function makeDeduplicatingHandler(prompter?: AskEscalator) {
const { handler, prompter: resolvedPrompter } = makeDedupWiring(prompter);
return { handler, prompter: resolvedPrompter };
}
// ── Event builders ─────────────────────────────────────────────────────────
/**
* Builds a tool-call event in the shape that external-directory-session-dedup
* tests use — `toolName` field (not `name`); both are accepted by
* `getToolNameFromValue`.
*/
export function makeExtDirToolEvent(
toolName: string,
path: string,
toolCallId = "tc-1",
) {
return { type: "tool_call" as const, toolCallId, toolName, input: { path } };
}
/** Builds a bash tool-call event for external-directory session-dedup tests. */
export function makeExtDirBashEvent(command: string, toolCallId = "tc-1") {
return {
type: "tool_call" as const,
toolCallId,
toolName: "bash",
input: { command },
};
}
@@ -224,7 +224,6 @@ export function makeGateCheckResult(
export function makeGateInputs(
overrides: {
getActiveSkillEntries?: () => SkillPromptEntry[];
getInfrastructureReadDirs?: () => string[];
getToolPreviewLimits?: () => ToolPreviewFormatterOptions;
getPathNormalizer?: () => PathNormalizer;
getShellToolAliases?: () => ShellToolsConfig | undefined;
@@ -234,8 +233,6 @@ export function makeGateInputs(
getActiveSkillEntries:
overrides.getActiveSkillEntries ??
vi.fn<() => SkillPromptEntry[]>(() => []),
getInfrastructureReadDirs:
overrides.getInfrastructureReadDirs ?? vi.fn<() => string[]>(() => []),
getToolPreviewLimits:
overrides.getToolPreviewLimits ??
vi.fn<() => ToolPreviewFormatterOptions>(() => ({
@@ -128,8 +128,6 @@ export function makeToolRegistry(
): ToolRegistry {
return {
getAll: vi.fn().mockReturnValue([{ name: "read" }, { name: "bash" }]),
getActive: vi.fn().mockReturnValue(["read", "bash"]),
setActive: vi.fn(),
...overrides,
};
}
@@ -212,7 +210,7 @@ export function makeBashCommandCheck(opts: {
*
* The `session` override bag maps to the real collaborators:
* - `checkPermission` → applied to `permissionManager.checkPermission`
* - `getActiveSkillEntries`, `getInfrastructureReadDirs`, `getToolPreviewLimits`
* - `getActiveSkillEntries`, `getToolPreviewLimits`
* → applied as vi.spyOn overrides on the real session
* - `resolveAgentName` → applied as a vi.spyOn override on the real session
*
@@ -283,11 +281,6 @@ export function makeHandler(overrides?: {
so.getActiveSkillEntries,
);
}
if (so?.getInfrastructureReadDirs) {
vi.spyOn(session, "getInfrastructureReadDirs").mockImplementation(
so.getInfrastructureReadDirs,
);
}
if (so?.getToolPreviewLimits) {
vi.spyOn(session, "getToolPreviewLimits").mockImplementation(
so.getToolPreviewLimits,
@@ -8,7 +8,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getGlobalConfigPath, getProjectConfigPath } from "#src/config-paths";
import { getGlobalConfigPath } from "#src/config-paths";
import { PermissionManager, type PolicyLoader } from "#src/permission-manager";
import type { ResolvedPolicyPaths } from "#src/policy-loader";
import type { Rule } from "#src/rule";
@@ -21,7 +21,6 @@ import type { PermissionState, ScopeConfig } from "#src/types";
export function createInMemoryPolicyLoader(
scopes: {
global?: ScopeConfig;
project?: ScopeConfig;
agent?: Record<string, ScopeConfig>;
projectAgent?: Record<string, ScopeConfig>;
} = {},
@@ -30,7 +29,6 @@ export function createInMemoryPolicyLoader(
const issues: string[] = [];
return {
loadGlobalConfig: () => scopes.global ?? ({} as const),
loadProjectConfig: () => scopes.project ?? ({} as const),
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || is intentional: handles both falsy name and missing key
loadAgentConfig: (name?: string) => (name && scopes.agent?.[name]) || {},
loadProjectAgentConfig: (name?: string) =>
@@ -42,8 +40,6 @@ export function createInMemoryPolicyLoader(
getResolvedPolicyPaths: (): ResolvedPolicyPaths => ({
globalConfigPath: "/in-memory/config.json",
globalConfigExists: true,
projectConfigPath: null,
projectConfigExists: false,
agentsDir: "/in-memory/agents",
agentsDirExists: false,
projectAgentsDir: null,
@@ -185,7 +181,6 @@ export function createManagerWithProject(
const manager = new PermissionManager({
globalConfigPath,
agentsDir,
projectGlobalConfigPath,
projectAgentsDir,
mcpServerNames: options.mcpServerNames,
});
@@ -245,7 +240,13 @@ export function createAgentDirHarness(opts: {
JSON.stringify({ permission: opts.globalPermission }, null, 2),
);
const projectConfigPath = getProjectConfigPath(cwd);
const projectConfigPath = join(
cwd,
".pi",
"extensions",
"pi-permission-system",
"policy.jsonc",
);
mkdirSync(join(cwd, ".pi", "extensions", "pi-permission-system"), {
recursive: true,
});
@@ -39,7 +39,6 @@ export function makePaths(
subagentSessionsDir: "/test/agent/subagent-sessions",
forwardingDir: "/test/agent/sessions/permission-forwarding",
globalLogsDir: "/test/agent/logs",
piInfrastructureDirs: ["/test/agent", "/test/agent/git"],
...overrides,
};
}
@@ -84,45 +84,6 @@ describe("PathNormalizer", () => {
expect(normalizer.isWithinDirectory("/a/x", "/a/b")).toBe(false);
});
test("isOutsideWorkingDirectory tests against the baked cwd", () => {
expect(normalizer.isOutsideWorkingDirectory("/projects/my-app/src")).toBe(
false,
);
expect(normalizer.isOutsideWorkingDirectory("/etc/hosts")).toBe(true);
});
test("isOutsideWorkingDirectory expands a home-relative token", () => {
expect(normalizer.isOutsideWorkingDirectory("~/secrets")).toBe(true);
});
test("isOutsideWorkingDirectory resolves a relative token inside cwd", () => {
expect(normalizer.isOutsideWorkingDirectory("src/index.ts")).toBe(false);
});
test("isOutsideWorkingDirectory follows an in-cwd symlink to an external target", () => {
// ./link -> /etc: realpathSync resolves the full token in one call.
realpathSync.mockImplementation((p: string) => {
if (p === "/projects/my-app/link/hosts") return "/etc/hosts";
return p;
});
expect(normalizer.isOutsideWorkingDirectory("./link/hosts")).toBe(true);
});
test("isOutsideWorkingDirectory keeps a path inside a symlinked cwd", () => {
// /tmp -> /private/tmp on macOS; cwd reported as the resolved /private/tmp.
realpathSync.mockImplementation((p: string) => {
if (p.startsWith("/tmp/")) return `/private/tmp${p.slice(4)}`;
if (p === "/tmp") return "/private/tmp";
return p;
});
const symlinkNormalizer = new PathNormalizer(
posixPathFlavor,
"/private/tmp",
);
expect(
symlinkNormalizer.isOutsideWorkingDirectory("/tmp/workspace/file.ts"),
).toBe(false);
});
test("comparableValue returns the lexical absolute form (no FS)", () => {
expect(normalizer.comparableValue("src/foo.ts")).toBe(
@@ -136,20 +97,6 @@ describe("PathNormalizer", () => {
expect(ap.value()).toBe("/projects/my-app/src/foo.ts");
});
test("isBoundaryOutsideWorkingDirectory tests a canonical path against cwd", () => {
expect(
normalizer.isBoundaryOutsideWorkingDirectory("/projects/my-app/src"),
).toBe(false);
expect(normalizer.isBoundaryOutsideWorkingDirectory("/etc/hosts")).toBe(
true,
);
});
test("isBoundaryOutsideWorkingDirectory excludes a safe device path", () => {
expect(normalizer.isBoundaryOutsideWorkingDirectory("/dev/null")).toBe(
false,
);
});
test("interpretBashCdTarget maps an absolute target to absolute (posix)", () => {
expect(normalizer.interpretBashCdTarget("/etc")).toEqual({
@@ -199,12 +146,6 @@ describe("PathNormalizer", () => {
).toBe(true);
});
test("isOutsideWorkingDirectory case-folds against the baked cwd", () => {
expect(
normalizer.isOutsideWorkingDirectory("c:\\projects\\app\\src"),
).toBe(false);
expect(normalizer.isOutsideWorkingDirectory("C:\\Other\\dir")).toBe(true);
});
test("comparableValue case-folds the lexical absolute form", () => {
expect(normalizer.comparableValue("src\\foo.ts")).toBe(
@@ -216,9 +157,6 @@ describe("PathNormalizer", () => {
const ap = normalizer.forBashToken("/dev/null");
expect(ap.value()).toBe("/dev/null");
expect(ap.boundaryValue()).toBe("/dev/null");
expect(
normalizer.isBoundaryOutsideWorkingDirectory(ap.boundaryValue()),
).toBe(false);
});
test("forBashToken preserves all four safe device paths", () => {
@@ -235,17 +173,11 @@ describe("PathNormalizer", () => {
test("forBashToken translates an MSYS drive mount to a Windows path", () => {
const ap = normalizer.forBashToken("/c/Other/x");
expect(ap.value()).toBe("c:\\other\\x");
expect(
normalizer.isBoundaryOutsideWorkingDirectory(ap.boundaryValue()),
).toBe(true);
});
test("forBashToken translates an in-cwd drive mount (not external)", () => {
test("forBashToken translates an in-cwd drive mount", () => {
const ap = normalizer.forBashToken("/c/projects/app/inside.txt");
expect(ap.value()).toBe("c:\\projects\\app\\inside.txt");
expect(
normalizer.isBoundaryOutsideWorkingDirectory(ap.boundaryValue()),
).toBe(false);
});
test("forBashToken keeps a non-mount POSIX absolute as a literal", () => {
@@ -283,45 +215,8 @@ describe("PathNormalizer", () => {
});
});
test("isBoundaryOutsideWorkingDirectory case-folds against the baked cwd", () => {
expect(
normalizer.isBoundaryOutsideWorkingDirectory("c:\\projects\\app\\src"),
).toBe(false);
expect(
normalizer.isBoundaryOutsideWorkingDirectory("c:\\other\\dir"),
).toBe(true);
});
});
describe("isInfrastructureRead", () => {
const normalizer = new PathNormalizer(posixPathFlavor, "/projects/my-app");
test("allows a read-only tool targeting a configured infra dir", () => {
const ap = normalizer.forPath("/infra/git/pkg/SKILL.md");
expect(normalizer.isInfrastructureRead("read", ap, ["/infra"])).toBe(
true,
);
});
test("does not allow a write tool targeting an infra dir", () => {
const ap = normalizer.forPath("/infra/git/pkg/file.ts");
expect(normalizer.isInfrastructureRead("write", ap, ["/infra"])).toBe(
false,
);
});
test("does not allow a read-only tool outside any infra dir", () => {
const ap = normalizer.forPath("/elsewhere/file.ts");
expect(normalizer.isInfrastructureRead("read", ap, ["/infra"])).toBe(
false,
);
});
test("allows a read targeting the project-local .pi/npm dir (from baked cwd)", () => {
const ap = normalizer.forPath("/projects/my-app/.pi/npm/dep/index.js");
expect(normalizer.isInfrastructureRead("read", ap, [])).toBe(true);
});
});
describe("entryExists", () => {
// Real filesystem: the probe's whole purpose is to consult fs state, so
@@ -1,439 +0,0 @@
import { homedir } from "node:os";
import { join } from "node:path";
import { beforeEach, describe, expect, test, vi } from "vitest";
// Hoisted stub so the vi.mock factory can reference it.
const { mockSpawnSync } = vi.hoisted(() => ({
mockSpawnSync: vi.fn(),
}));
// Mock node:child_process so tests that exercise the subprocess fallback path
// don't actually invoke npm. Default: subprocess fails (non-zero exit), so
// tests focused on the walk-up strategy continue to expect null.
vi.mock("node:child_process", () => ({
spawnSync: mockSpawnSync,
default: { spawnSync: mockSpawnSync },
}));
import { discoverGlobalNodeModulesRoot } from "#src/node-modules-discovery";
import { posixPathFlavor, win32PathFlavor } from "#src/path/path-flavor";
import { isPiInfrastructureRead } from "#src/path/pi-infrastructure-read";
// ── discoverGlobalNodeModulesRoot ──────────────────────────────────────────
describe("discoverGlobalNodeModulesRoot", () => {
beforeEach(() => {
// Default: subprocess fails, so walk-up-focused tests see null for URLs
// with no node_modules ancestor.
mockSpawnSync.mockReset();
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
});
test("returns the node_modules dir when the file is inside one", () => {
const url =
"file:///opt/homebrew/lib/node_modules/pi-permission-system/dist/external-directory.js";
expect(discoverGlobalNodeModulesRoot(url)).toBe(
"/opt/homebrew/lib/node_modules",
);
});
test("returns node_modules for a deeply nested file", () => {
const url =
"file:///home/user/.nvm/versions/node/v20/lib/node_modules/pi-permission-system/src/external-directory.js";
expect(discoverGlobalNodeModulesRoot(url)).toBe(
"/home/user/.nvm/versions/node/v20/lib/node_modules",
);
});
test("returns node_modules for a bun global install path", () => {
const url =
"file:///home/user/.bun/install/global/node_modules/pi-permission-system/dist/external-directory.js";
expect(discoverGlobalNodeModulesRoot(url)).toBe(
"/home/user/.bun/install/global/node_modules",
);
});
test("returns the innermost (closest-to-file) node_modules ancestor", () => {
// The walk-up algorithm stops at the first node_modules dir it encounters,
// which is the innermost one when the file is inside a nested install.
// In practice this never happens for a real global install — the extension
// is always directly at <global_root>/node_modules/pi-permission-system/…
const url =
"file:///opt/lib/node_modules/some-pkg/node_modules/pi-permission-system/dist/index.js";
expect(discoverGlobalNodeModulesRoot(url)).toBe(
"/opt/lib/node_modules/some-pkg/node_modules",
);
});
test("returns null when the file is not inside any node_modules directory", () => {
const url =
"file:///home/user/development/pi-permission-system/dist/external-directory.js";
expect(discoverGlobalNodeModulesRoot(url)).toBeNull();
});
test("returns null for a root-level file", () => {
const url = "file:///external-directory.js";
expect(discoverGlobalNodeModulesRoot(url)).toBeNull();
});
test("returns null for an invalid URL", () => {
expect(discoverGlobalNodeModulesRoot("not-a-url")).toBeNull();
});
test("works with the real import.meta.url of this extension (smoke test)", () => {
// The extension IS installed inside a node_modules tree when running in CI
// or global install. In a local dev checkout the result may be null — that's
// the documented graceful-degradation path.
const result = discoverGlobalNodeModulesRoot();
expect(result === null || result.endsWith("node_modules")).toBe(true);
});
test("the discovered path includes the pi-permission-system package directory", () => {
const url =
"file:///opt/homebrew/lib/node_modules/pi-permission-system/dist/external-directory.js";
const root = discoverGlobalNodeModulesRoot(url);
expect(root).not.toBeNull();
expect(join(root!, "pi-permission-system")).toBe(
"/opt/homebrew/lib/node_modules/pi-permission-system",
);
});
});
// ── isPiInfrastructureRead ─────────────────────────────────────────────────
const INFRA_DIRS = [
"/home/user/.pi/agent",
"/home/user/.pi/agent/git",
"/opt/homebrew/lib/node_modules",
];
const CWD = "/home/user/project";
describe("isPiInfrastructureRead", () => {
// ── read tools allowed for infra paths ──────────────────────────────────
test("allows 'read' tool for a file inside agentDir", () => {
expect(
isPiInfrastructureRead(
"read",
"/home/user/.pi/agent/extensions/pi-permission-system/config.json",
INFRA_DIRS,
CWD,
posixPathFlavor,
),
).toBe(true);
});
test("allows 'find' tool for a path inside node_modules infra dir", () => {
expect(
isPiInfrastructureRead(
"find",
"/opt/homebrew/lib/node_modules/pi-ask-user/skills",
INFRA_DIRS,
CWD,
posixPathFlavor,
),
).toBe(true);
});
test("allows 'grep' tool for a path inside agentDir/git", () => {
expect(
isPiInfrastructureRead(
"grep",
"/home/user/.pi/agent/git/some-package/README.md",
INFRA_DIRS,
CWD,
posixPathFlavor,
),
).toBe(true);
});
test("allows 'ls' tool for a path inside node_modules infra dir", () => {
expect(
isPiInfrastructureRead(
"ls",
"/opt/homebrew/lib/node_modules/pi-permission-system",
INFRA_DIRS,
CWD,
posixPathFlavor,
),
).toBe(true);
});
// ── write tools never allowed even for infra paths ───────────────────────
test("blocks 'write' tool for a file inside agentDir", () => {
expect(
isPiInfrastructureRead(
"write",
"/home/user/.pi/agent/extensions/pi-permission-system/config.json",
INFRA_DIRS,
CWD,
posixPathFlavor,
),
).toBe(false);
});
test("blocks 'edit' tool for a file inside node_modules", () => {
expect(
isPiInfrastructureRead(
"edit",
"/opt/homebrew/lib/node_modules/pi-ask-user/skills/ask-user/SKILL.md",
INFRA_DIRS,
CWD,
posixPathFlavor,
),
).toBe(false);
});
test("blocks 'bash' tool regardless of path", () => {
expect(
isPiInfrastructureRead(
"bash",
"/opt/homebrew/lib/node_modules/pi-ask-user/SKILL.md",
INFRA_DIRS,
CWD,
posixPathFlavor,
),
).toBe(false);
});
// ── non-infra paths not allowed ──────────────────────────────────────────
test("does not allow 'read' for a path outside all infra dirs", () => {
expect(
isPiInfrastructureRead(
"read",
"/etc/passwd",
INFRA_DIRS,
CWD,
posixPathFlavor,
),
).toBe(false);
});
test("does not allow 'read' for a path only partially matching an infra dir prefix", () => {
// /home/user/.pi/agent-other should not match /home/user/.pi/agent
expect(
isPiInfrastructureRead(
"read",
"/home/user/.pi/agent-other/config.json",
INFRA_DIRS,
CWD,
posixPathFlavor,
),
).toBe(false);
});
// ── project-local Pi packages (.pi/npm, .pi/git) ─────────────────────────
test("allows 'read' for a path inside project-local .pi/npm/", () => {
expect(
isPiInfrastructureRead(
"read",
`${CWD}/.pi/npm/node_modules/some-skill/SKILL.md`,
INFRA_DIRS,
CWD,
posixPathFlavor,
),
).toBe(true);
});
test("allows 'read' for a path inside project-local .pi/git/", () => {
expect(
isPiInfrastructureRead(
"read",
`${CWD}/.pi/git/github.com/org/skill-repo/SKILL.md`,
INFRA_DIRS,
CWD,
posixPathFlavor,
),
).toBe(true);
});
test("blocks 'write' for a path inside project-local .pi/npm/", () => {
expect(
isPiInfrastructureRead(
"write",
`${CWD}/.pi/npm/node_modules/some-skill/SKILL.md`,
INFRA_DIRS,
CWD,
posixPathFlavor,
),
).toBe(false);
});
// ── empty / edge cases ───────────────────────────────────────────────────
test("returns false when infrastructureDirs is empty and path is not project-local", () => {
expect(
isPiInfrastructureRead("read", "/etc/passwd", [], CWD, posixPathFlavor),
).toBe(false);
});
test("returns false when infrastructureDirs is empty but path IS project-local .pi/npm", () => {
// Project-local paths are checked separately from the dirs array.
expect(
isPiInfrastructureRead(
"read",
`${CWD}/.pi/npm/node_modules/x/SKILL.md`,
[],
CWD,
posixPathFlavor,
),
).toBe(true);
});
});
// ── isPiInfrastructureRead — glob patterns ─────────────────────────────────
describe("isPiInfrastructureRead with glob patterns", () => {
test("glob entry matches a versioned nested path", () => {
expect(
isPiInfrastructureRead(
"read",
"/opt/homebrew/Cellar/pi-coding-agent/0.74.0/libexec/lib/node_modules/@earendil-works/pi-coding-agent/SKILL.md",
["/opt/homebrew/*/@earendil-works/pi-coding-agent/*"],
CWD,
posixPathFlavor,
),
).toBe(true);
});
test("** behaves the same as * (matches across path separators)", () => {
expect(
isPiInfrastructureRead(
"read",
"/opt/homebrew/Cellar/pi-coding-agent/0.74.0/libexec/lib/node_modules/@earendil-works/pi-coding-agent/SKILL.md",
["/opt/homebrew/**/@earendil-works/pi-coding-agent/**"],
CWD,
posixPathFlavor,
),
).toBe(true);
});
test("glob entry does not match an unrelated path", () => {
expect(
isPiInfrastructureRead(
"read",
"/etc/passwd",
["/opt/homebrew/*/@earendil-works/pi-coding-agent/*"],
CWD,
posixPathFlavor,
),
).toBe(false);
});
test("? matches exactly one character", () => {
expect(
isPiInfrastructureRead(
"read",
"/opt/homebrew/X/file.md",
["/opt/homebrew/?/file.md"],
CWD,
posixPathFlavor,
),
).toBe(true);
});
test("? does not match multiple characters", () => {
expect(
isPiInfrastructureRead(
"read",
"/opt/homebrew/abc/file.md",
["/opt/homebrew/?/file.md"],
CWD,
posixPathFlavor,
),
).toBe(false);
});
test("mixed array of plain dirs and glob patterns — both branches work", () => {
const dirs = [
"/home/user/.pi/agent",
"/opt/homebrew/*/@earendil-works/pi-coding-agent/*",
];
expect(
isPiInfrastructureRead(
"read",
"/home/user/.pi/agent/config.json",
dirs,
CWD,
posixPathFlavor,
),
).toBe(true);
expect(
isPiInfrastructureRead(
"read",
"/opt/homebrew/Cellar/pi-coding-agent/0.74.0/libexec/lib/node_modules/@earendil-works/pi-coding-agent/SKILL.md",
dirs,
CWD,
posixPathFlavor,
),
).toBe(true);
});
test("plain entry with ~ prefix matches after home expansion", () => {
const home = homedir();
expect(
isPiInfrastructureRead(
"read",
`${home}/.pi/agent/config.json`,
["~/.pi/agent"],
CWD,
posixPathFlavor,
),
).toBe(true);
});
test("write tool with a glob-matching path is still rejected", () => {
expect(
isPiInfrastructureRead(
"write",
"/opt/homebrew/Cellar/pi-coding-agent/0.74.0/libexec/lib/node_modules/@earendil-works/pi-coding-agent/SKILL.md",
["/opt/homebrew/**/@earendil-works/pi-coding-agent/**"],
CWD,
posixPathFlavor,
),
).toBe(false);
});
});
// ── isPiInfrastructureRead — win32 case-insensitive matching ───────────────
describe("isPiInfrastructureRead on win32", () => {
test("plain infra dir matches a case-different path", () => {
expect(
isPiInfrastructureRead(
"read",
"c:\\users\\foo\\.pi\\agent\\config.json",
["C:\\Users\\Foo\\.pi\\agent"],
"C:\\proj",
win32PathFlavor,
),
).toBe(true);
});
test("glob infra dir matches case-insensitively", () => {
expect(
isPiInfrastructureRead(
"read",
"c:\\users\\foo\\npm\\node_modules\\@earendil-works\\pi-coding-agent\\skill.md",
["C:\\Users\\Foo\\**\\pi-coding-agent\\**"],
"C:\\proj",
win32PathFlavor,
),
).toBe(true);
});
test("rejects a path outside every infra dir", () => {
expect(
isPiInfrastructureRead(
"read",
"c:\\windows\\system32\\drivers\\etc\\hosts",
["C:\\Users\\Foo\\.pi\\agent"],
"C:\\proj",
win32PathFlavor,
),
).toBe(false);
});
});
@@ -132,18 +132,6 @@ describe("suggestSessionPattern", () => {
});
});
describe("external_directory surface", () => {
it("returns parent-directory glob from deriveApprovalPattern", () => {
const result = suggestSessionPattern(
"external_directory",
"/tmp/foo.txt",
);
expect(result).toMatchObject({
surface: "external_directory",
pattern: "/tmp/*",
});
});
});
describe("path surface", () => {
it("returns directory-scoped pattern for a file path", () => {
@@ -225,15 +213,6 @@ describe("suggestSessionPattern", () => {
);
});
it("external_directory label includes surface prefix", () => {
const result = suggestSessionPattern(
"external_directory",
"/tmp/foo.txt",
);
expect(result.label).toBe(
'Yes, allow access to external directory "/tmp/*" for this session',
);
});
it("path-bearing tool label includes path pattern", () => {
const result = suggestSessionPattern("edit", "src/file.ts");
@@ -167,7 +167,6 @@ describe("emitDecisionEvent", () => {
"policy_allow",
"policy_deny",
"session_approved",
"infrastructure_auto_allowed",
"user_approved",
"user_approved_for_session",
"user_denied",
@@ -1,108 +0,0 @@
import { describe, expect, it } from "vitest";
import { PermissionManager } from "#src/permission-manager";
import type { ScopeConfig } from "#src/types";
import { createInMemoryPolicyLoader } from "#test/helpers/manager-harness";
/**
* Fail-closed clamp (#646): when a non-global config scope (project / agent /
* project-agent) is invalid, the composed policy is floored so nothing resolves
* more permissively than `ask` (`allow` → `ask`, tagged `origin: "fail-closed"`).
* An invalid global scope never triggers the clamp; hard `deny` survives.
*/
function makeManager(scopes: {
global?: ScopeConfig;
project?: ScopeConfig;
agent?: Record<string, ScopeConfig>;
projectAgent?: Record<string, ScopeConfig>;
}): PermissionManager {
return new PermissionManager({
policyLoader: createInMemoryPolicyLoader(scopes),
});
}
const bashCheck = {
kind: "tool" as const,
surface: "bash",
input: { command: "echo hi" },
};
describe("PermissionManager fail-closed clamp on invalid non-global scope", () => {
it("floors a lower-scope allow to ask when a project scope is invalid", () => {
const manager = makeManager({
global: { permission: { bash: "allow" } },
project: { invalid: true },
});
const result = manager.check(bashCheck);
expect(result.state).toBe("ask");
expect(result.origin).toBe("fail-closed");
});
it("preserves a lower-scope deny when a project scope is invalid", () => {
const manager = makeManager({
global: { permission: { bash: "deny" } },
project: { invalid: true },
});
const result = manager.check(bashCheck);
expect(result.state).toBe("deny");
expect(result.origin).not.toBe("fail-closed");
});
it("floors when an agent scope is invalid", () => {
const manager = makeManager({
global: { permission: { bash: "allow" } },
agent: { coder: { invalid: true } },
});
const result = manager.check({ ...bashCheck, agentName: "coder" });
expect(result.state).toBe("ask");
expect(result.origin).toBe("fail-closed");
});
it("does not floor when the invalid flag is on the global scope", () => {
// Global is the lowest precedence — nothing more permissive is inherited,
// so its invalid flag is ignored by the clamp.
const manager = makeManager({
global: { permission: { bash: "allow" }, invalid: true },
});
const result = manager.check(bashCheck);
expect(result.state).toBe("allow");
expect(result.origin).not.toBe("fail-closed");
});
it("does not floor when all scopes are valid", () => {
const manager = makeManager({
global: { permission: { bash: "allow" } },
project: { permission: { read: "allow" } },
});
const result = manager.check(bashCheck);
expect(result.state).toBe("allow");
expect(result.origin).not.toBe("fail-closed");
});
it("floors getToolPermission too (display parity)", () => {
const manager = makeManager({
global: { permission: { bash: "allow" } },
project: { invalid: true },
});
expect(manager.getToolPermission("bash")).toBe("ask");
});
it("appends a fail-closed notice to config issues naming the invalid scope", () => {
const manager = makeManager({
global: { permission: { bash: "allow" } },
project: { invalid: true },
});
expect(manager.getConfigIssues()).toEqual([
"Invalid project configuration detected — failing closed: 'allow' rules " +
"are clamped to 'ask' for this session until the configuration is corrected.",
]);
});
it("emits no fail-closed notice when all scopes are valid", () => {
const manager = makeManager({
global: { permission: { bash: "allow" } },
project: { permission: { read: "allow" } },
});
expect(manager.getConfigIssues()).toEqual([]);
});
});
File diff suppressed because it is too large Load Diff
@@ -13,12 +13,10 @@ import { createInMemoryPolicyLoader } from "#test/helpers/manager-harness";
function makeManager(
global: Record<string, unknown>,
isYoloEnabled: () => boolean,
project?: ScopeConfig,
): PermissionManager {
return new PermissionManager({
policyLoader: createInMemoryPolicyLoader({
global: { permission: global } as ScopeConfig,
project,
}),
isYoloEnabled,
});
@@ -95,34 +93,3 @@ describe("PermissionManager yolo rewrite", () => {
expect(manager.getToolPermission("bash")).toBe("ask");
});
});
describe("PermissionManager fail-closed clamp under yolo (#646)", () => {
it("yolo re-permits a fail-closed floored allow (allow→ask→allow)", () => {
// Invalid project scope floors global's `allow` to `ask` at composition;
// yolo then rewrites the `ask` back to `allow`. yolo is an explicit
// full-permissive opt-in, so yolo users are unaffected by the clamp.
const manager = makeManager({ bash: "allow" }, () => true, {
invalid: true,
});
const result = manager.check({
kind: "tool",
surface: "bash",
input: { command: "echo hi" },
});
expect(result.state).toBe("allow");
expect(result.origin).toBe("yolo");
});
it("preserves a hard deny under yolo even with an invalid scope", () => {
const manager = makeManager({ bash: "deny" }, () => true, {
invalid: true,
});
const result = manager.check({
kind: "tool",
surface: "bash",
input: { command: "rm -rf /" },
});
expect(result.state).toBe("deny");
expect(result.origin).not.toBe("yolo");
});
});
@@ -271,29 +271,6 @@ describe("PermissionSession", () => {
});
});
describe("infrastructure paths", () => {
it("getInfrastructureReadDirs combines piInfrastructureDirs and piInfrastructureReadPaths", () => {
const configStore = makeConfigStore({
current: vi.fn().mockReturnValue({
piInfrastructureReadPaths: ["/extra/path"],
}),
});
const { session } = createSession({ configStore });
expect(session.getInfrastructureReadDirs()).toEqual([
"/test/agent",
"/test/agent/git",
"/extra/path",
]);
});
it("getInfrastructureReadDirs returns only piInfrastructureDirs when config omits the field", () => {
const { session } = createSession();
expect(session.getInfrastructureReadDirs()).toEqual([
"/test/agent",
"/test/agent/git",
]);
});
});
describe("config delegation", () => {
it("refreshConfig delegates to configStore.refresh with the trust flag", () => {
@@ -160,20 +160,6 @@ describe("checkPermission", () => {
);
});
it("resolves an external_directory path query through an access-path intent matching the canonical alias", () => {
realpathSync.mockImplementation((p: string) =>
p === "/test/project/link" ? "/test/project/real" : p,
);
const { service, resolver } = makeService();
service.checkPermission("external_directory", "link");
expect(resolver.resolve).toHaveBeenCalledTimes(1);
const intent = resolver.resolve.mock.calls[0][0];
expect(intent.kind).toBe("access-path");
if (intent.kind === "access-path") {
expect(intent.surface).toBe("external_directory");
expect(intent.path.matchValues()).toContain("/test/project/real");
}
});
it("resolves a path-bearing tool query (read) through an access-path intent", () => {
const { service, resolver } = makeService();
@@ -78,70 +78,6 @@ describe("FilePolicyLoader.loadGlobalConfig", () => {
});
});
// ---------------------------------------------------------------------------
// loadProjectConfig
// ---------------------------------------------------------------------------
describe("FilePolicyLoader.loadProjectConfig", () => {
it("returns empty ScopeConfig when no project path is configured", () => {
const loader = new FilePolicyLoader({
globalConfigPath: "/nonexistent/config.json",
agentsDir: "/nonexistent/agents",
});
const config = loader.loadProjectConfig();
expect(config).toEqual({});
});
it("returns ScopeConfig from a project config file", () => {
const baseDir = makeTempDir();
try {
const projectConfigPath = join(baseDir, "project-config.json");
writeFileSync(
projectConfigPath,
JSON.stringify({ permission: { bash: "allow" } }),
);
const loader = new FilePolicyLoader({
globalConfigPath: "/nonexistent/config.json",
agentsDir: "/nonexistent/agents",
projectGlobalConfigPath: projectConfigPath,
});
const config = loader.loadProjectConfig();
expect(config.permission).toEqual({ bash: "allow" });
expect(config.invalid).toBeUndefined();
} finally {
rmSync(baseDir, { recursive: true, force: true });
}
});
it("marks the scope invalid when the project config file is rejected", () => {
const baseDir = makeTempDir();
try {
const projectConfigPath = join(baseDir, "project-config.json");
// Unrecognized key — schema rejection, the issue's "typo" scenario.
writeFileSync(projectConfigPath, JSON.stringify({ debugLo: true }));
const loader = new FilePolicyLoader({
globalConfigPath: "/nonexistent/config.json",
agentsDir: "/nonexistent/agents",
projectGlobalConfigPath: projectConfigPath,
});
const config = loader.loadProjectConfig();
expect(config.invalid).toBe(true);
} finally {
rmSync(baseDir, { recursive: true, force: true });
}
});
it("leaves invalid unset when no project file is present", () => {
const loader = new FilePolicyLoader({
globalConfigPath: "/nonexistent/config.json",
agentsDir: "/nonexistent/agents",
projectGlobalConfigPath: "/nonexistent/project-config.json",
});
const config = loader.loadProjectConfig();
expect(config.invalid).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// loadAgentConfig / loadProjectAgentConfig
// ---------------------------------------------------------------------------
@@ -270,8 +206,6 @@ describe("FilePolicyLoader.getResolvedPolicyPaths", () => {
expect(paths.globalConfigExists).toBe(true);
expect(paths.agentsDir).toBe(agentsDir);
expect(paths.agentsDirExists).toBe(true);
expect(paths.projectConfigPath).toBeNull();
expect(paths.projectConfigExists).toBe(false);
expect(paths.projectAgentsDir).toBeNull();
expect(paths.projectAgentsDirExists).toBe(false);
} finally {
@@ -136,76 +136,6 @@ describe("renderPolicyDenial", () => {
).toBe("[pi-permission-system] Denied by policy: 'bash'.");
});
it("names the escaped boundary for a tool external-directory deny", () => {
expect(
renderPolicyDenial(
payload(
"external_directory",
{
surface: "external_directory",
toolName: "write",
value: "/etc/hosts",
matchedPattern: "*",
},
[{ label: "working directory", text: "/repo", detail: null }],
),
null,
),
).toBe(
"[pi-permission-system] Denied by policy: 'external_directory' for tool 'write' for path '/etc/hosts' (rule '*'): outside working directory '/repo'.",
);
});
it("names the canonical target when a path resolves elsewhere", () => {
expect(
renderPolicyDenial(
payload(
"external_directory",
{
surface: "external_directory",
toolName: "read",
value: "link",
matchedPattern: "*",
},
[
{ label: "resolves to", text: "/etc/shadow", detail: null },
{ label: "working directory", text: "/repo", detail: null },
],
),
null,
),
).toBe(
"[pi-permission-system] Denied by policy: 'external_directory' for tool 'read' for path 'link' (resolves to '/etc/shadow') (rule '*'): outside working directory '/repo'.",
);
});
it("names every escaping path but not the command for a bash external-directory deny", () => {
expect(
renderPolicyDenial(
payload(
"bash_external_directory",
{
surface: "external_directory",
toolName: "bash",
value: "diff /etc/hosts ~/.ssh/config",
matchedPattern: "*",
},
[
{ label: "working directory", text: "/repo", detail: null },
{ label: "external path", text: "/etc/hosts", detail: null },
{
label: "external path",
text: "~/.ssh/config",
detail: "/home/me/.ssh/config",
},
],
),
null,
),
).toBe(
"[pi-permission-system] Denied by policy: 'external_directory' for tool 'bash' for paths '/etc/hosts', '~/.ssh/config' (resolves to '/home/me/.ssh/config') (rule '*'): outside working directory '/repo'.",
);
});
it("names the MCP target", () => {
expect(
@@ -370,25 +300,6 @@ describe("renderUnavailableDenial", () => {
);
});
it("omits the escaped boundary, which no retry shape would change", () => {
expect(
renderUnavailableDenial(
payload(
"external_directory",
{
surface: "external_directory",
toolName: "write",
value: "/etc/hosts",
matchedPattern: "*",
},
[{ label: "working directory", text: "/repo", detail: null }],
),
null,
),
).toBe(
"[pi-permission-system] This 'external_directory' call for tool 'write' for path '/etc/hosts' (rule '*') requires approval, but no interactive UI is available.",
);
});
});
describe("the flagged-element field cap", () => {
@@ -48,24 +48,6 @@ describe("renderPromptDialog", () => {
]);
});
it("names the gate surface when it differs from the tool", () => {
expect(
render({
kind: "external_directory",
request: requestFacts({
surface: "external_directory",
toolName: "write",
value: "/etc/hosts",
matchedPattern: "*",
}),
}),
).toEqual([
"tool : write",
"surface : external_directory",
"rule : *",
"path : /etc/hosts",
]);
});
it("omits an agent line for an unnamed local requester, and a surface the value label already names", () => {
expect(
@@ -214,36 +196,6 @@ describe("renderPromptDialog", () => {
]);
});
it("keeps an entry's detail on the entry's own line", () => {
expect(
render({
kind: "bash_external_directory",
request: requestFacts({
surface: "external_directory",
toolName: "bash",
value: "cat /etc/hosts /tmp/x",
matchedPattern: "*",
}),
evidence: [
{ label: "working directory", text: "/repo", detail: null },
{
label: "external path",
text: "/etc/hosts",
detail: "/private/etc/hosts",
},
{ label: "external path", text: "/tmp/x", detail: null },
],
}),
).toEqual([
"tool : bash",
"surface : external_directory",
"rule : *",
"command : cat /etc/hosts /tmp/x",
"working directory : /repo",
"external path : /etc/hosts → /private/etc/hosts",
"external path : /tmp/x",
]);
});
it("renders a skill read's path", () => {
expect(
@@ -558,35 +510,6 @@ describe("renderPromptDialog", () => {
]);
});
it("paints each escaping path rather than the command that referenced them", () => {
expect(
renderPromptDialog(
makePromptPayload({
kind: "bash_external_directory",
request: {
...makePromptPayload().request,
surface: "external_directory",
toolName: "bash",
value: "cat /etc/hosts /etc/hostsbackup",
matchedPattern: "*",
},
evidence: [
{ label: "working directory", text: "/repo", detail: null },
{ label: "external path", text: "/etc/hosts", detail: null },
],
}),
completeViewBudget(200),
mark,
).lines,
).toEqual([
"tool : bash",
"surface : external_directory",
"rule : *",
"command : cat [/etc/hosts] /etc/hostsbackup",
"working directory : /repo",
"external path : [/etc/hosts]",
]);
});
it("leaves the text untouched when no paint is supplied", () => {
expect(
@@ -26,10 +26,6 @@ function payloadOf(
};
}
/** An `external path` evidence entry, as the bash external-directory gate emits it. */
function externalPath(path: string, resolved: string | null): PromptEvidence {
return { label: "external path", text: path, detail: resolved };
}
describe("flaggedElements", () => {
it("flags the decision-relevant value for a single-value ask", () => {
@@ -44,17 +40,6 @@ describe("flaggedElements", () => {
]);
});
it("flags the escaping paths rather than the command for a bash external-directory ask", () => {
expect(
flaggedElements(
payloadOf("bash_external_directory", "diff /etc/hosts ~/.ssh/config", [
{ label: "working directory", text: "/repo", detail: null },
externalPath("/etc/hosts", null),
externalPath("~/.ssh/config", "/home/me/.ssh/config"),
]),
),
).toEqual(["/etc/hosts", "~/.ssh/config"]);
});
it("flags nothing when the value is empty", () => {
expect(flaggedElements(payloadOf("tool", ""))).toEqual([]);
@@ -64,11 +49,9 @@ describe("flaggedElements", () => {
describe("valueLabel", () => {
it.each([
["bash", "command"],
["bash_external_directory", "command"],
["mcp", "target"],
["tool", "tool"],
["path", "path"],
["external_directory", "path"],
["skill", "skill"],
["skill_read", "skill"],
] as const)("labels a %s ask's value %s", (kind, label) => {
@@ -88,11 +71,6 @@ describe("valueLabel", () => {
});
describe("flaggedElementLabel", () => {
it("labels the escaping paths a bash external-directory ask flags", () => {
expect(
flaggedElementLabel(payloadOf("bash_external_directory", "cmd")),
).toBe("path");
});
it.each([
"bash",
@@ -1,9 +1,5 @@
import { describe, expect, test } from "vitest";
import {
buildBashExternalDirectoryAskPayload,
buildExternalDirectoryAskPayload,
buildPathAskPayload,
} from "#src/presentation/path-ask-payload";
import { buildPathAskPayload } from "#src/presentation/path-ask-payload";
describe("buildPathAskPayload", () => {
test("carries the typed path as the decision value and the rule that fired", () => {
@@ -38,89 +34,3 @@ describe("buildPathAskPayload", () => {
).toEqual({ agentName: null, forwarded: false, sessionId: null });
});
});
describe("buildExternalDirectoryAskPayload", () => {
test("carries the typed path, the boundary, and the requester", () => {
const payload = buildExternalDirectoryAskPayload({
toolName: "write",
pathValue: "/tmp/out.txt",
cwd: "/projects/my-app",
agentName: "my-agent",
});
expect(payload.kind).toBe("external_directory");
expect(payload.request.toolName).toBe("write");
expect(payload.request.value).toBe("/tmp/out.txt");
expect(payload.request.requester.agentName).toBe("my-agent");
expect(payload.evidence).toEqual([
{ label: "working directory", text: "/projects/my-app", detail: null },
]);
});
test("discloses the resolved path as its own entry when it differs", () => {
expect(
buildExternalDirectoryAskPayload({
toolName: "read",
pathValue: "demo-symlink-passwd",
resolvedPath: "/etc/passwd",
cwd: "/projects/my-app",
agentName: null,
}).evidence,
).toEqual([
{ label: "resolves to", text: "/etc/passwd", detail: null },
{ label: "working directory", text: "/projects/my-app", detail: null },
]);
});
test("omits the disclosure when resolvedPath is undefined", () => {
const payload = buildExternalDirectoryAskPayload({
toolName: "read",
pathValue: "/etc/passwd",
cwd: "/projects/my-app",
agentName: null,
});
expect(payload.evidence).toEqual([
{ label: "working directory", text: "/projects/my-app", detail: null },
]);
});
});
describe("buildBashExternalDirectoryAskPayload", () => {
test("makes the command the value and the paths it reached evidence", () => {
const payload = buildBashExternalDirectoryAskPayload({
command: "cat /etc/passwd",
externalPaths: [{ path: "/etc/passwd" }],
cwd: "/projects/my-app",
agentName: "my-agent",
toolName: "bash",
});
expect(payload.kind).toBe("bash_external_directory");
expect(payload.request.value).toBe("cat /etc/passwd");
expect(payload.request.requester.agentName).toBe("my-agent");
expect(payload.evidence).toEqual([
{ label: "working directory", text: "/projects/my-app", detail: null },
{ label: "external path", text: "/etc/passwd", detail: null },
]);
});
test("binds each path's canonical alias to its own entry", () => {
const payload = buildBashExternalDirectoryAskPayload({
command: "cat a b",
externalPaths: [
{ path: "/a", resolvedPath: "/private/a" },
{ path: "/b" },
],
cwd: "/repo",
agentName: null,
toolName: "bash",
});
expect(payload.evidence).toEqual([
{ label: "working directory", text: "/repo", detail: null },
{ label: "external path", text: "/a", detail: "/private/a" },
{ label: "external path", text: "/b", detail: null },
]);
});
});
+15 -15
View File
@@ -118,8 +118,8 @@ describe("evaluate", () => {
).toBe("ask");
});
test("last-match-wins: later conflicting rule overrides earlier", () => {
const ruleset: Ruleset = [allowBashGit, denyBashGitPush];
test("specific pattern wins even when declared before a broad match", () => {
const ruleset: Ruleset = [denyBashGitPush, allowBashGit];
const result = evaluate(
"bash",
"git push origin main",
@@ -129,7 +129,7 @@ describe("evaluate", () => {
expect(result).toEqual(denyBashGitPush);
});
test("last-match-wins: broad deny followed by specific allow", () => {
test("specific allow wins over a broad deny", () => {
const denyAll: Rule = {
surface: "bash",
pattern: "*",
@@ -236,7 +236,7 @@ describe("evaluate", () => {
expect(
evaluate("bash", "git status", [withoutLayer], posixPathFlavor).action,
).toBe("allow");
// Layer metadata does not affect last-match-wins ordering.
// Layer metadata does not affect specificity ordering.
const ruleset: Rule[] = [withDefault, withLayer];
expect(evaluate("bash", "git status", ruleset, posixPathFlavor)).toEqual(
withLayer,
@@ -245,7 +245,7 @@ describe("evaluate", () => {
const reversedRuleset: Rule[] = [withLayer, withDefault];
expect(
evaluate("bash", "git status", reversedRuleset, posixPathFlavor),
).toEqual(withDefault);
).toEqual(withLayer);
});
test("evaluate() preserves origin on a matched rule", () => {
@@ -280,7 +280,7 @@ describe("evaluate", () => {
expect(result.reason).toBe("Use pnpm instead");
});
test("evaluate() carries reason through last-match-wins when deny wins", () => {
test("evaluate() carries the reason from the most-specific deny", () => {
const allowAll: Rule = {
surface: "bash",
pattern: "*",
@@ -361,26 +361,26 @@ describe("evaluate", () => {
}
});
// ── Windows: path-surface patterns fold case (last-match-wins) ──────────
// ── Windows: path-surface patterns fold case ────────────────────────────
const denyExternalAll: Rule = {
surface: "external_directory",
surface: "path",
pattern: "*",
action: "deny",
layer: "config",
origin: "global",
};
const allowExternalPi: Rule = {
surface: "external_directory",
surface: "path",
pattern: "C:\\Users\\Foo\\pi\\*",
action: "allow",
layer: "config",
origin: "global",
};
test("win32: external_directory allow override matches a lowercased path over a preceding deny", () => {
test("win32: a specific path allow matches a lowercased value", () => {
const result = evaluate(
"external_directory",
"path",
"c:\\users\\foo\\pi\\docs\\readme.md",
[denyExternalAll, allowExternalPi],
win32PathFlavor,
@@ -390,7 +390,7 @@ describe("evaluate", () => {
test("posix: the same mixed-case override stays case-sensitive (falls through to deny)", () => {
const result = evaluate(
"external_directory",
"path",
"c:\\users\\foo\\pi\\docs\\readme.md",
[denyExternalAll, allowExternalPi],
posixPathFlavor,
@@ -398,16 +398,16 @@ describe("evaluate", () => {
expect(result.action).toBe("deny");
});
test("win32: a forward-slash external_directory pattern matches a backslash value", () => {
test("win32: a forward-slash path pattern matches a backslash value", () => {
const allowForwardSlash: Rule = {
surface: "external_directory",
surface: "path",
pattern: "C:/Users/Foo/pi/*",
action: "allow",
layer: "config",
origin: "global",
};
const result = evaluate(
"external_directory",
"path",
"c:\\users\\foo\\pi\\docs\\readme.md",
[denyExternalAll, allowForwardSlash],
win32PathFlavor,
@@ -1,382 +0,0 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { sanitizeAvailableToolsSection } from "#src/system-prompt-sanitizer";
afterEach(() => {
vi.restoreAllMocks();
});
// Helpers for building prompt sections.
function availableToolsSection(tools: string[]): string {
return ["Available tools:", ...tools.map((t) => `- ${t}`)].join("\n");
}
function guidelinesSection(guidelines: string[]): string {
return ["Guidelines:", ...guidelines.map((g) => `- ${g}`)].join("\n");
}
function prompt(...sections: string[]): string {
return sections.join("\n\n");
}
describe("sanitizeAvailableToolsSection — Available tools section", () => {
test("keeps allowed tool lines and the header, drops denied ones", () => {
const input = prompt(
availableToolsSection(["bash", "read"]),
"Other content",
);
const result = sanitizeAvailableToolsSection(input, ["read"]);
expect(result.removed).toBe(true);
expect(result.prompt).toContain("Available tools:");
expect(result.prompt).toContain("- read");
expect(result.prompt).not.toContain("- bash");
});
test("leaves the section untouched when every tool is allowed", () => {
const input = prompt(
availableToolsSection(["bash", "read"]),
"Other content",
);
const result = sanitizeAvailableToolsSection(input, ["bash", "read"]);
expect(result.removed).toBe(false);
expect(result.prompt).toBe(input);
});
// Bug #33: findSection extends to lines.length when no subsequent recognised
// header follows, so content after the last section is silently deleted.
test("preserves content that follows the Available tools section (bug #33)", () => {
const input = prompt(
availableToolsSection(["bash", "read"]),
"Other content",
);
const result = sanitizeAvailableToolsSection(input, ["read"]);
expect(result.prompt).toContain("Other content");
});
test("removes the whole section when no tool is allowed", () => {
const input = prompt(
availableToolsSection(["bash", "read"]),
"Other content",
);
const result = sanitizeAvailableToolsSection(input, []);
expect(result.removed).toBe(true);
expect(result.prompt).not.toContain("Available tools:");
expect(result.prompt).toContain("Other content");
});
test("removed flag is false when no Available tools section is present", () => {
const input = "Just some instructions.\n\nNo tools section.";
const result = sanitizeAvailableToolsSection(input, ["bash"]);
expect(result.removed).toBe(false);
expect(result.prompt).toBe(input);
});
test("keeps non-tool boilerplate prose near the section", () => {
const input = [
"Available tools:",
"- read: Read file contents",
"- bash: Run shell commands",
"",
"In addition to the tools above, you may have access to other custom tools depending on the project.",
].join("\n");
const result = sanitizeAvailableToolsSection(input, ["read"]);
expect(result.prompt).toContain("- read: Read file contents");
expect(result.prompt).not.toContain("- bash: Run shell commands");
expect(result.prompt).toContain("In addition to the tools above");
});
test("returns original prompt reference unchanged when nothing is removed", () => {
const input = "No tools section here.";
const result = sanitizeAvailableToolsSection(input, []);
expect(result.prompt).toBe(input);
});
test("narrowing the full listing yields the already-narrowed listing (cache byte-stability)", () => {
const allowed = ["read", "edit", "write"];
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 fromFull = sanitizeAvailableToolsSection(fullProse, allowed).prompt;
const fromNarrowed = sanitizeAvailableToolsSection(
narrowedProse,
allowed,
).prompt;
// Idempotent on the already-narrowed input Pi feeds back on later turns.
expect(fromNarrowed).toBe(narrowedProse);
// Turn 1 (full) and turn 2+ (narrowed) produce identical wire bytes.
expect(fromFull).toBe(fromNarrowed);
});
});
describe("sanitizeAvailableToolsSection — Guidelines section", () => {
test("removes bash guideline when bash is not in allowed tools", () => {
const input = prompt(
guidelinesSection(["use bash for file operations like ls, rg, find"]),
);
const result = sanitizeAvailableToolsSection(input, []);
expect(result.removed).toBe(true);
expect(result.prompt).not.toContain("use bash for file operations");
});
test("keeps bash guideline when bash is in allowed tools", () => {
const input = prompt(
guidelinesSection(["use bash for file operations like ls, rg, find"]),
);
const result = sanitizeAvailableToolsSection(input, ["bash"]);
expect(result.removed).toBe(false);
expect(result.prompt).toContain("use bash for file operations");
});
test("removes read guideline when read is not allowed", () => {
const input = prompt(
guidelinesSection(["use read to examine files instead of cat or sed."]),
);
const result = sanitizeAvailableToolsSection(input, []);
expect(result.removed).toBe(true);
expect(result.prompt).not.toContain("use read to examine files");
});
test("keeps read guideline when read is allowed", () => {
const input = prompt(
guidelinesSection(["use read to examine files instead of cat or sed."]),
);
const result = sanitizeAvailableToolsSection(input, ["read"]);
expect(result.removed).toBe(false);
expect(result.prompt).toContain("use read to examine files");
});
test("removes edit guideline when edit is not allowed", () => {
const input = prompt(
guidelinesSection([
"use edit for precise changes (old text must match exactly)",
]),
);
const result = sanitizeAvailableToolsSection(input, []);
expect(result.removed).toBe(true);
expect(result.prompt).not.toContain("use edit for precise changes");
});
test("removes write guideline when write is not allowed", () => {
const input = prompt(
guidelinesSection(["use write only for new files or complete rewrites"]),
);
const result = sanitizeAvailableToolsSection(input, []);
expect(result.removed).toBe(true);
expect(result.prompt).not.toContain("use write only for new files");
});
test("removes entire Guidelines section when all bullets are filtered out", () => {
const input = prompt(
guidelinesSection([
"use bash for file operations like ls, rg, find",
"use write only for new files or complete rewrites",
]),
);
const result = sanitizeAvailableToolsSection(input, []);
expect(result.removed).toBe(true);
expect(result.prompt).not.toContain("Guidelines:");
});
test("preserves unrecognised guidelines regardless of allowed tools", () => {
const input = prompt(
guidelinesSection(["some custom guideline not in the rules"]),
);
const result = sanitizeAvailableToolsSection(input, []);
expect(result.removed).toBe(false);
expect(result.prompt).toContain("some custom guideline not in the rules");
});
test("handles both sections together: removes tools section and filters guidelines", () => {
const input = prompt(
availableToolsSection(["bash"]),
guidelinesSection([
"use bash for file operations like ls, rg, find",
"use write only for new files or complete rewrites",
"some custom guideline not in the rules",
]),
);
const result = sanitizeAvailableToolsSection(input, []);
expect(result.removed).toBe(true);
expect(result.prompt).not.toContain("Available tools:");
expect(result.prompt).not.toContain("use bash for file operations");
expect(result.prompt).not.toContain("use write only for new files");
expect(result.prompt).toContain("some custom guideline not in the rules");
});
test("trims whitespace from allowed tool names", () => {
const input = prompt(
guidelinesSection(["use bash for file operations like ls, rg, find"]),
);
const result = sanitizeAvailableToolsSection(input, [" bash "]);
expect(result.removed).toBe(false);
expect(result.prompt).toContain("use bash for file operations");
});
});
describe("sanitizeAvailableToolsSection — multi-section prompt", () => {
test("collapses extra blank lines after removal", () => {
const input = prompt(
"Intro",
availableToolsSection(["bash"]),
guidelinesSection(["use bash for file operations like ls, rg, find"]),
"Closing",
);
const result = sanitizeAvailableToolsSection(input, []);
// No run of 3+ consecutive newlines
expect(result.prompt).not.toMatch(/\n{3,}/);
});
});
describe("sanitizeAvailableToolsSection — findSection boundary edge cases", () => {
test("preserves content after Guidelines when Guidelines is the last recognised section", () => {
const input = prompt(
guidelinesSection(["use bash for file operations like ls, rg, find"]),
"Trailing custom instructions",
);
const result = sanitizeAvailableToolsSection(input, []);
expect(result.prompt).toContain("Trailing custom instructions");
});
test("preserves trailing prose when both sections are removed", () => {
const input = prompt(
availableToolsSection(["bash"]),
guidelinesSection(["use bash for file operations like ls, rg, find"]),
"Important user note",
);
const result = sanitizeAvailableToolsSection(input, []);
expect(result.removed).toBe(true);
expect(result.prompt).not.toContain("Available tools:");
expect(result.prompt).not.toContain("Guidelines:");
expect(result.prompt).toContain("Important user note");
});
test("section at EOF is removed entirely when no tool is allowed", () => {
const input = availableToolsSection(["bash", "read"]);
const result = sanitizeAvailableToolsSection(input, []);
expect(result.removed).toBe(true);
expect(result.prompt).toBe("");
});
test("section followed by blank lines then prose — prose survives removal", () => {
const input = ["Available tools:", "- bash", "", "", "Custom note"].join(
"\n",
);
const result = sanitizeAvailableToolsSection(input, []);
expect(result.removed).toBe(true);
expect(result.prompt).toContain("Custom note");
expect(result.prompt).not.toContain("Available tools:");
});
});
// ---------------------------------------------------------------------------
// Moved from permission-system.test.ts catch-all (#342)
// ---------------------------------------------------------------------------
test("System prompt sanitizer keeps the active tools in the Available tools section", () => {
const prompt = [
"Available tools:",
"- read: Read file contents",
"- mcp: Discover, inspect, and call MCP tools across configured servers",
"",
"In addition to the tools above, you may have access to other custom tools depending on the project.",
"",
"Guidelines:",
"- Use mcp for MCP discovery first: search by capability, describe one exact tool name, then call it.",
"- Be concise in your responses",
].join("\n");
const result = sanitizeAvailableToolsSection(prompt, ["read", "mcp"]);
expect(result.removed).toBe(false);
expect(result.prompt).toContain("Available tools:");
expect(result.prompt).toContain("- read: Read file contents");
expect(result.prompt).toContain("- mcp: Discover");
expect(result.prompt).toContain("In addition to the tools above");
expect(result.prompt).toMatch(/Guidelines:/);
});
test("System prompt sanitizer drops a denied tool's line but keeps the section", () => {
const prompt = [
"Available tools:",
"- read: Read file contents",
"- mcp: Discover, inspect, and call MCP tools across configured servers",
"",
"Guidelines:",
"- Use mcp for MCP discovery first: search by capability, describe one exact tool name, then call it.",
"- Be concise in your responses",
].join("\n");
const result = sanitizeAvailableToolsSection(prompt, ["read"]);
expect(result.removed).toBe(true);
expect(result.prompt).toContain("Available tools:");
expect(result.prompt).toContain("- read: Read file contents");
expect(result.prompt).not.toContain("- mcp: Discover");
});
test("System prompt sanitizer removes denied tool guidelines while keeping global guidance", () => {
const prompt = [
"Guidelines:",
"- Use task when work SHOULD be delegated to one or more specialized agents instead of handled entirely in the current session.",
"- Use mcp for MCP discovery first: search by capability, describe one exact tool name, then call it.",
"- Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)",
"- Be concise in your responses",
"- Show file paths clearly when working with files",
].join("\n");
const result = sanitizeAvailableToolsSection(prompt, ["bash", "grep", "mcp"]);
expect(result.removed).toBe(true);
expect(result.prompt).not.toContain("Use task when work SHOULD");
expect(result.prompt).toMatch(/Use mcp for MCP discovery first/i);
expect(result.prompt).toMatch(/Prefer grep\/find\/ls tools over bash/i);
expect(result.prompt).toMatch(/Be concise in your responses/);
expect(result.prompt).toMatch(
/Show file paths clearly when working with files/,
);
});
test("System prompt sanitizer removes inactive built-in write guidance", () => {
const prompt = [
"Guidelines:",
"- Use write only for new files or complete rewrites",
"- When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did",
"- Be concise in your responses",
].join("\n");
const result = sanitizeAvailableToolsSection(prompt, ["read"]);
expect(result.removed).toBe(true);
expect(result.prompt).not.toContain(
"Use write only for new files or complete rewrites",
);
expect(result.prompt).not.toContain(
"do NOT use cat or bash to display what you did",
);
expect(result.prompt).toMatch(/Be concise in your responses/);
});
@@ -0,0 +1,21 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const permissionSources = [
"src/index.ts",
"src/tool-registry.ts",
"src/handlers/before-agent-start.ts",
];
describe("Tool Search ownership boundary", () => {
it("never reads or changes Pi's active tool list", () => {
const source = permissionSources
.map((path) => readFileSync(join(process.cwd(), path), "utf8"))
.join("\n");
expect(source).not.toContain("getActiveTools");
expect(source).not.toContain("setActiveTools");
});
});
@@ -84,7 +84,7 @@ describe("findCompiledWildcardMatch", () => {
expect(findCompiledWildcardMatch(patterns, "npm install")).toBeNull();
});
test("last-match-wins precedence: later pattern overrides earlier", () => {
test("the low-level compiled matcher returns the last matching entry", () => {
const patterns = compileWildcardPatternEntries([
["git *", "allow"],
["git push *", "deny"],
@@ -95,7 +95,7 @@ describe("findCompiledWildcardMatch", () => {
expect(result?.matchedPattern).toBe("git push *");
});
test("last-match-wins: specific deny before broad allow matches the later one", () => {
test("the low-level matcher does not apply permission-rule specificity", () => {
const patterns = compileWildcardPatternEntries([
["*", "deny"],
["git status", "allow"],