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

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

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