Files

1295 lines
49 KiB
TypeScript

/**
* Composition-root tests for `piPermissionSystemExtension(pi)`.
*
* These run the real factory via the `makeFakePi()` harness and assert the
* wiring contracts that unit tests cannot see: handler-registration
* completeness, shared-instance contracts across factory invocations, teardown,
* service↔gate registry sharing, and `ready`-after-publish ordering.
*
* Every test runs the factory, which mutates three process-global `Symbol.for()`
* slots and reads `PI_CODING_AGENT_DIR`. The shared `beforeEach`/`afterEach`
* isolate the agent dir to a tmpdir and clear every global slot so factory runs
* do not leak across tests.
*/
import {
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import {
createEventBus,
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";
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");
const SUBAGENT_REGISTRY_KEY = Symbol.for(
"@gotgenes/pi-permission-system:subagent-registry",
);
const SERVING_REGISTRY_KEY = Symbol.for(
"@gotgenes/pi-permission-system:serving-registry",
);
/** The six events the factory must register a handler for. */
const EXPECTED_HANDLERS = [
"before_agent_start",
"input",
"resources_discover",
"session_shutdown",
"session_start",
"tool_call",
];
let agentDir: string;
beforeEach(() => {
agentDir = mkdtempSync(join(tmpdir(), "pi-perm-comp-root-"));
vi.stubEnv("PI_CODING_AGENT_DIR", agentDir);
});
afterEach(() => {
// Drop every process-global slot so factory runs do not leak across tests.
const store = globalThis as Record<symbol, unknown>;
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Symbol-keyed global property
delete store[SERVICE_KEY];
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Symbol-keyed global property
delete store[SUBAGENT_REGISTRY_KEY];
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Symbol-keyed global property
delete store[SERVING_REGISTRY_KEY];
vi.unstubAllEnvs();
rmSync(agentDir, { recursive: true, force: true });
});
// ── Shared helpers ──────────────────────────────────────────────────────────
/** Write the global config file under the stubbed agent dir. */
function writeGlobalConfig(config: Record<string, unknown>): void {
const globalConfigPath = getGlobalConfigPath(agentDir);
mkdirSync(dirname(globalConfigPath), { recursive: true });
writeFileSync(
globalConfigPath,
`${JSON.stringify({ ...DEFAULT_EXTENSION_CONFIG, ...config }, null, 2)}\n`,
"utf8",
);
}
/** Write a project config file under `<cwd>/.pi/extensions/pi-permission-system`. */
function writeProjectConfig(
cwd: string,
config: Record<string, unknown>,
): void {
const dir = join(cwd, ".pi", "extensions", "pi-permission-system");
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, "config.json"),
`${JSON.stringify(config, null, 2)}\n`,
"utf8",
);
}
/** A `ui.select` implementation for a test ctx. */
type CtxSelect = (
title: string,
options: string[],
) => Promise<string | undefined>;
/**
* Build a test `ctx` with the scaffolding every composition-root ctx shares —
* `cwd`, a trusted project, a minimal `sessionManager`, and a `ui` whose
* `notify`/`setStatus`/`input` are inert. Callers vary only `hasUI` and the
* `select` behavior that drives (or declines) a prompt.
*/
function makeBaseCtx(
cwd: string,
sessionId: string,
options: {
hasUI?: boolean;
select?: CtxSelect;
isProjectTrusted?: boolean;
} = {},
): unknown {
const trusted = options.isProjectTrusted ?? true;
return {
cwd,
hasUI: options.hasUI ?? true,
isProjectTrusted: (): boolean => trusted,
sessionManager: {
getEntries: (): unknown[] => [],
getSessionId: (): string => sessionId,
getSessionDir: (): string => cwd,
},
ui: {
notify: (): void => {},
setStatus: (): void => {},
select:
options.select ?? (async (): Promise<string | undefined> => undefined),
input: async (): Promise<string | undefined> => undefined,
},
};
}
/** Build a minimal subagent `ctx` (no UI) for driving tool-call gates. */
function makeChildCtx(cwd: string, sessionId: string): unknown {
return makeBaseCtx(cwd, sessionId, { hasUI: false });
}
/**
* Build a UI-present `ctx` that records the titles passed to `ui.select`, and
* approves every prompt. The ask-prompt message (which embeds the tool-input
* preview) is the first line of the select title.
*/
function makeUiCtx(cwd: string, capturedTitles: string[]): { ctx: unknown } {
const ctx = makeBaseCtx(cwd, "ui-session", {
select: async (title: string): Promise<string | undefined> => {
capturedTitles.push(title);
return "Yes";
},
});
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(
pi: ReturnType<typeof makeFakePi>,
ctx: unknown,
): Promise<unknown> {
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", () => {
const pi = makeFakePi();
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
expect([...pi.handlers.keys()].sort()).toEqual(EXPECTED_HANDLERS);
});
});
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 () => {
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-teardown-cwd-"));
const pi = makeFakePi();
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
// The service is published at session_start, not at factory init.
await fireSessionStart(pi, makeChildCtx(cwd, "top-session"));
expect(getPermissionsService()).toBeDefined();
await pi.fire("session_shutdown");
// Service slot cleared.
expect(getPermissionsService()).toBeUndefined();
// Lifecycle unsubscribed: a post-shutdown session-created must not register.
pi.events.emit(SUBAGENT_CHILD_SESSION_CREATED, {
sessionId: "late-child",
parentSessionId: "p-late",
});
expect(getSubagentSessionRegistry().has("late-child")).toBe(false);
rmSync(cwd, { recursive: true, force: true });
});
});
describe("service and gate share one formatter registry", () => {
// A formatter registered through the published service must be consulted by
// the live gate handler — proving both reference the same
// ToolInputFormatterRegistry instance the factory created once.
it("surfaces a service-registered formatter in the gate's ask prompt", async () => {
writeGlobalConfig({
permission: { "*": "allow", demo: "ask" },
});
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-ui-cwd-"));
const pi = makeFakePi({ toolNames: ["demo"] });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
const capturedTitles: string[] = [];
const { ctx } = makeUiCtx(cwd, capturedTitles);
// The service is published at session_start; publish before resolving it.
await fireSessionStart(pi, ctx);
const previewMarker = "PREVIEW::shared-registry-proof";
getPermissionsService()!.registerToolInputFormatter(
"demo",
() => previewMarker,
);
const result = (await pi.fire(
"tool_call",
{ toolName: "demo", toolCallId: "demo-ask", input: { foo: "bar" } },
ctx,
)) as { block?: true };
// The gate prompted (not blocked) and the prompt embedded the formatter's
// preview — so the gate consulted the same registry the service wrote to.
expect(result.block).toBeUndefined();
expect(capturedTitles.some((t) => t.includes(previewMarker))).toBe(true);
rmSync(cwd, { recursive: true, force: true });
});
});
describe("service and gate share one access extractor registry", () => {
// An extractor registered through the published service must be consulted by
// the live gate handler — proving both reference the same
// ToolAccessExtractorRegistry instance the factory created once (#352).
it("path-gates a custom-shaped tool via a service-registered extractor", async () => {
writeGlobalConfig({
permission: { "*": "allow", path: { "*.env": "deny" } },
});
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-ext-cwd-"));
const pi = makeFakePi({ toolNames: ["ffgrep"] });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
const { ctx } = makeUiCtx(cwd, []);
await fireSessionStart(pi, ctx);
// ffgrep carries its path under a non-standard key; without the extractor
// the default input.path convention would miss it.
getPermissionsService()!.registerToolAccessExtractor("ffgrep", (input) =>
typeof input.target === "string" ? input.target : undefined,
);
const result = (await pi.fire(
"tool_call",
{ toolName: "ffgrep", toolCallId: "ff-1", input: { target: ".env" } },
ctx,
)) as { block?: true };
// The path deny fired — so the gate extracted ffgrep's path through the
// same registry the service wrote to.
expect(result.block).toBe(true);
rmSync(cwd, { recursive: true, force: true });
});
});
describe("service and chain share one authorizer registry", () => {
// A link registered through the published service must be consulted by the
// live ask gate when the operator names it in authorizerChain — proving both
// the registerAuthorizer surface and AuthorizerSelection reference the same
// AuthorizerRegistry instance the factory created once (#599).
it("consults a service-registered, config-named link at the ask gate", async () => {
writeGlobalConfig({
permission: { "*": "ask" },
authorizerChain: ["typo-judge"],
});
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-auth-cwd-"));
const pi = makeFakePi({ toolNames: ["demo"] });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
const capturedTitles: string[] = [];
const { ctx } = makeUiCtx(cwd, capturedTitles);
await fireSessionStart(pi, ctx);
// Registered after session_start via the published service; link resolution
// is per-ask (ADR 0007 §4), so it is honored on the first ask.
getPermissionsService()!.registerAuthorizer("typo-judge", () =>
Promise.resolve({ kind: "deny", reason: "typo path" }),
);
const result = (await pi.fire(
"tool_call",
{ toolName: "demo", toolCallId: "d-1", input: {} },
ctx,
)) as { block?: true };
// The link denied before the (approving) UI terminal was reached — so the
// gate escalated through the same registry the service wrote to, and the
// config named it (opt-in activation).
expect(result.block).toBe(true);
expect(capturedTitles).toEqual([]);
rmSync(cwd, { recursive: true, force: true });
});
it("ignores a registered link the operator did not name (opt-in)", async () => {
writeGlobalConfig({ permission: { "*": "ask" } }); // authorizerChain omitted
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-auth-optin-"));
const pi = makeFakePi({ toolNames: ["demo"] });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
const capturedTitles: string[] = [];
const { ctx } = makeUiCtx(cwd, capturedTitles);
await fireSessionStart(pi, ctx);
getPermissionsService()!.registerAuthorizer("typo-judge", () =>
Promise.resolve({ kind: "deny", reason: "typo path" }),
);
const result = (await pi.fire(
"tool_call",
{ toolName: "demo", toolCallId: "d-2", input: {} },
ctx,
)) as { block?: true };
// Registration alone grants no authority: the un-named link is dormant, so
// the ask reached the approving UI terminal.
expect(result.block).toBeUndefined();
expect(capturedTitles).toHaveLength(1);
rmSync(cwd, { recursive: true, force: true });
});
});
describe("ready emitted after service publication", () => {
// Ordering contracts exist only at the composition root: a consumer reacting
// to permissions:ready must be able to resolve the service immediately. The
// service is published and ready fires at session_start (not factory init).
it("publishes the service before emitting permissions:ready", async () => {
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-ready-cwd-"));
const seen: string[] = [];
const pi = makeFakePi();
pi.events.on(PERMISSIONS_READY_CHANNEL, () => {
seen.push(getPermissionsService() ? "present" : "missing");
});
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
// ready is not emitted at load; only after session_start publishes.
expect(seen).toEqual([]);
await fireSessionStart(pi, makeChildCtx(cwd, "top-session"));
expect(seen).toEqual(["present"]);
rmSync(cwd, { recursive: true, force: true });
});
});
describe("single source of truth for session state", () => {
// Regression guard for the split-brain bug: before the fix, the gate path
// recorded session approvals into a private SessionRules instance that the
// service never saw. After the fix, both readers use the same SessionRules
// the gate writes into.
it("gate session-approval is visible to the service", async () => {
writeGlobalConfig({
permission: { "*": "allow", demo: "ask" },
});
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-sot-cwd-"));
const pi = makeFakePi({ toolNames: ["demo"] });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
// UI ctx that approves the gate prompt for this session (options[1]).
// Return the second option label-agnostically — always the "for this
// session" choice regardless of the exact label text.
const ctx = makeBaseCtx(cwd, "sot-session", {
select: async (
_title: string,
options: string[],
): Promise<string | undefined> => options[1],
});
await fireSessionStart(pi, ctx);
// Drive a tool_call on "demo"; the gate prompts and the mock selects
// options[1], recording a session-scoped approval.
await pi.fire(
"tool_call",
{
toolName: "demo",
toolCallId: "demo-for-session",
input: { foo: "bar" },
},
ctx,
);
// Service accessor must see the session approval.
// Before the fix this was "ask" — the service read an empty SessionRules.
const serviceResult = getPermissionsService()!.checkPermission("demo");
expect(serviceResult.state).toBe("allow");
rmSync(cwd, { recursive: true, force: true });
});
});
describe("service path queries evaluate the supplied path (#503)", () => {
// Before #503 the service path query dropped the value (buildInputForSurface
// returned {} for the `path` surface), so the query collapsed to ["*"] and a
// path-specific rule never fired. The query now builds an AccessPath, so the
// supplied path flows through the resolver → manager and matches `path` rules
// end-to-end.
it("resolves a path-surface query against a deny rule on the supplied path", async () => {
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-svc-path-cwd-"));
const target = join(cwd, "secrets.env");
writeGlobalConfig({ permission: { path: { [target]: "deny" } } });
const pi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
await fireSessionStart(pi, makeChildCtx(cwd, "svc-path-session"));
const result = getPermissionsService()!.checkPermission("path", target);
expect(result.state).toBe("deny");
rmSync(cwd, { recursive: true, force: true });
});
});
describe("project trust gates project-scoped config (#644)", () => {
it("does not let an untrusted project's `bash: allow` override global `bash: deny`", async () => {
writeGlobalConfig({ permission: { "*": "ask", bash: "deny" } });
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-untrusted-cwd-"));
writeProjectConfig(cwd, { permission: { bash: "allow" } });
const pi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
await fireSessionStart(
pi,
makeBaseCtx(cwd, "untrusted-session", { isProjectTrusted: false }),
);
// Global `deny` survives: the untrusted project scope was never loaded.
expect(
getPermissionsService()!.checkPermission("bash", "echo hi").state,
).toBe("deny");
rmSync(cwd, { recursive: true, force: true });
});
it("lets a trusted project's `bash: allow` override global `bash: deny`", async () => {
writeGlobalConfig({ permission: { "*": "ask", bash: "deny" } });
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-trusted-cwd-"));
writeProjectConfig(cwd, { permission: { bash: "allow" } });
const pi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
await fireSessionStart(
pi,
makeBaseCtx(cwd, "trusted-session", { isProjectTrusted: true }),
);
// The trusted project override applies (last-match-wins).
expect(
getPermissionsService()!.checkPermission("bash", "echo hi").state,
).toBe("allow");
rmSync(cwd, { recursive: true, force: true });
});
});
describe("bash bare-token path gating (#509, #645)", () => {
// A bash bare-filename argument (`cat id_rsa`) once bypassed the `path`
// surface entirely: the broad classifier accepted only tokens starting with
// `.`, containing `/` or `..`, or a Windows drive-letter absolute. #509
// closed that for a token whose *spelling* matched a specific `path` rule;
// #645 replaced spelling-matching with an existence probe, so candidacy comes
// from the filesystem and a symlink is matched by rules naming its target.
async function fireBashToolCall(
pi: ReturnType<typeof makeFakePi>,
ctx: unknown,
command: string,
): Promise<{ block?: true; reason?: string }> {
return (await pi.fire(
"tool_call",
{ name: "bash", input: { command }, toolCallId: "tc-1" },
ctx,
)) as { block?: true; reason?: string };
}
it("denies an existing bare filename matching a specific path deny rule", async () => {
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-bare-token-cwd-"));
writeFileSync(join(cwd, "id_rsa"), "key");
writeGlobalConfig({
permission: { "*": "allow", path: { id_rsa: "deny" } },
});
const pi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
const ctx = makeChildCtx(cwd, "bare-token-session-deny");
await fireSessionStart(pi, ctx);
const result = await fireBashToolCall(pi, ctx, "cat id_rsa");
expect(result.block).toBe(true);
rmSync(cwd, { recursive: true, force: true });
});
it("denies an existing bare filename matching a wildcard path deny rule", async () => {
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-bare-token-cwd-"));
writeFileSync(join(cwd, "key.pem"), "key");
writeGlobalConfig({
permission: { "*": "allow", path: { "*.pem": "deny" } },
});
const pi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
const ctx = makeChildCtx(cwd, "bare-token-session-wildcard");
await fireSessionStart(pi, ctx);
const result = await fireBashToolCall(pi, ctx, "cat key.pem");
expect(result.block).toBe(true);
rmSync(cwd, { recursive: true, force: true });
});
it("denies a bare symlink whose target matches a path deny rule (#645)", async () => {
// The operator's case: the rule names the target, not the link. Matching a
// token's spelling could never catch this; canonicalization after the probe
// does.
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-bare-symlink-cwd-"));
writeFileSync(join(cwd, ".some.secret"), "s3cret");
symlinkSync(join(cwd, ".some.secret"), join(cwd, "a_sym"));
writeGlobalConfig({
permission: { "*": "allow", path: { "*.some.secret": "deny" } },
});
const pi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
const ctx = makeChildCtx(cwd, "bare-symlink-session-deny");
await fireSessionStart(pi, ctx);
const result = await fireBashToolCall(pi, ctx, "cat a_sym");
expect(result.block).toBe(true);
rmSync(cwd, { recursive: true, force: true });
});
it("leaves a bare token that does not match any path rule unaffected", async () => {
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-bare-token-cwd-"));
writeGlobalConfig({
permission: { "*": "allow", path: { id_rsa: "deny" } },
});
const pi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
const ctx = makeChildCtx(cwd, "bare-token-session-unaffected");
await fireSessionStart(pi, ctx);
const result = await fireBashToolCall(pi, ctx, "git status");
expect(result.block).toBeUndefined();
rmSync(cwd, { recursive: true, force: true });
});
it("leaves a bare token naming no file unaffected even under a path deny rule (#645)", async () => {
// The probe's precision: `id_rsa` matches the rule by spelling, but names
// nothing here, so it is not an operand and is not gated.
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-bare-absent-cwd-"));
writeGlobalConfig({
permission: { "*": "allow", path: { id_rsa: "deny" } },
});
const pi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
const ctx = makeChildCtx(cwd, "bare-token-session-absent");
await fireSessionStart(pi, ctx);
const result = await fireBashToolCall(pi, ctx, "cat id_rsa");
expect(result.block).toBeUndefined();
rmSync(cwd, { recursive: true, force: true });
});
it("leaves an existing bare file unrestricted when no explicit path rule matches (#58)", async () => {
// The universal-fallback guard is what keeps probe promotion from becoming
// a prompt firehose: a promoted token matching only the synthesized default
// is unrestricted, so a real file with no rule naming it stays allowed.
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-bare-norule-cwd-"));
writeFileSync(join(cwd, "README"), "docs");
writeGlobalConfig({
permission: { "*": "allow", path: { id_rsa: "deny" } },
});
const pi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
const ctx = makeChildCtx(cwd, "bare-token-session-norule");
await fireSessionStart(pi, ctx);
const result = await fireBashToolCall(pi, ctx, "cat README");
expect(result.block).toBeUndefined();
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", () => {
// The fix (#302) scopes the process-global service slot to the publishing
// instance. The parent publishes at its session_start; an in-process child
// (registered by session id) skips publishing, and its identity-scoped
// teardown is a no-op — so the parent's service is the one that resolves
// throughout the child's lifecycle and survives the child's shutdown.
it("keeps the parent's service published across the child's lifecycle", async () => {
const parentCwd = mkdtempSync(join(tmpdir(), "pi-perm-parent-cwd-"));
const childCwd = mkdtempSync(join(tmpdir(), "pi-perm-child-cwd-"));
const childSessionId = "child-session-mi";
const parentPi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(parentPi as unknown as ExtensionAPI);
const childPi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(childPi as unknown as ExtensionAPI);
// The parent is not a registered child, so it publishes its service.
await fireSessionStart(
parentPi,
makeChildCtx(parentCwd, "parent-session-mi"),
);
const parentService = getPermissionsService();
expect(parentService).toBeDefined();
// The child is registered in the shared global registry before its own
// session_start, so it detects itself and skips publishing.
getSubagentSessionRegistry().register(childSessionId, {
parentSessionId: "parent-session-mi",
});
await fireSessionStart(childPi, makeChildCtx(childCwd, childSessionId));
// Mid-run: the slot resolves the parent's service, never the child's.
expect(getPermissionsService()).toBe(parentService);
// The child's shutdown is a no-op for the slot it never owned.
await childPi.fire("session_shutdown");
expect(getPermissionsService()).toBe(parentService);
rmSync(parentCwd, { recursive: true, force: true });
rmSync(childCwd, { recursive: true, force: true });
});
});
describe("session approvals do not leak across same-cwd session switches", () => {
// Pi caches the extension *import* (the jiti module, factory function) for
// same-cwd `/new` / `/resume` / `/fork` / `/import` switches
// (earendil-works/pi#5905). The factory is still re-invoked per switch, and
// `session_shutdown` still fires — so a session-scoped "allow for this
// session" grant must not survive into the next session.
//
// Two factory invocations against the same cwd model the cached-import
// switch: invocation #1 records an approval and shuts down; invocation #2 is
// the re-invoked cached factory. The new session must start with an empty
// SessionRules. Two independent mechanisms keep it empty, and the grant only
// leaks if *both* break together: `session_shutdown` clears the first
// instance's rules, and the re-invoked factory builds a fresh SessionRules
// (no module-scoped state bridges the switch — the per-session reset the
// fresh-jiti load used to provide is gone once the import is cached).
/** A UI ctx that approves the gate's "for this session" option (options[1]). */
function makeSessionApprovingCtx(cwd: string, sessionId: string): unknown {
return makeBaseCtx(cwd, sessionId, {
select: async (
_title: string,
options: string[],
): Promise<string | undefined> => options[1],
});
}
it("starts the next same-cwd session with an empty session ruleset", async () => {
writeGlobalConfig({
permission: { "*": "allow", demo: "ask" },
});
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-switch-cwd-"));
// ── Session #1: approve `demo` for the session, then shut down ──────────
const firstPi = makeFakePi({ toolNames: ["demo"] });
piPermissionSystemExtension(firstPi as unknown as ExtensionAPI);
const firstCtx = makeSessionApprovingCtx(cwd, "switch-session-1");
await fireSessionStart(firstPi, firstCtx);
// The gate prompts and the mock selects options[1], recording a
// session-scoped approval the service can read back.
await firstPi.fire(
"tool_call",
{ toolName: "demo", toolCallId: "demo-approve", input: { foo: "bar" } },
firstCtx,
);
expect(getPermissionsService()!.checkPermission("demo").state).toBe(
"allow",
);
// The switch tears down the old session before the new one starts.
await firstPi.fire("session_shutdown");
// ── Session #2: the re-invoked cached factory, same cwd ────────────────
const secondPi = makeFakePi({ toolNames: ["demo"] });
piPermissionSystemExtension(secondPi as unknown as ExtensionAPI);
await fireSessionStart(secondPi, makeChildCtx(cwd, "switch-session-2"));
// The previous session's approval must not be visible: `demo` is back to
// its configured `ask`, not the carried-over `allow`.
expect(getPermissionsService()!.checkPermission("demo").state).toBe("ask");
rmSync(cwd, { recursive: true, force: true });
});
});
describe("forwarded grant-scope selection round-trip", () => {
// A UI-present serving ctx whose `select` drives the two-step forwarded
// dialog: the main prompt picks "for this session" (options[1]); the scope
// prompt (options include a "The whole session …" label) returns the chosen
// scope. Every `select` options array is recorded so a test can prove the
// human was (or was not) re-prompted.
function makeServingCtx(
cwd: string,
sessionId: string,
selectLog: string[][],
scope: "whole" | "subagent",
): unknown {
return makeBaseCtx(cwd, sessionId, {
select: async (
_title: string,
options: string[],
): Promise<string | undefined> => {
selectLog.push(options);
const wholeOption = options.find((o) =>
o.startsWith("The whole session"),
);
if (wholeOption) {
return scope === "whole" ? wholeOption : options[0];
}
return options[1];
},
});
}
it("records a whole-session grant on the serving node so later forwards and the parent's own action resolve without a second prompt", async () => {
writeGlobalConfig({ permission: { "*": "allow", demo: "ask" } });
const parentCwd = mkdtempSync(join(tmpdir(), "pi-perm-parent-"));
const childCwd = mkdtempSync(join(tmpdir(), "pi-perm-child-"));
const parentSessionId = "parent-whole-1";
const childSessionId = "child-whole-1";
const selectLog: string[][] = [];
const parentBus = createEventBus();
const parentPi = makeFakePi({ events: parentBus, toolNames: ["demo"] });
piPermissionSystemExtension(parentPi as unknown as ExtensionAPI);
const childPi = makeFakePi({
events: createEventBus(),
toolNames: ["demo"],
});
piPermissionSystemExtension(childPi as unknown as ExtensionAPI);
// The parent starts serving (its poll drains the inbox using the UI ctx).
const parentCtx = makeServingCtx(
parentCwd,
parentSessionId,
selectLog,
"whole",
);
await fireSessionStart(parentPi, parentCtx);
parentBus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
sessionId: childSessionId,
parentSessionId,
});
// 1. First child `demo` forwards; the human grants the whole session.
const firstResult = (await childPi.fire(
"tool_call",
{ toolName: "demo", toolCallId: "demo-1", input: {} },
makeChildCtx(childCwd, childSessionId),
)) as { block?: true };
expect(firstResult.block).toBeUndefined();
// One serve = main dialog + scope dialog.
expect(selectLog).toHaveLength(2);
// 2. A second child `demo` re-forwards and the serving node auto-approves
// from its recorded whole-session grant — no new human prompt.
const secondResult = (await childPi.fire(
"tool_call",
{ toolName: "demo", toolCallId: "demo-2", input: {} },
makeChildCtx(childCwd, childSessionId),
)) as { block?: true };
expect(secondResult.block).toBeUndefined();
expect(selectLog).toHaveLength(2);
// 3. The parent's own `demo` is session-approved by the same grant.
const parentResult = (await parentPi.fire(
"tool_call",
{ toolName: "demo", toolCallId: "demo-parent", input: {} },
parentCtx,
)) as { block?: true };
expect(parentResult.block).toBeUndefined();
expect(selectLog).toHaveLength(2);
await parentPi.fire("session_shutdown");
rmSync(parentCwd, { recursive: true, force: true });
rmSync(childCwd, { recursive: true, force: true });
});
it("contains a subagent-only grant to the requesting child so the parent's own action still prompts", async () => {
writeGlobalConfig({ permission: { "*": "allow", demo: "ask" } });
const parentCwd = mkdtempSync(join(tmpdir(), "pi-perm-parent-"));
const childCwd = mkdtempSync(join(tmpdir(), "pi-perm-child-"));
const parentSessionId = "parent-sub-1";
const childSessionId = "child-sub-1";
const selectLog: string[][] = [];
const parentBus = createEventBus();
const parentPi = makeFakePi({ events: parentBus, toolNames: ["demo"] });
piPermissionSystemExtension(parentPi as unknown as ExtensionAPI);
const childPi = makeFakePi({
events: createEventBus(),
toolNames: ["demo"],
});
piPermissionSystemExtension(childPi as unknown as ExtensionAPI);
const parentCtx = makeServingCtx(
parentCwd,
parentSessionId,
selectLog,
"subagent",
);
await fireSessionStart(parentPi, parentCtx);
parentBus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
sessionId: childSessionId,
parentSessionId,
});
// 1. First child `demo` forwards; the human grants this subagent only.
const firstResult = (await childPi.fire(
"tool_call",
{ toolName: "demo", toolCallId: "demo-1", input: {} },
makeChildCtx(childCwd, childSessionId),
)) as { block?: true };
expect(firstResult.block).toBeUndefined();
expect(selectLog).toHaveLength(2);
// 2. The child recorded the grant locally: its next `demo` resolves as a
// session approval with no forward, so the serving node is not consulted.
const secondResult = (await childPi.fire(
"tool_call",
{ toolName: "demo", toolCallId: "demo-2", input: {} },
makeChildCtx(childCwd, childSessionId),
)) as { block?: true };
expect(secondResult.block).toBeUndefined();
expect(selectLog).toHaveLength(2);
// 3. The parent holds no grant, so its own `demo` prompts again.
const parentResult = (await parentPi.fire(
"tool_call",
{ toolName: "demo", toolCallId: "demo-parent", input: {} },
parentCtx,
)) as { block?: true };
expect(parentResult.block).toBeUndefined();
expect(selectLog.length).toBeGreaterThan(2);
await parentPi.fire("session_shutdown");
rmSync(parentCwd, { recursive: true, force: true });
rmSync(childCwd, { recursive: true, force: true });
});
});
describe("yolo grants asks synthesized after resolution", () => {
// The composition-stage ask→allow rewrite cannot reach a floor applied to a
// parsed command unit, so under yolo the wrapper and unparseable sentinels
// used to prompt anyway (#712). These drive the real factory end to end.
async function runBashCommand(
config: Record<string, unknown>,
command: string,
): Promise<{ blocked: boolean; prompts: string[] }> {
writeGlobalConfig(config);
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-yolo-cwd-"));
const pi = makeFakePi({ toolNames: ["bash"] });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
const prompts: string[] = [];
const { ctx } = makeUiCtx(cwd, prompts);
await fireSessionStart(pi, ctx);
const result = (await pi.fire(
"tool_call",
{ toolName: "bash", toolCallId: "yolo-1", input: { command } },
ctx,
)) as { block?: true };
rmSync(cwd, { recursive: true, force: true });
return { blocked: result.block === true, prompts };
}
const permissiveBash = {
permission: { "*": "allow", bash: { "*": "allow" } },
};
it("auto-approves an indirection wrapper under yolo", async () => {
const outcome = await runBashCommand(
{ ...permissiveBash, yoloMode: true },
"git status | xargs grep foo",
);
expect(outcome).toEqual({ blocked: false, prompts: [] });
});
it("still floors an indirection wrapper to a prompt with yolo off", async () => {
const outcome = await runBashCommand(
{ ...permissiveBash, yoloMode: false },
"git status | xargs grep foo",
);
expect(outcome.blocked).toBe(false);
expect(outcome.prompts).toHaveLength(1);
expect(outcome.prompts[0]).toContain("<indirection-bash-wrapper>");
});
it("auto-approves an unparseable command under yolo", async () => {
const outcome = await runBashCommand(
{ ...permissiveBash, yoloMode: true },
"> out.txt",
);
expect(outcome).toEqual({ blocked: false, prompts: [] });
});
it("blocks a denied wrapper under yolo", async () => {
const outcome = await runBashCommand(
{
permission: { "*": "allow", bash: { "*": "allow", "xargs*": "deny" } },
yoloMode: true,
},
"git status | xargs grep foo",
);
expect(outcome).toEqual({ blocked: true, prompts: [] });
});
it("blocks a denied unparseable command under yolo", async () => {
const outcome = await runBashCommand(
{ permission: { "*": "allow", bash: { "*": "deny" } }, yoloMode: true },
"> out.txt",
);
expect(outcome).toEqual({ blocked: true, prompts: [] });
});
});