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>
This commit is contained in:
云服务部-叶林立
2026-10-19 01:54:17 -07:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent dec48dab6d
commit 7e6a8faae1
87 changed files with 2831 additions and 2658 deletions
@@ -107,17 +107,6 @@ describe("describeBashPathGate", () => {
expect(desc.preCheck?.state).toBe("deny");
});
it("returns GateDescriptor when a token evaluates to ask", async () => {
const result = await describeGate(
makeTcc(),
makeResolver(makeCheckResult({ state: "ask", matchedPattern: "*" })),
);
expect(result).not.toBeNull();
expect(isGateDescriptor(result)).toBe(true);
const desc = result as GateDescriptor;
expect(desc.preCheck?.state).toBe("ask");
});
it("descriptor includes triggering token in prompt message", async () => {
const result = (await describeGate(
makeTcc(),
@@ -129,14 +118,6 @@ describe("describeBashPathGate", () => {
expect(result.payload.request.value).toBe(".env");
});
it("descriptor decision uses surface 'path'", async () => {
const result = (await describeGate(
makeTcc(),
makeResolver(makeCheckResult({ state: "deny", matchedPattern: "*.env" })),
)) as GateDescriptor;
expect(result.decision.surface).toBe("path");
});
it("carries the deciding token's access facts on promptDetails (bash path surface)", async () => {
const resolver = makeResolver(
makeCheckResult({ state: "deny", matchedPattern: "*.env" }),
@@ -9,11 +9,6 @@ describe("pickMostRestrictive", () => {
expect(pickMostRestrictive([])).toBeUndefined();
});
it("returns the single result for a one-element list", () => {
const only = makeGateCheckResult({ state: "allow" });
expect(pickMostRestrictive([only])).toBe(only);
});
it("prefers deny over ask and allow regardless of position", () => {
const allow = makeGateCheckResult({ state: "allow", matchedPattern: "a" });
const ask = makeGateCheckResult({ state: "ask", matchedPattern: "b" });
@@ -40,13 +35,4 @@ describe("pickMostRestrictive", () => {
expect(pickMostRestrictive([deny1, deny2])).toBe(deny1);
});
it("keeps the first ask on ties when no deny is present", () => {
const allow = makeGateCheckResult({ state: "allow" });
const ask1 = makeGateCheckResult({ state: "ask", matchedPattern: "first" });
const ask2 = makeGateCheckResult({
state: "ask",
matchedPattern: "second",
});
expect(pickMostRestrictive([allow, ask1, ask2])).toBe(ask1);
});
});
@@ -9,7 +9,6 @@ import {
deriveResolution,
resolveYoloGrant,
} from "#src/handlers/gates/helpers";
import { posixPathFlavor } from "#src/path/path-flavor";
import type { PermissionCheckResult } from "#src/types";
import { makeCheckResult } from "#test/helpers/handler-fixtures";
@@ -130,50 +129,18 @@ describe("buildDecisionEvent", () => {
});
});
it("normalises undefined origin to null", () => {
it("normalises absent optional fields to null", () => {
const event = buildDecisionEvent(
{ surface: "bash", value: "git status" },
makeCheck({ origin: undefined }),
{ surface: "read", value: "read" },
makeCheck({ origin: undefined, matchedPattern: undefined }),
null,
"allow",
"user_approved",
"deny",
"policy_deny",
);
expect(event.origin).toBeNull();
});
it("normalises null agentName to null", () => {
const event = buildDecisionEvent(
{ surface: "read", value: "read" },
makeCheck(),
null,
"deny",
"policy_deny",
);
expect(event.agentName).toBeNull();
});
it("normalises undefined matchedPattern to null", () => {
const event = buildDecisionEvent(
{ surface: "read", value: "read" },
makeCheck({ matchedPattern: undefined }),
null,
"deny",
"policy_deny",
);
expect(event.matchedPattern).toBeNull();
});
it("passes result and resolution through", () => {
const event = buildDecisionEvent(
{ surface: "bash", value: "rm -rf /" },
makeCheck(),
null,
"deny",
"user_denied",
);
expect(event.result).toBe("deny");
expect(event.resolution).toBe("user_denied");
});
});
describe("resolveYoloGrant", () => {
@@ -227,18 +194,6 @@ describe("resolveYoloGrant", () => {
});
describe("accessFactsFromPath", () => {
it("projects the AccessPath's match set and boundary as strings", () => {
const path = AccessPath.forPath("/outside/x.ts", {
cwd: "/repo",
flavor: posixPathFlavor,
});
expect(accessFactsFromPath("external_directory", path)).toEqual({
surface: "external_directory",
matchValues: path.matchValues(),
boundaryValue: path.boundaryValue(),
});
});
it("collapses an empty boundary (literal-only path) to null", () => {
const path = AccessPath.forLiteral("relative-token");
expect(path.boundaryValue()).toBe("");
@@ -118,32 +118,6 @@ describe("describePathGate", () => {
expect(desc.preCheck?.state).toBe("deny");
});
it("returns GateDescriptor when path check result is ask", () => {
const resolver = makeResolver(
makeCheckResult({ state: "ask", matchedPattern: "*.env" }),
);
const result = describePathGate(makeTcc(), resolver, normalizer);
expect(result).not.toBeNull();
expect(isGateDescriptor(result)).toBe(true);
const desc = result as GateDescriptor;
expect(desc.surface).toBe("path");
expect(desc.preCheck?.state).toBe("ask");
});
it("descriptor has correct session approval surface and pattern", () => {
const resolver = makeResolver(
makeCheckResult({ state: "ask", matchedPattern: "*" }),
);
const result = describePathGate(
makeTcc({ input: { path: "/test/project/src/.env" } }),
resolver,
normalizer,
) as GateDescriptor;
expect(result.sessionApproval).toBeDefined();
expect(result.sessionApproval?.surface).toBe("path");
expect(result.sessionApproval?.representativePattern).toBeDefined();
});
it("binds a current-directory file's session approval to the cwd subtree", () => {
const resolver = makeResolver(
makeCheckResult({ state: "ask", matchedPattern: "*" }),
@@ -171,6 +145,7 @@ describe("describePathGate", () => {
expect(result.payload.kind).toBe("path");
expect(result.payload.request.toolName).toBe("read");
expect(result.payload.request.value).toBe(".env");
expect(result.payload.request.matchedPattern).toBe("*.env");
expect(result.payload.request.requester.agentName).toBeNull();
});
@@ -194,21 +169,6 @@ describe("describePathGate", () => {
});
});
it("emits a path payload naming the matched rule", () => {
const resolver = makeResolver(
makeCheckResult({ state: "ask", matchedPattern: "*.env" }),
);
const result = describePathGate(
makeTcc(),
resolver,
normalizer,
) as GateDescriptor;
expect(result.payload.kind).toBe("path");
expect(result.payload.request.value).toBe(".env");
expect(result.payload.request.matchedPattern).toBe("*.env");
});
it("descriptor decision uses surface 'path' and the file path as value", () => {
const resolver = makeResolver(
makeCheckResult({ state: "deny", matchedPattern: "*.env" }),
@@ -685,20 +685,6 @@ describe("GateRunner.run — null and bypass dispatch", () => {
});
expect(deps.reporter.writeReviewLog).not.toHaveBeenCalled();
});
it("routes a descriptor to the gate check logic and returns allow", async () => {
const { runner } = makeGateRunner();
const result = await runner.run(makeDescriptor(), null);
expect(result).toEqual({ action: "allow" });
});
it("routes a descriptor to the gate check logic and returns block", async () => {
const { runner } = makeGateRunner({
resolveResult: makeCheckResult({ state: "deny", matchedPattern: "*" }),
});
const result = await runner.run(makeDescriptor(), null);
expect(result).toMatchObject({ action: "block" });
});
});
// ── GateRunner — request identity ────────────────────────────────────
@@ -61,34 +61,6 @@ describe("SkillInputGatePipeline.evaluate", () => {
expect(notifier.warn).not.toHaveBeenCalled();
});
it("does not call notifier.warn when the skill requires approval (ask)", async () => {
const inputs = makeSkillInputInputs({
checkPermission: () => makeCheckResult({ state: "ask" }),
});
const notifier = makeNotifier();
const { runner } = makeGateRunner();
const pipeline = new SkillInputGatePipeline(inputs);
await pipeline.evaluate("librarian", null, notifier, runner);
expect(notifier.warn).not.toHaveBeenCalled();
});
it("includes agent name in the deny notice when agent is present", async () => {
const inputs = makeSkillInputInputs({
checkPermission: () => makeCheckResult({ state: "deny" }),
});
const notifier = makeNotifier();
const { runner } = makeGateRunner();
const pipeline = new SkillInputGatePipeline(inputs);
await pipeline.evaluate("librarian", "code-agent", notifier, runner);
expect(notifier.warn).toHaveBeenCalledWith(
expect.stringContaining("code-agent"),
);
});
// ── outcome mapping ─────────────────────────────────────────────────────
it("returns allow when the gate passes", async () => {
@@ -144,18 +116,4 @@ describe("SkillInputGatePipeline.evaluate", () => {
"code-agent",
);
});
it("calls checkPermission with undefined agentName when agentName is null", async () => {
const inputs = makeSkillInputInputs();
const { runner } = makeGateRunner();
const pipeline = new SkillInputGatePipeline(inputs);
await pipeline.evaluate("explorer", null, makeNotifier(), runner);
expect(inputs.checkPermission).toHaveBeenCalledWith(
"skill",
{ name: "explorer" },
undefined,
);
});
});
@@ -18,36 +18,14 @@ function makeSkillCheck(state: "allow" | "deny" | "ask") {
// ── describeSkillInputGate ─────────────────────────────────────────────────
describe("describeSkillInputGate", () => {
it("sets surface to 'skill'", () => {
const descriptor = describeSkillInputGate(
"librarian",
null,
makeSkillCheck("allow"),
);
expect(descriptor.surface).toBe("skill");
});
it("sets input.name to the skill name", () => {
const descriptor = describeSkillInputGate(
"librarian",
null,
makeSkillCheck("allow"),
);
expect(descriptor.input).toEqual({ name: "librarian" });
});
it("passes preCheck through verbatim", () => {
const check = makeSkillCheck("deny");
const descriptor = describeSkillInputGate("librarian", null, check);
expect(descriptor.preCheck).toBe(check);
});
it("makes the skill the payload's decision-relevant value", () => {
const descriptor = describeSkillInputGate(
"librarian",
null,
makeSkillCheck("allow"),
);
expect(descriptor.surface).toBe("skill");
expect(descriptor.input).toEqual({ name: "librarian" });
expect(descriptor.payload.kind).toBe("skill");
expect(descriptor.payload.request.surface).toBe("skill");
expect(descriptor.payload.request.value).toBe("librarian");
@@ -76,26 +54,6 @@ describe("describeSkillInputGate", () => {
});
});
it("emits a skill payload naming the skill as the decision value", () => {
const descriptor = describeSkillInputGate(
"librarian",
"code-agent",
makeSkillCheck("ask"),
);
expect(descriptor.payload.kind).toBe("skill");
expect(descriptor.payload.request.value).toBe("librarian");
});
it("names the skill in promptDetails so the prompt can identify it", () => {
const descriptor = describeSkillInputGate(
"librarian",
null,
makeSkillCheck("ask"),
);
expect(descriptor.promptDetails.skillName).toBe("librarian");
});
it("sets logContext source to 'skill_input' with skill name and agent", () => {
const descriptor = describeSkillInputGate(
"librarian",
@@ -133,14 +91,4 @@ describe("describeSkillInputGate", () => {
boundaryValue: null,
});
});
it("does not set preResolved or sessionApproval", () => {
const descriptor = describeSkillInputGate(
"librarian",
null,
makeSkillCheck("allow"),
);
expect(descriptor.preResolved).toBeUndefined();
expect(descriptor.sessionApproval).toBeUndefined();
});
});
@@ -77,32 +77,17 @@ describe("describeSkillReadGate", () => {
expect(result).toBeNull();
});
it("returns GateDescriptor with preResolved.state matching skill entry state (ask)", () => {
const result = describeSkillReadGate(makeTcc(), normalizer, () => [
makeSkillEntry({ state: "ask" }),
]);
expect(result).not.toBeNull();
const desc = result!;
expect(desc.preResolved).toEqual({ state: "ask" });
});
it("returns GateDescriptor with preResolved.state matching skill entry state (allow)", () => {
const result = describeSkillReadGate(makeTcc(), normalizer, () => [
makeSkillEntry({ state: "allow" }),
]);
expect(result).not.toBeNull();
const desc = result!;
expect(desc.preResolved).toEqual({ state: "allow" });
});
it("returns GateDescriptor with preResolved.state matching skill entry state (deny)", () => {
const result = describeSkillReadGate(makeTcc(), normalizer, () => [
makeSkillEntry({ state: "deny" }),
]);
expect(result).not.toBeNull();
const desc = result!;
expect(desc.preResolved).toEqual({ state: "deny" });
});
it.each(["ask", "allow", "deny"] as const)(
"returns GateDescriptor with preResolved.state matching skill entry state (%s)",
(state) => {
const result = describeSkillReadGate(makeTcc(), normalizer, () => [
makeSkillEntry({ state }),
]);
expect(result).not.toBeNull();
const desc = result!;
expect(desc.preResolved).toEqual({ state });
},
);
it("decision surface is 'skill' and decision value is the skill name", () => {
const result = describeSkillReadGate(makeTcc(), normalizer, () => [
@@ -123,15 +108,6 @@ describe("describeSkillReadGate", () => {
});
});
it("emits a skill_read payload keeping the skill as the decision value", () => {
const result = describeSkillReadGate(makeTcc(), normalizer, () => [
makeSkillEntry({ name: "my-skill" }),
])!;
expect(result.payload.kind).toBe("skill_read");
expect(result.payload.request.value).toBe("my-skill");
});
it("payload contains the skill name and the path it was reached through", () => {
const result = describeSkillReadGate(makeTcc(), normalizer, () => [
makeSkillEntry({ name: "librarian" }),
@@ -175,11 +151,4 @@ describe("describeSkillReadGate", () => {
agentName: "agent-1",
});
});
it("surface is 'skill' on the descriptor", () => {
const result = describeSkillReadGate(makeTcc(), normalizer, () => [
makeSkillEntry(),
])!;
expect(result.surface).toBe("skill");
});
});
@@ -103,34 +103,6 @@ describe("ToolCallGatePipeline", () => {
expect(runSpy).toHaveBeenCalledTimes(1);
});
it("calls getToolPreviewLimits() during evaluate", async () => {
const getToolPreviewLimits = vi.fn(() => ({
toolInputPreviewMaxLength: 500,
toolTextSummaryMaxLength: 100,
}));
const resolver = makeResolver(makeCheckResult());
const inputs = makeGateInputs({ getToolPreviewLimits });
const { runner } = makeGateRunner();
const pipeline = new ToolCallGatePipeline(resolver, inputs);
await pipeline.evaluate(makeTcc({ toolName: "read", input: {} }), runner);
expect(getToolPreviewLimits).toHaveBeenCalled();
});
it("calls getActiveSkillEntries() during evaluate", async () => {
const getActiveSkillEntries = vi.fn<() => []>(() => []);
const resolver = makeResolver(makeCheckResult());
const inputs = makeGateInputs({ getActiveSkillEntries });
const { runner } = makeGateRunner();
const pipeline = new ToolCallGatePipeline(resolver, inputs);
await pipeline.evaluate(makeTcc({ toolName: "read", input: {} }), runner);
expect(getActiveSkillEntries).toHaveBeenCalled();
});
it("does not call BashProgram.parse for non-bash tools", async () => {
const resolver = makeResolver(makeCheckResult());
const inputs = makeGateInputs();
@@ -53,23 +53,14 @@ const normalizer = new PathNormalizer(posixPathFlavor, "/test/project");
// ── tests ──────────────────────────────────────────────────────────────────
describe("describeToolGate", () => {
it("returns descriptor with tool name as surface for standard tools", () => {
const desc = describeToolGate(
makeTcc({ toolName: "read" }),
makeCheckResult("ask"),
makeFormatter(),
);
expect(desc.surface).toBe("read");
expect(desc.decision.surface).toBe("read");
});
it("returns descriptor with tool name as decision value for standard tools", () => {
it("gates a standard tool on its own surface and name", () => {
const desc = describeToolGate(
makeTcc({ toolName: "write" }),
makeCheckResult("ask"),
makeFormatter(),
);
expect(desc.decision.value).toBe("write");
expect(desc.surface).toBe("write");
expect(desc.decision).toEqual({ surface: "write", value: "write" });
});
it("returns bash surface with command in decision.value for bash tools", () => {
@@ -85,6 +76,7 @@ describe("describeToolGate", () => {
expect(desc.surface).toBe("bash");
expect(desc.decision.surface).toBe("bash");
expect(desc.decision.value).toBe("git status");
expect(desc.payload.request.value).toBe("git status");
});
it("gates an aliased shell tool on the bash surface while keeping its tool name in logs (#574)", () => {
@@ -177,42 +169,6 @@ describe("describeToolGate", () => {
expect(desc.payload.request.requester.agentName).toBeNull();
});
it("names the requesting agent on the payload when provided", () => {
const check = makeCheckResult("ask", { toolName: "read" });
const desc = describeToolGate(
makeTcc({ agentName: "my-agent" }),
check,
makeFormatter(),
);
expect(desc.payload.request.requester.agentName).toBe("my-agent");
});
it("carries the command as the decision-relevant value for a bash ask", () => {
const check = makeCheckResult("ask", { toolName: "bash", command: "ls" });
const desc = describeToolGate(
makeTcc({ toolName: "bash", input: { command: "ls" } }),
check,
makeFormatter(),
);
expect(desc.payload.kind).toBe("bash");
expect(desc.payload.request.value).toBe("ls");
});
it("populates sessionApproval via suggestSessionPattern", () => {
const check = makeCheckResult("ask", {
toolName: "bash",
command: "git status",
});
const desc = describeToolGate(
makeTcc({ toolName: "bash", input: { command: "git status" } }),
check,
makeFormatter(),
);
expect(desc.sessionApproval).toBeDefined();
expect(desc.sessionApproval?.surface).toBe("bash");
expect(desc.sessionApproval?.representativePattern).toBeDefined();
});
it("binds a current-directory file's session approval to the cwd subtree", () => {
const check = makeCheckResult("ask", { toolName: "edit" });
const desc = describeToolGate(
@@ -275,7 +231,6 @@ describe("describeToolGate", () => {
toolCallId: "tc-42",
toolName: "read",
});
expect(desc.promptDetails.sessionLabel).toBeDefined();
});
it("carries the AccessPath's facts on promptDetails for a path-bearing tool", () => {
@@ -328,15 +283,4 @@ describe("describeToolGate", () => {
});
expect(desc.logContext.command).toBe("ls");
});
it("uses toolName as input for checkPermission surface", () => {
const desc = describeToolGate(
makeTcc({ toolName: "edit", input: { path: "/a.ts" } }),
makeCheckResult("ask", { toolName: "edit" }),
makeFormatter(),
normalizer.forPath("/a.ts"),
);
expect(desc.surface).toBe("edit");
expect(desc.input).toEqual({ path: "/a.ts" });
});
});