mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
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:
co-authored by
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
a0ee9389c7
commit
2aa1be0b54
@@ -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);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user