吸收数字员工技能调用审计
This commit is contained in:
@@ -655,6 +655,10 @@ export async function handleQimingclawDigitalApi<T>(
|
||||
const rows = filterDailyReportRows(dailyReportRows(await readQimingclawSnapshot()), url.searchParams);
|
||||
return { ...paginateBusinessRows(rows, url.searchParams), source: BUSINESS_VIEW_SOURCE } as T;
|
||||
}
|
||||
if (method === 'GET' && pathname === '/api/digital-employee/skill-call-audits') {
|
||||
const rows = filterSkillCallAuditRows(skillCallAuditRows(await readQimingclawSnapshot()), url.searchParams);
|
||||
return { ...paginateBusinessRows(rows, url.searchParams), source: BUSINESS_VIEW_SOURCE } as T;
|
||||
}
|
||||
const businessPlanDetailMatch = pathname.match(/^\/api\/digital-employee\/plans\/([^/]+)$/);
|
||||
if (method === 'GET' && businessPlanDetailMatch) {
|
||||
const planId = decodeURIComponent(businessPlanDetailMatch[1] || '');
|
||||
@@ -2324,6 +2328,46 @@ function businessApprovalRows(snapshot: QimingclawSnapshot | null): BusinessView
|
||||
});
|
||||
}
|
||||
|
||||
function skillCallAuditRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
|
||||
return runtimeEvents(snapshot)
|
||||
.filter((event) => ['skill_call_allowed', 'skill_call_rejected', 'skill_call_observed'].includes(event.kind))
|
||||
.map((event) => {
|
||||
const payload = asRecord(event.payload) ?? {};
|
||||
return {
|
||||
audit_id: event.id,
|
||||
event_id: event.id,
|
||||
remote_id: event.remoteId ?? null,
|
||||
decision: stringValue(payload.decision) || event.kind.replace('skill_call_', ''),
|
||||
source: stringValue(payload.source) || null,
|
||||
server_id: stringValue(payload.server_id) || null,
|
||||
tool_name: stringValue(payload.tool_name) || null,
|
||||
skill_id: stringValue(payload.skill_id) || null,
|
||||
call_id: stringValue(payload.call_id) || null,
|
||||
session_id: stringValue(payload.session_id) || null,
|
||||
task_id: (event.taskId ?? stringValue(payload.task_id)) || null,
|
||||
run_id: (event.runId ?? stringValue(payload.run_id)) || null,
|
||||
reason_codes: Array.isArray(payload.reason_codes) ? payload.reason_codes : [],
|
||||
message: event.message,
|
||||
occurred_at: event.occurredAt,
|
||||
sync_status: event.syncStatus ?? null,
|
||||
payload,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => stringValue(right.occurred_at).localeCompare(stringValue(left.occurred_at)));
|
||||
}
|
||||
|
||||
function filterSkillCallAuditRows(rows: BusinessViewRow[], searchParams: URLSearchParams): BusinessViewRow[] {
|
||||
const skillId = stringValue(searchParams.get('skill_id'));
|
||||
const serverId = stringValue(searchParams.get('server_id'));
|
||||
const toolName = stringValue(searchParams.get('tool_name'));
|
||||
const decision = stringValue(searchParams.get('decision'));
|
||||
return rows
|
||||
.filter((row) => !skillId || stringValue(row.skill_id) === skillId)
|
||||
.filter((row) => !serverId || stringValue(row.server_id) === serverId)
|
||||
.filter((row) => !toolName || stringValue(row.tool_name) === toolName)
|
||||
.filter((row) => !decision || stringValue(row.decision) === decision);
|
||||
}
|
||||
|
||||
function dailyReportRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
|
||||
return buildArtifacts(snapshot)
|
||||
.filter(isDailyReportArtifact)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,7 +16,7 @@
|
||||
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
|
||||
}
|
||||
</script>
|
||||
<script type="module" crossorigin src="./assets/index-CRPlZfKF.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-CdwW2SHB.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BarRiSjT.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -26,6 +26,7 @@ const testFiles = [
|
||||
"src/main/services/digitalEmployee/schedulerService.test.ts",
|
||||
"src/main/ipc/digitalEmployeeHandlers.test.ts",
|
||||
"src/main/ipc/eventForwarders.test.ts",
|
||||
"src/main/services/engines/acp/acpEngine.test.ts",
|
||||
"src/main/services/digitalEmployee/syncService.test.ts",
|
||||
"src/main/services/digitalEmployee/skillCatalogService.test.ts",
|
||||
"src/main/services/digitalEmployee/planTemplateService.test.ts",
|
||||
|
||||
@@ -12,6 +12,16 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const mockDigitalEmployee = vi.hoisted(() => ({
|
||||
recordRuntimeEvent: vi.fn(),
|
||||
recordSkillCallAudit: vi.fn(),
|
||||
evaluateSkillCall: vi.fn(() => ({
|
||||
decision: "observed",
|
||||
reason_codes: ["skill_not_matched"],
|
||||
source: "qimingclaw-acp",
|
||||
server_id: null,
|
||||
tool_name: "Write",
|
||||
skill_id: null,
|
||||
policy_snapshot: {},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("electron-log", () => ({
|
||||
@@ -29,6 +39,17 @@ vi.mock("../services/computerServer", () => ({
|
||||
|
||||
vi.mock("../services/digitalEmployee/stateService", () => ({
|
||||
recordDigitalEmployeeRuntimeEvent: mockDigitalEmployee.recordRuntimeEvent,
|
||||
recordDigitalEmployeeSkillCallAudit: mockDigitalEmployee.recordSkillCallAudit,
|
||||
}));
|
||||
|
||||
vi.mock("../services/digitalEmployee/skillExecutionPolicy", () => ({
|
||||
evaluateDigitalEmployeeSkillCall: mockDigitalEmployee.evaluateSkillCall,
|
||||
}));
|
||||
|
||||
vi.mock("../services/packages/mcp", () => ({
|
||||
mcpProxyManager: {
|
||||
getConfig: () => ({ mcpServers: {} }),
|
||||
},
|
||||
}));
|
||||
|
||||
// Use dynamic import to get the actual agentService (an EventEmitter) after mocking
|
||||
@@ -69,6 +90,8 @@ describe("eventForwarders", () => {
|
||||
unregisterEventForwarders();
|
||||
vi.clearAllMocks();
|
||||
mockDigitalEmployee.recordRuntimeEvent.mockClear();
|
||||
mockDigitalEmployee.recordSkillCallAudit.mockClear();
|
||||
mockDigitalEmployee.evaluateSkillCall.mockClear();
|
||||
});
|
||||
|
||||
it("registerEventForwarders registers listeners on agentService", () => {
|
||||
@@ -202,6 +225,13 @@ describe("eventForwarders", () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockDigitalEmployee.recordSkillCallAudit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
callId: "tool-1",
|
||||
decision: "observed",
|
||||
reasonCodes: ["skill_not_matched"],
|
||||
sessionId: "session-1",
|
||||
}));
|
||||
expect(JSON.stringify(mockDigitalEmployee.recordSkillCallAudit.mock.calls[0][0])).not.toContain("/tmp/report.md");
|
||||
});
|
||||
|
||||
it("projects permission and file events into approval and artifact hints", () => {
|
||||
|
||||
@@ -2,7 +2,12 @@ import { agentService } from "../services/engines/unifiedAgent";
|
||||
import type { UnifiedSessionMessage } from "../services/engines/unifiedAgent";
|
||||
import { pushSseEvent } from "../services/computerServer";
|
||||
import { firstTokenTrace } from "../services/engines/perf/firstTokenTrace";
|
||||
import { recordDigitalEmployeeRuntimeEvent } from "../services/digitalEmployee/stateService";
|
||||
import {
|
||||
recordDigitalEmployeeRuntimeEvent,
|
||||
recordDigitalEmployeeSkillCallAudit,
|
||||
} from "../services/digitalEmployee/stateService";
|
||||
import { evaluateDigitalEmployeeSkillCall } from "../services/digitalEmployee/skillExecutionPolicy";
|
||||
import { mcpProxyManager } from "../services/packages/mcp";
|
||||
import type { HandlerContext } from "@shared/types/ipc";
|
||||
import log from "electron-log";
|
||||
|
||||
@@ -25,6 +30,42 @@ function readEventField(data: unknown, keys: string[]): string | undefined {
|
||||
function recordForwardedEvent(eventType: string, data: unknown): void {
|
||||
const normalized = normalizeForwardedEvent(eventType, data);
|
||||
recordDigitalEmployeeRuntimeEvent(normalized);
|
||||
recordSkillCallAuditForForwardedEvent(eventType, data, normalized);
|
||||
}
|
||||
|
||||
function recordSkillCallAuditForForwardedEvent(
|
||||
eventType: string,
|
||||
data: unknown,
|
||||
normalized: Parameters<typeof recordDigitalEmployeeRuntimeEvent>[0],
|
||||
): void {
|
||||
const subtype = readEventField(data, ["subType", "sessionUpdate", "type", "messageType"]);
|
||||
if (eventType !== "computer:progress" || (subtype !== "tool_call" && subtype !== "tool_call_update")) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const toolName = readEventField(data, ["toolTitle", "title", "name", "kind"]);
|
||||
const policy = evaluateDigitalEmployeeSkillCall({
|
||||
source: "qimingclaw-acp",
|
||||
toolName,
|
||||
callId: readEventField(data, ["toolCallId", "id"]) || `${subtype}:${Date.now()}`,
|
||||
sessionId: readEventField(data, ["sessionId", "session_id", "id"]),
|
||||
mcpConfig: mcpProxyManager.getConfig(),
|
||||
});
|
||||
recordDigitalEmployeeSkillCallAudit({
|
||||
callId: readEventField(data, ["toolCallId", "id"]) || `${subtype}:${Date.now()}`,
|
||||
source: policy.source,
|
||||
serverId: policy.server_id,
|
||||
toolName: policy.tool_name || toolName,
|
||||
skillId: policy.skill_id,
|
||||
decision: policy.decision,
|
||||
reasonCodes: policy.reason_codes,
|
||||
policySnapshot: policy.policy_snapshot,
|
||||
sessionId: normalized.session_id,
|
||||
status: readEventField(data, ["status"]) || normalized.status,
|
||||
});
|
||||
} catch (error) {
|
||||
log.warn("[EventForwarders] Failed to record skill call audit:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeForwardedEvent(
|
||||
|
||||
@@ -311,4 +311,81 @@ describe("digital employee skill catalog service", () => {
|
||||
}),
|
||||
]));
|
||||
});
|
||||
|
||||
it("removes disabled MCP server skills from agent execution config", async () => {
|
||||
const {
|
||||
applyDigitalEmployeeMcpExecutionPolicy,
|
||||
setDigitalEmployeeSkillEnabled,
|
||||
} = await import("./skillCatalogService");
|
||||
|
||||
setDigitalEmployeeSkillEnabled("qimingclaw-mcp-server-crm", false);
|
||||
|
||||
const result = applyDigitalEmployeeMcpExecutionPolicy(mockMcp.config);
|
||||
|
||||
expect(result.mcpServers).not.toHaveProperty("crm");
|
||||
});
|
||||
|
||||
it("narrows MCP allowTools when a tool skill is disabled", async () => {
|
||||
const {
|
||||
applyDigitalEmployeeMcpExecutionPolicy,
|
||||
setDigitalEmployeeSkillEnabled,
|
||||
} = await import("./skillCatalogService");
|
||||
|
||||
setDigitalEmployeeSkillEnabled("qimingclaw-mcp-tool-crm-crm-export", false);
|
||||
|
||||
const result = applyDigitalEmployeeMcpExecutionPolicy(mockMcp.config);
|
||||
|
||||
expect(result.mcpServers?.crm?.allowTools).toEqual(["crm.search"]);
|
||||
});
|
||||
|
||||
it("adds disabled MCP tools to denyTools when no allowTools exists", async () => {
|
||||
const {
|
||||
applyDigitalEmployeeMcpExecutionPolicy,
|
||||
setDigitalEmployeeSkillEnabled,
|
||||
} = await import("./skillCatalogService");
|
||||
|
||||
setDigitalEmployeeSkillEnabled("qimingclaw-mcp-tool-files-file-delete", false);
|
||||
|
||||
const result = applyDigitalEmployeeMcpExecutionPolicy({
|
||||
mcpServers: {
|
||||
files: {
|
||||
command: "node",
|
||||
args: ["files.js"],
|
||||
discoveredTools: ["file.read", "file.delete"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.mcpServers?.files?.denyTools).toEqual(["file.delete"]);
|
||||
});
|
||||
|
||||
it("rejects denied or allow-missing MCP calls and observes unknown calls", async () => {
|
||||
const { evaluateDigitalEmployeeSkillCall } = await import("./skillCatalogService");
|
||||
|
||||
expect(evaluateDigitalEmployeeSkillCall({
|
||||
toolName: "crm.delete",
|
||||
mcpConfig: mockMcp.config,
|
||||
})).toEqual(expect.objectContaining({
|
||||
decision: "rejected",
|
||||
reason_codes: ["mcp_tool_denied"],
|
||||
skill_id: "qimingclaw-mcp-tool-crm-crm-delete",
|
||||
}));
|
||||
|
||||
expect(evaluateDigitalEmployeeSkillCall({
|
||||
serverId: "crm",
|
||||
toolName: "crm.update",
|
||||
mcpConfig: mockMcp.config,
|
||||
})).toEqual(expect.objectContaining({
|
||||
decision: "rejected",
|
||||
reason_codes: ["mcp_tool_not_allowed"],
|
||||
}));
|
||||
|
||||
expect(evaluateDigitalEmployeeSkillCall({
|
||||
toolName: "unknown_tool",
|
||||
mcpConfig: mockMcp.config,
|
||||
})).toEqual(expect.objectContaining({
|
||||
decision: "observed",
|
||||
reason_codes: ["skill_not_matched"],
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,8 +5,21 @@ import { getGuiAgentServerStatus } from "../packages/guiAgentServer";
|
||||
import { getWindowsMcpStatus } from "../packages/windowsMcp";
|
||||
import { isWindows } from "../system/shellEnv";
|
||||
import { getConfiguredPorts } from "../startupPorts";
|
||||
import { readSetting, writeSetting } from "../../db";
|
||||
import { writeSetting } from "../../db";
|
||||
import { readDigitalEmployeeRuntimeRecords } from "./stateService";
|
||||
import {
|
||||
SKILL_POLICIES_SETTING_KEY,
|
||||
readDigitalEmployeeSkillPolicies as readDigitalEmployeeSkillPoliciesImpl,
|
||||
normalizeDigitalEmployeeSkillPolicies as normalizeDigitalEmployeeSkillPoliciesImpl,
|
||||
skillIdForMcpServer,
|
||||
skillIdForMcpTool,
|
||||
applyDigitalEmployeeMcpExecutionPolicy as applyDigitalEmployeeMcpExecutionPolicyImpl,
|
||||
evaluateDigitalEmployeeSkillCall as evaluateDigitalEmployeeSkillCallImpl,
|
||||
type DigitalEmployeeSkillPolicies,
|
||||
type DigitalEmployeeMcpPolicyConfig,
|
||||
type DigitalEmployeeSkillCallPolicyInput,
|
||||
type DigitalEmployeeSkillCallPolicyResult,
|
||||
} from "./skillExecutionPolicy";
|
||||
|
||||
export interface DigitalEmployeeSkillRecentCall {
|
||||
event_id: string;
|
||||
@@ -85,16 +98,6 @@ export interface DigitalEmployeeSkillCatalogOptions {
|
||||
guiServerStatus?: DigitalEmployeeLocalServiceStatus | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeSkillPolicyRecord {
|
||||
enabled: boolean;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeSkillPolicies {
|
||||
version: 1;
|
||||
skills: Record<string, DigitalEmployeeSkillPolicyRecord>;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeSkillPolicyUpdateResult {
|
||||
ok: boolean;
|
||||
skill_id: string;
|
||||
@@ -102,8 +105,6 @@ export interface DigitalEmployeeSkillPolicyUpdateResult {
|
||||
policies: DigitalEmployeeSkillPolicies;
|
||||
}
|
||||
|
||||
const SKILL_POLICIES_SETTING_KEY = "digital_employee_skill_policies_v1";
|
||||
|
||||
const ACP_SKILLS: AcpSkillDefinition[] = [
|
||||
{
|
||||
skill_id: "qimingclaw-acp-session",
|
||||
@@ -143,6 +144,26 @@ const ACP_SKILLS: AcpSkillDefinition[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export function readDigitalEmployeeSkillPolicies(): DigitalEmployeeSkillPolicies {
|
||||
return readDigitalEmployeeSkillPoliciesImpl();
|
||||
}
|
||||
|
||||
export function normalizeDigitalEmployeeSkillPolicies(value: unknown): DigitalEmployeeSkillPolicies {
|
||||
return normalizeDigitalEmployeeSkillPoliciesImpl(value);
|
||||
}
|
||||
|
||||
export function applyDigitalEmployeeMcpExecutionPolicy(
|
||||
config: DigitalEmployeeMcpPolicyConfig,
|
||||
): DigitalEmployeeMcpPolicyConfig {
|
||||
return applyDigitalEmployeeMcpExecutionPolicyImpl(config);
|
||||
}
|
||||
|
||||
export function evaluateDigitalEmployeeSkillCall(
|
||||
input: DigitalEmployeeSkillCallPolicyInput,
|
||||
): DigitalEmployeeSkillCallPolicyResult {
|
||||
return evaluateDigitalEmployeeSkillCallImpl(input);
|
||||
}
|
||||
|
||||
const GUI_AGENT_TOOLS = [
|
||||
"gui_execute_task",
|
||||
"gui_analyze_screen",
|
||||
@@ -223,7 +244,7 @@ export function listDigitalEmployeeSkillCapabilities(
|
||||
const serverCalls = recentCallsForServer(calls, serverId, effectiveTools);
|
||||
|
||||
skills.push({
|
||||
skill_id: `qimingclaw-mcp-server-${slugifySkillPart(serverId)}`,
|
||||
skill_id: skillIdForMcpServer(serverId),
|
||||
name: `MCP 服务:${serverId}`,
|
||||
version: "1.0.0",
|
||||
enabled,
|
||||
@@ -250,7 +271,7 @@ export function listDigitalEmployeeSkillCapabilities(
|
||||
for (const toolName of effectiveTools.slice(0, 120)) {
|
||||
const toolCalls = recentCallsForTool(calls, toolName);
|
||||
skills.push({
|
||||
skill_id: `qimingclaw-mcp-tool-${slugifySkillPart(serverId)}-${slugifySkillPart(toolName)}`,
|
||||
skill_id: skillIdForMcpTool(serverId, toolName),
|
||||
name: `MCP 工具:${toolName}`,
|
||||
version: "1.0.0",
|
||||
enabled,
|
||||
@@ -284,10 +305,6 @@ export function listDigitalEmployeeSkillCapabilities(
|
||||
};
|
||||
}
|
||||
|
||||
export function readDigitalEmployeeSkillPolicies(): DigitalEmployeeSkillPolicies {
|
||||
return normalizeSkillPolicies(readSetting(SKILL_POLICIES_SETTING_KEY));
|
||||
}
|
||||
|
||||
export function setDigitalEmployeeSkillEnabled(
|
||||
skillId: string,
|
||||
enabled: boolean,
|
||||
@@ -335,27 +352,6 @@ function applySkillPolicies(
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeSkillPolicies(value: unknown): DigitalEmployeeSkillPolicies {
|
||||
const record = objectRecord(value);
|
||||
const rawSkills = objectRecord(record?.skills) ?? record;
|
||||
const skills: Record<string, DigitalEmployeeSkillPolicyRecord> = {};
|
||||
for (const [rawSkillId, rawPolicy] of Object.entries(rawSkills ?? {})) {
|
||||
const skillId = normalizeSkillId(rawSkillId);
|
||||
if (!skillId) continue;
|
||||
if (typeof rawPolicy === "boolean") {
|
||||
skills[skillId] = { enabled: rawPolicy, updatedAt: "" };
|
||||
continue;
|
||||
}
|
||||
const policy = objectRecord(rawPolicy);
|
||||
if (!policy || typeof policy.enabled !== "boolean") continue;
|
||||
skills[skillId] = {
|
||||
enabled: policy.enabled,
|
||||
updatedAt: stringValue(policy.updatedAt ?? policy.updated_at) || "",
|
||||
};
|
||||
}
|
||||
return { version: 1, skills };
|
||||
}
|
||||
|
||||
function normalizeSkillId(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
@@ -527,7 +523,9 @@ function toAcpRecentCall(
|
||||
event_id: event.id,
|
||||
kind: event.kind,
|
||||
message: event.message,
|
||||
status: stringValue(payload?.status) || null,
|
||||
status: event.kind.startsWith("skill_call_")
|
||||
? stringValue(payload?.decision) || stringValue(payload?.status) || null
|
||||
: stringValue(payload?.status) || null,
|
||||
occurred_at: event.occurredAt,
|
||||
tool_name: toolName,
|
||||
project_id: stringValue(payload?.projectId ?? payload?.project_id) || null,
|
||||
@@ -695,7 +693,9 @@ function toRecentCall(event: {
|
||||
event_id: event.id,
|
||||
kind: event.kind,
|
||||
message: event.message,
|
||||
status: stringValue(payload?.status) || null,
|
||||
status: event.kind.startsWith("skill_call_")
|
||||
? stringValue(payload?.decision) || stringValue(payload?.status) || null
|
||||
: stringValue(payload?.status) || null,
|
||||
occurred_at: event.occurredAt,
|
||||
tool_name: toolName,
|
||||
project_id: stringValue(payload?.projectId ?? payload?.project_id) || null,
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import { readSetting } from "../../db";
|
||||
|
||||
export interface DigitalEmployeeSkillPolicyRecord {
|
||||
enabled: boolean;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeSkillPolicies {
|
||||
version: 1;
|
||||
skills: Record<string, DigitalEmployeeSkillPolicyRecord>;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeMcpServerPolicyEntry {
|
||||
allowTools?: string[];
|
||||
denyTools?: string[];
|
||||
discoveredTools?: string[];
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeMcpPolicyConfig {
|
||||
allowTools?: string[];
|
||||
denyTools?: string[];
|
||||
mcpServers?: Record<string, DigitalEmployeeMcpServerPolicyEntry>;
|
||||
}
|
||||
|
||||
export type DigitalEmployeeSkillCallDecision = "allowed" | "rejected" | "observed";
|
||||
|
||||
export interface DigitalEmployeeSkillCallPolicyInput {
|
||||
source?: string | null;
|
||||
serverId?: string | null;
|
||||
toolName?: string | null;
|
||||
skillId?: string | null;
|
||||
callId?: string | null;
|
||||
sessionId?: string | null;
|
||||
taskId?: string | null;
|
||||
runId?: string | null;
|
||||
mcpConfig?: DigitalEmployeeMcpPolicyConfig | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeSkillCallPolicyResult {
|
||||
decision: DigitalEmployeeSkillCallDecision;
|
||||
reason_codes: string[];
|
||||
source: string;
|
||||
server_id: string | null;
|
||||
tool_name: string | null;
|
||||
skill_id: string | null;
|
||||
policy_snapshot: {
|
||||
skill_enabled?: boolean;
|
||||
matched_skill_id?: string | null;
|
||||
server_enabled?: boolean;
|
||||
global_allow_tools: string[];
|
||||
global_deny_tools: string[];
|
||||
server_allow_tools: string[];
|
||||
server_deny_tools: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export const SKILL_POLICIES_SETTING_KEY = "digital_employee_skill_policies_v1";
|
||||
|
||||
export function readDigitalEmployeeSkillPolicies(): DigitalEmployeeSkillPolicies {
|
||||
return normalizeSkillPolicies(readSetting(SKILL_POLICIES_SETTING_KEY));
|
||||
}
|
||||
|
||||
export function normalizeDigitalEmployeeSkillPolicies(value: unknown): DigitalEmployeeSkillPolicies {
|
||||
return normalizeSkillPolicies(value);
|
||||
}
|
||||
|
||||
export function skillIdForMcpServer(serverId: string): string {
|
||||
return `qimingclaw-mcp-server-${slugifySkillPart(serverId)}`;
|
||||
}
|
||||
|
||||
export function skillIdForMcpTool(serverId: string, toolName: string): string {
|
||||
return `qimingclaw-mcp-tool-${slugifySkillPart(serverId)}-${slugifySkillPart(toolName)}`;
|
||||
}
|
||||
|
||||
export function applyDigitalEmployeeMcpExecutionPolicy(
|
||||
config: DigitalEmployeeMcpPolicyConfig,
|
||||
): DigitalEmployeeMcpPolicyConfig {
|
||||
const policies = readDigitalEmployeeSkillPolicies();
|
||||
const servers = config.mcpServers ?? {};
|
||||
const nextServers: Record<string, DigitalEmployeeMcpServerPolicyEntry> = {};
|
||||
const globalAllowTools = normalizeToolList(config.allowTools);
|
||||
const globalDenyTools = normalizeToolList(config.denyTools);
|
||||
|
||||
for (const [serverId, entry] of Object.entries(servers)) {
|
||||
if (entry.enabled === false || isSkillDisabled(policies, skillIdForMcpServer(serverId))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const discoveredTools = normalizeToolList(entry.discoveredTools);
|
||||
const serverAllowTools = normalizeToolList(entry.allowTools);
|
||||
const serverDenyTools = normalizeToolList(entry.denyTools);
|
||||
const effectiveAllowTools = serverAllowTools.length > 0 ? serverAllowTools : globalAllowTools;
|
||||
const effectiveDenyTools = serverDenyTools.length > 0 ? serverDenyTools : globalDenyTools;
|
||||
const candidateTools = uniqueStrings([
|
||||
...discoveredTools,
|
||||
...serverAllowTools,
|
||||
...serverDenyTools,
|
||||
...globalAllowTools,
|
||||
...globalDenyTools,
|
||||
]);
|
||||
const disabledTools = candidateTools.filter((tool) => isSkillDisabled(policies, skillIdForMcpTool(serverId, tool)));
|
||||
const disabledSet = new Set(disabledTools);
|
||||
|
||||
if (effectiveAllowTools.length > 0) {
|
||||
const nextAllow = effectiveAllowTools.filter((tool) => !disabledSet.has(tool));
|
||||
if (nextAllow.length === 0) continue;
|
||||
nextServers[serverId] = {
|
||||
...entry,
|
||||
allowTools: nextAllow,
|
||||
denyTools: uniqueStrings([...effectiveDenyTools, ...serverDenyTools]),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
nextServers[serverId] = {
|
||||
...entry,
|
||||
...(disabledTools.length > 0 || effectiveDenyTools.length > 0
|
||||
? { denyTools: uniqueStrings([...effectiveDenyTools, ...disabledTools]) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
mcpServers: nextServers,
|
||||
};
|
||||
}
|
||||
|
||||
export function evaluateDigitalEmployeeSkillCall(
|
||||
input: DigitalEmployeeSkillCallPolicyInput,
|
||||
): DigitalEmployeeSkillCallPolicyResult {
|
||||
const policies = readDigitalEmployeeSkillPolicies();
|
||||
const toolName = normalizeString(input.toolName);
|
||||
const explicitServerId = normalizeString(input.serverId);
|
||||
const explicitSkillId = normalizeString(input.skillId);
|
||||
const mcpConfig = input.mcpConfig ?? null;
|
||||
const match = matchMcpSkill(input, mcpConfig);
|
||||
const serverId = explicitServerId || match.serverId;
|
||||
const skillId = explicitSkillId || match.toolSkillId || match.serverSkillId;
|
||||
const source = normalizeString(input.source) || (match.serverId ? "qimingclaw-mcp" : "qimingclaw-acp");
|
||||
const globalAllowTools = normalizeToolList(mcpConfig?.allowTools);
|
||||
const globalDenyTools = normalizeToolList(mcpConfig?.denyTools);
|
||||
const serverEntry = serverId ? mcpConfig?.mcpServers?.[serverId] : undefined;
|
||||
const serverAllowTools = normalizeToolList(serverEntry?.allowTools);
|
||||
const serverDenyTools = normalizeToolList(serverEntry?.denyTools);
|
||||
const effectiveAllowTools = serverAllowTools.length > 0 ? serverAllowTools : globalAllowTools;
|
||||
const effectiveDenyTools = serverDenyTools.length > 0 ? serverDenyTools : globalDenyTools;
|
||||
const reasonCodes: string[] = [];
|
||||
const matchedSkillId = skillId || null;
|
||||
const matchedPolicy = matchedSkillId ? policies.skills[matchedSkillId] : undefined;
|
||||
const serverPolicy = match.serverSkillId ? policies.skills[match.serverSkillId] : undefined;
|
||||
|
||||
if (!matchedSkillId && !match.serverId) {
|
||||
reasonCodes.push("skill_not_matched");
|
||||
return result("observed", reasonCodes, source, serverId || null, toolName || null, null, {
|
||||
matched_skill_id: null,
|
||||
global_allow_tools: globalAllowTools,
|
||||
global_deny_tools: globalDenyTools,
|
||||
server_allow_tools: serverAllowTools,
|
||||
server_deny_tools: serverDenyTools,
|
||||
});
|
||||
}
|
||||
|
||||
if (serverEntry?.enabled === false || serverPolicy?.enabled === false || matchedPolicy?.enabled === false) {
|
||||
reasonCodes.push("skill_disabled");
|
||||
return result("rejected", reasonCodes, source, serverId || null, toolName || null, matchedSkillId, {
|
||||
skill_enabled: false,
|
||||
matched_skill_id: matchedSkillId,
|
||||
server_enabled: serverEntry?.enabled !== false && serverPolicy?.enabled !== false,
|
||||
global_allow_tools: globalAllowTools,
|
||||
global_deny_tools: globalDenyTools,
|
||||
server_allow_tools: serverAllowTools,
|
||||
server_deny_tools: serverDenyTools,
|
||||
});
|
||||
}
|
||||
|
||||
if (toolName && effectiveDenyTools.includes(toolName)) {
|
||||
reasonCodes.push("mcp_tool_denied");
|
||||
return result("rejected", reasonCodes, source, serverId || null, toolName, matchedSkillId, {
|
||||
skill_enabled: true,
|
||||
matched_skill_id: matchedSkillId,
|
||||
server_enabled: true,
|
||||
global_allow_tools: globalAllowTools,
|
||||
global_deny_tools: globalDenyTools,
|
||||
server_allow_tools: serverAllowTools,
|
||||
server_deny_tools: serverDenyTools,
|
||||
});
|
||||
}
|
||||
|
||||
if (toolName && effectiveAllowTools.length > 0 && !effectiveAllowTools.includes(toolName)) {
|
||||
reasonCodes.push("mcp_tool_not_allowed");
|
||||
return result("rejected", reasonCodes, source, serverId || null, toolName, matchedSkillId, {
|
||||
skill_enabled: true,
|
||||
matched_skill_id: matchedSkillId,
|
||||
server_enabled: true,
|
||||
global_allow_tools: globalAllowTools,
|
||||
global_deny_tools: globalDenyTools,
|
||||
server_allow_tools: serverAllowTools,
|
||||
server_deny_tools: serverDenyTools,
|
||||
});
|
||||
}
|
||||
|
||||
reasonCodes.push("skill_policy_allowed");
|
||||
return result("allowed", reasonCodes, source, serverId || null, toolName || null, matchedSkillId, {
|
||||
skill_enabled: true,
|
||||
matched_skill_id: matchedSkillId,
|
||||
server_enabled: true,
|
||||
global_allow_tools: globalAllowTools,
|
||||
global_deny_tools: globalDenyTools,
|
||||
server_allow_tools: serverAllowTools,
|
||||
server_deny_tools: serverDenyTools,
|
||||
});
|
||||
}
|
||||
|
||||
function matchMcpSkill(
|
||||
input: DigitalEmployeeSkillCallPolicyInput,
|
||||
mcpConfig: DigitalEmployeeMcpPolicyConfig | null,
|
||||
): { serverId: string | null; serverSkillId: string | null; toolSkillId: string | null } {
|
||||
const explicitServerId = normalizeString(input.serverId);
|
||||
const toolName = normalizeString(input.toolName);
|
||||
const explicitSkillId = normalizeString(input.skillId);
|
||||
if (explicitSkillId.startsWith("qimingclaw-mcp-server-")) {
|
||||
const serverId = explicitServerId || serverIdForSkillId(explicitSkillId, mcpConfig);
|
||||
return { serverId, serverSkillId: explicitSkillId, toolSkillId: null };
|
||||
}
|
||||
if (explicitSkillId.startsWith("qimingclaw-mcp-tool-")) {
|
||||
const serverId = explicitServerId || serverIdForSkillId(explicitSkillId, mcpConfig);
|
||||
return { serverId, serverSkillId: serverId ? skillIdForMcpServer(serverId) : null, toolSkillId: explicitSkillId };
|
||||
}
|
||||
if (explicitServerId) {
|
||||
return {
|
||||
serverId: explicitServerId,
|
||||
serverSkillId: skillIdForMcpServer(explicitServerId),
|
||||
toolSkillId: toolName ? skillIdForMcpTool(explicitServerId, toolName) : null,
|
||||
};
|
||||
}
|
||||
if (!toolName || !mcpConfig?.mcpServers) {
|
||||
return { serverId: null, serverSkillId: null, toolSkillId: null };
|
||||
}
|
||||
for (const [serverId, entry] of Object.entries(mcpConfig.mcpServers)) {
|
||||
const candidates = normalizeToolList([
|
||||
...normalizeToolList(entry.discoveredTools),
|
||||
...normalizeToolList(entry.allowTools),
|
||||
...normalizeToolList(entry.denyTools),
|
||||
...normalizeToolList(mcpConfig.allowTools),
|
||||
...normalizeToolList(mcpConfig.denyTools),
|
||||
]);
|
||||
if (candidates.includes(toolName)) {
|
||||
return {
|
||||
serverId,
|
||||
serverSkillId: skillIdForMcpServer(serverId),
|
||||
toolSkillId: skillIdForMcpTool(serverId, toolName),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { serverId: null, serverSkillId: null, toolSkillId: null };
|
||||
}
|
||||
|
||||
function serverIdForSkillId(skillId: string, mcpConfig: DigitalEmployeeMcpPolicyConfig | null): string | null {
|
||||
for (const serverId of Object.keys(mcpConfig?.mcpServers ?? {})) {
|
||||
if (skillId === skillIdForMcpServer(serverId)) return serverId;
|
||||
if (skillId.startsWith(`qimingclaw-mcp-tool-${slugifySkillPart(serverId)}-`)) return serverId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function result(
|
||||
decision: DigitalEmployeeSkillCallDecision,
|
||||
reasonCodes: string[],
|
||||
source: string,
|
||||
serverId: string | null,
|
||||
toolName: string | null,
|
||||
skillId: string | null,
|
||||
policySnapshot: DigitalEmployeeSkillCallPolicyResult["policy_snapshot"],
|
||||
): DigitalEmployeeSkillCallPolicyResult {
|
||||
return {
|
||||
decision,
|
||||
reason_codes: reasonCodes,
|
||||
source,
|
||||
server_id: serverId,
|
||||
tool_name: toolName,
|
||||
skill_id: skillId,
|
||||
policy_snapshot: policySnapshot,
|
||||
};
|
||||
}
|
||||
|
||||
function isSkillDisabled(policies: DigitalEmployeeSkillPolicies, skillId: string): boolean {
|
||||
return policies.skills[skillId]?.enabled === false;
|
||||
}
|
||||
|
||||
function normalizeSkillPolicies(value: unknown): DigitalEmployeeSkillPolicies {
|
||||
const record = objectRecord(value);
|
||||
const rawSkills = objectRecord(record?.skills) ?? record;
|
||||
const skills: Record<string, DigitalEmployeeSkillPolicyRecord> = {};
|
||||
for (const [rawSkillId, rawPolicy] of Object.entries(rawSkills ?? {})) {
|
||||
const skillId = normalizeString(rawSkillId);
|
||||
if (!skillId) continue;
|
||||
if (typeof rawPolicy === "boolean") {
|
||||
skills[skillId] = { enabled: rawPolicy, updatedAt: "" };
|
||||
continue;
|
||||
}
|
||||
const policy = objectRecord(rawPolicy);
|
||||
if (!policy || typeof policy.enabled !== "boolean") continue;
|
||||
skills[skillId] = {
|
||||
enabled: policy.enabled,
|
||||
updatedAt: normalizeString(policy.updatedAt ?? policy.updated_at),
|
||||
};
|
||||
}
|
||||
return { version: 1, skills };
|
||||
}
|
||||
|
||||
function normalizeToolList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return uniqueStrings(value.filter((item): item is string => (
|
||||
typeof item === "string" && item.trim().length > 0
|
||||
)).map((item) => item.trim()));
|
||||
}
|
||||
|
||||
function uniqueStrings(values: string[]): string[] {
|
||||
return [...new Set(values.filter((value) => value.trim()).map((value) => value.trim()))];
|
||||
}
|
||||
|
||||
function normalizeString(value: unknown): string {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : "";
|
||||
}
|
||||
|
||||
function objectRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function slugifySkillPart(value: string): string {
|
||||
const slug = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
if (slug) return slug;
|
||||
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
|
||||
}
|
||||
return `skill-${hash.toString(16)}`;
|
||||
}
|
||||
@@ -317,6 +317,65 @@ describe("digital employee state service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("records skill call audit events without storing sensitive raw input", async () => {
|
||||
const { recordDigitalEmployeeSkillCallAudit } = await import("./stateService");
|
||||
|
||||
const rejected = recordDigitalEmployeeSkillCallAudit({
|
||||
callId: "tool-call-1",
|
||||
source: "qimingclaw-mcp",
|
||||
serverId: "crm",
|
||||
toolName: "crm.delete",
|
||||
skillId: "qimingclaw-mcp-tool-crm-crm-delete",
|
||||
decision: "rejected",
|
||||
reasonCodes: ["mcp_tool_denied"],
|
||||
policySnapshot: {
|
||||
server_deny_tools: ["crm.delete"],
|
||||
rawInput: { token: "should-not-be-persisted" },
|
||||
},
|
||||
sessionId: "session-a",
|
||||
status: "permission_request",
|
||||
occurredAt: "2026-06-07T08:32:00.000Z",
|
||||
});
|
||||
const allowed = recordDigitalEmployeeSkillCallAudit({
|
||||
callId: "tool-call-2",
|
||||
source: "qimingclaw-mcp",
|
||||
serverId: "crm",
|
||||
toolName: "crm.search",
|
||||
skillId: "qimingclaw-mcp-tool-crm-crm-search",
|
||||
decision: "allowed",
|
||||
reasonCodes: ["skill_policy_allowed"],
|
||||
policySnapshot: { server_allow_tools: ["crm.search"] },
|
||||
sessionId: "session-a",
|
||||
occurredAt: "2026-06-07T08:33:00.000Z",
|
||||
});
|
||||
|
||||
expect(rejected?.eventId).toBe("skill-call:rejected:tool-call-1:2026-06-07T08-32-00-000Z");
|
||||
expect(allowed?.eventId).toBe("skill-call:allowed:tool-call-2:2026-06-07T08-33-00-000Z");
|
||||
expect(mockState.db?.events.get(rejected!.eventId)).toMatchObject({
|
||||
kind: "skill_call_rejected",
|
||||
message: "技能调用已拒绝:crm.delete",
|
||||
});
|
||||
expect(mockState.db?.events.get(allowed!.eventId)).toMatchObject({
|
||||
kind: "skill_call_allowed",
|
||||
message: "技能调用已允许:crm.search",
|
||||
});
|
||||
const payload = JSON.parse(mockState.db?.events.get(rejected!.eventId)?.payload ?? "{}");
|
||||
expect(payload).toMatchObject({
|
||||
call_id: "tool-call-1",
|
||||
source: "qimingclaw-mcp",
|
||||
server_id: "crm",
|
||||
tool_name: "crm.delete",
|
||||
decision: "rejected",
|
||||
reason_codes: ["mcp_tool_denied"],
|
||||
session_id: "session-a",
|
||||
});
|
||||
expect(JSON.stringify(payload)).not.toContain("should-not-be-persisted");
|
||||
expect(mockState.db?.outbox.get(`event:insert:${rejected!.eventId}`)).toMatchObject({
|
||||
entity_type: "event",
|
||||
status: "pending",
|
||||
});
|
||||
});
|
||||
|
||||
it("records and lists route decisions as syncable events", async () => {
|
||||
const {
|
||||
listDigitalEmployeeRouteDecisions,
|
||||
|
||||
@@ -186,6 +186,32 @@ export interface DigitalEmployeeEventRecord {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type DigitalEmployeeSkillCallDecision = "allowed" | "rejected" | "observed";
|
||||
|
||||
export interface DigitalEmployeeSkillCallAuditInput {
|
||||
callId?: string | null;
|
||||
source?: string | null;
|
||||
serverId?: string | null;
|
||||
toolName?: string | null;
|
||||
skillId?: string | null;
|
||||
decision: DigitalEmployeeSkillCallDecision;
|
||||
reasonCodes?: string[] | null;
|
||||
policySnapshot?: Record<string, unknown> | null;
|
||||
sessionId?: string | null;
|
||||
taskId?: string | null;
|
||||
runId?: string | null;
|
||||
status?: string | null;
|
||||
occurredAt?: string | null;
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeSkillCallAuditRecord {
|
||||
eventId: string;
|
||||
kind: "skill_call_allowed" | "skill_call_rejected" | "skill_call_observed";
|
||||
decision: DigitalEmployeeSkillCallDecision;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeRouteDecisionRecord {
|
||||
id: string;
|
||||
route_kind: string;
|
||||
@@ -943,6 +969,80 @@ export function listDigitalEmployeeRouteDecisions(options: { limit?: number } =
|
||||
.filter((decision): decision is DigitalEmployeeRouteDecisionRecord => Boolean(decision));
|
||||
}
|
||||
|
||||
export function recordDigitalEmployeeSkillCallAudit(
|
||||
input: DigitalEmployeeSkillCallAuditInput,
|
||||
): DigitalEmployeeSkillCallAuditRecord | null {
|
||||
const db = getDigitalEmployeeDb();
|
||||
if (!db) return null;
|
||||
const now = input.occurredAt || new Date().toISOString();
|
||||
const decision: DigitalEmployeeSkillCallDecision = ["allowed", "rejected", "observed"].includes(input.decision)
|
||||
? input.decision
|
||||
: "observed";
|
||||
const kind = `skill_call_${decision}` as DigitalEmployeeSkillCallAuditRecord["kind"];
|
||||
const callId = stringValue(input.callId)
|
||||
|| stringValue(input.runId)
|
||||
|| stringValue(input.sessionId)
|
||||
|| `${Date.now()}`;
|
||||
const eventId = `skill-call:${decision}:${safeId(callId)}:${safeId(now)}`;
|
||||
const source = stringValue(input.source) || "qimingclaw-acp";
|
||||
const toolName = stringValue(input.toolName) || null;
|
||||
const serverId = stringValue(input.serverId) || null;
|
||||
const skillId = stringValue(input.skillId) || null;
|
||||
const payload: Record<string, unknown> = {
|
||||
call_id: callId,
|
||||
source,
|
||||
server_id: serverId,
|
||||
tool_name: toolName,
|
||||
skill_id: skillId,
|
||||
decision,
|
||||
reason_codes: normalizeSkillIds(input.reasonCodes ?? []),
|
||||
policy_snapshot: sanitizeSkillPolicySnapshot(objectRecord(input.policySnapshot) ?? {}),
|
||||
session_id: stringValue(input.sessionId) || null,
|
||||
task_id: stringValue(input.taskId) || null,
|
||||
run_id: stringValue(input.runId) || null,
|
||||
status: stringValue(input.status) || decision,
|
||||
};
|
||||
insertEvent(
|
||||
{
|
||||
event_id: eventId,
|
||||
kind,
|
||||
occurred_at: now,
|
||||
message: input.message || skillCallAuditMessage(decision, toolName, skillId),
|
||||
plan_id: null,
|
||||
task_id: stringValue(input.taskId) || null,
|
||||
},
|
||||
stringValue(input.runId) || null,
|
||||
payload,
|
||||
);
|
||||
return { eventId, kind, decision, payload };
|
||||
}
|
||||
|
||||
function sanitizeSkillPolicySnapshot(snapshot: Record<string, unknown>): Record<string, unknown> {
|
||||
const allowedKeys = new Set([
|
||||
"skill_enabled",
|
||||
"matched_skill_id",
|
||||
"server_enabled",
|
||||
"global_allow_tools",
|
||||
"global_deny_tools",
|
||||
"server_allow_tools",
|
||||
"server_deny_tools",
|
||||
]);
|
||||
return Object.fromEntries(
|
||||
Object.entries(snapshot).filter(([key]) => allowedKeys.has(key)),
|
||||
);
|
||||
}
|
||||
|
||||
function skillCallAuditMessage(
|
||||
decision: DigitalEmployeeSkillCallDecision,
|
||||
toolName: string | null,
|
||||
skillId: string | null,
|
||||
): string {
|
||||
const target = toolName || skillId || "unknown";
|
||||
if (decision === "rejected") return `技能调用已拒绝:${target}`;
|
||||
if (decision === "allowed") return `技能调用已允许:${target}`;
|
||||
return `技能调用已观测:${target}`;
|
||||
}
|
||||
|
||||
function routeDecisionFromEventPayload(payload: unknown): DigitalEmployeeRouteDecisionRecord | null {
|
||||
const record = objectRecord(payload);
|
||||
const decision = objectRecord(record?.routeDecision ?? record?.route_decision ?? payload);
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -38,6 +38,7 @@ import { resolveNpmPackageEntry } from "../utils/spawnNoWindow";
|
||||
import { APP_DATA_DIR_NAME } from "../constants";
|
||||
import { isWindows } from "../system/shellEnv";
|
||||
import { persistentMcpBridge } from "./persistentMcpBridge";
|
||||
import { applyDigitalEmployeeMcpExecutionPolicy } from "../digitalEmployee/skillExecutionPolicy";
|
||||
|
||||
type PerfValue = string | number | boolean | null | undefined;
|
||||
|
||||
@@ -920,7 +921,8 @@ class McpProxyManager {
|
||||
string,
|
||||
{ command: string; args: string[]; env?: Record<string, string> }
|
||||
> | null {
|
||||
const servers = this.config.mcpServers;
|
||||
const policyConfig = applyDigitalEmployeeMcpExecutionPolicy(this.config) as McpServersConfig;
|
||||
const servers = policyConfig.mcpServers;
|
||||
if (!servers || Object.keys(servers).length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -947,8 +949,8 @@ class McpProxyManager {
|
||||
// 2. stdio server:
|
||||
// - persistent server(如 chrome-devtools)→ 优先使用 bridge URL(已在 PersistentMcpBridge 中运行)
|
||||
// - 动态 MCP server → bridge 中没有注册,降级到 stdio 配置(由 mcp-proxy 按需 spawn)
|
||||
const globalAllowTools = this.config.allowTools;
|
||||
const globalDenyTools = this.config.denyTools;
|
||||
const globalAllowTools = policyConfig.allowTools;
|
||||
const globalDenyTools = policyConfig.denyTools;
|
||||
|
||||
for (const [name, entry] of Object.entries(servers)) {
|
||||
const effectiveAllowTools =
|
||||
|
||||
@@ -568,9 +568,9 @@ GET /api/digital-employee/approvals
|
||||
- 建议:先沉淀安全重启审计和人工确认体验,再评估更细粒度的服务恢复策略。
|
||||
|
||||
6. **技能生命周期与策略**
|
||||
- 已吸收:ACP / MCP / GUI / file server 能力已投射到 Skill Catalog,技能库不再使用 demo fallback;Skill Library 启用/禁用已写入 qimingclaw 本地 `digital_employee_skill_policies_v1` 设置,并回写目录 `enabled` / `governance.effective_policy`;入口路由候选技能会按本地策略过滤。
|
||||
- 缺口:仍缺少技能安装、版本管理、权限范围、真实 allow/deny 执行拦截和调用审计视图。
|
||||
- 建议:下一轮围绕技能权限 scope 和调用审计,把策略从“路由候选过滤”推进到“实际工具调用前校验”。
|
||||
- 已吸收:ACP / MCP / GUI / file server 能力已投射到 Skill Catalog,技能库不再使用 demo fallback;Skill Library 启用/禁用已写入 qimingclaw 本地 `digital_employee_skill_policies_v1` 设置,并回写目录 `enabled` / `governance.effective_policy`;入口路由候选技能会按本地策略过滤;MCP 注入 Agent 前会应用本地技能策略,ACP permission / tool update 会写入 `skill_call_allowed` / `skill_call_rejected` / `skill_call_observed` 审计事件,embedded 已提供 `/api/digital-employee/skill-call-audits` 只读投影,技能调用策略与审计已开始闭环。
|
||||
- 缺口:仍缺少技能安装、版本管理、细粒度权限范围、管理端调用审计视图和远端策略下发。
|
||||
- 建议:下一轮围绕管理端审计消费和远端策略下发,把本地调用审计推进到跨端治理闭环。
|
||||
|
||||
7. **产物交付闭环**
|
||||
- 已吸收:运行 payload 中的 artifacts / outputs / files / uri 可提取为正式 `digital_artifacts` 并同步;File Server workspace / upload / download / export / file update 生命周期状态已开始写入 Artifact payload 的 `delivery` 对象,embedded `/api/artifact` 和日报产物来源可读取交付阶段、状态、文件与 workspace 信息。
|
||||
|
||||
Reference in New Issue
Block a user