feat: 组合包配置以包内 config/ 为唯一来源,并新增受保护写入人工审批

配置统一:权限策略、硬防御、Tool Search、提示词封面以及新迁入的
RTK、pi-compaction、pi-worktree、pi-hermes-memory 配置全部由扩展
直接读取已安装组合包内的 config/,pi install / pi update 即完成部署;
删除 rebuild-bundle-config.mjs 与 bundle-manifest.json,my-pi/ 只保留
日志与缓存。update.sh 成功升级后将默认 git 来源的已安装副本
reset 回发布版本。AGENTS.md 新增「组合包配置原则」。

脚本整合:search_config.sh / ssh_config.sh 合并为 configure.sh
search|ssh;install.sh / update.sh 共享函数抽到 scripts/lib.sh,远程
安装器改为先下载到临时文件再执行,下载失败可被检测。

权限:hard-guards 新增 protectedWrites,写入 Pi 凭据/设置、已安装
组合包与扩展目录、pi-ssh 凭据库时一律人工确认(跳过 AutoReview、
yolo 与 Session grant,无 UI 时拒绝);放行 ls、LSP 查询、ctx 只读、
web_search、Chrome 只读与 git 只读命令,skill_manage 改为 ask。

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
云服务部-叶林立
2026-10-20 01:05:31 -07:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent a0ee9389c7
commit 2aa1be0b54
105 changed files with 2337 additions and 1413 deletions
@@ -467,4 +467,75 @@ describe("AuthorizerSelection", () => {
);
});
});
describe("human-only asks (protected writes)", () => {
const humanOnlyDetails = (): PromptPermissionDetails =>
makeDetails({ humanOnly: true });
it("goes straight to the local user and never runs configured links", async () => {
const link = vi
.fn<Authorizer["authorize"]>()
.mockResolvedValue({ kind: "allow" });
const registry = new AuthorizerRegistry();
registry.register("judge", link);
const prompter = makePrompterApi();
const selection = new AuthorizerSelection(
makeDeps({
prompter,
authorizerRegistry: registry,
getAuthorizerChain: () => ["judge"],
}),
);
selection.activate(makeCtx({ hasUI: true }));
await selection.escalate(humanOnlyDetails());
expect(link).not.toHaveBeenCalled();
expect(prompter.prompt).toHaveBeenCalledWith(
expect.any(LocalUserAuthorizer),
expect.anything(),
);
});
it("denies without prompting when the terminal is DenyingAuthorizer", async () => {
const prompter = makeInvokingPrompter();
const selection = new AuthorizerSelection(makeDeps({ prompter }));
// No UI, not a subagent → DenyingAuthorizer terminal.
selection.activate(makeCtx({ hasUI: false }));
const decision = await selection.escalate(humanOnlyDetails());
expect(decision).toEqual({
approved: false,
state: "denied_with_reason",
denialReason:
"写入受保护文件需要人工确认,但当前没有可用的确认界面",
confirmationUnavailable: true,
decidedBy: {
kind: "unavailable",
reason: "protected_write_no_local_ui",
},
});
expect(prompter.prompt).toHaveBeenCalledOnce();
});
it("denies instead of relaying to a parent on a subagent node", async () => {
const prompter = makeInvokingPrompter();
const selection = new AuthorizerSelection(
makeDeps({ detection: makeDetection(true), prompter }),
);
selection.activate(makeCtx({ hasUI: false }));
const decision = await selection.escalate(humanOnlyDetails());
// ParentAuthorizer.forward would have been invoked if relaying were
// still allowed; instead the human-only denial is returned.
expect(decision.approved).toBe(false);
expect(decision.state).toBe("denied_with_reason");
expect(decision.denialReason).toBe(
"写入受保护文件需要人工确认,但当前没有可用的确认界面",
);
expect(prompter.prompt).toHaveBeenCalledOnce();
});
});
});
@@ -149,6 +149,33 @@ describe("LocalUserAuthorizer", () => {
);
});
it("hides the session option and forwards the system note on a human-only ask", async () => {
const { deps, decisionFn } = makeDeps();
const authorizer = new LocalUserAuthorizer(deps);
const deferNotes = [
{
origin: "system" as const,
text: "⚠ 该操作将写入受保护文件:/vault/auth.json(凭据,匹配 /vault/auth.json)。",
},
];
await authorizer.authorize(
makeDetails({
humanOnly: true,
deferNotes,
// A stale label must not resurrect the session option.
sessionLabel: "Yes, for 'read' tool",
}),
);
expect(decisionFn).toHaveBeenCalledWith(
expect.anything(),
expect.any(String),
expect.anything(),
{ hideSessionOption: true, deferNotes },
);
});
it("emits the UI event before calling requestPermissionDecision", async () => {
const calls: string[] = [];
const events = {
@@ -178,6 +178,37 @@ describe("requestPermissionDecisionFromUi", () => {
expect(options[1]).toBe("Yes, for this session");
});
describe("hideSessionOption (human-only asks)", () => {
it("omits the session option when hideSessionOption is set", async () => {
const selectFn = vi.fn().mockResolvedValue("Yes");
const ui: PermissionDecisionUi = { select: selectFn, input: vi.fn() };
await requestPermissionDecisionFromUi(ui, "Title", "Message", {
hideSessionOption: true,
});
const options = selectFn.mock.calls[0][1] as string[];
expect(options).toEqual(["Yes", "No", "No, provide reason"]);
});
it("does not open the scope select when the session option is hidden", async () => {
const selectFn = vi.fn().mockResolvedValue("Yes");
const ui: PermissionDecisionUi = { select: selectFn, input: vi.fn() };
const result = await requestPermissionDecisionFromUi(
ui,
"Title",
"Message",
{
hideSessionOption: true,
sessionScope: {
subagentLabel: "This subagent only",
servingSessionLabel: "The whole session",
},
},
);
expect(selectFn).toHaveBeenCalledTimes(1);
expect(result).toEqual({ approved: true, state: "approved" });
});
});
describe("sessionScope two-step (forwarded asks)", () => {
const sessionScope = {
subagentLabel: "This subagent only",
@@ -28,7 +28,6 @@ import {
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";
@@ -55,9 +54,30 @@ const EXPECTED_HANDLERS = [
let agentDir: string;
/**
* Policy and hard-guard files live under the per-test temp dir and are passed
* to the factory explicitly — the bundle `config/` files are never touched.
*/
function testPolicyPath(): string {
return join(agentDir, "pi-permission-policy.jsonc");
}
function testHardGuardsPath(): string {
return join(agentDir, "pi-permission-hard-guards.jsonc");
}
function testConfigPaths(): { configPath: string; hardGuardConfigPath: string } {
return {
configPath: testPolicyPath(),
hardGuardConfigPath: testHardGuardsPath(),
};
}
beforeEach(() => {
agentDir = mkdtempSync(join(tmpdir(), "pi-perm-comp-root-"));
vi.stubEnv("PI_CODING_AGENT_DIR", agentDir);
writeFileSync(
testHardGuardsPath(),
JSON.stringify({ version: 1, permission: { "*": "allow" } }),
);
});
afterEach(() => {
@@ -75,9 +95,9 @@ afterEach(() => {
// ── Shared helpers ──────────────────────────────────────────────────────────
/** Write the global config file under the stubbed agent dir. */
/** Write the global policy file under the stubbed temp dir. */
function writeGlobalConfig(config: Record<string, unknown>): void {
const globalConfigPath = getGlobalConfigPath(agentDir);
const globalConfigPath = testPolicyPath();
mkdirSync(dirname(globalConfigPath), { recursive: true });
writeFileSync(
globalConfigPath,
@@ -174,7 +194,7 @@ function fireSessionStart(
describe("event-handler registration completeness", () => {
it("registers a handler for every required event exactly once", () => {
const pi = makeFakePi();
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
expect([...pi.handlers.keys()].sort()).toEqual(EXPECTED_HANDLERS);
});
@@ -186,7 +206,7 @@ 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);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
// The service is published at session_start, not at factory init.
await fireSessionStart(pi, makeChildCtx(cwd, "top-session"));
@@ -219,7 +239,7 @@ describe("service and gate share one formatter registry", () => {
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-ui-cwd-"));
const pi = makeFakePi({ toolNames: ["demo"] });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
const capturedTitles: string[] = [];
const { ctx } = makeUiCtx(cwd, capturedTitles);
@@ -257,7 +277,7 @@ describe("service and gate share one access extractor registry", () => {
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-ext-cwd-"));
const pi = makeFakePi({ toolNames: ["ffgrep"] });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
const { ctx } = makeUiCtx(cwd, []);
await fireSessionStart(pi, ctx);
@@ -295,7 +315,7 @@ describe("service and chain share one authorizer registry", () => {
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-auth-cwd-"));
const pi = makeFakePi({ toolNames: ["demo"] });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
const capturedTitles: string[] = [];
const { ctx } = makeUiCtx(cwd, capturedTitles);
@@ -327,7 +347,7 @@ describe("service and chain share one authorizer registry", () => {
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-auth-optin-"));
const pi = makeFakePi({ toolNames: ["demo"] });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
const capturedTitles: string[] = [];
const { ctx } = makeUiCtx(cwd, capturedTitles);
@@ -364,7 +384,7 @@ describe("ready emitted after service publication", () => {
seen.push(getPermissionsService() ? "present" : "missing");
});
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
// ready is not emitted at load; only after session_start publishes.
expect(seen).toEqual([]);
@@ -389,7 +409,7 @@ describe("single source of truth for session state", () => {
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-sot-cwd-"));
const pi = makeFakePi({ toolNames: ["demo"] });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
// UI ctx that approves the gate prompt for this session (options[1]).
// Return the second option label-agnostically — always the "for this
@@ -436,7 +456,7 @@ describe("service path queries evaluate the supplied path (#503)", () => {
writeGlobalConfig({ permission: { path: { [target]: "deny" } } });
const pi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
await fireSessionStart(pi, makeChildCtx(cwd, "svc-path-session"));
const result = getPermissionsService()!.checkPermission("path", target);
@@ -453,7 +473,7 @@ describe("project trust gates project-scoped config (#644)", () => {
writeProjectConfig(cwd, { permission: { bash: "allow" } });
const pi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
await fireSessionStart(
pi,
makeBaseCtx(cwd, "untrusted-session", { isProjectTrusted: false }),
@@ -473,7 +493,7 @@ describe("project trust gates project-scoped config (#644)", () => {
writeProjectConfig(cwd, { permission: { bash: "allow" } });
const pi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
await fireSessionStart(
pi,
makeBaseCtx(cwd, "trusted-session", { isProjectTrusted: true }),
@@ -516,7 +536,7 @@ describe("bash bare-token path gating (#509, #645)", () => {
});
const pi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
const ctx = makeChildCtx(cwd, "bare-token-session-deny");
await fireSessionStart(pi, ctx);
@@ -534,7 +554,7 @@ describe("bash bare-token path gating (#509, #645)", () => {
});
const pi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
const ctx = makeChildCtx(cwd, "bare-token-session-wildcard");
await fireSessionStart(pi, ctx);
@@ -556,7 +576,7 @@ describe("bash bare-token path gating (#509, #645)", () => {
});
const pi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
const ctx = makeChildCtx(cwd, "bare-symlink-session-deny");
await fireSessionStart(pi, ctx);
@@ -573,7 +593,7 @@ describe("bash bare-token path gating (#509, #645)", () => {
});
const pi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
const ctx = makeChildCtx(cwd, "bare-token-session-unaffected");
await fireSessionStart(pi, ctx);
@@ -592,7 +612,7 @@ describe("bash bare-token path gating (#509, #645)", () => {
});
const pi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
const ctx = makeChildCtx(cwd, "bare-token-session-absent");
await fireSessionStart(pi, ctx);
@@ -613,7 +633,7 @@ describe("bash bare-token path gating (#509, #645)", () => {
});
const pi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
const ctx = makeChildCtx(cwd, "bare-token-session-norule");
await fireSessionStart(pi, ctx);
@@ -637,9 +657,9 @@ describe("multi-instance global service interplay", () => {
const childSessionId = "child-session-mi";
const parentPi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(parentPi as unknown as ExtensionAPI);
piPermissionSystemExtension(parentPi as unknown as ExtensionAPI, testConfigPaths());
const childPi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(childPi as unknown as ExtensionAPI);
piPermissionSystemExtension(childPi as unknown as ExtensionAPI, testConfigPaths());
// The parent is not a registered child, so it publishes its service.
await fireSessionStart(
@@ -703,7 +723,7 @@ describe("session approvals do not leak across same-cwd session switches", () =>
// ── Session #1: approve `demo` for the session, then shut down ──────────
const firstPi = makeFakePi({ toolNames: ["demo"] });
piPermissionSystemExtension(firstPi as unknown as ExtensionAPI);
piPermissionSystemExtension(firstPi as unknown as ExtensionAPI, testConfigPaths());
const firstCtx = makeSessionApprovingCtx(cwd, "switch-session-1");
await fireSessionStart(firstPi, firstCtx);
@@ -724,7 +744,7 @@ describe("session approvals do not leak across same-cwd session switches", () =>
// ── Session #2: the re-invoked cached factory, same cwd ────────────────
const secondPi = makeFakePi({ toolNames: ["demo"] });
piPermissionSystemExtension(secondPi as unknown as ExtensionAPI);
piPermissionSystemExtension(secondPi as unknown as ExtensionAPI, testConfigPaths());
await fireSessionStart(secondPi, makeChildCtx(cwd, "switch-session-2"));
@@ -776,12 +796,12 @@ describe("forwarded grant-scope selection round-trip", () => {
const parentBus = createEventBus();
const parentPi = makeFakePi({ events: parentBus, toolNames: ["demo"] });
piPermissionSystemExtension(parentPi as unknown as ExtensionAPI);
piPermissionSystemExtension(parentPi as unknown as ExtensionAPI, testConfigPaths());
const childPi = makeFakePi({
events: createEventBus(),
toolNames: ["demo"],
});
piPermissionSystemExtension(childPi as unknown as ExtensionAPI);
piPermissionSystemExtension(childPi as unknown as ExtensionAPI, testConfigPaths());
// The parent starts serving (its poll drains the inbox using the UI ctx).
const parentCtx = makeServingCtx(
@@ -841,12 +861,12 @@ describe("forwarded grant-scope selection round-trip", () => {
const parentBus = createEventBus();
const parentPi = makeFakePi({ events: parentBus, toolNames: ["demo"] });
piPermissionSystemExtension(parentPi as unknown as ExtensionAPI);
piPermissionSystemExtension(parentPi as unknown as ExtensionAPI, testConfigPaths());
const childPi = makeFakePi({
events: createEventBus(),
toolNames: ["demo"],
});
piPermissionSystemExtension(childPi as unknown as ExtensionAPI);
piPermissionSystemExtension(childPi as unknown as ExtensionAPI, testConfigPaths());
const parentCtx = makeServingCtx(
parentCwd,
@@ -905,7 +925,7 @@ describe("yolo grants asks synthesized after resolution", () => {
writeGlobalConfig(config);
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-yolo-cwd-"));
const pi = makeFakePi({ toolNames: ["bash"] });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
const prompts: string[] = [];
const { ctx } = makeUiCtx(cwd, prompts);
+22 -68
View File
@@ -1,6 +1,6 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
@@ -673,13 +673,14 @@ describe("loadAndMergeConfigs", () => {
let tempDir: string;
let agentDir: string;
let cwd: string;
let extensionRoot: string;
let globalConfigPath: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "config-merge-test-"));
agentDir = join(tempDir, "agent");
cwd = join(tempDir, "project");
extensionRoot = join(tempDir, "ext");
// Tests inject a temp policy path; the bundle config/ file is never touched.
globalConfigPath = join(tempDir, "config", "pi-permission-policy.jsonc");
});
afterEach(() => {
@@ -687,9 +688,8 @@ describe("loadAndMergeConfigs", () => {
});
function writeGlobal(content: Record<string, unknown>): void {
const dir = join(agentDir, "my-pi", "permission-system");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "policy.jsonc"), JSON.stringify(content));
mkdirSync(dirname(globalConfigPath), { recursive: true });
writeFileSync(globalConfigPath, JSON.stringify(content));
}
function writeProject(content: Record<string, unknown>): void {
@@ -712,72 +712,26 @@ describe("loadAndMergeConfigs", () => {
writeFileSync(join(dir, "pi-permissions.jsonc"), JSON.stringify(content));
}
function writeLegacyExtensionConfig(content: Record<string, unknown>): void {
mkdirSync(extensionRoot, { recursive: true });
writeFileSync(join(extensionRoot, "config.json"), JSON.stringify(content));
}
it("detects legacy global policy and emits migration issue", () => {
it("ignores leftover files at legacy locations", () => {
writeLegacyGlobalPolicy({
defaultPolicy: { tools: "allow" },
tools: { read: "allow" },
});
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
expect(result.issues).toHaveLength(1);
expect(result.issues[0]).toContain("pi-permissions.jsonc");
expect(result.issues[0]).toContain("my-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({
debugLog: true,
yoloMode: true,
});
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
expect(result.issues).toHaveLength(1);
expect(result.issues[0]).toContain(extensionRoot);
expect(result.merged.debugLog).toBe(true);
expect(result.merged.yoloMode).toBe(true);
});
it("does not emit legacy extension config issue when its path equals the runtime legacy path", () => {
const newGlobalDir = join(agentDir, "extensions", "pi-permission-system");
mkdirSync(newGlobalDir, { recursive: true });
writeFileSync(
join(newGlobalDir, "config.json"),
JSON.stringify({ debugLog: true }),
);
const result = loadAndMergeConfigs(agentDir, cwd, newGlobalDir);
expect(result.issues.filter((i) => i.includes("legacy"))).toHaveLength(0);
});
it("emits no issues when no legacy files exist and no new files exist", () => {
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
expect(result.issues).toEqual([]);
});
it("new-layout config takes precedence over legacy config at same scope", () => {
writeGlobal({
permission: { "*": "deny" },
});
writeLegacyGlobalPolicy({
permission: { "*": "allow" },
});
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
// New layout wins (legacy loaded first, new layout loaded second → new wins)
const result = loadAndMergeConfigs(globalConfigPath);
// The legacy file is simply ignored — never merged, never flagged.
expect(result.issues).toEqual([]);
expect(result.merged.permission).toEqual({ "*": "deny" });
// But legacy still emits a migration warning
expect(result.issues.some((i) => i.includes("pi-permissions.jsonc"))).toBe(
true,
);
});
it("emits no issues when no config file exists", () => {
const result = loadAndMergeConfigs(globalConfigPath);
expect(result.issues).toEqual([]);
expect(result.merged).toEqual({});
});
it("warns when the merged config leaves bash inheriting a permissive top-level '*'", () => {
@@ -785,7 +739,7 @@ describe("loadAndMergeConfigs", () => {
permission: { "*": "allow", read: "allow" },
});
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
const result = loadAndMergeConfigs(globalConfigPath);
expect(result.issues.some((i) => i.includes("bash"))).toBe(true);
});
@@ -794,7 +748,7 @@ describe("loadAndMergeConfigs", () => {
permission: { "*": "allow", bash: { "*": "ask" } },
});
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
const result = loadAndMergeConfigs(globalConfigPath);
expect(result.issues).toEqual([]);
});
@@ -803,7 +757,7 @@ describe("loadAndMergeConfigs", () => {
writeProject({ permission: { bash: "allow" } });
writeLegacyProjectPolicy({ permission: { "*": "allow" } });
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
const result = loadAndMergeConfigs(globalConfigPath);
expect(result.merged.permission).toEqual({ "*": "ask", bash: "deny" });
expect(result.issues).toEqual([]);
@@ -816,7 +770,7 @@ describe("loadAndMergeConfigs", () => {
toolInputPreviewMaxLength: 400,
});
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
const result = loadAndMergeConfigs(globalConfigPath);
expect(result.issues).toHaveLength(1);
expect(result.issues[0]).toContain("toolInputPreviewMaxLength");
@@ -828,7 +782,7 @@ describe("loadAndMergeConfigs", () => {
toolTextSummaryMaxLength: 120,
});
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
const result = loadAndMergeConfigs(globalConfigPath);
expect(result.merged.permission).toEqual({ "*": "ask", bash: "deny" });
});
@@ -836,7 +790,7 @@ describe("loadAndMergeConfigs", () => {
it("stays silent when neither cap is set", () => {
writeGlobal({ permission: { "*": "ask", bash: "deny" } });
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
const result = loadAndMergeConfigs(globalConfigPath);
expect(result.issues).toEqual([]);
});
+16 -11
View File
@@ -1,30 +1,35 @@
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
getGlobalConfigPath,
getGlobalLogsDir,
getLegacyGlobalPolicyPath,
getHardGuardConfigPath,
} from "#src/config-paths";
// Bundle-level `config/` is the single source of truth — two levels up from
// both this test file and src/config-paths.ts.
const BUNDLE_CONFIG_DIR = fileURLToPath(new URL("../../config", import.meta.url));
describe("config-paths", () => {
const agentDir = "/home/user/.pi/agent";
it("places the global policy at my-pi/permission-system/policy.jsonc under the agent dir", () => {
expect(getGlobalConfigPath(agentDir)).toBe(
join(agentDir, "my-pi", "permission-system", "policy.jsonc"),
it("reads the global policy from the bundle-level config directory", () => {
expect(getGlobalConfigPath()).toBe(
join(BUNDLE_CONFIG_DIR, "pi-permission-policy.jsonc"),
);
});
it("places logs next to the global config dir", () => {
it("reads hard guards from the bundle-level config directory", () => {
expect(getHardGuardConfigPath()).toBe(
join(BUNDLE_CONFIG_DIR, "pi-permission-hard-guards.jsonc"),
);
});
it("keeps logs under the disposable agent-dir my-pi tree", () => {
expect(getGlobalLogsDir(agentDir)).toBe(
join(agentDir, "my-pi", "permission-system", "logs"),
);
});
it("keeps the legacy global policy path for migration detection", () => {
expect(getLegacyGlobalPolicyPath(agentDir)).toBe(
join(agentDir, "pi-permissions.jsonc"),
);
});
});
@@ -1,6 +1,6 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { loadAndMergeConfigs } from "#src/config-loader";
@@ -20,15 +20,12 @@ import {
*/
describe("config pipeline seam", () => {
let tempDir: string;
let agentDir: string;
let cwd: string;
let extensionRoot: string;
let globalConfigPath: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "config-pipeline-test-"));
agentDir = join(tempDir, "agent");
cwd = join(tempDir, "project");
extensionRoot = join(tempDir, "ext");
// Tests inject a temp policy path; the bundle config/ file is never touched.
globalConfigPath = join(tempDir, "config", "pi-permission-policy.jsonc");
});
afterEach(() => {
@@ -36,15 +33,14 @@ describe("config pipeline seam", () => {
});
function writeGlobal(content: Record<string, unknown>): void {
const dir = join(agentDir, "my-pi", "permission-system");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "policy.jsonc"), JSON.stringify(content));
mkdirSync(dirname(globalConfigPath), { recursive: true });
writeFileSync(globalConfigPath, JSON.stringify(content));
}
it("a runtime knob survives the full pipeline", () => {
writeGlobal({ debugLog: true });
const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
const mergeResult = loadAndMergeConfigs(globalConfigPath);
const config = normalizePermissionSystemConfig(mergeResult.merged);
expect(config.debugLog).toBe(true);
@@ -56,7 +52,7 @@ describe("config pipeline seam", () => {
it("a deprecated preview cap reaches the merge intermediate but not the runtime config", () => {
writeGlobal({ toolInputPreviewMaxLength: 1000 });
const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
const mergeResult = loadAndMergeConfigs(globalConfigPath);
const config = normalizePermissionSystemConfig(mergeResult.merged);
expect(mergeResult.merged.toolInputPreviewMaxLength).toBe(1000);
@@ -66,7 +62,7 @@ describe("config pipeline seam", () => {
it("dialog budget fields survive the full pipeline and resolve to a render budget", () => {
writeGlobal({ promptMaxRows: 12, promptFieldMaxWidth: 80 });
const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
const mergeResult = loadAndMergeConfigs(globalConfigPath);
const config = normalizePermissionSystemConfig(mergeResult.merged);
expect(config.promptMaxRows).toBe(12);
@@ -78,7 +74,7 @@ describe("config pipeline seam", () => {
});
it("falls back to the default render budget when the config names neither field", () => {
const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
const mergeResult = loadAndMergeConfigs(globalConfigPath);
const config = normalizePermissionSystemConfig(mergeResult.merged);
expect(resolveRenderBudget(config)).toEqual(DEFAULT_RENDER_BUDGET);
@@ -87,7 +83,7 @@ describe("config pipeline seam", () => {
it("a deprecated text-summary cap likewise stops at the merge intermediate", () => {
writeGlobal({ toolTextSummaryMaxLength: 250 });
const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
const mergeResult = loadAndMergeConfigs(globalConfigPath);
const config = normalizePermissionSystemConfig(mergeResult.merged);
expect(mergeResult.merged.toolTextSummaryMaxLength).toBe(250);
@@ -96,8 +92,8 @@ describe("config pipeline seam", () => {
it("defaults apply when config file is absent", () => {
// No config files written — agentDir and cwd directories don't exist.
const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
// No config file written — the injected path does not exist.
const mergeResult = loadAndMergeConfigs(globalConfigPath);
const config = normalizePermissionSystemConfig(mergeResult.merged);
expect(config.debugLog).toBe(false);
@@ -15,7 +15,7 @@ import { PermissionManager } from "#src/permission-manager";
test("buildResolvedConfigLogEntry reports global and agent policy paths", () => {
const policyPaths: ResolvedPolicyPaths = {
globalConfigPath: "/home/user/.pi/agent/my-pi/permission-system/policy.jsonc",
globalConfigPath: "/bundle/config/pi-permission-policy.jsonc",
globalConfigExists: true,
agentsDir: "/home/user/.pi/agent/agents",
agentsDirExists: true,
@@ -25,11 +25,7 @@ test("buildResolvedConfigLogEntry reports global and agent policy paths", () =>
const result = buildResolvedConfigLogEntry({ policyPaths });
expect(result).toEqual({
...policyPaths,
legacyGlobalPolicyDetected: false,
legacyExtensionConfigDetected: false,
});
expect(result).toEqual(policyPaths);
});
test("config.resolved entry appears in review log via logger", () => {
@@ -78,8 +74,6 @@ test("config.resolved entry appears in review log via logger", () => {
expect(parsed.agentsDirExists).toBe(false);
expect(parsed.projectAgentsDir).toBe(null);
expect(parsed.projectAgentsDirExists).toBe(false);
expect(parsed.legacyGlobalPolicyDetected).toBe(false);
expect(parsed.legacyExtensionConfigDetected).toBe(false);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
+13 -36
View File
@@ -122,6 +122,7 @@ function makeStore(overrides: Partial<ConfigStoreDeps> = {}): {
agentDir: "/test/agent",
policyPaths: makePolicyPathProvider(),
logger,
configPath: "/test/bundle-config/pi-permission-policy.jsonc",
...overrides,
};
return { store: new ConfigStore(deps), logger };
@@ -159,34 +160,26 @@ describe("ConfigStore", () => {
// ── refresh() ─────────────────────────────────────────────────────────
describe("refresh()", () => {
it("uses the passed ctx cwd for global-only config loading", () => {
it("loads the configured global policy path", () => {
const { store } = makeStore();
store.refresh(makeCtx({ cwd: "/my/project" }), true);
expect(mockLoadAndMergeConfigs).toHaveBeenCalledWith(
"/test/agent",
"/my/project",
expect.any(String),
"/test/bundle-config/pi-permission-policy.jsonc",
);
});
it("does not add a project config scope when the project is untrusted", () => {
it("loads the same global policy regardless of project trust", () => {
const { store } = makeStore();
store.refresh(makeCtx({ cwd: "/my/project" }), false);
expect(mockLoadAndMergeConfigs).toHaveBeenCalledWith(
"/test/agent",
"/my/project",
expect.any(String),
"/test/bundle-config/pi-permission-policy.jsonc",
);
});
it("uses empty string cwd when no ctx is provided", () => {
const { store } = makeStore();
it("falls back to the bundle policy path when no override is configured", () => {
const { store } = makeStore({ configPath: undefined });
store.refresh(undefined, true);
expect(mockLoadAndMergeConfigs).toHaveBeenCalledWith(
"/test/agent",
"",
expect.any(String),
);
expect(mockLoadAndMergeConfigs).toHaveBeenCalledWith(undefined);
});
it("updates current() with normalized merged result", () => {
@@ -433,30 +426,14 @@ describe("ConfigStore", () => {
expect(mockProvider.getResolvedPolicyPaths).toHaveBeenCalled();
});
it("passes legacy detection results to buildResolvedConfigLogEntry", () => {
it("passes the resolved policy paths to buildResolvedConfigLogEntry", () => {
const { store } = makeStore();
// Make one legacy path exist
mockExistsSync.mockImplementation((p: string) =>
p.includes("policies.json"),
);
store.logResolvedPaths("/some/project");
expect(mockBuildResolvedConfigLogEntry).toHaveBeenCalledWith(
expect.objectContaining({
legacyGlobalPolicyDetected: expect.any(Boolean),
legacyExtensionConfigDetected: expect.any(Boolean),
expect(mockBuildResolvedConfigLogEntry).toHaveBeenCalledWith({
policyPaths: expect.objectContaining({
globalConfigPath: "/agent/config.json",
}),
);
});
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)
const calls = mockExistsSync.mock.calls.map(([p]: [string]) => p);
const projectCalls = calls.filter(
(p) => p.includes("/null/") || p.includes("null"),
);
expect(projectCalls).toHaveLength(0);
});
});
});
});
@@ -0,0 +1,117 @@
import { describe, expect, it, vi } from "vitest";
import { protectedWriteDeferNote } from "#src/handlers/gates/protected-write";
import type { ProtectedWriteTarget } from "#src/handlers/gates/descriptor";
import type { PermissionCheckResult } from "#src/types";
import { DECIDED_BY_HUMAN } from "#test/helpers/decision-fixtures";
import { makeDescriptor, makeGateRunner } from "#test/helpers/gate-fixtures";
import { makeCheckResult } from "#test/helpers/handler-fixtures";
const MARKER: ProtectedWriteTarget = {
path: "/vault/auth.json",
pattern: "/vault/auth.json",
label: "Pi 凭据",
extraCount: 0,
};
function protectedDescriptor(check: PermissionCheckResult) {
return makeDescriptor({
humanOnly: MARKER,
sessionApproval: undefined,
preCheck: check,
});
}
describe("GateRunner — humanOnly protected writes", () => {
it("still escalates when the resolved check is a session grant", async () => {
const { runner, deps } = makeGateRunner();
const outcome = await runner.run(
protectedDescriptor(
makeCheckResult({
state: "ask",
source: "session",
matchedPattern: "/vault/**",
}),
),
null,
);
expect(deps.escalate).toHaveBeenCalledOnce();
expect(outcome).toEqual({ action: "allow" });
expect(deps.reporter.writeReviewLog).not.toHaveBeenCalledWith(
"permission_request.session_approved",
expect.anything(),
);
});
it("still escalates when yolo mode is enabled", async () => {
const { runner, deps } = makeGateRunner({ yolo: true });
await runner.run(
protectedDescriptor(
makeCheckResult({ state: "ask", source: "tool", origin: "builtin" }),
),
null,
);
expect(deps.escalate).toHaveBeenCalledOnce();
expect(deps.reporter.writeReviewLog).not.toHaveBeenCalledWith(
"permission_request.auto_approved",
expect.anything(),
);
});
it("escalates with humanOnly, the system note, and no session approval", async () => {
const { runner, deps } = makeGateRunner();
await runner.run(
protectedDescriptor(
makeCheckResult({ state: "ask", source: "tool", origin: "builtin" }),
),
null,
);
expect(deps.escalate).toHaveBeenCalledWith(
expect.objectContaining({
humanOnly: true,
deferNotes: [protectedWriteDeferNote(MARKER)],
}),
);
const details = vi.mocked(deps.escalate).mock.calls[0][0];
expect(details.sessionApproval).toBeUndefined();
});
it("records no session grant when the user approves", async () => {
const { runner, deps } = makeGateRunner({
escalate: vi.fn().mockResolvedValue({
approved: true,
state: "approved_for_session",
decidedBy: DECIDED_BY_HUMAN,
}),
});
const outcome = await runner.run(
protectedDescriptor(
makeCheckResult({ state: "ask", source: "tool", origin: "builtin" }),
),
null,
);
// A session-shaped approval cannot be recorded: the descriptor carries no
// sessionApproval to grant.
expect(deps.recordSessionApproval).not.toHaveBeenCalled();
expect(outcome).toEqual({ action: "allow" });
});
});
describe("protectedWriteDeferNote", () => {
it("renders the exact system note", () => {
expect(protectedWriteDeferNote(MARKER)).toEqual({
origin: "system",
text: "⚠ 该操作将写入受保护文件:/vault/auth.json(Pi 凭据,匹配 /vault/auth.json)。此类写入不经 AutoReview,必须由你人工确认。",
});
});
it("adds the extra-target count", () => {
const note = protectedWriteDeferNote({ ...MARKER, extraCount: 2 });
expect(note.text).toContain(",另有 2 个受保护目标");
});
});
@@ -0,0 +1,300 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// Mock node:fs so realpathSync (used by canonicalizePath) is controllable for
// the symlink-into-protected-directory case; identity by default.
const realpathSync = vi.hoisted(() =>
vi.fn<(path: string) => string>((p) => p),
);
vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
return {
...actual,
realpathSync,
default: { ...actual, realpathSync },
};
});
import { BashProgram } from "#src/access-intent/bash/program";
import type { ShellInvocation } from "#src/access-intent/tool-kind";
import {
type GateDescriptor,
type GateResult,
isGateDescriptor,
} from "#src/handlers/gates/descriptor";
import { describeProtectedWriteGate } from "#src/handlers/gates/protected-write";
import type { ProtectedWriteRule } from "#src/hard-guard-loader";
import { pathFlavorForPlatform } from "#src/path/path-flavor";
import { PathNormalizer } from "#src/path-normalizer";
import { makeTcc } from "#test/helpers/gate-fixtures";
const CWD = "/test/project";
beforeEach(() => {
realpathSync.mockReset();
realpathSync.mockImplementation((p: string) => p);
});
const PROTECTED_WRITES: ProtectedWriteRule[] = [
{ pattern: "/vault/auth.json", label: "Pi 凭据" },
{ pattern: "/protected/**", label: "受保护目录" },
];
function normalizer(): PathNormalizer {
return new PathNormalizer(pathFlavorForPlatform(process.platform), CWD);
}
async function describeBashCall(
command: string,
rules: readonly ProtectedWriteRule[] = PROTECTED_WRITES,
) {
const tcc = makeTcc({ toolName: "bash", input: { command } });
const bashProgram = command
? await BashProgram.parse(command, normalizer())
: null;
const shell: ShellInvocation = { command, workdir: undefined };
return describeProtectedWriteGate(
tcc,
bashProgram,
shell,
normalizer(),
rules,
);
}
function describeToolCall(
toolName: string,
input: unknown,
rules: readonly ProtectedWriteRule[] = PROTECTED_WRITES,
) {
const tcc = makeTcc({ toolName, input });
return describeProtectedWriteGate(tcc, null, null, normalizer(), rules);
}
function asDescriptor(result: GateResult): GateDescriptor {
expect(isGateDescriptor(result)).toBe(true);
return result as GateDescriptor;
}
describe("describeProtectedWriteGate", () => {
describe("write/edit tools", () => {
it("prompts on a write tool targeting a protected file", () => {
const desc = asDescriptor(
describeToolCall("write", { path: "/vault/auth.json" }),
);
expect(desc.humanOnly).toEqual({
path: "/vault/auth.json",
pattern: "/vault/auth.json",
label: "Pi 凭据",
extraCount: 0,
});
expect(desc.preCheck?.state).toBe("ask");
expect(desc.sessionApproval).toBeUndefined();
expect(desc.logContext.protectedWrite).toEqual({
path: "/vault/auth.json",
pattern: "/vault/auth.json",
label: "Pi 凭据",
});
});
it("prompts on an edit tool targeting a protected file", () => {
expect(
isGateDescriptor(
describeToolCall("edit", { path: "/vault/auth.json" }),
),
).toBe(true);
});
it("does not gate a read of a protected path", () => {
expect(
describeToolCall("read", { path: "/vault/auth.json" }),
).toBeNull();
});
it("covers a whole subtree, not just the directory root", () => {
expect(
isGateDescriptor(
describeToolCall("write", { path: "/protected/deep/nested/x.json" }),
),
).toBe(true);
});
it("prompts on a symlink whose target is protected", () => {
realpathSync.mockImplementation((p: string) =>
p === "/work/link" ? "/vault/auth.json" : p,
);
const desc = asDescriptor(
describeToolCall("write", { path: "/work/link" }),
);
expect(desc.humanOnly?.pattern).toBe("/vault/auth.json");
});
});
describe("bash writes", () => {
it("prompts on a redirect into a protected file", async () => {
expect(
isGateDescriptor(await describeBashCall("echo x > /vault/auth.json")),
).toBe(true);
});
it("prompts on a redirect into a not-yet-existing protected path", async () => {
expect(
isGateDescriptor(
await describeBashCall("echo 1 > /protected/new-file.json"),
),
).toBe(true);
});
it("does not prompt on cat of a protected file", async () => {
expect(await describeBashCall("cat /vault/auth.json")).toBeNull();
});
it("does not prompt when a protected read feeds an unprotected write", async () => {
expect(
await describeBashCall("cat /vault/auth.json > /tmp/x"),
).toBeNull();
});
it("does not prompt on a redirect-in read of a protected file", async () => {
expect(await describeBashCall("cat < /vault/auth.json")).toBeNull();
});
it("prompts on tee of a protected file", async () => {
expect(
isGateDescriptor(await describeBashCall("tee /vault/auth.json")),
).toBe(true);
});
it("prompts on cp into a protected file", async () => {
expect(
isGateDescriptor(
await describeBashCall("cp /tmp/x /vault/auth.json"),
),
).toBe(true);
});
it("prompts on sed -i of a protected file", async () => {
expect(
isGateDescriptor(
await describeBashCall("sed -i s/a/b/ /vault/auth.json"),
),
).toBe(true);
});
it("prompts on an opaque bash -c payload writing a protected file", async () => {
expect(
isGateDescriptor(
await describeBashCall('bash -c "echo > /vault/auth.json"'),
),
).toBe(true);
});
it("prompts on a relative redirect after cd into a protected directory", async () => {
expect(
isGateDescriptor(
await describeBashCall("cd /protected && echo 1 > rel.json"),
),
).toBe(true);
});
it("prompts on an unparseable command containing a protected path", async () => {
const tcc = makeTcc({
toolName: "bash",
input: { command: "echo broken > /vault/auth.json" },
});
// Parser failure is injected: the gate must scan the text directly.
const shell: ShellInvocation = {
command: "echo broken > /vault/auth.json",
workdir: undefined,
};
const result = describeProtectedWriteGate(
tcc,
null,
shell,
normalizer(),
PROTECTED_WRITES,
);
expect(isGateDescriptor(result)).toBe(true);
});
it("reports the extra protected targets in the same call", async () => {
const desc = asDescriptor(
await describeBashCall(
"echo x > /vault/auth.json && echo y > /protected/a.json",
),
);
expect(desc.humanOnly?.extraCount).toBe(1);
});
});
describe("non-local tools and empty rules", () => {
it("does not gate an ssh_bash-style aliased shell tool", async () => {
const tcc = makeTcc({
toolName: "ssh_bash",
input: { command: "echo x > /vault/auth.json" },
});
const bashProgram = await BashProgram.parse(
"echo x > /vault/auth.json",
normalizer(),
);
const shell: ShellInvocation = {
command: "echo x > /vault/auth.json",
workdir: undefined,
};
expect(
describeProtectedWriteGate(
tcc,
bashProgram,
shell,
normalizer(),
PROTECTED_WRITES,
),
).toBeNull();
});
it("returns null when no protectedWrites are configured", async () => {
expect(
await describeBashCall("echo x > /vault/auth.json", []),
).toBeNull();
expect(
describeToolCall("write", { path: "/vault/auth.json" }, []),
).toBeNull();
});
});
describe("bare token existence probe", () => {
// `auth.json` is a bare token (no path shape), so it only becomes a
// candidate when the resolver's existence probe finds it on disk; the
// probe must carry the same role/program metadata as shaped tokens.
it("does not prompt on a read of an existing bare-token file in a protected directory", async () => {
const dir = mkdtempSync(join(tmpdir(), "pw-probe-"));
try {
writeFileSync(join(dir, "auth.json"), "x");
const rules = [{ pattern: `${dir}/**`, label: "受保护目录" }];
expect(
await describeBashCall(`cd ${dir} && cat auth.json`, rules),
).toBeNull();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("prompts on rm of an existing bare-token file in a protected directory", async () => {
const dir = mkdtempSync(join(tmpdir(), "pw-probe-"));
try {
writeFileSync(join(dir, "auth.json"), "x");
const rules = [{ pattern: `${dir}/**`, label: "受保护目录" }];
expect(
isGateDescriptor(
await describeBashCall(`cd ${dir} && rm auth.json`, rules),
),
).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
});
@@ -1,6 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AccessPath } from "#src/access-intent/access-path";
import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline";
import { PathNormalizer } from "#src/path-normalizer";
@@ -37,7 +36,7 @@ function makeMockBashProgram(command = "echo hello") {
commandText: vi.fn(() => command),
commands: vi.fn<() => []>(() => []),
pathRuleCandidates: vi.fn<() => []>(() => []),
externalPaths: vi.fn<() => AccessPath[]>(() => []),
writeCandidates: vi.fn<() => []>(() => []),
};
}
@@ -200,7 +199,7 @@ describe("ToolCallGatePipeline", () => {
commandText: vi.fn(() => text),
commands: vi.fn(() => [{ text }]),
pathRuleCandidates: vi.fn<() => []>(() => []),
externalPaths: vi.fn<() => AccessPath[]>(() => []),
writeCandidates: vi.fn<() => []>(() => []),
};
}
@@ -1,5 +1,5 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
@@ -7,10 +7,25 @@ import { HardGuardPolicyLoader } from "#src/hard-guard-loader";
const roots: string[] = [];
const ENV_KEYS = ["PI_CODING_AGENT_DIR", "XDG_CONFIG_HOME"] as const;
const savedEnv: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>> =
{};
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
for (const key of ENV_KEYS) {
if (savedEnv[key] === undefined) delete process.env[key];
else process.env[key] = savedEnv[key];
delete savedEnv[key];
}
});
function setEnv(key: (typeof ENV_KEYS)[number], value: string | undefined) {
if (!(key in savedEnv)) savedEnv[key] = process.env[key];
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
describe("HardGuardPolicyLoader", () => {
it("loads only the global JSONC hard-guard file and preserves allow exceptions", () => {
const root = mkdtempSync(join(tmpdir(), "hard-guard-loader-"));
@@ -27,7 +42,7 @@ describe("HardGuardPolicyLoader", () => {
}
}`);
const loader = new HardGuardPolicyLoader(userPath, join(root, "missing-fallback.jsonc"));
const loader = new HardGuardPolicyLoader(userPath);
const config = loader.loadGlobalConfig();
expect(config.permission).toEqual({
@@ -38,13 +53,24 @@ describe("HardGuardPolicyLoader", () => {
expect(loader.getConfigIssues()).toEqual([]);
});
it("fails closed when neither the user file nor fallback is valid", () => {
it("fails closed with an issue when the file is missing", () => {
const root = mkdtempSync(join(tmpdir(), "hard-guard-loader-"));
roots.push(root);
const userPath = join(root, "hard-guards.jsonc");
const loader = new HardGuardPolicyLoader(userPath);
expect(loader.loadGlobalConfig().permission).toEqual({ "*": "deny" });
expect(loader.getConfigIssues().join("\n")).toContain("denying all");
});
it("fails closed when the file is invalid", () => {
const root = mkdtempSync(join(tmpdir(), "hard-guard-loader-"));
roots.push(root);
const userPath = join(root, "hard-guards.jsonc");
writeFileSync(userPath, `{ "version": 1, "permission": { "*": "ask" } }`);
const loader = new HardGuardPolicyLoader(userPath, join(root, "missing-fallback.jsonc"));
const loader = new HardGuardPolicyLoader(userPath);
expect(loader.loadGlobalConfig().permission).toEqual({ "*": "deny" });
expect(loader.getConfigIssues().join("\n")).toContain("allow or deny");
@@ -59,10 +85,7 @@ describe("HardGuardPolicyLoader", () => {
`{ "version": 2, "permission": { "*": "allow" } }`,
);
const loader = new HardGuardPolicyLoader(
userPath,
join(root, "missing-fallback.jsonc"),
);
const loader = new HardGuardPolicyLoader(userPath);
expect(loader.loadGlobalConfig().permission).toEqual({ "*": "deny" });
expect(loader.getConfigIssues().join("\n")).toContain(
@@ -79,10 +102,7 @@ describe("HardGuardPolicyLoader", () => {
`{ "version": 1, "permission": { "*": "allow", "~/.ssh/**": "deny" } }`,
);
const loader = new HardGuardPolicyLoader(
userPath,
join(root, "missing-fallback.jsonc"),
);
const loader = new HardGuardPolicyLoader(userPath);
const first = loader.loadGlobalConfig();
expect(first.permission).toEqual({
"*": "allow",
@@ -107,10 +127,7 @@ describe("HardGuardPolicyLoader", () => {
`{ "version": 1, "permission": { "*": "allow", "~/.ssh/**": "deny" } }`,
);
const loader = new HardGuardPolicyLoader(
userPath,
join(root, "missing-fallback.jsonc"),
);
const loader = new HardGuardPolicyLoader(userPath);
loader.loadGlobalConfig();
writeFileSync(userPath, "{ not json");
loader.loadGlobalConfig();
@@ -126,4 +143,106 @@ describe("HardGuardPolicyLoader", () => {
"~/.aws/**": "deny",
});
});
describe("protectedWrites", () => {
function writeConfig(userPath: string, protectedWrites: unknown): void {
writeFileSync(
userPath,
JSON.stringify({
version: 1,
permission: { "*": "allow" },
protectedWrites,
}),
);
}
function makeLoader() {
const root = mkdtempSync(join(tmpdir(), "hard-guard-loader-"));
roots.push(root);
const userPath = join(root, "hard-guards.jsonc");
return { root, userPath, loader: new HardGuardPolicyLoader(userPath) };
}
it("expands {agentDir} honoring PI_CODING_AGENT_DIR", () => {
setEnv("PI_CODING_AGENT_DIR", "/tmp/fake-agent-dir");
const { userPath, loader } = makeLoader();
writeConfig(userPath, [
{ pattern: "{agentDir}/auth.json", label: "Pi 凭据" },
]);
expect(loader.getProtectedWrites()).toEqual([
{ pattern: "/tmp/fake-agent-dir/auth.json", label: "Pi 凭据" },
]);
});
it("expands {configHome} honoring XDG_CONFIG_HOME", () => {
setEnv("XDG_CONFIG_HOME", "/tmp/fake-config-home");
const { userPath, loader } = makeLoader();
writeConfig(userPath, [
{ pattern: "{configHome}/my-pi/**", label: "pi-ssh 凭据库" },
]);
expect(loader.getProtectedWrites()).toEqual([
{ pattern: "/tmp/fake-config-home/my-pi/**", label: "pi-ssh 凭据库" },
]);
});
it("falls back to ~/.config for {configHome} and expands leading ~/", () => {
setEnv("XDG_CONFIG_HOME", undefined);
const { userPath, loader } = makeLoader();
writeConfig(userPath, [
{ pattern: "{configHome}/my-pi/**", label: "xdg" },
{ pattern: "~/secret.txt", label: "home" },
]);
expect(loader.getProtectedWrites()).toEqual([
{ pattern: join(homedir(), ".config", "my-pi/**"), label: "xdg" },
{ pattern: join(homedir(), "secret.txt"), label: "home" },
]);
});
it("returns empty rules when no protectedWrites key exists", () => {
const { userPath, loader } = makeLoader();
writeFileSync(
userPath,
`{ "version": 1, "permission": { "*": "allow" } }`,
);
expect(loader.getProtectedWrites()).toEqual([]);
expect(loader.getConfigIssues()).toEqual([]);
});
it("returns empty rules when the file is missing (requests deny anyway)", () => {
const { loader } = makeLoader();
expect(loader.getProtectedWrites()).toEqual([]);
expect(loader.getConfigIssues().join("\n")).toContain("denying all");
});
it("treats a malformed protectedWrites as an invalid file", () => {
const { userPath, loader } = makeLoader();
writeConfig(userPath, { pattern: "/x", label: "missing array" });
expect(loader.loadGlobalConfig().permission).toEqual({ "*": "deny" });
expect(loader.getConfigIssues().join("\n")).toContain(
"protectedWrites",
);
});
it("keeps the last good protectedWrites when the file turns invalid", () => {
const { userPath, loader } = makeLoader();
writeConfig(userPath, [
{ pattern: "/vault/auth.json", label: "Pi 凭据" },
]);
loader.loadGlobalConfig();
writeFileSync(userPath, "{ definitely not json at all");
loader.loadGlobalConfig();
expect(loader.getProtectedWrites()).toEqual([
{ pattern: "/vault/auth.json", label: "Pi 凭据" },
]);
expect(loader.getConfigIssues().join("\n")).toContain("Failed to load");
});
});
});
@@ -8,7 +8,6 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
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";
@@ -231,7 +230,7 @@ export function createAgentDirHarness(opts: {
const agentDir = join(baseDir, "agent");
const cwd = join(baseDir, "project");
const globalConfigPath = getGlobalConfigPath(agentDir);
const globalConfigPath = join(agentDir, "pi-permission-policy.jsonc");
mkdirSync(dirname(globalConfigPath), {
recursive: true,
});
@@ -1,9 +1,8 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getGlobalConfigPath } from "#src/config-paths";
import piPermissionSystemExtension from "#src/index";
import type {
PermissionDecisionEvent,
@@ -213,18 +212,26 @@ describe("emitDecisionEvent", () => {
describe("piPermissionSystemExtension ready event wiring", () => {
let baseDir: string;
let originalAgentDir: string | undefined;
// Policy and hard-guard files are injected through the factory options so
// the bundle `config/` files are never touched.
let policyPath: string;
let hardGuardsPath: string;
beforeEach(() => {
baseDir = mkdtempSync(join(tmpdir(), "pi-perm-events-test-"));
originalAgentDir = process.env.PI_CODING_AGENT_DIR;
const globalConfigPath = getGlobalConfigPath(baseDir);
mkdirSync(dirname(globalConfigPath), { recursive: true });
policyPath = join(baseDir, "pi-permission-policy.jsonc");
hardGuardsPath = join(baseDir, "pi-permission-hard-guards.jsonc");
mkdirSync(join(baseDir, "agents"), { recursive: true });
writeFileSync(
globalConfigPath,
policyPath,
`${JSON.stringify({ permission: { "*": "ask" } })}\n`,
"utf8",
);
writeFileSync(
hardGuardsPath,
JSON.stringify({ version: 1, permission: { "*": "allow" } }),
);
process.env.PI_CODING_AGENT_DIR = baseDir;
});
@@ -255,7 +262,10 @@ describe("piPermissionSystemExtension ready event wiring", () => {
setActiveTools: vi.fn(),
registerProvider: vi.fn(),
events: { emit: emitSpy, on: vi.fn().mockReturnValue(() => undefined) },
} as never);
} as never, {
configPath: policyPath,
hardGuardConfigPath: hardGuardsPath,
});
// ready is not emitted at load — only after session_start publishes.
expect(
@@ -1112,12 +1112,16 @@ describe("cross-cutting path surface — home-expanded values", () => {
// ---------------------------------------------------------------------------
test("project policy files are ignored", () => {
const { agentDir, cwd, cleanup } = createAgentDirHarness({
const { agentDir, cwd, globalConfigPath, cleanup } = createAgentDirHarness({
globalPermission: { "*": "ask", bash: "deny" },
projectPermission: { bash: "allow" },
});
try {
const manager = new PermissionManager({ agentDir });
const manager = new PermissionManager({
globalConfigPath,
agentsDir: join(agentDir, "agents"),
projectAgentsDir: join(cwd, ".pi", "agents"),
});
manager.configureForCwd(cwd);
expect(checkTool(manager, "bash", { command: "echo hi" }).state).toBe(
"deny",
@@ -1898,27 +1902,17 @@ permission:
// PI_CODING_AGENT_DIR support — moved from catch-all (#342)
// ---------------------------------------------------------------------------
test("PermissionManager reads config from PI_CODING_AGENT_DIR when set", () => {
test("PermissionManager reads the bundle-level policy regardless of PI_CODING_AGENT_DIR", () => {
// The bundle config/ directory is the single source of truth; the agent dir
// env var no longer influences the ordinary-policy location.
const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-envdir-"));
const agentsDir = join(baseDir, "agents");
const newConfigPath = getGlobalConfigPath(baseDir);
mkdirSync(agentsDir, { recursive: true });
mkdirSync(dirname(newConfigPath), { recursive: true });
const config: ScopeConfig = {
permission: { "*": "deny", read: "allow" },
};
writeFileSync(newConfigPath, JSON.stringify(config), "utf8");
const original = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = baseDir;
try {
const manager = new PermissionManager();
const result = checkTool(manager, "read", {});
expect(result.state).toBe("allow");
const result2 = checkTool(manager, "write", {});
expect(result2.state).toBe("deny");
const resolved = manager.getResolvedPolicyPaths();
expect(resolved.globalConfigPath).toBe(getGlobalConfigPath());
expect(resolved.globalConfigPath).not.toContain(baseDir);
} finally {
if (original !== undefined) {
process.env.PI_CODING_AGENT_DIR = original;
@@ -69,9 +69,9 @@ describe("bundled policy seed", () => {
describe("backward compatibility with a deployed config", () => {
it("still accepts the retired annotators key and ignores it", () => {
// The bundle never overwrites a deployed policy.jsonc, so a machine that
// ran the annotator era keeps `annotators` in its file. Rejecting it
// would fail that machine closed instead of ignoring the key.
// A config file saved by an older bundle revision may still carry the
// retired `annotators` key. Rejecting it would fail that file closed
// instead of ignoring the key.
const result = unifiedConfigSchema.safeParse({
annotators: ["model-explanation"],
});
@@ -1,8 +1,7 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { getGlobalConfigPath } from "#src/config-paths";
import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
import piPermissionSystemExtension from "#src/index";
import type { ScopeConfig } from "#src/types";
@@ -18,22 +17,30 @@ type MockHandler = (
describe("session_start handler consolidation", () => {
let baseDir: string;
let originalAgentDir: string | undefined;
// Policy and hard-guard files are injected through the factory options so
// the bundle `config/` files are never touched.
let policyPath: string;
let hardGuardsPath: string;
beforeEach(() => {
baseDir = mkdtempSync(join(tmpdir(), "pi-permission-session-start-"));
originalAgentDir = process.env.PI_CODING_AGENT_DIR;
const globalConfigPath = getGlobalConfigPath(baseDir);
policyPath = join(baseDir, "pi-permission-policy.jsonc");
hardGuardsPath = join(baseDir, "pi-permission-hard-guards.jsonc");
mkdirSync(join(baseDir, "agents"), { recursive: true });
mkdirSync(dirname(globalConfigPath), { recursive: true });
const config: ScopeConfig = {
permission: { "*": "ask" },
};
writeFileSync(
globalConfigPath,
policyPath,
`${JSON.stringify({ ...DEFAULT_EXTENSION_CONFIG, ...config }, null, 2)}\n`,
"utf8",
);
writeFileSync(
hardGuardsPath,
JSON.stringify({ version: 1, permission: { "*": "allow" } }),
);
process.env.PI_CODING_AGENT_DIR = baseDir;
});
@@ -63,7 +70,10 @@ describe("session_start handler consolidation", () => {
emit: (): void => {},
on: (): (() => void) => () => undefined,
},
} as never);
} as never, {
configPath: policyPath,
hardGuardConfigPath: hardGuardsPath,
});
const sessionStartHandlers = registrations.filter(
(r) => r.name === "session_start",
@@ -87,7 +97,10 @@ describe("session_start handler consolidation", () => {
emit: (): void => {},
on: (): (() => void) => () => undefined,
},
} as never);
} as never, {
configPath: policyPath,
hardGuardConfigPath: hardGuardsPath,
});
const sessionStartHandlers = registrations.filter(
(r) => r.name === "session_start",