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

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

@@ -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)

View File

@@ -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>

View File

@@ -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",

View File

@@ -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", () => {

View File

@@ -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(

View File

@@ -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"],
}));
});
});

View File

@@ -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,

View File

@@ -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)}`;
}

View File

@@ -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,

View File

@@ -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);

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,

View File

@@ -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 =

View File

@@ -568,9 +568,9 @@ GET /api/digital-employee/approvals
- 建议:先沉淀安全重启审计和人工确认体验,再评估更细粒度的服务恢复策略。
6. **技能生命周期与策略**
- 已吸收ACP / MCP / GUI / file server 能力已投射到 Skill Catalog技能库不再使用 demo fallbackSkill Library 启用/禁用已写入 qimingclaw 本地 `digital_employee_skill_policies_v1` 设置,并回写目录 `enabled` / `governance.effective_policy`;入口路由候选技能会按本地策略过滤。
- 缺口:仍缺少技能安装、版本管理、权限范围、真实 allow/deny 执行拦截和调用审计视图
- 建议:下一轮围绕技能权限 scope 和调用审计,把策略从“路由候选过滤”推进到“实际工具调用前校验”
- 已吸收ACP / MCP / GUI / file server 能力已投射到 Skill Catalog技能库不再使用 demo fallbackSkill 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 信息。