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

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

删除:

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

测试:

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

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

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

471 lines
16 KiB
TypeScript

/**
* Unit tests for AuthorizerSelection.
*
* AuthorizerSelection owns the stored ExtensionContext and is the sole
* implementation of the AskEscalator role. These tests verify the
* escalate/reject contract across activation state.
*/
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { describe, expect, it, vi } from "vitest";
import { ParentAuthorizer } from "#src/authority/approval-escalator";
import type { Authorizer } from "#src/authority/authorizer";
import { AuthorizerRegistry } from "#src/authority/authorizer-registry";
import { AuthorizerSelection } from "#src/authority/authorizer-selection";
import { LocalUserAuthorizer } from "#src/authority/local-user-authorizer";
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
import type { PromptPermissionDetails } from "#src/authority/permission-prompter";
import {
makeAuthorizerSelectionDeps as makeDeps,
makeDetection,
makeInvokingPrompter,
makePrompterApi,
registerLink as register,
} from "#test/helpers/authorizer-fixtures";
import { makeAuthorizerLog } from "#test/helpers/authorizer-log-fixtures";
import { DECIDED_BY_HUMAN } from "#test/helpers/decision-fixtures";
import { makePromptDetails as makeDetails } from "#test/helpers/prompt-details-fixtures";
// ── Test helpers ──────────────────────────────────────────────────────────
function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
return {
cwd: "/test/project",
hasUI: true,
ui: {
setStatus: vi.fn(),
notify: vi.fn(),
select: vi.fn(),
input: vi.fn(),
},
sessionManager: {
getEntries: vi.fn().mockReturnValue([]),
getSessionDir: vi.fn().mockReturnValue("/sessions/test"),
getSessionId: vi.fn().mockReturnValue(null),
addEntry: vi.fn(),
},
...overrides,
} as unknown as ExtensionContext;
}
/** Details carrying the gate-computed surface supplied to authorizers. */
function makeDetailsOn(surface: string): PromptPermissionDetails {
return makeDetails({
accessIntent: { surface, matchValues: ["/v"], boundaryValue: null },
});
}
// ── Tests ─────────────────────────────────────────────────────────────────
describe("AuthorizerSelection", () => {
describe("escalate", () => {
it("rejects before activate", async () => {
const selection = new AuthorizerSelection(makeDeps());
await expect(selection.escalate(makeDetails())).rejects.toThrow(
"escalate called before the session was activated",
);
});
it("delegates to deps.prompter.prompt with the selected authorizer", async () => {
const prompter = makePrompterApi();
const selection = new AuthorizerSelection(makeDeps({ prompter }));
const ctx = makeCtx({ hasUI: true });
selection.activate(ctx);
const details = makeDetails();
const result = await selection.escalate(details);
expect(prompter.prompt).toHaveBeenCalledWith(
expect.any(LocalUserAuthorizer),
details,
);
expect(result).toEqual({
approved: true,
state: "approved",
decidedBy: DECIDED_BY_HUMAN,
});
});
it("uses the most recently selected authorizer", async () => {
const prompter = makePrompterApi();
const selection = new AuthorizerSelection(makeDeps({ prompter }));
selection.activate(makeCtx({ hasUI: false }));
selection.activate(makeCtx({ hasUI: true }));
await selection.escalate(makeDetails());
expect(prompter.prompt).toHaveBeenCalledWith(
expect.any(LocalUserAuthorizer),
expect.anything(),
);
});
it("rejects after deactivate", async () => {
const selection = new AuthorizerSelection(makeDeps());
selection.activate(makeCtx());
selection.deactivate();
await expect(selection.escalate(makeDetails())).rejects.toThrow(
"escalate called before the session was activated",
);
});
it("returns the prompter decision", async () => {
const decision: PermissionPromptDecision = {
approved: false,
state: "denied",
decidedBy: DECIDED_BY_HUMAN,
denialReason: "user declined",
};
const prompter = makePrompterApi();
prompter.prompt.mockResolvedValue(decision);
const selection = new AuthorizerSelection(makeDeps({ prompter }));
selection.activate(makeCtx());
const result = await selection.escalate(makeDetails());
expect(result).toEqual(decision);
});
});
describe("lifecycle", () => {
it("multiple activate calls still escalate successfully", async () => {
const prompter = makePrompterApi();
const selection = new AuthorizerSelection(makeDeps({ prompter }));
selection.activate(makeCtx({ cwd: "/old" }));
selection.activate(makeCtx({ cwd: "/new" }));
await selection.escalate(makeDetails());
expect(prompter.prompt).toHaveBeenCalledOnce();
});
});
describe("chain resolution", () => {
it("consults a configured link before the terminal", async () => {
const registry = new AuthorizerRegistry();
register(registry, "judge", { kind: "deny", reason: "typo path" });
const selection = new AuthorizerSelection(
makeDeps({
prompter: makeInvokingPrompter(),
authorizerRegistry: registry,
getAuthorizerChain: () => ["judge"],
}),
);
selection.activate(makeCtx({ hasUI: true }));
const decision = await selection.escalate(makeDetailsOn("bash"));
// The link decided (deny_with_reason); the LocalUserAuthorizer terminal
// was never reached (it would have approved by default).
expect(decision).toEqual({
approved: false,
state: "denied_with_reason",
denialReason: "typo path",
decidedBy: {
kind: "authorizer",
name: "judge",
verdict: "deny",
reason: "typo path",
},
});
});
it("injects the session review-log seam into each link (ADR 0007 §3)", async () => {
const logger = makeAuthorizerLog();
const link = vi
.fn<Authorizer["authorize"]>()
.mockResolvedValue({ kind: "defer" });
const registry = new AuthorizerRegistry();
registry.register("judge", link);
const selection = new AuthorizerSelection(
makeDeps({
prompter: makeInvokingPrompter(),
authorizerRegistry: registry,
getAuthorizerChain: () => ["judge"],
logger,
}),
);
selection.activate(makeCtx({ hasUI: true }));
await selection.escalate(makeDetailsOn("bash"));
// The link is handed the session logger as its third argument, so it can
// record a decision trail to the shared review log.
expect(link).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
logger,
);
});
it("resolves links in config order (first non-defer wins)", async () => {
const registry = new AuthorizerRegistry();
register(registry, "a", { kind: "deny", reason: "a-wins" });
register(registry, "b", { kind: "deny", reason: "b-wins" });
const selection = new AuthorizerSelection(
makeDeps({
prompter: makeInvokingPrompter(),
authorizerRegistry: registry,
getAuthorizerChain: () => ["a", "b"],
}),
);
selection.activate(makeCtx({ hasUI: true }));
const decision = await selection.escalate(makeDetailsOn("bash"));
expect(decision).toEqual({
approved: false,
state: "denied_with_reason",
denialReason: "a-wins",
decidedBy: {
kind: "authorizer",
name: "a",
verdict: "deny",
reason: "a-wins",
},
});
});
it("skips an unregistered configured name with a warning", async () => {
const registry = new AuthorizerRegistry();
register(registry, "present", {
kind: "deny",
reason: "present-decided",
});
const logger = makeAuthorizerLog();
const selection = new AuthorizerSelection(
makeDeps({
prompter: makeInvokingPrompter(),
authorizerRegistry: registry,
getAuthorizerChain: () => ["missing", "present"],
logger,
}),
);
selection.activate(makeCtx({ hasUI: true }));
const decision = await selection.escalate(makeDetailsOn("bash"));
// The unregistered "missing" link is skipped fail-safe; "present"
// decides, and is the name credited — the skipped one is not.
expect(decision).toEqual({
approved: false,
state: "denied_with_reason",
denialReason: "present-decided",
decidedBy: {
kind: "authorizer",
name: "present",
verdict: "deny",
reason: "present-decided",
},
});
expect(logger.review).toHaveBeenCalledWith(
"authorizer_chain_unregistered_link",
{ requestId: "req-1", name: "missing" },
);
});
it("records the resolved link names on the ask", async () => {
const registry = new AuthorizerRegistry();
register(registry, "judge", { kind: "defer" });
const logger = makeAuthorizerLog();
const selection = new AuthorizerSelection(
makeDeps({
prompter: makeInvokingPrompter(),
authorizerRegistry: registry,
getAuthorizerChain: () => ["judge"],
logger,
}),
);
selection.activate(makeCtx({ hasUI: true }));
await selection.escalate(makeDetailsOn("bash"));
// Positive evidence the link was consulted: a link that defers decides
// nothing and would otherwise leave no trace of having run.
expect(logger.review).toHaveBeenCalledWith("authorizer_chain_resolved", {
requestId: "req-1",
links: ["judge"],
});
});
it("records only the names it could resolve", async () => {
const registry = new AuthorizerRegistry();
register(registry, "present", { kind: "defer" });
const logger = makeAuthorizerLog();
const selection = new AuthorizerSelection(
makeDeps({
prompter: makeInvokingPrompter(),
authorizerRegistry: registry,
getAuthorizerChain: () => ["missing", "present"],
logger,
}),
);
selection.activate(makeCtx({ hasUI: true }));
await selection.escalate(makeDetailsOn("bash"));
expect(logger.review).toHaveBeenCalledWith("authorizer_chain_resolved", {
requestId: "req-1",
links: ["present"],
});
});
it("records no consultation when no configured name resolved", async () => {
const logger = makeAuthorizerLog();
const selection = new AuthorizerSelection(
makeDeps({
prompter: makeInvokingPrompter(),
getAuthorizerChain: () => ["missing"],
logger,
}),
);
selection.activate(makeCtx({ hasUI: true }));
await selection.escalate(makeDetailsOn("bash"));
// Nothing ran, so there is no consultation to record; the per-name
// warning already reports the skip.
expect(logger.review).not.toHaveBeenCalledWith(
"authorizer_chain_resolved",
expect.anything(),
);
});
it("lets a configured authorizer approve the path surface", async () => {
const registry = new AuthorizerRegistry();
register(registry, "judge", { kind: "allow" });
const selection = new AuthorizerSelection(
makeDeps({
prompter: makeInvokingPrompter(),
authorizerRegistry: registry,
getAuthorizerChain: () => ["judge"],
}),
);
selection.activate(makeCtx({ hasUI: false }));
const decision = await selection.escalate(makeDetailsOn("path"));
// A configured authorizer's allow is final for this ask and does not
// create a persistent session grant. The terminal is never reached.
expect(decision).toEqual({
approved: true,
state: "approved",
decidedBy: {
kind: "authorizer",
name: "judge",
verdict: "allow",
reason: null,
},
});
});
it("a registered but un-named link grants no authority (terminal identity)", async () => {
const registry = new AuthorizerRegistry();
register(registry, "judge", { kind: "allow" });
const prompter = makePrompterApi();
const selection = new AuthorizerSelection(
makeDeps({
prompter,
authorizerRegistry: registry,
getAuthorizerChain: () => [], // not named → opt-in withheld
}),
);
selection.activate(makeCtx({ hasUI: true }));
await selection.escalate(makeDetails());
// Empty chain ⇒ the selected value is the terminal instance itself.
expect(prompter.prompt).toHaveBeenCalledWith(
expect.any(LocalUserAuthorizer),
expect.anything(),
);
});
});
describe("chain delegation on a relaying node", () => {
/** A no-UI subagent node: its terminal relays the ask to the serving node. */
function makeRelayingSelection(
overrides: Parameters<typeof makeDeps>[0] = {},
): AuthorizerSelection {
const selection = new AuthorizerSelection(
makeDeps({ detection: makeDetection(true), ...overrides }),
);
selection.activate(makeCtx({ hasUI: false }));
return selection;
}
it("composes no links, so the ask reaches the relaying terminal unchanged", async () => {
const registry = new AuthorizerRegistry();
register(registry, "judge", { kind: "deny", reason: "judged locally" });
const prompter = makePrompterApi();
const selection = makeRelayingSelection({
prompter,
authorizerRegistry: registry,
getAuthorizerChain: () => ["judge"],
});
const details = makeDetailsOn("bash");
await selection.escalate(details);
// Zero links ⇒ the composed chain *is* the terminal instance, so the
// registered link never ran: the serving node adjudicates this ask.
expect(prompter.prompt).toHaveBeenCalledWith(
expect.any(ParentAuthorizer),
details,
);
});
it("records the delegated chain instead of the resolved one", async () => {
const registry = new AuthorizerRegistry();
register(registry, "judge", { kind: "deny", reason: "judged locally" });
const logger = makeAuthorizerLog();
const selection = makeRelayingSelection({
authorizerRegistry: registry,
getAuthorizerChain: () => ["judge"],
logger,
});
await selection.escalate(makeDetailsOn("bash"));
expect(logger.review).toHaveBeenCalledWith("authorizer_chain_delegated", {
requestId: "req-1",
links: ["judge"],
});
expect(logger.review).not.toHaveBeenCalledWith(
"authorizer_chain_resolved",
expect.anything(),
);
});
it("does not report an unregistrable link as an unregistered one", async () => {
const logger = makeAuthorizerLog();
const selection = makeRelayingSelection({
getAuthorizerChain: () => ["model-judge"],
logger,
});
await selection.escalate(makeDetailsOn("bash"));
// A child cannot host a link at all (#699), so its absence is the design,
// not the misconfiguration `authorizer_chain_unregistered_link` reports.
expect(logger.review).not.toHaveBeenCalledWith(
"authorizer_chain_unregistered_link",
expect.anything(),
);
});
it("records nothing when no chain is configured", async () => {
const logger = makeAuthorizerLog();
const selection = makeRelayingSelection({ logger });
await selection.escalate(makeDetailsOn("bash"));
expect(logger.review).not.toHaveBeenCalledWith(
"authorizer_chain_delegated",
expect.anything(),
);
});
});
});