Files
my-pi/pi-permission-system/test/composition-root.test.ts
T
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 7e6a8faae1 feat(pi-permission-system): AutoReview 改为跟随主模型的对话式工具调用审查
AutoReview:

- 审查器回到 Codex Guardian 风格的对话式审查,默认跟随会话当前主模型
  (每次审查实时读取 ctx.model);provider/model 必须同时设置才固定审查
  模型,只设其一视为配置错误并转人工。
- 结论只通过 approve / reject 两个工具给出,参数仅 reason;不强制
  toolChoice,正文文本(即使是合法 JSON)一律忽略。
- approve 自动放行;reject 转人工并在弹窗显示理由(标注模型生成)。
  超时、Provider 错误、截断、未调用工具、多次调用、参数无效等异常同样
  转人工,并附固定的系统说明;用户按 Esc 中止时直接拒绝。
- 模型调用改为 modelRegistry.streamSimple(...).result():Pi 1.3.0 面向
  扩展的 ModelRegistry 没有 completeSimple,原写法在真实环境中每次都会
  转人工。
- 对话记录保留可信度标注;工具结果在有更短的 pi-compaction 摘要时替换
  为摘要;token 估算改为非 ASCII 字符按 1 token 计;预算可配置。
- 同一条用户消息内完全相同的请求只缓存 approve 结论。
- 默认 reasoning low、总超时 30 秒、最多 2 次尝试、maxOutputTokens 4000。

删除:

- 未发布过的 TypeSafe Jev 分类器实现、DenialCircuitBreaker。
- 整个 annotator 机制(model-explanation、注册表、registerAnnotator、
  provider-timeout);annotators 配置键保留为已弃用并忽略,避免旧配置
  被拒绝。

测试:

- 新增 reviewer / transcript / 弹窗说明 / 默认配置测试;清理重复、恒真
  或只测实现细节的旧测试。
- 使用 deepseek-v4.1-flash 实测 5 个场景各 5 次,25 次结论全部符合预期。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 01:54:17 -07:00

978 lines
37 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,
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 { 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 { 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",
);
/** 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, "policy.jsonc"),
`${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 };
}
/** 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);
}
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("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("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" } });
const pi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
await fireSessionStart(
pi,
makeBaseCtx(cwd, "trusted-session", { isProjectTrusted: true }),
);
// Project policy files are not part of the permission configuration.
expect(
getPermissionsService()!.checkPermission("bash", "echo hi").state,
).toBe("deny");
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 });
});
});
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: [] });
});
});