吸收数字员工技能调用审计

This commit is contained in:
baiyanyun
2026-06-10 09:24:02 +08:00
parent 91540dec0c
commit cc36d8dcaf
15 changed files with 894 additions and 73 deletions

View File

@@ -12,6 +12,17 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import * as path from "path";
import * as dependencies from "@main/services/system/dependencies";
const mockMcpConfig = vi.hoisted(() => ({
value: {
mcpServers: {},
} as Record<string, unknown>,
}));
const mockDigitalEmployee = vi.hoisted(() => ({
recordSkillCallAudit: vi.fn(),
settings: new Map<string, unknown>(),
}));
vi.mock("electron-log", () => ({
default: {
info: vi.fn(),
@@ -71,6 +82,24 @@ vi.mock("@main/services/sandbox/policy", () => ({
getBundledWindowsSandboxHelperPath: vi.fn(() => null),
}));
vi.mock("../../db", () => ({
readSetting: (key: string) => mockDigitalEmployee.settings.get(key) ?? null,
}));
vi.mock("../../../db", () => ({
readSetting: (key: string) => mockDigitalEmployee.settings.get(key) ?? null,
}));
vi.mock("../../packages/mcp", () => ({
mcpProxyManager: {
getConfig: () => mockMcpConfig.value,
},
}));
vi.mock("../../digitalEmployee/stateService", () => ({
recordDigitalEmployeeSkillCallAudit: mockDigitalEmployee.recordSkillCallAudit,
}));
import { AcpEngine } from "./acpEngine";
import * as acpClient from "./acpClient";
@@ -147,6 +176,12 @@ function setupEngineForCreateSession(
return { engine, newSession };
}
beforeEach(() => {
mockDigitalEmployee.settings = new Map<string, unknown>();
mockDigitalEmployee.recordSkillCallAudit.mockClear();
mockMcpConfig.value = { mcpServers: {} };
});
describe("AcpEngine.abortSession", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -477,6 +512,56 @@ describe("AcpEngine.createSession", () => {
});
describe("AcpEngine.handlePermissionRequest(strict)", () => {
it("命中禁用 MCP 工具策略时拒绝 permission request 并写审计", async () => {
const { engine, sessionId } = setupEngine("qimingcode");
mockMcpConfig.value = {
mcpServers: {
crm: {
command: "node",
args: ["crm.js"],
discoveredTools: ["crm.search", "crm.delete"],
},
},
};
mockDigitalEmployee.settings.set("digital_employee_skill_policies_v1", {
version: 1,
skills: {
"qimingclaw-mcp-tool-crm-crm-delete": {
enabled: false,
updatedAt: "2026-06-07T08:00:00.000Z",
},
},
});
const result = await (engine as any).handlePermissionRequest({
sessionId,
toolCall: {
toolCallId: "tc-policy-1",
kind: "tool",
title: "crm.delete",
rawInput: { customer_id: "secret-customer" },
},
options: [
{
optionId: "allow-once",
kind: "allow_once",
name: "allow once",
},
],
});
expect(result).toEqual({ outcome: { outcome: "cancelled" } });
expect(mockDigitalEmployee.recordSkillCallAudit).toHaveBeenCalledWith(expect.objectContaining({
callId: "tc-policy-1",
toolName: "crm.delete",
skillId: "qimingclaw-mcp-tool-crm-crm-delete",
decision: "rejected",
reasonCodes: ["skill_disabled"],
sessionId,
}));
expect(JSON.stringify(mockDigitalEmployee.recordSkillCallAudit.mock.calls[0][0])).not.toContain("secret-customer");
});
it("strict 下 workspace 内写入仅放行 allow_once", async () => {
const { engine, sessionId } = setupEngine("qimingcode");
(engine as any).config = { engine: "qimingcode", workspaceDir: "/tmp/ws" };

View File

@@ -83,6 +83,9 @@ import { ACP_ABORT_TIMEOUT, ACP_PROMPT_TIMEOUT } from "@shared/constants";
import { APP_DATA_DIR_NAME } from "../../constants";
import { perfEmitter } from "../perf/perfEmitter";
import { firstTokenTrace } from "../perf/firstTokenTrace";
import { mcpProxyManager } from "../../packages/mcp";
import { evaluateDigitalEmployeeSkillCall } from "../../digitalEmployee/skillExecutionPolicy";
import { recordDigitalEmployeeSkillCallAudit } from "../../digitalEmployee/stateService";
/** Safe JSON.stringify that handles circular references */
function safeStringify(obj: unknown): string {
@@ -2418,6 +2421,38 @@ User question: ${request.prompt}`;
return { outcome: { outcome: "cancelled" } };
}
const skillPolicy = evaluateDigitalEmployeeSkillCall({
source: "qimingclaw-acp-permission",
toolName: params.toolCall.title || params.toolCall.kind || null,
callId: params.toolCall.toolCallId,
sessionId: acpSessionId,
mcpConfig: mcpProxyManager.getConfig(),
});
recordDigitalEmployeeSkillCallAudit({
callId: params.toolCall.toolCallId,
source: skillPolicy.source,
serverId: skillPolicy.server_id,
toolName: skillPolicy.tool_name || params.toolCall.title || params.toolCall.kind || null,
skillId: skillPolicy.skill_id,
decision: skillPolicy.decision,
reasonCodes: skillPolicy.reason_codes,
policySnapshot: skillPolicy.policy_snapshot,
sessionId: acpSessionId,
status: "permission_request",
message: skillPolicy.decision === "rejected"
? `技能权限请求已拒绝:${params.toolCall.title || params.toolCall.kind || "unknown"}`
: `技能权限请求已审计:${params.toolCall.title || params.toolCall.kind || "unknown"}`,
});
if (skillPolicy.decision === "rejected") {
log.warn(`${this.logTag} 🚫 Skill policy rejected permission request`, {
toolCallId: params.toolCall.toolCallId,
toolTitle: params.toolCall.title,
reasonCodes: skillPolicy.reason_codes,
skillId: skillPolicy.skill_id,
});
return { outcome: { outcome: "cancelled" } };
}
const strictEnabled = this.isStrictSandboxActiveForQimingCode();
const strictCheck = evaluateStrictWritePermission(params, {
strictEnabled,