mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 18:55:22 +00:00
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>
419 lines
14 KiB
TypeScript
419 lines
14 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
// Mock node:os so tilde-expansion is deterministic across platforms.
|
|
vi.mock("node:os", () => {
|
|
const homedir = vi.fn(() => "/mock/home");
|
|
return {
|
|
homedir,
|
|
default: { homedir },
|
|
};
|
|
});
|
|
|
|
import { AccessPath } from "#src/access-intent/access-path";
|
|
import { BashProgram } from "#src/access-intent/bash/program";
|
|
import { describeBashPathGate } from "#src/handlers/gates/bash-path";
|
|
import type {
|
|
GateBypass,
|
|
GateDescriptor,
|
|
GateResult,
|
|
} from "#src/handlers/gates/descriptor";
|
|
import { isGateBypass, isGateDescriptor } from "#src/handlers/gates/descriptor";
|
|
import type { ToolCallContext } from "#src/handlers/gates/types";
|
|
import { pathFlavorForPlatform, posixPathFlavor } from "#src/path/path-flavor";
|
|
import { PathNormalizer } from "#src/path-normalizer";
|
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
import { getNonEmptyString, toRecord } from "#src/value-guards";
|
|
|
|
import {
|
|
makeGateCheckResult as makeCheckResult,
|
|
makePathDispatchResolver,
|
|
makeResolver,
|
|
makeTcc,
|
|
} from "#test/helpers/gate-fixtures";
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
/**
|
|
* Mirror the handler's parse-once derivation: parse the bash command into a
|
|
* shared `BashProgram` and inject it, exactly as `permission-gate-handler.ts`
|
|
* does, so the gate is exercised through the production wiring.
|
|
*/
|
|
async function describeGate(
|
|
tcc: ToolCallContext,
|
|
resolver: ScopedPermissionResolver,
|
|
): Promise<GateResult> {
|
|
return describeGateOnPlatform(process.platform, tcc, resolver);
|
|
}
|
|
|
|
/**
|
|
* Variant of {@link describeGate} that injects an explicit host platform, so a
|
|
* win32-specific decision can be exercised on a POSIX CI host (and vice versa)
|
|
* without mocking `node:path` (#520).
|
|
*/
|
|
async function describeGateOnPlatform(
|
|
platform: NodeJS.Platform,
|
|
tcc: ToolCallContext,
|
|
resolver: ScopedPermissionResolver,
|
|
): Promise<GateResult> {
|
|
const command = getNonEmptyString(toRecord(tcc.input).command);
|
|
const bashProgram =
|
|
tcc.toolName === "bash" && command
|
|
? await BashProgram.parse(
|
|
command,
|
|
new PathNormalizer(pathFlavorForPlatform(platform), tcc.cwd),
|
|
)
|
|
: null;
|
|
return describeBashPathGate(tcc, bashProgram, resolver);
|
|
}
|
|
|
|
// ── tests ──────────────────────────────────────────────────────────────────
|
|
|
|
describe("describeBashPathGate", () => {
|
|
it("returns null for non-bash tools", async () => {
|
|
const result = await describeGate(
|
|
makeTcc({ toolName: "read", input: { path: ".env" } }),
|
|
makeResolver(),
|
|
);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("returns null when no tokens are extracted", async () => {
|
|
const result = await describeGate(
|
|
makeTcc({ input: { command: "echo hello" } }),
|
|
makeResolver(),
|
|
);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("returns null when all tokens evaluate to allow", async () => {
|
|
const result = await describeGate(
|
|
makeTcc(),
|
|
makeResolver(makeCheckResult({ state: "allow" })),
|
|
);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("returns GateDescriptor when a token evaluates to deny", async () => {
|
|
const result = await describeGate(
|
|
makeTcc(),
|
|
makeResolver(makeCheckResult({ state: "deny", matchedPattern: "*.env" })),
|
|
);
|
|
expect(result).not.toBeNull();
|
|
expect(isGateDescriptor(result)).toBe(true);
|
|
const desc = result as GateDescriptor;
|
|
expect(desc.surface).toBe("path");
|
|
expect(desc.preCheck?.state).toBe("deny");
|
|
});
|
|
|
|
it("descriptor includes triggering token in prompt message", async () => {
|
|
const result = (await describeGate(
|
|
makeTcc(),
|
|
makeResolver(makeCheckResult({ state: "deny", matchedPattern: "*.env" })),
|
|
)) as GateDescriptor;
|
|
expect(result.promptDetails.command).toBe("cat .env");
|
|
// The bash path gate asks about the offending token, not the command.
|
|
expect(result.payload.kind).toBe("path");
|
|
expect(result.payload.request.value).toBe(".env");
|
|
});
|
|
|
|
it("carries the deciding token's access facts on promptDetails (bash path surface)", async () => {
|
|
const resolver = makeResolver(
|
|
makeCheckResult({ state: "deny", matchedPattern: "*.env" }),
|
|
);
|
|
const result = (await describeGate(makeTcc(), resolver)) as GateDescriptor;
|
|
// The facts are the string projection of the same AccessPath the gate
|
|
// resolved for the deciding token.
|
|
const intent = resolver.resolve.mock.calls.at(-1)?.[0];
|
|
const path = intent?.kind === "access-path" ? intent.path : undefined;
|
|
expect(path).toBeDefined();
|
|
expect(result.promptDetails.accessIntent).toEqual({
|
|
surface: "path",
|
|
matchValues: path?.matchValues(),
|
|
boundaryValue: path?.boundaryValue(),
|
|
});
|
|
});
|
|
|
|
it("returns GateBypass when session rule covers the path", async () => {
|
|
const result = await describeGate(
|
|
makeTcc(),
|
|
makeResolver(makeCheckResult({ state: "allow", source: "session" })),
|
|
);
|
|
expect(result).not.toBeNull();
|
|
expect(isGateBypass(result)).toBe(true);
|
|
expect((result as GateBypass).action).toBe("allow");
|
|
expect((result as GateBypass).decidedBy).toEqual({
|
|
kind: "session_approval",
|
|
surface: "path",
|
|
pattern: null,
|
|
});
|
|
});
|
|
|
|
it("returns null when command is missing", async () => {
|
|
const result = await describeGate(makeTcc({ input: {} }), makeResolver());
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("evaluates most restrictive across multiple tokens", async () => {
|
|
const resolver = makePathDispatchResolver(
|
|
{ "src/foo.ts": makeCheckResult({ state: "allow" }) },
|
|
makeCheckResult({ state: "deny", matchedPattern: "*.env" }),
|
|
);
|
|
const result = await describeGate(
|
|
makeTcc({ input: { command: "cat src/foo.ts .env" } }),
|
|
resolver,
|
|
);
|
|
expect(result).not.toBeNull();
|
|
expect(isGateDescriptor(result)).toBe(true);
|
|
expect((result as GateDescriptor).preCheck?.state).toBe("deny");
|
|
});
|
|
|
|
it("deny wins in multi-token: cp .env README.md", async () => {
|
|
const resolver = makePathDispatchResolver(
|
|
{ ".env": makeCheckResult({ state: "deny", matchedPattern: "*.env" }) },
|
|
makeCheckResult({ state: "allow" }),
|
|
);
|
|
const result = await describeGate(
|
|
makeTcc({ input: { command: "cp .env README.md" } }),
|
|
resolver,
|
|
);
|
|
expect(result).not.toBeNull();
|
|
expect(isGateDescriptor(result)).toBe(true);
|
|
const desc = result as GateDescriptor;
|
|
expect(desc.preCheck?.state).toBe("deny");
|
|
expect(desc.decision.value).toBe(".env");
|
|
});
|
|
|
|
it("extracts redirect target: echo test > .env triggers deny", async () => {
|
|
const resolver = makePathDispatchResolver(
|
|
{ ".env": makeCheckResult({ state: "deny", matchedPattern: "*.env" }) },
|
|
makeCheckResult({ state: "allow" }),
|
|
);
|
|
const result = await describeGate(
|
|
makeTcc({ input: { command: "echo test > .env" } }),
|
|
resolver,
|
|
);
|
|
expect(result).not.toBeNull();
|
|
expect(isGateDescriptor(result)).toBe(true);
|
|
expect((result as GateDescriptor).preCheck?.state).toBe("deny");
|
|
});
|
|
|
|
it("returns null when all tokens match only the universal default", async () => {
|
|
const result = await describeGate(
|
|
makeTcc(),
|
|
makeResolver(
|
|
makeCheckResult({
|
|
state: "ask",
|
|
matchedPattern: undefined,
|
|
source: "special",
|
|
origin: "builtin",
|
|
}),
|
|
),
|
|
);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("ignores tokens matching universal default but fires for explicit rule matches", async () => {
|
|
const resolver = makePathDispatchResolver(
|
|
{ ".env": makeCheckResult({ state: "deny", matchedPattern: "*.env" }) },
|
|
// Other tokens match only the universal default (no matchedPattern)
|
|
makeCheckResult({
|
|
state: "ask",
|
|
matchedPattern: undefined,
|
|
source: "special",
|
|
origin: "builtin",
|
|
}),
|
|
);
|
|
const result = await describeGate(
|
|
makeTcc({ input: { command: "cat src/foo.ts .env" } }),
|
|
resolver,
|
|
);
|
|
expect(result).not.toBeNull();
|
|
expect(isGateDescriptor(result)).toBe(true);
|
|
const desc = result as GateDescriptor;
|
|
expect(desc.preCheck?.state).toBe("deny");
|
|
expect(desc.decision.value).toBe(".env");
|
|
});
|
|
|
|
it("resolves cd-aware policy values while keeping the raw prompt token", async () => {
|
|
const resolver = makeResolver(
|
|
makeCheckResult({ state: "deny", matchedPattern: "*" }),
|
|
);
|
|
const result = (await describeGate(
|
|
makeTcc({
|
|
input: { command: "cd nested && cat src/file.txt" },
|
|
cwd: "/test/project",
|
|
}),
|
|
resolver,
|
|
)) as GateDescriptor;
|
|
|
|
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
kind: "access-path",
|
|
surface: "path",
|
|
path: AccessPath.forPath("src/file.txt", {
|
|
cwd: "/test/project",
|
|
resolveBase: "/test/project/nested",
|
|
flavor: posixPathFlavor,
|
|
}),
|
|
agentName: undefined,
|
|
});
|
|
// The raw token drives the prompt payload, the decision, and the approval.
|
|
expect(result.payload.request.value).toBe("src/file.txt");
|
|
expect(result.decision.value).toBe("src/file.txt");
|
|
});
|
|
|
|
it("does not resolve relative policy values through an unknown cd", async () => {
|
|
const resolver = makeResolver(
|
|
makeCheckResult({ state: "deny", matchedPattern: "*" }),
|
|
);
|
|
await describeGate(
|
|
makeTcc({
|
|
input: { command: 'cd "$DIR" && cat src/foo.ts' },
|
|
cwd: "/test/project",
|
|
}),
|
|
resolver,
|
|
);
|
|
|
|
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
kind: "access-path",
|
|
surface: "path",
|
|
path: AccessPath.forLiteral("src/foo.ts"),
|
|
agentName: undefined,
|
|
});
|
|
});
|
|
|
|
it("binds a current-directory token's session approval to the cwd subtree", async () => {
|
|
const resolver = makeResolver(
|
|
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
);
|
|
const result = (await describeGate(
|
|
makeTcc({
|
|
input: { command: "cat .env" },
|
|
cwd: "/test/project",
|
|
}),
|
|
resolver,
|
|
)) as GateDescriptor;
|
|
|
|
expect(result.decision.value).toBe(".env");
|
|
expect(result.sessionApproval?.surface).toBe("path");
|
|
expect(result.sessionApproval?.representativePattern).toBe(
|
|
"/test/project/*",
|
|
);
|
|
});
|
|
});
|
|
|
|
// Home-relative path characterization (#350) ──────────────────────────────
|
|
//
|
|
// The parser extracts ~/... tokens from bash commands; the resolver receives
|
|
// the raw token and normalizeInput handles expansion. These tests verify the
|
|
// gate correctly dispatches ~/... tokens through the deny/ask path.
|
|
|
|
describe("describeBashPathGate — home-relative paths", () => {
|
|
it("extracts ~/... token and builds descriptor on deny", async () => {
|
|
// node:os is mocked: homedir() returns "/mock/home".
|
|
// cat ~/.ssh/config → token "~/.ssh/config" extracted.
|
|
const resolver = makePathDispatchResolver(
|
|
{
|
|
"/mock/home/.ssh/config": makeCheckResult({
|
|
state: "deny",
|
|
matchedPattern: "~/.ssh/*",
|
|
}),
|
|
},
|
|
makeCheckResult({ state: "allow" }),
|
|
);
|
|
const result = (await describeGate(
|
|
makeTcc({ input: { command: "cat ~/.ssh/config" } }),
|
|
resolver,
|
|
)) as GateDescriptor;
|
|
|
|
expect(isGateDescriptor(result)).toBe(true);
|
|
expect(result.preCheck?.state).toBe("deny");
|
|
expect(result.promptDetails.command).toBe("cat ~/.ssh/config");
|
|
expect(result.payload.request.value).toBe("~/.ssh/config");
|
|
});
|
|
|
|
it("extracts $HOME/... token and builds descriptor on deny", async () => {
|
|
const resolver = makePathDispatchResolver(
|
|
{
|
|
"/mock/home/.ssh/config": makeCheckResult({
|
|
state: "deny",
|
|
matchedPattern: "$HOME/.ssh/*",
|
|
}),
|
|
},
|
|
makeCheckResult({ state: "allow" }),
|
|
);
|
|
const result = (await describeGate(
|
|
makeTcc({ input: { command: "cat $HOME/.ssh/config" } }),
|
|
resolver,
|
|
)) as GateDescriptor;
|
|
|
|
expect(isGateDescriptor(result)).toBe(true);
|
|
expect(result.preCheck?.state).toBe("deny");
|
|
// A plain `$HOME` reference is resolved at token collection (#694), so the
|
|
// displayed token is the path the shell will actually touch — and it now
|
|
// agrees with the session-approval pattern, which has always been derived
|
|
// from the expanded `AccessPath.value()`. A `~` token keeps its raw
|
|
// spelling: it is shape-classified directly and never needed
|
|
// collection-time expansion.
|
|
expect(result.payload.request.value).toBe("/mock/home/.ssh/config");
|
|
});
|
|
});
|
|
|
|
// Win32 backslash-relative path gating (#520) ──────────────────────────────
|
|
//
|
|
// On Windows a backslash is a path separator, so a backslash-relative bash
|
|
// argument (`cat dir\file`) must be gated by a `path` rule the same as its
|
|
// forward-slash equivalent (`dir/file`). On POSIX `\` is a legal filename
|
|
// character, so the token stays bare and is not gated.
|
|
|
|
describe("describeBashPathGate — win32 backslash-relative paths", () => {
|
|
it("denies a backslash-relative token matching a path rule on win32", async () => {
|
|
// The win32 normalizer resolves `dir\file` to matchValues including the
|
|
// relative `dir\file` alias, which the rule (`dir/file`, folded to
|
|
// `dir\file` under win32 separators) matches.
|
|
const resolver = makePathDispatchResolver(
|
|
{
|
|
"dir\\file": makeCheckResult({
|
|
state: "deny",
|
|
matchedPattern: "dir/file",
|
|
}),
|
|
},
|
|
makeCheckResult({ state: "allow" }),
|
|
);
|
|
const result = (await describeGateOnPlatform(
|
|
"win32",
|
|
makeTcc({
|
|
input: { command: "cat dir\\file" },
|
|
cwd: "C:\\Projects\\App",
|
|
}),
|
|
resolver,
|
|
)) as GateDescriptor;
|
|
|
|
expect(isGateDescriptor(result)).toBe(true);
|
|
expect(result.preCheck?.state).toBe("deny");
|
|
expect(result.payload.request.value).toBe("dir\\file");
|
|
});
|
|
|
|
it("does not gate a backslash-relative token on posix (stays bare)", async () => {
|
|
const resolver = makePathDispatchResolver(
|
|
{
|
|
"dir\\file": makeCheckResult({
|
|
state: "deny",
|
|
matchedPattern: "dir/file",
|
|
}),
|
|
},
|
|
makeCheckResult({ state: "allow" }),
|
|
);
|
|
const result = await describeGateOnPlatform(
|
|
"linux",
|
|
makeTcc({
|
|
input: { command: "cat dir\\file" },
|
|
cwd: "/projects/app",
|
|
}),
|
|
resolver,
|
|
);
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|