mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 18:55:22 +00:00
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:
co-authored by
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
dec48dab6d
commit
7e6a8faae1
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,7 +68,7 @@ describe("handleInput decision events — skill gate", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("emits allow with user_approved when state=ask and user approves", async () => {
|
||||
it("emits a decision event on the escalate path", async () => {
|
||||
const { handler, events } = makeHandler({
|
||||
session: {
|
||||
checkPermission: makeSkillCheckPermission("ask"),
|
||||
@@ -92,84 +92,4 @@ describe("handleInput decision events — skill gate", () => {
|
||||
resolution: "user_approved",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits deny with user_denied when state=ask and user denies", async () => {
|
||||
const { handler, events } = makeHandler({
|
||||
session: {
|
||||
checkPermission: makeSkillCheckPermission("ask"),
|
||||
},
|
||||
prompter: {
|
||||
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue({
|
||||
approved: false,
|
||||
state: "denied",
|
||||
decidedBy: DECIDED_BY_HUMAN,
|
||||
}),
|
||||
},
|
||||
});
|
||||
await handler.handleInput({ text: "/skill:explorer" }, makeCtx());
|
||||
|
||||
const decisions = getDecisionEvents(events);
|
||||
expect(decisions).toHaveLength(1);
|
||||
expect(decisions[0]).toMatchObject({
|
||||
surface: "skill",
|
||||
value: "explorer",
|
||||
result: "deny",
|
||||
resolution: "user_denied",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits deny with confirmation_unavailable when state=ask but no UI", async () => {
|
||||
const { handler, events } = makeHandler({
|
||||
session: {
|
||||
checkPermission: makeSkillCheckPermission("ask"),
|
||||
},
|
||||
prompter: {
|
||||
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue({
|
||||
approved: false,
|
||||
state: "denied",
|
||||
confirmationUnavailable: true,
|
||||
decidedBy: DECIDED_BY_HUMAN,
|
||||
}),
|
||||
},
|
||||
});
|
||||
await handler.handleInput(
|
||||
{ text: "/skill:explorer" },
|
||||
makeCtx({ hasUI: false }),
|
||||
);
|
||||
|
||||
const decisions = getDecisionEvents(events);
|
||||
expect(decisions).toHaveLength(1);
|
||||
expect(decisions[0]).toMatchObject({
|
||||
surface: "skill",
|
||||
value: "explorer",
|
||||
result: "deny",
|
||||
resolution: "confirmation_unavailable",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits allow with auto_approved when prompt returns autoApproved:true", async () => {
|
||||
const { handler, events } = makeHandler({
|
||||
session: {
|
||||
checkPermission: makeSkillCheckPermission("ask"),
|
||||
},
|
||||
prompter: {
|
||||
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue({
|
||||
approved: true,
|
||||
state: "approved",
|
||||
autoApproved: true,
|
||||
decidedBy: DECIDED_BY_HUMAN,
|
||||
}),
|
||||
},
|
||||
});
|
||||
await handler.handleInput({ text: "/skill:explorer" }, makeCtx());
|
||||
|
||||
const decisions = getDecisionEvents(events);
|
||||
expect(decisions).toHaveLength(1);
|
||||
expect(decisions[0]).toMatchObject({
|
||||
surface: "skill",
|
||||
value: "explorer",
|
||||
result: "allow",
|
||||
resolution: "auto_approved",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,12 +64,6 @@ describe("handleInput", () => {
|
||||
expect(result).toEqual({ action: "continue" });
|
||||
});
|
||||
|
||||
it("does not check permissions for non-skill input", async () => {
|
||||
const { handler, permissionManager } = makeHandler();
|
||||
await handler.handleInput(makeInputEvent("just a message"), makeCtx());
|
||||
expect(permissionManager.check).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns continue when skill is allowed", async () => {
|
||||
const { handler } = makeHandler();
|
||||
const result = await handler.handleInput(
|
||||
|
||||
@@ -117,14 +117,6 @@ describe("handleSessionStart", () => {
|
||||
expect(configStore.logResolvedPaths).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("resolves agent name from ctx", async () => {
|
||||
const ctx = makeCtx();
|
||||
const { handler, session } = makeSetup();
|
||||
const spy = vi.spyOn(session, "resolveAgentName");
|
||||
await handler.handleSessionStart({ reason: "startup" }, ctx);
|
||||
expect(spy).toHaveBeenCalledWith(ctx);
|
||||
});
|
||||
|
||||
it("notifies each policy issue", async () => {
|
||||
const { handler, logger } = makeSetup({
|
||||
configIssues: ["issue A", "issue B"],
|
||||
@@ -134,12 +126,6 @@ describe("handleSessionStart", () => {
|
||||
expect(logger.warn).toHaveBeenCalledWith("issue B");
|
||||
});
|
||||
|
||||
it("does not warn when there are no policy issues", async () => {
|
||||
const { handler, logger } = makeSetup();
|
||||
await handler.handleSessionStart({ reason: "startup" }, makeCtx());
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("writes lifecycle.reload debug log when reason is reload", async () => {
|
||||
const ctx = makeCtx({ cwd: "/proj" });
|
||||
const { handler, logger } = makeSetup();
|
||||
@@ -151,12 +137,6 @@ describe("handleSessionStart", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not write lifecycle.reload debug log for non-reload reasons", async () => {
|
||||
const { handler, logger } = makeSetup();
|
||||
await handler.handleSessionStart({ reason: "startup" }, makeCtx());
|
||||
expect(logger.debug).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("activates the service for the session with ctx", async () => {
|
||||
const ctx = makeCtx();
|
||||
const { handler, serviceLifecycle } = makeSetup();
|
||||
@@ -248,11 +228,6 @@ describe("handleSessionShutdown", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not throw when runtime context is null", async () => {
|
||||
const { handler } = makeSetup();
|
||||
await expect(handler.handleSessionShutdown()).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("calls shutdown on the session", async () => {
|
||||
const { handler, session } = makeSetup();
|
||||
const spy = vi.spyOn(session, "shutdown");
|
||||
|
||||
@@ -102,155 +102,3 @@ describe("handleToolCall decision events — session_approved", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── user_approved path ─────────────────────────────────────────────────────
|
||||
|
||||
describe("handleToolCall decision events — user_approved", () => {
|
||||
it("emits allow with user_approved when state=ask and user approves once", async () => {
|
||||
const { handler, events } = makeHandler({
|
||||
session: {
|
||||
checkPermission: vi
|
||||
.fn()
|
||||
.mockReturnValue(makeCheckResult({ state: "ask" })),
|
||||
},
|
||||
prompter: {
|
||||
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue({
|
||||
approved: true,
|
||||
state: "approved",
|
||||
decidedBy: DECIDED_BY_HUMAN,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await handler.handleToolCall(makeToolCallEvent("read"), makeCtx());
|
||||
|
||||
const decisions = getDecisionEvents(events);
|
||||
expect(decisions).toHaveLength(1);
|
||||
expect(decisions[0]).toMatchObject({
|
||||
result: "allow",
|
||||
resolution: "user_approved",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits allow with user_approved_for_session when user approves for session", async () => {
|
||||
const { handler, events } = makeHandler({
|
||||
session: {
|
||||
checkPermission: vi
|
||||
.fn()
|
||||
.mockReturnValue(makeCheckResult({ state: "ask" })),
|
||||
},
|
||||
prompter: {
|
||||
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue({
|
||||
approved: true,
|
||||
state: "approved_for_session",
|
||||
decidedBy: DECIDED_BY_HUMAN,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await handler.handleToolCall(makeToolCallEvent("read"), makeCtx());
|
||||
|
||||
const decisions = getDecisionEvents(events);
|
||||
expect(decisions).toHaveLength(1);
|
||||
expect(decisions[0]).toMatchObject({
|
||||
result: "allow",
|
||||
resolution: "user_approved_for_session",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── user_denied path ───────────────────────────────────────────────────────
|
||||
|
||||
describe("handleToolCall decision events — user_denied", () => {
|
||||
it("emits deny with user_denied when state=ask and user denies", async () => {
|
||||
const { handler, events } = makeHandler({
|
||||
session: {
|
||||
checkPermission: vi
|
||||
.fn()
|
||||
.mockReturnValue(makeCheckResult({ state: "ask" })),
|
||||
},
|
||||
prompter: {
|
||||
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue({
|
||||
approved: false,
|
||||
state: "denied",
|
||||
decidedBy: DECIDED_BY_HUMAN,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await handler.handleToolCall(makeToolCallEvent("read"), makeCtx());
|
||||
|
||||
const decisions = getDecisionEvents(events);
|
||||
expect(decisions).toHaveLength(1);
|
||||
expect(decisions[0]).toMatchObject({
|
||||
result: "deny",
|
||||
resolution: "user_denied",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── confirmation_unavailable path ──────────────────────────────────────────
|
||||
|
||||
describe("handleToolCall decision events — confirmation_unavailable", () => {
|
||||
it("emits deny with confirmation_unavailable when state=ask but no UI", async () => {
|
||||
const { handler, events } = makeHandler({
|
||||
session: {
|
||||
checkPermission: vi
|
||||
.fn()
|
||||
.mockReturnValue(makeCheckResult({ state: "ask" })),
|
||||
},
|
||||
prompter: {
|
||||
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue({
|
||||
approved: false,
|
||||
state: "denied",
|
||||
confirmationUnavailable: true,
|
||||
decidedBy: DECIDED_BY_HUMAN,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await handler.handleToolCall(
|
||||
makeToolCallEvent("read"),
|
||||
makeCtx({ hasUI: false }),
|
||||
);
|
||||
|
||||
const decisions = getDecisionEvents(events);
|
||||
expect(decisions).toHaveLength(1);
|
||||
expect(decisions[0]).toMatchObject({
|
||||
result: "deny",
|
||||
resolution: "confirmation_unavailable",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ── auto_approved path (yolo mode) ───────────────────────────────────
|
||||
|
||||
describe("handleToolCall decision events — auto_approved", () => {
|
||||
it("emits allow with auto_approved when prompt returns autoApproved:true", async () => {
|
||||
const { handler, events } = makeHandler({
|
||||
session: {
|
||||
checkPermission: vi
|
||||
.fn()
|
||||
.mockReturnValue(makeCheckResult({ state: "ask" })),
|
||||
},
|
||||
prompter: {
|
||||
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue({
|
||||
approved: true,
|
||||
state: "approved",
|
||||
autoApproved: true,
|
||||
decidedBy: DECIDED_BY_HUMAN,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await handler.handleToolCall(makeToolCallEvent("read"), makeCtx());
|
||||
|
||||
const decisions = getDecisionEvents(events);
|
||||
expect(decisions).toHaveLength(1);
|
||||
expect(decisions[0]).toMatchObject({
|
||||
result: "allow",
|
||||
resolution: "auto_approved",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,26 +48,6 @@ describe("getEventInput", () => {
|
||||
// ── handleToolCall ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("handleToolCall", () => {
|
||||
it("activates session with ctx", async () => {
|
||||
const ctx = makeCtx();
|
||||
const { handler, forwarding } = makeHandler();
|
||||
await handler.handleToolCall(makeToolCallEvent("read"), ctx);
|
||||
// session.activate(ctx) calls forwarding.start(ctx) on the real session
|
||||
expect(forwarding.start).toHaveBeenCalledWith(ctx);
|
||||
});
|
||||
|
||||
it("blocks when tool name cannot be resolved", async () => {
|
||||
const { handler } = makeHandler();
|
||||
const result = await handler.handleToolCall(
|
||||
{ type: "tool_call" },
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
action: "block",
|
||||
reason: expect.stringContaining("tool"),
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks when tool is not registered", async () => {
|
||||
const { handler } = makeHandler({ tools: ["read"] });
|
||||
const result = await handler.handleToolCall(
|
||||
|
||||
@@ -26,20 +26,6 @@ describe("validateRequestedTool", () => {
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("blocks when name field is an empty string", () => {
|
||||
const result = validateRequestedTool({ name: "" }, TOOLS);
|
||||
expect(result.status).toBe("block");
|
||||
});
|
||||
|
||||
it("blocks when name field is null", () => {
|
||||
const result = validateRequestedTool({ name: null }, TOOLS);
|
||||
expect(result.status).toBe("block");
|
||||
});
|
||||
|
||||
it("blocks when event is a primitive", () => {
|
||||
const result = validateRequestedTool("not-an-object", TOOLS);
|
||||
expect(result.status).toBe("block");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unregistered tool", () => {
|
||||
@@ -48,22 +34,6 @@ describe("validateRequestedTool", () => {
|
||||
expect(result.status).toBe("block");
|
||||
});
|
||||
|
||||
it("includes available tool names in the block reason", () => {
|
||||
const result = validateRequestedTool({ name: "unknown-tool" }, TOOLS);
|
||||
expect(result.status).toBe("block");
|
||||
const { reason } = result as Extract<
|
||||
RequestedToolValidation,
|
||||
{ status: "block" }
|
||||
>;
|
||||
expect(reason).toContain("read");
|
||||
expect(reason).toContain("bash");
|
||||
expect(reason).toContain("edit");
|
||||
});
|
||||
|
||||
it("blocks with empty available list when no tools are registered", () => {
|
||||
const result = validateRequestedTool({ name: "anything" }, []);
|
||||
expect(result.status).toBe("block");
|
||||
});
|
||||
});
|
||||
|
||||
describe("registered tool (ok path)", () => {
|
||||
@@ -81,12 +51,5 @@ describe("validateRequestedTool", () => {
|
||||
const result = validateRequestedTool({ name: "bash" }, TOOLS);
|
||||
expect(result).toEqual({ status: "ok", toolName: "bash" });
|
||||
});
|
||||
|
||||
it("resolves tool name via the `arguments` field naming convention", () => {
|
||||
// getToolNameFromValue reads `.name` then falls back to other fields;
|
||||
// a plain `{ name: "edit" }` event is sufficient here.
|
||||
const result = validateRequestedTool({ name: "edit" }, TOOLS);
|
||||
expect(result).toEqual({ status: "ok", toolName: "edit" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user