diff --git a/qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/src/lib/api.ts b/qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/src/lib/api.ts index c336b1a1..a2b83daa 100644 --- a/qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/src/lib/api.ts +++ b/qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/src/lib/api.ts @@ -625,6 +625,12 @@ export function pullSkillPolicies(): Promise<{ ok: boolean; skipped?: boolean; e }); } +export function pullRiskApprovalPolicy(): Promise<{ ok: boolean; skipped?: boolean; error?: string | null }> { + return apiFetch<{ ok: boolean; skipped?: boolean; error?: string | null }>('/api/risk-approval/policy/pull', { + method: 'POST', + }); +} + export function getPlanSteps(params: Record = {}): Promise> { const query = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { diff --git a/qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/src/lib/qimingclawAdapter.ts b/qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/src/lib/qimingclawAdapter.ts index ef5418b1..cb7f4a1c 100644 --- a/qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/src/lib/qimingclawAdapter.ts +++ b/qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/src/lib/qimingclawAdapter.ts @@ -55,6 +55,8 @@ declare global { getSkillCapabilities?: () => Promise; setSkillEnabled?: (skillId: string, enabled: boolean) => Promise<{ ok?: boolean; skill_id?: string; enabled?: boolean; error?: string }>; pullSkillPolicies?: () => Promise<{ ok?: boolean; skipped?: boolean; error?: string | null }>; + pullRiskApprovalPolicy?: () => Promise<{ ok?: boolean; skipped?: boolean; error?: string | null }>; + evaluateRiskPolicy?: (input: QimingclawRiskPolicyInput) => Promise; getPlanTemplates?: () => Promise; listMemories?: (options?: { query?: string; category?: string; limit?: number }) => Promise; addMemory?: (input: QimingclawMemoryUpsertInput) => Promise; @@ -353,6 +355,32 @@ interface QimingclawRouteDecisionInput { recordedAt?: string | null; } +interface QimingclawRiskPolicyInput { + actionKind?: string | null; + actionLabel?: string | null; + riskLevel?: string | null; + reasonCodes?: string[] | null; + actor?: string | null; + source?: string | null; + planId?: string | null; + taskId?: string | null; + runId?: string | null; + correlationId?: string | null; + payload?: Record | null; +} + +interface QimingclawRiskPolicyResult { + ok: boolean; + decision: 'allowed' | 'approval_required' | 'rejected'; + action_kind: string; + risk_level: string; + reason_codes?: string[]; + approval_id?: string | null; + event_id?: string | null; + status?: string; + error?: string | null; +} + interface QimingclawRuntimeRecords { generatedAt: string; plans: QimingclawPlanRecord[]; @@ -979,6 +1007,9 @@ export async function handleQimingclawDigitalApi( if (method === 'POST' && pathname === '/api/skill/policies/pull') { return await pullBridgeSkillPolicies() as T; } + if (method === 'POST' && pathname === '/api/risk-approval/policy/pull') { + return await pullBridgeRiskApprovalPolicy() as T; + } if (method === 'POST' && pathname.startsWith('/api/skill/') && pathname.endsWith('/enabled')) { const skillId = decodeURIComponent(pathname.split('/')[3] || ''); const body = parseJsonBody<{ enabled?: boolean }>(options.body); @@ -1114,6 +1145,65 @@ async function pullBridgeSkillPolicies(): Promise<{ ok: boolean; skipped?: boole } } +async function pullBridgeRiskApprovalPolicy(): Promise<{ ok: boolean; skipped?: boolean; error?: string | null }> { + const bridge = window.QimingClawBridge?.digital; + if (!bridge?.pullRiskApprovalPolicy) { + return { ok: false, error: 'qimingclaw_bridge_unavailable' }; + } + try { + const result = await bridge.pullRiskApprovalPolicy(); + return { + ok: result?.ok !== false, + ...(result?.skipped ? { skipped: true } : {}), + ...(result?.error ? { error: result.error } : {}), + }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; + } +} + +async function evaluateBridgeRiskPolicy(input: QimingclawRiskPolicyInput): Promise { + const bridge = window.QimingClawBridge?.digital; + if (!bridge?.evaluateRiskPolicy) { + return { + ok: true, + decision: 'allowed', + action_kind: input.actionKind || 'unknown_action', + risk_level: input.riskLevel || 'normal', + reason_codes: ['qimingclaw_bridge_unavailable'], + status: 'allowed', + }; + } + try { + return await bridge.evaluateRiskPolicy(input); + } catch (error) { + return { + ok: true, + decision: 'allowed', + action_kind: input.actionKind || 'unknown_action', + risk_level: input.riskLevel || 'normal', + reason_codes: ['risk_policy_evaluation_failed'], + status: 'allowed', + error: error instanceof Error ? error.message : String(error), + }; + } +} + +function riskPolicyBlockedResponse(result: QimingclawRiskPolicyResult): Record | null { + if (result.decision === 'allowed') return null; + return { + ok: false, + status: result.decision, + decision: result.decision, + action_kind: result.action_kind, + risk_level: result.risk_level, + reason_codes: result.reason_codes ?? [], + approval_id: result.approval_id ?? null, + event_id: result.event_id ?? null, + error: result.decision === 'approval_required' ? 'risk_approval_required' : 'risk_policy_rejected', + }; +} + function dedupeSkills(skills: SkillSpec[]): SkillSpec[] { const seen = new Set(); const result: SkillSpec[] = []; @@ -1511,6 +1601,29 @@ async function handleManagedServiceRestart( serviceId: string, body: Record, ): Promise { + const riskPolicy = await evaluateBridgeRiskPolicy({ + actionKind: `managed_service.restart.${serviceId}`, + actionLabel: `重启服务:${serviceId}`, + riskLevel: 'high', + reasonCodes: ['managed_service_restart'], + actor: stringValue(body.actor) || 'digital_employee_ui', + source: 'sgrobot-digital-adapter', + correlationId: `managed-service:${serviceId}:restart`, + payload: { service_id: serviceId, reason: stringValue(body.reason) || null }, + }); + const blocked = riskPolicyBlockedResponse(riskPolicy); + if (blocked) { + const snapshot = await readQimingclawSnapshot(); + return { + ok: false, + service_id: serviceId, + action: 'restart', + status: blocked.status === 'approval_required' ? 'rejected' : 'failed', + error: String(blocked.error || blocked.status || 'risk_policy_blocked'), + managed_service: buildManagedServices(snapshot).find((service) => service.service_id === serviceId) ?? null, + event_id: stringValue(blocked.event_id) || null, + }; + } const bridge = window.QimingClawBridge?.digital; if (!bridge?.restartManagedService) { const snapshot = await readQimingclawSnapshot(); @@ -1552,6 +1665,28 @@ async function handleArtifactAccess( ): Promise { const bridge = window.QimingClawBridge?.digital; const action = normalizeArtifactAccessAction(stringValue(body.action)); + const riskPolicy = await evaluateBridgeRiskPolicy({ + actionKind: `artifact.access.${action}`, + actionLabel: `访问产物:${action}`, + riskLevel: action === 'download' || action === 'open_location' ? 'high' : 'medium', + reasonCodes: ['artifact_access'], + actor: stringValue(body.actor) || 'digital_employee_ui', + source: 'sgrobot-digital-adapter', + correlationId: `artifact:${artifactId}:${action}`, + payload: { artifact_id: artifactId, action }, + }); + const blocked = riskPolicyBlockedResponse(riskPolicy); + if (blocked) { + return { + ok: false, + artifact_id: artifactId, + action, + status: 'rejected', + error: String(blocked.error || blocked.status || 'risk_policy_blocked'), + reason_codes: Array.isArray(blocked.reason_codes) ? blocked.reason_codes.map(String) : ['risk_policy_blocked'], + event_id: stringValue(blocked.event_id) || null, + }; + } if (!bridge?.accessArtifact) { return { ok: false, @@ -1592,6 +1727,21 @@ async function handleArtifactRetention( const bridge = window.QimingClawBridge?.digital; const action = stringValue(body.action) || 'mark_review'; const artifact = artifactDetailRow(snapshot, artifactId); + const riskPolicy = await evaluateBridgeRiskPolicy({ + actionKind: `artifact.retention.${action}`, + actionLabel: `调整产物保留策略:${action}`, + riskLevel: action === 'mark_expire' || action === 'clear_retention' ? 'high' : 'medium', + reasonCodes: ['artifact_retention'], + actor: stringValue(body.actor) || 'digital_employee_ui', + source: 'sgrobot-digital-adapter', + planId: artifact ? stringValue(artifact.plan_id) || null : null, + taskId: artifact ? stringValue(artifact.task_id) || null : null, + runId: artifact ? stringValue(artifact.run_id) || null : null, + correlationId: `artifact:${artifactId}:retention:${action}`, + payload: { artifact_id: artifactId, action }, + }); + const blocked = riskPolicyBlockedResponse(riskPolicy); + if (blocked) return { ...blocked, artifact_id: artifactId, action, item: artifact }; if (!bridge?.recordArtifactLifecycle) { return { ok: false, artifact_id: artifactId, action, status: 'rejected', error: 'bridge_unavailable' }; } @@ -1627,6 +1777,21 @@ async function handleApprovalAction( const bridge = window.QimingClawBridge?.digital; const action = stringValue(body.action) || 'comment'; const approval = approvalDetailRow(snapshot, approvalId); + const riskPolicy = await evaluateBridgeRiskPolicy({ + actionKind: `approval.action.${action}`, + actionLabel: `审批治理动作:${action}`, + riskLevel: action === 'mark_risk' || action === 'delegate' || action === 'mark_timeout' ? 'medium' : 'normal', + reasonCodes: ['approval_action'], + actor: stringValue(body.actor) || 'digital_employee_ui', + source: 'sgrobot-digital-adapter', + planId: approval ? stringValue(approval.plan_id) || null : null, + taskId: approval ? stringValue(approval.task_id) || null : null, + runId: approval ? stringValue(approval.run_id) || null : null, + correlationId: `approval:${approvalId}:action:${action}`, + payload: { approval_id: approvalId, action }, + }); + const blocked = riskPolicyBlockedResponse(riskPolicy); + if (blocked) return { ...blocked, approval_id: approvalId, action, item: approval }; if (!bridge?.recordApprovalAction) { return { ok: false, approval_id: approvalId, action, status: 'rejected', error: 'bridge_unavailable' }; } @@ -5794,6 +5959,12 @@ function compactRecord(record: Record): Record return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined && value !== null && value !== '')); } +function governanceCommandRiskLevel(commandKind: string): string { + if (['activate_plan', 'run_schedule_now', 'start_run', 'cancel_run', 'cancel_task'].includes(commandKind)) return 'high'; + if (['delete_schedule', 'pause_schedule', 'skip_task', 'retry_task', 'provide_task_input'].includes(commandKind)) return 'medium'; + return 'normal'; +} + async function handleGovernanceCommand( body: GovernanceCommandRequest, snapshot: QimingclawSnapshot | null, @@ -5815,6 +5986,39 @@ async function handleGovernanceCommand( const scheduleId = stringFromUnknown(body.context_refs?.schedule_id) || stringFromUnknown(body.payload?.schedule_id); + const riskPolicy = await evaluateBridgeRiskPolicy({ + actionKind: `governance.${body.command_kind}`, + actionLabel: `治理命令:${body.command_kind}`, + riskLevel: governanceCommandRiskLevel(body.command_kind), + reasonCodes: ['governance_command'], + actor: stringValue(body.payload?.actor) || 'digital_employee_ui', + source: 'sgrobot-digital-adapter', + planId: planId || null, + taskId: taskId || null, + runId: runId || null, + correlationId, + payload: { + command_kind: body.command_kind, + schedule_id: scheduleId || null, + step_id: stepId || null, + }, + }); + const blocked = riskPolicyBlockedResponse(riskPolicy); + if (blocked) { + const response: GovernanceCommandResponse = { + command_id: commandId, + accepted: false, + correlation_id: correlationId, + error: 'policy_blocked', + message: blocked.status === 'approval_required' + ? '高风险治理命令需要审批后执行。' + : '高风险治理命令已被风险策略拒绝。', + result: blocked, + }; + await recordGovernanceCommand(body, response); + return response; + } + if (body.command_kind === 'create_plan') { const createdPlanId = `qimingclaw-local-${slugifyIdPart(stringFromUnknown(body.payload?.title) || planId || 'plan')}-${Date.now()}`; const response = governanceAccepted(commandId, correlationId, '已在 qimingclaw 兼容层创建本地计划草稿。', { @@ -6234,6 +6438,34 @@ async function dispatchPlanReadySteps( snapshot: QimingclawSnapshot | null = null, ): Promise { const targetPlanId = stringValue(planId); + const riskPolicy = await evaluateBridgeRiskPolicy({ + actionKind: 'plan_step.dispatch_ready_steps', + actionLabel: '派发 Ready PlanStep', + riskLevel: 'high', + reasonCodes: ['plan_step_dispatch'], + actor: 'digital_employee_ui', + source: 'sgrobot-digital-adapter', + planId: targetPlanId || null, + correlationId: `plan-step-dispatch:${targetPlanId || 'unknown'}`, + payload: { plan_id: targetPlanId || null, trigger_source: 'manual' }, + }); + const blocked = riskPolicyBlockedResponse(riskPolicy); + if (blocked) { + return { + ok: false, + plan_id: targetPlanId || null, + trigger_source: 'manual', + orchestration_id: stringValue(blocked.event_id) || `risk-blocked-${slugifyIdPart(targetPlanId || 'unknown')}`, + candidates: 0, + dispatched: 0, + skipped: 0, + failed: 1, + dispatched_step_ids: [], + skipped_step_ids: [], + failed_step_ids: [], + errors: [{ stepId: targetPlanId || 'unknown', error: String(blocked.error || blocked.status || 'risk_policy_blocked') }], + }; + } const bridgeResult = targetPlanId ? await window.QimingClawBridge?.digital?.dispatchPlanReadySteps?.(targetPlanId).catch(() => null) : null; if (bridgeResult) return bridgeResult; const now = new Date().toISOString(); diff --git a/qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/src/pages/digital/CommandDeck.tsx b/qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/src/pages/digital/CommandDeck.tsx index b23afe7d..84fc6670 100644 --- a/qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/src/pages/digital/CommandDeck.tsx +++ b/qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/src/pages/digital/CommandDeck.tsx @@ -12,6 +12,7 @@ import { governanceCommand, postDigitalEmployeeWorkdayAction, pullPlanStepDispatchPolicy, + pullRiskApprovalPolicy, recordApprovalAction, restartManagedService, } from '@/lib/api'; @@ -1391,6 +1392,27 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit } }, [fetchProjection]); + const refreshRiskApprovalPolicy = useCallback(async () => { + setActionBusy('pull_risk_approval_policy'); + setActionError(null); + setActionNotice(null); + try { + const result = await pullRiskApprovalPolicy(); + if (!mountedRef.current) return; + await fetchProjection(); + if (!mountedRef.current) return; + setActionNotice(result.ok + ? '风险审批策略已拉取。' + : `风险审批策略拉取失败,已沿用本地策略:${result.error || '未知错误'}。`); + } catch (error) { + if (mountedRef.current) { + setActionError(error instanceof Error ? error.message : '拉取风险审批策略失败'); + } + } finally { + if (mountedRef.current) setActionBusy(null); + } + }, [fetchProjection]); + const copySyncFailureDiagnostic = useCallback(async (failure: ManagementSyncFailure) => { const diagnostic = buildSyncFailureDiagnostic(failure, managementSyncStatus); try { @@ -2049,6 +2071,16 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit