Files
my-pi/pi-permission-system/test/authority/permission-dialog.test.ts
T
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 2aa1be0b54 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>
2026-10-20 01:05:31 -07:00

331 lines
10 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import {
createDeniedPermissionDecision,
isPermissionDecisionState,
normalizePermissionDenialReason,
type PermissionDecisionUi,
requestPermissionDecisionFromUi,
} from "#src/authority/permission-dialog";
describe("isPermissionDecisionState", () => {
it("accepts approved", () => {
expect(isPermissionDecisionState("approved")).toBe(true);
});
it("accepts denied", () => {
expect(isPermissionDecisionState("denied")).toBe(true);
});
it("accepts denied_with_reason", () => {
expect(isPermissionDecisionState("denied_with_reason")).toBe(true);
});
it("accepts approved_for_session", () => {
expect(isPermissionDecisionState("approved_for_session")).toBe(true);
});
it("accepts approved_for_serving_session", () => {
expect(isPermissionDecisionState("approved_for_serving_session")).toBe(
true,
);
});
it("rejects unknown strings", () => {
expect(isPermissionDecisionState("unknown")).toBe(false);
});
it("rejects non-strings", () => {
expect(isPermissionDecisionState(42)).toBe(false);
expect(isPermissionDecisionState(null)).toBe(false);
});
});
describe("requestPermissionDecisionFromUi", () => {
it("returns approved when user selects Yes", async () => {
const ui: PermissionDecisionUi = {
select: vi.fn().mockResolvedValue("Yes"),
input: vi.fn(),
};
const result = await requestPermissionDecisionFromUi(
ui,
"Title",
"Message",
);
expect(result).toEqual({ approved: true, state: "approved" });
});
it("returns approved_for_session when user selects session option", async () => {
const ui: PermissionDecisionUi = {
select: vi.fn().mockResolvedValue("Yes, for this session"),
input: vi.fn(),
};
const result = await requestPermissionDecisionFromUi(
ui,
"Title",
"Message",
);
expect(result).toEqual({ approved: true, state: "approved_for_session" });
});
it("returns denied when user selects No", async () => {
const ui: PermissionDecisionUi = {
select: vi.fn().mockResolvedValue("No"),
input: vi.fn(),
};
const result = await requestPermissionDecisionFromUi(
ui,
"Title",
"Message",
);
expect(result).toEqual({ approved: false, state: "denied" });
});
it("returns denied_with_reason when user provides reason", async () => {
const ui: PermissionDecisionUi = {
select: vi.fn().mockResolvedValue("No, provide reason"),
input: vi.fn().mockResolvedValue("not now"),
};
const result = await requestPermissionDecisionFromUi(
ui,
"Title",
"Message",
);
expect(result).toEqual({
approved: false,
state: "denied_with_reason",
denialReason: "not now",
});
});
it("returns denied when user selects deny-with-reason but gives empty input", async () => {
const ui: PermissionDecisionUi = {
select: vi.fn().mockResolvedValue("No, provide reason"),
input: vi.fn().mockResolvedValue(""),
};
const result = await requestPermissionDecisionFromUi(
ui,
"Title",
"Message",
);
expect(result).toEqual({ approved: false, state: "denied" });
});
it("returns denied when user dismisses dialog (undefined)", async () => {
const ui: PermissionDecisionUi = {
select: vi.fn().mockResolvedValue(undefined),
input: vi.fn(),
};
const result = await requestPermissionDecisionFromUi(
ui,
"Title",
"Message",
);
expect(result).toEqual({ approved: false, state: "denied" });
});
it("passes four options to ui.select", async () => {
const selectFn = vi.fn().mockResolvedValue("Yes");
const ui: PermissionDecisionUi = {
select: selectFn,
input: vi.fn(),
};
await requestPermissionDecisionFromUi(ui, "Title", "Message");
const options = selectFn.mock.calls[0][1] as string[];
expect(options).toEqual([
"Yes",
"Yes, for this session",
"No",
"No, provide reason",
]);
});
it("uses custom sessionLabel when provided", async () => {
const selectFn = vi.fn().mockResolvedValue("Yes");
const ui: PermissionDecisionUi = {
select: selectFn,
input: vi.fn(),
};
await requestPermissionDecisionFromUi(ui, "Title", "Message", {
sessionLabel: 'Yes, allow "git *" for this session',
});
const options = selectFn.mock.calls[0][1] as string[];
expect(options[1]).toBe('Yes, allow "git *" for this session');
});
it("still returns approved_for_session when user selects the custom session label", async () => {
const customLabel = 'Yes, allow "git *" for this session';
const ui: PermissionDecisionUi = {
select: vi.fn().mockResolvedValue(customLabel),
input: vi.fn(),
};
const result = await requestPermissionDecisionFromUi(
ui,
"Title",
"Message",
{ sessionLabel: customLabel },
);
expect(result).toEqual({ approved: true, state: "approved_for_session" });
});
it("falls back to default session label when no options provided", async () => {
const selectFn = vi.fn().mockResolvedValue("Yes");
const ui: PermissionDecisionUi = {
select: selectFn,
input: vi.fn(),
};
await requestPermissionDecisionFromUi(ui, "Title", "Message");
const options = selectFn.mock.calls[0][1] as string[];
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",
servingSessionLabel: "The whole session",
};
it("opens a second scope select after the session option is chosen", async () => {
const selectFn = vi
.fn()
.mockResolvedValueOnce("Yes, for this session")
.mockResolvedValueOnce("This subagent only");
const ui: PermissionDecisionUi = { select: selectFn, input: vi.fn() };
await requestPermissionDecisionFromUi(ui, "Title", "Message", {
sessionScope,
});
expect(selectFn).toHaveBeenCalledTimes(2);
const scopeOptions = selectFn.mock.calls[1][1] as string[];
expect(scopeOptions).toEqual(["This subagent only", "The whole session"]);
});
it("maps the subagent scope to approved_for_session", async () => {
const ui: PermissionDecisionUi = {
select: vi
.fn()
.mockResolvedValueOnce("Yes, for this session")
.mockResolvedValueOnce("This subagent only"),
input: vi.fn(),
};
const result = await requestPermissionDecisionFromUi(
ui,
"Title",
"Message",
{ sessionScope },
);
expect(result).toEqual({ approved: true, state: "approved_for_session" });
});
it("maps the whole-session scope to approved_for_serving_session", async () => {
const ui: PermissionDecisionUi = {
select: vi
.fn()
.mockResolvedValueOnce("Yes, for this session")
.mockResolvedValueOnce("The whole session"),
input: vi.fn(),
};
const result = await requestPermissionDecisionFromUi(
ui,
"Title",
"Message",
{ sessionScope },
);
expect(result).toEqual({
approved: true,
state: "approved_for_serving_session",
});
});
it("defaults to the least-privilege subagent scope when the scope select is cancelled", async () => {
const ui: PermissionDecisionUi = {
select: vi
.fn()
.mockResolvedValueOnce("Yes, for this session")
.mockResolvedValueOnce(undefined),
input: vi.fn(),
};
const result = await requestPermissionDecisionFromUi(
ui,
"Title",
"Message",
{ sessionScope },
);
expect(result).toEqual({ approved: true, state: "approved_for_session" });
});
it("does not open the scope select when the user picks plain Yes", async () => {
const selectFn = vi.fn().mockResolvedValueOnce("Yes");
const ui: PermissionDecisionUi = { select: selectFn, input: vi.fn() };
const result = await requestPermissionDecisionFromUi(
ui,
"Title",
"Message",
{ sessionScope },
);
expect(selectFn).toHaveBeenCalledTimes(1);
expect(result).toEqual({ approved: true, state: "approved" });
});
});
});
describe("normalizePermissionDenialReason", () => {
it("returns trimmed string for non-empty input", () => {
expect(normalizePermissionDenialReason(" reason ")).toBe("reason");
});
it("returns undefined for empty string", () => {
expect(normalizePermissionDenialReason("")).toBeUndefined();
});
it("returns undefined for non-string", () => {
expect(normalizePermissionDenialReason(42)).toBeUndefined();
});
});
describe("createDeniedPermissionDecision", () => {
it("returns denied_with_reason when reason provided", () => {
expect(createDeniedPermissionDecision("nope")).toEqual({
approved: false,
state: "denied_with_reason",
denialReason: "nope",
});
});
it("returns denied when no reason", () => {
expect(createDeniedPermissionDecision()).toEqual({
approved: false,
state: "denied",
});
});
});