feat(l4): add AutonomyManager for tiered authorization system (Phase 3)
Implements L4 self-evolution authorization with: Autonomy Levels: - Supervised: All actions require user confirmation - Assisted: Low-risk actions auto-execute, high-risk need approval - Autonomous: Agent decides, only high-impact actions notify Features: - Risk-based action classification (low/medium/high) - Importance threshold for auto-approval - Approval workflow with pending queue - Full audit logging with rollback support - Configurable action permissions per level Security: - High-risk actions ALWAYS require confirmation - Self-modification disabled by default even in autonomous mode - All autonomous actions logged for audit - One-click rollback to any historical state Tests: 30 passing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
504
desktop/src/components/AutonomyConfig.tsx
Normal file
504
desktop/src/components/AutonomyConfig.tsx
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
/**
|
||||||
|
* AutonomyConfig - Configuration UI for L4 self-evolution authorization
|
||||||
|
*
|
||||||
|
* Allows users to configure:
|
||||||
|
* - Autonomy level (supervised/assisted/autonomous)
|
||||||
|
* - Individual action permissions
|
||||||
|
* - Approval thresholds
|
||||||
|
* - Audit log viewing
|
||||||
|
*
|
||||||
|
* Part of ZCLAW L4 Self-Evolution capability.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Shield,
|
||||||
|
ShieldAlert,
|
||||||
|
ShieldCheck,
|
||||||
|
ShieldQuestion,
|
||||||
|
Settings,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
RotateCcw,
|
||||||
|
Info,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
getAutonomyManager,
|
||||||
|
DEFAULT_AUTONOMY_CONFIGS,
|
||||||
|
type AutonomyManager,
|
||||||
|
type AutonomyConfig,
|
||||||
|
type AutonomyLevel,
|
||||||
|
type AuditLogEntry,
|
||||||
|
type ActionType,
|
||||||
|
} from '../lib/autonomy-manager';
|
||||||
|
|
||||||
|
// === Types ===
|
||||||
|
|
||||||
|
interface AutonomyConfigProps {
|
||||||
|
className?: string;
|
||||||
|
onConfigChange?: (config: AutonomyConfig) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Autonomy Level Config ===
|
||||||
|
|
||||||
|
const LEVEL_CONFIG: Record<AutonomyLevel, {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: typeof Shield;
|
||||||
|
color: string;
|
||||||
|
}> = {
|
||||||
|
supervised: {
|
||||||
|
label: '监督模式',
|
||||||
|
description: '所有操作都需要用户确认',
|
||||||
|
icon: ShieldQuestion,
|
||||||
|
color: 'text-yellow-500',
|
||||||
|
},
|
||||||
|
assisted: {
|
||||||
|
label: '辅助模式',
|
||||||
|
description: '低风险操作自动执行,高风险需确认',
|
||||||
|
icon: ShieldAlert,
|
||||||
|
color: 'text-blue-500',
|
||||||
|
},
|
||||||
|
autonomous: {
|
||||||
|
label: '自主模式',
|
||||||
|
description: 'Agent 自主决策,仅高影响操作通知',
|
||||||
|
icon: ShieldCheck,
|
||||||
|
color: 'text-green-500',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTION_LABELS: Record<ActionType, string> = {
|
||||||
|
memory_save: '自动保存记忆',
|
||||||
|
memory_delete: '删除记忆',
|
||||||
|
identity_update: '更新身份文件',
|
||||||
|
identity_rollback: '回滚身份',
|
||||||
|
skill_install: '安装技能',
|
||||||
|
skill_uninstall: '卸载技能',
|
||||||
|
config_change: '修改配置',
|
||||||
|
workflow_trigger: '触发工作流',
|
||||||
|
hand_trigger: '触发 Hand',
|
||||||
|
llm_call: '调用 LLM',
|
||||||
|
reflection_run: '运行反思',
|
||||||
|
compaction_run: '运行压缩',
|
||||||
|
};
|
||||||
|
|
||||||
|
// === Components ===
|
||||||
|
|
||||||
|
function LevelSelector({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: AutonomyLevel;
|
||||||
|
onChange: (level: AutonomyLevel) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(Object.keys(LEVEL_CONFIG) as AutonomyLevel[]).map((level) => {
|
||||||
|
const config = LEVEL_CONFIG[level];
|
||||||
|
const Icon = config.icon;
|
||||||
|
const isSelected = value === level;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={level}
|
||||||
|
onClick={() => onChange(level)}
|
||||||
|
className={`w-full flex items-start gap-3 p-3 rounded-lg border transition-all text-left ${
|
||||||
|
isSelected
|
||||||
|
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/30'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className={`w-5 h-5 mt-0.5 flex-shrink-0 ${config.color}`} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className={`text-sm font-medium ${
|
||||||
|
isSelected ? 'text-purple-700 dark:text-purple-400' : 'text-gray-700 dark:text-gray-300'
|
||||||
|
}`}>
|
||||||
|
{config.label}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
{config.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isSelected && (
|
||||||
|
<CheckCircle className="w-4 h-4 text-purple-500 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionToggle({
|
||||||
|
action,
|
||||||
|
label,
|
||||||
|
enabled,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
action: ActionType;
|
||||||
|
label: string;
|
||||||
|
enabled: boolean;
|
||||||
|
onChange: (enabled: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center justify-between py-2 ${disabled ? 'opacity-50' : ''}`}>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">{label}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => !disabled && onChange(!enabled)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`relative w-9 h-5 rounded-full transition-colors ${
|
||||||
|
enabled ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'
|
||||||
|
} ${disabled ? 'cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
animate={{ x: enabled ? 18 : 0 }}
|
||||||
|
className="absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuditLogEntryItem({
|
||||||
|
entry,
|
||||||
|
onRollback,
|
||||||
|
}: {
|
||||||
|
entry: AuditLogEntry;
|
||||||
|
onRollback?: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const outcomeColors = {
|
||||||
|
success: 'text-green-500',
|
||||||
|
failed: 'text-red-500',
|
||||||
|
rolled_back: 'text-yellow-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
const outcomeLabels = {
|
||||||
|
success: '成功',
|
||||||
|
failed: '失败',
|
||||||
|
rolled_back: '已回滚',
|
||||||
|
};
|
||||||
|
|
||||||
|
const time = new Date(entry.timestamp).toLocaleString('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-gray-100 dark:border-gray-800 last:border-b-0">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="w-full flex items-center gap-2 py-2 px-1 hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors"
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronDown className="w-3 h-3 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-3 h-3 text-gray-400" />
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-gray-400">{time}</span>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300 flex-1 text-left truncate">
|
||||||
|
{ACTION_LABELS[entry.action] || entry.action}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs ${outcomeColors[entry.outcome]}`}>
|
||||||
|
{outcomeLabels[entry.outcome]}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{expanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
className="px-6 pb-2 space-y-1"
|
||||||
|
>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
风险: {entry.decision.riskLevel} · 重要性: {entry.decision.importance}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
原因: {entry.decision.reason}
|
||||||
|
</div>
|
||||||
|
{entry.outcome !== 'rolled_back' && entry.decision.riskLevel !== 'low' && (
|
||||||
|
<button
|
||||||
|
onClick={() => onRollback?.(entry.id)}
|
||||||
|
className="flex items-center gap-1 text-xs text-yellow-600 dark:text-yellow-400 hover:underline mt-1"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3 h-3" />
|
||||||
|
回滚此操作
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Main Component ===
|
||||||
|
|
||||||
|
export function AutonomyConfig({ className = '', onConfigChange }: AutonomyConfigProps) {
|
||||||
|
const [manager] = useState(() => getAutonomyManager());
|
||||||
|
const [config, setConfig] = useState<AutonomyConfig>(manager.getConfig());
|
||||||
|
const [auditLog, setAuditLog] = useState<AuditLogEntry[]>([]);
|
||||||
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
|
||||||
|
// Load audit log
|
||||||
|
useEffect(() => {
|
||||||
|
setAuditLog(manager.getAuditLog(50));
|
||||||
|
}, [manager]);
|
||||||
|
|
||||||
|
const updateConfig = useCallback(
|
||||||
|
(updates: Partial<AutonomyConfig>) => {
|
||||||
|
setConfig((prev) => {
|
||||||
|
const next = { ...prev, ...updates };
|
||||||
|
setHasChanges(true);
|
||||||
|
onConfigChange?.(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onConfigChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleLevelChange = useCallback((level: AutonomyLevel) => {
|
||||||
|
const newConfig = DEFAULT_AUTONOMY_CONFIGS[level];
|
||||||
|
setConfig(newConfig);
|
||||||
|
setHasChanges(true);
|
||||||
|
onConfigChange?.(newConfig);
|
||||||
|
}, [onConfigChange]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
manager.updateConfig(config);
|
||||||
|
setHasChanges(false);
|
||||||
|
}, [manager, config]);
|
||||||
|
|
||||||
|
const handleRollback = useCallback((auditId: string) => {
|
||||||
|
if (manager.rollback(auditId)) {
|
||||||
|
setAuditLog(manager.getAuditLog(50));
|
||||||
|
}
|
||||||
|
}, [manager]);
|
||||||
|
|
||||||
|
const handleClearLog = useCallback(() => {
|
||||||
|
manager.clearAuditLog();
|
||||||
|
setAuditLog([]);
|
||||||
|
}, [manager]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col h-full ${className}`}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="w-5 h-5 text-purple-500" />
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">自主授权</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!hasChanges}
|
||||||
|
className="px-3 py-1.5 text-sm bg-purple-500 hover:bg-purple-600 disabled:bg-gray-300 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||||
|
{/* Autonomy Level */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShieldAlert className="w-4 h-4 text-gray-500" />
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
自主级别
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<LevelSelector value={config.level} onChange={handleLevelChange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Allowed Actions */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Settings className="w-4 h-4 text-gray-500" />
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
允许的操作
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="pl-6 space-y-1 border-l-2 border-gray-200 dark:border-gray-700">
|
||||||
|
<ActionToggle
|
||||||
|
action="memory_save"
|
||||||
|
label="自动保存记忆"
|
||||||
|
enabled={config.allowedActions.memoryAutoSave}
|
||||||
|
onChange={(enabled) =>
|
||||||
|
updateConfig({
|
||||||
|
allowedActions: { ...config.allowedActions, memoryAutoSave: enabled },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ActionToggle
|
||||||
|
action="identity_update"
|
||||||
|
label="自动更新身份文件"
|
||||||
|
enabled={config.allowedActions.identityAutoUpdate}
|
||||||
|
onChange={(enabled) =>
|
||||||
|
updateConfig({
|
||||||
|
allowedActions: { ...config.allowedActions, identityAutoUpdate: enabled },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ActionToggle
|
||||||
|
action="skill_install"
|
||||||
|
label="自动安装技能"
|
||||||
|
enabled={config.allowedActions.skillAutoInstall}
|
||||||
|
onChange={(enabled) =>
|
||||||
|
updateConfig({
|
||||||
|
allowedActions: { ...config.allowedActions, skillAutoInstall: enabled },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ActionToggle
|
||||||
|
action="selfModification"
|
||||||
|
label="自我修改行为"
|
||||||
|
enabled={config.allowedActions.selfModification}
|
||||||
|
onChange={(enabled) =>
|
||||||
|
updateConfig({
|
||||||
|
allowedActions: { ...config.allowedActions, selfModification: enabled },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ActionToggle
|
||||||
|
action="compaction_run"
|
||||||
|
label="自动上下文压缩"
|
||||||
|
enabled={config.allowedActions.autoCompaction}
|
||||||
|
onChange={(enabled) =>
|
||||||
|
updateConfig({
|
||||||
|
allowedActions: { ...config.allowedActions, autoCompaction: enabled },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ActionToggle
|
||||||
|
action="reflection_run"
|
||||||
|
label="自动反思"
|
||||||
|
enabled={config.allowedActions.autoReflection}
|
||||||
|
onChange={(enabled) =>
|
||||||
|
updateConfig({
|
||||||
|
allowedActions: { ...config.allowedActions, autoReflection: enabled },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Approval Thresholds */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-gray-500" />
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
审批阈值
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="pl-6 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
重要性上限
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="10"
|
||||||
|
value={config.approvalThreshold.importanceMax}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig({
|
||||||
|
approvalThreshold: {
|
||||||
|
...config.approvalThreshold,
|
||||||
|
importanceMax: parseInt(e.target.value),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-24 h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-purple-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 w-6 text-right">
|
||||||
|
{config.approvalThreshold.importanceMax}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
风险等级上限
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={config.approvalThreshold.riskMax}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig({
|
||||||
|
approvalThreshold: {
|
||||||
|
...config.approvalThreshold,
|
||||||
|
riskMax: e.target.value as 'low' | 'medium' | 'high',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
<option value="low">低风险</option>
|
||||||
|
<option value="medium">中风险</option>
|
||||||
|
<option value="high">高风险</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Audit Log */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4 text-gray-500" />
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
审计日志
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400">({auditLog.length} 条)</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleClearLog}
|
||||||
|
className="p-1 text-gray-400 hover:text-red-500 transition-colors"
|
||||||
|
title="清除日志"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{auditLog.length > 0 ? (
|
||||||
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden max-h-64 overflow-y-auto">
|
||||||
|
{auditLog
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.map((entry) => (
|
||||||
|
<AuditLogEntryItem
|
||||||
|
key={entry.id}
|
||||||
|
entry={entry}
|
||||||
|
onRollback={handleRollback}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-gray-400 dark:text-gray-500 text-sm">
|
||||||
|
暂无审计日志
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="flex items-start gap-2 p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg text-xs text-yellow-600 dark:text-yellow-400">
|
||||||
|
<Info className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||||
|
<p>
|
||||||
|
高风险操作(删除记忆、修改身份文件)始终需要用户确认,无论自主级别如何设置。
|
||||||
|
所有自主操作都会记录在审计日志中,支持一键回滚。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AutonomyConfig;
|
||||||
364
desktop/src/lib/__tests__/autonomy-manager.test.ts
Normal file
364
desktop/src/lib/__tests__/autonomy-manager.test.ts
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
/**
|
||||||
|
* AutonomyManager Tests - L4 Self-Evolution Authorization
|
||||||
|
*
|
||||||
|
* Tests for the tiered authorization system:
|
||||||
|
* - Level-based permissions (supervised/assisted/autonomous)
|
||||||
|
* - Risk assessment for actions
|
||||||
|
* - Approval workflow
|
||||||
|
* - Audit logging
|
||||||
|
* - Rollback functionality
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
AutonomyManager,
|
||||||
|
getAutonomyManager,
|
||||||
|
resetAutonomyManager,
|
||||||
|
canAutoExecute,
|
||||||
|
executeWithAutonomy,
|
||||||
|
DEFAULT_AUTONOMY_CONFIGS,
|
||||||
|
type ActionType,
|
||||||
|
type AutonomyLevel,
|
||||||
|
} from '../autonomy-manager';
|
||||||
|
|
||||||
|
// === Helper to create fresh manager ===
|
||||||
|
|
||||||
|
function createManager(level: AutonomyLevel = 'assisted'): AutonomyManager {
|
||||||
|
resetAutonomyManager();
|
||||||
|
return getAutonomyManager({ ...DEFAULT_AUTONOMY_CONFIGS[level] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Risk Assessment Tests ===
|
||||||
|
|
||||||
|
describe('AutonomyManager Risk Assessment', () => {
|
||||||
|
let manager: AutonomyManager;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
manager = createManager('assisted');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
resetAutonomyManager();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should classify memory_save as low risk', () => {
|
||||||
|
const decision = manager.evaluate('memory_save', { importance: 3 });
|
||||||
|
expect(decision.riskLevel).toBe('low');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should classify memory_delete as high risk', () => {
|
||||||
|
const decision = manager.evaluate('memory_delete');
|
||||||
|
expect(decision.riskLevel).toBe('high');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should classify identity_update as high risk', () => {
|
||||||
|
const decision = manager.evaluate('identity_update');
|
||||||
|
expect(decision.riskLevel).toBe('high');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow risk override', () => {
|
||||||
|
const decision = manager.evaluate('memory_save', { riskOverride: 'high' });
|
||||||
|
expect(decision.riskLevel).toBe('high');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Level-Based Permission Tests ===
|
||||||
|
|
||||||
|
describe('AutonomyManager Level Permissions', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
resetAutonomyManager();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Supervised Mode', () => {
|
||||||
|
let manager: AutonomyManager;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
manager = createManager('supervised');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require approval for all actions', () => {
|
||||||
|
const decision = manager.evaluate('memory_save', { importance: 1 });
|
||||||
|
expect(decision.requiresApproval).toBe(true);
|
||||||
|
expect(decision.allowed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not auto-execute even low-risk actions', () => {
|
||||||
|
const decision = manager.evaluate('reflection_run', { importance: 1 });
|
||||||
|
expect(decision.allowed).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Assisted Mode', () => {
|
||||||
|
let manager: AutonomyManager;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
manager = createManager('assisted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should auto-approve low importance, low risk actions', () => {
|
||||||
|
const decision = manager.evaluate('memory_save', { importance: 3 });
|
||||||
|
expect(decision.allowed).toBe(true);
|
||||||
|
expect(decision.requiresApproval).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require approval for high importance actions', () => {
|
||||||
|
const decision = manager.evaluate('memory_save', { importance: 8 });
|
||||||
|
expect(decision.requiresApproval).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should always require approval for high risk actions', () => {
|
||||||
|
const decision = manager.evaluate('memory_delete', { importance: 1 });
|
||||||
|
expect(decision.requiresApproval).toBe(true);
|
||||||
|
expect(decision.allowed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not auto-approve identity updates', () => {
|
||||||
|
const decision = manager.evaluate('identity_update', { importance: 3 });
|
||||||
|
expect(decision.allowed).toBe(false);
|
||||||
|
expect(decision.requiresApproval).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Autonomous Mode', () => {
|
||||||
|
let manager: AutonomyManager;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
manager = createManager('autonomous');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should auto-approve medium risk, medium importance actions', () => {
|
||||||
|
const decision = manager.evaluate('skill_install', { importance: 5 });
|
||||||
|
expect(decision.allowed).toBe(true);
|
||||||
|
expect(decision.requiresApproval).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should still require approval for high risk actions', () => {
|
||||||
|
const decision = manager.evaluate('memory_delete', { importance: 1 });
|
||||||
|
expect(decision.allowed).toBe(false);
|
||||||
|
expect(decision.requiresApproval).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not auto-approve self-modification', () => {
|
||||||
|
// Even in autonomous mode, self-modification requires approval
|
||||||
|
manager.updateConfig({
|
||||||
|
allowedActions: {
|
||||||
|
...manager.getConfig().allowedActions,
|
||||||
|
selfModification: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const decision = manager.evaluate('identity_update', { importance: 3 });
|
||||||
|
expect(decision.allowed).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Approval Workflow Tests ===
|
||||||
|
|
||||||
|
describe('AutonomyManager Approval Workflow', () => {
|
||||||
|
let manager: AutonomyManager;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
manager = createManager('supervised');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
resetAutonomyManager();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should request approval and return approval ID', () => {
|
||||||
|
const decision = manager.evaluate('memory_save');
|
||||||
|
const approvalId = manager.requestApproval(decision);
|
||||||
|
|
||||||
|
expect(approvalId).toMatch(/^approval_/);
|
||||||
|
expect(manager.getPendingApprovals().length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should approve pending action', () => {
|
||||||
|
const decision = manager.evaluate('memory_save');
|
||||||
|
const approvalId = manager.requestApproval(decision);
|
||||||
|
|
||||||
|
const result = manager.approve(approvalId);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(manager.getPendingApprovals().length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject pending action', () => {
|
||||||
|
const decision = manager.evaluate('memory_save');
|
||||||
|
const approvalId = manager.requestApproval(decision);
|
||||||
|
|
||||||
|
const result = manager.reject(approvalId);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(manager.getPendingApprovals().length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-existent approval', () => {
|
||||||
|
expect(manager.approve('non_existent')).toBe(false);
|
||||||
|
expect(manager.reject('non_existent')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Audit Log Tests ===
|
||||||
|
|
||||||
|
describe('AutonomyManager Audit Log', () => {
|
||||||
|
let manager: AutonomyManager;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
manager = createManager('assisted');
|
||||||
|
manager.clearAuditLog();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
resetAutonomyManager();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log decisions', () => {
|
||||||
|
manager.evaluate('memory_save', { importance: 3 });
|
||||||
|
|
||||||
|
const log = manager.getAuditLog();
|
||||||
|
expect(log.length).toBe(1);
|
||||||
|
expect(log[0].action).toBe('memory_save');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should limit log to 100 entries', () => {
|
||||||
|
for (let i = 0; i < 150; i++) {
|
||||||
|
manager.evaluate('memory_save', { importance: i % 10 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const log = manager.getAuditLog(200);
|
||||||
|
expect(log.length).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear audit log', () => {
|
||||||
|
manager.evaluate('memory_save');
|
||||||
|
manager.evaluate('reflection_run');
|
||||||
|
|
||||||
|
expect(manager.getAuditLog().length).toBe(2);
|
||||||
|
|
||||||
|
manager.clearAuditLog();
|
||||||
|
|
||||||
|
expect(manager.getAuditLog().length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support rollback', () => {
|
||||||
|
manager.evaluate('memory_save');
|
||||||
|
const log = manager.getAuditLog();
|
||||||
|
const entryId = log[0].id;
|
||||||
|
|
||||||
|
const result = manager.rollback(entryId);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
|
||||||
|
const updatedLog = manager.getAuditLog();
|
||||||
|
expect(updatedLog[0].outcome).toBe('rolled_back');
|
||||||
|
expect(updatedLog[0].rolledBackAt).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow double rollback', () => {
|
||||||
|
manager.evaluate('memory_save');
|
||||||
|
const log = manager.getAuditLog();
|
||||||
|
const entryId = log[0].id;
|
||||||
|
|
||||||
|
manager.rollback(entryId);
|
||||||
|
const result = manager.rollback(entryId);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Config Management Tests ===
|
||||||
|
|
||||||
|
describe('AutonomyManager Config Management', () => {
|
||||||
|
let manager: AutonomyManager;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
manager = createManager('assisted');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
resetAutonomyManager();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get current config', () => {
|
||||||
|
const config = manager.getConfig();
|
||||||
|
expect(config.level).toBe('assisted');
|
||||||
|
expect(config.allowedActions.memoryAutoSave).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update config', () => {
|
||||||
|
manager.updateConfig({
|
||||||
|
approvalThreshold: {
|
||||||
|
importanceMax: 8,
|
||||||
|
riskMax: 'medium',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = manager.getConfig();
|
||||||
|
expect(config.approvalThreshold.importanceMax).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should change level', () => {
|
||||||
|
manager.setLevel('autonomous');
|
||||||
|
|
||||||
|
const config = manager.getConfig();
|
||||||
|
expect(config.level).toBe('autonomous');
|
||||||
|
expect(config.allowedActions.memoryAutoSave).toBe(true);
|
||||||
|
expect(config.allowedActions.identityAutoUpdate).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Helper Function Tests ===
|
||||||
|
|
||||||
|
describe('Helper Functions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAutonomyManager();
|
||||||
|
getAutonomyManager({ ...DEFAULT_AUTONOMY_CONFIGS.assisted });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
resetAutonomyManager();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('canAutoExecute', () => {
|
||||||
|
it('should return true for auto-approvable actions', () => {
|
||||||
|
const result = canAutoExecute('memory_save', 3);
|
||||||
|
expect(result.canProceed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for actions needing approval', () => {
|
||||||
|
const result = canAutoExecute('memory_delete', 1);
|
||||||
|
expect(result.canProceed).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('executeWithAutonomy', () => {
|
||||||
|
it('should execute auto-approved actions immediately', async () => {
|
||||||
|
const executor = vi.fn().mockResolvedValue('success');
|
||||||
|
|
||||||
|
const result = await executeWithAutonomy('memory_save', 3, executor);
|
||||||
|
|
||||||
|
expect(result.executed).toBe(true);
|
||||||
|
expect(result.result).toBe('success');
|
||||||
|
expect(executor).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not execute actions needing approval', async () => {
|
||||||
|
const executor = vi.fn().mockResolvedValue('success');
|
||||||
|
|
||||||
|
const result = await executeWithAutonomy('memory_delete', 1, executor);
|
||||||
|
|
||||||
|
expect(result.executed).toBe(false);
|
||||||
|
expect(executor).not.toHaveBeenCalled();
|
||||||
|
expect(result.approvalId).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onApprovalNeeded callback', async () => {
|
||||||
|
const executor = vi.fn().mockResolvedValue('success');
|
||||||
|
const onApprovalNeeded = vi.fn();
|
||||||
|
|
||||||
|
await executeWithAutonomy('memory_delete', 1, executor, onApprovalNeeded);
|
||||||
|
|
||||||
|
expect(onApprovalNeeded).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
548
desktop/src/lib/autonomy-manager.ts
Normal file
548
desktop/src/lib/autonomy-manager.ts
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
/**
|
||||||
|
* Autonomy Manager - Tiered authorization system for L4 self-evolution
|
||||||
|
*
|
||||||
|
* Provides granular control over what actions the Agent can take autonomously:
|
||||||
|
* - Supervised: All actions require user confirmation
|
||||||
|
* - Assisted: Low-risk actions execute automatically
|
||||||
|
* - Autonomous: Agent decides when to act and notify
|
||||||
|
*
|
||||||
|
* Security boundaries:
|
||||||
|
* - High-risk operations ALWAYS require confirmation
|
||||||
|
* - All autonomous actions are logged for audit
|
||||||
|
* - One-click rollback to any historical state
|
||||||
|
*
|
||||||
|
* Reference: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md §6.4.3
|
||||||
|
*/
|
||||||
|
|
||||||
|
// === Types ===
|
||||||
|
|
||||||
|
export type AutonomyLevel = 'supervised' | 'assisted' | 'autonomous';
|
||||||
|
|
||||||
|
export type RiskLevel = 'low' | 'medium' | 'high';
|
||||||
|
|
||||||
|
export type ActionType =
|
||||||
|
| 'memory_save'
|
||||||
|
| 'memory_delete'
|
||||||
|
| 'identity_update'
|
||||||
|
| 'identity_rollback'
|
||||||
|
| 'skill_install'
|
||||||
|
| 'skill_uninstall'
|
||||||
|
| 'config_change'
|
||||||
|
| 'workflow_trigger'
|
||||||
|
| 'hand_trigger'
|
||||||
|
| 'llm_call'
|
||||||
|
| 'reflection_run'
|
||||||
|
| 'compaction_run';
|
||||||
|
|
||||||
|
export interface AutonomyConfig {
|
||||||
|
level: AutonomyLevel;
|
||||||
|
allowedActions: {
|
||||||
|
memoryAutoSave: boolean;
|
||||||
|
identityAutoUpdate: boolean;
|
||||||
|
skillAutoInstall: boolean;
|
||||||
|
selfModification: boolean;
|
||||||
|
autoCompaction: boolean;
|
||||||
|
autoReflection: boolean;
|
||||||
|
};
|
||||||
|
approvalThreshold: {
|
||||||
|
importanceMax: number; // Auto-approve if importance <= this (default: 5)
|
||||||
|
riskMax: RiskLevel; // Auto-approve if risk <= this (default: 'low')
|
||||||
|
};
|
||||||
|
notifyOnAction: boolean; // Notify user after autonomous action
|
||||||
|
auditLogEnabled: boolean; // Log all autonomous actions
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutonomyDecision {
|
||||||
|
action: ActionType;
|
||||||
|
allowed: boolean;
|
||||||
|
requiresApproval: boolean;
|
||||||
|
reason: string;
|
||||||
|
riskLevel: RiskLevel;
|
||||||
|
importance: number;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogEntry {
|
||||||
|
id: string;
|
||||||
|
action: ActionType;
|
||||||
|
decision: AutonomyDecision;
|
||||||
|
context: Record<string, unknown>;
|
||||||
|
outcome: 'success' | 'failed' | 'rolled_back';
|
||||||
|
rolledBackAt?: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Risk Mapping ===
|
||||||
|
|
||||||
|
const ACTION_RISK_MAP: Record<ActionType, RiskLevel> = {
|
||||||
|
memory_save: 'low',
|
||||||
|
memory_delete: 'high',
|
||||||
|
identity_update: 'high',
|
||||||
|
identity_rollback: 'medium',
|
||||||
|
skill_install: 'medium',
|
||||||
|
skill_uninstall: 'medium',
|
||||||
|
config_change: 'medium',
|
||||||
|
workflow_trigger: 'low',
|
||||||
|
hand_trigger: 'medium',
|
||||||
|
llm_call: 'low',
|
||||||
|
reflection_run: 'low',
|
||||||
|
compaction_run: 'low',
|
||||||
|
};
|
||||||
|
|
||||||
|
const RISK_ORDER: Record<RiskLevel, number> = {
|
||||||
|
low: 1,
|
||||||
|
medium: 2,
|
||||||
|
high: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
// === Default Configs ===
|
||||||
|
|
||||||
|
export const DEFAULT_AUTONOMY_CONFIGS: Record<AutonomyLevel, AutonomyConfig> = {
|
||||||
|
supervised: {
|
||||||
|
level: 'supervised',
|
||||||
|
allowedActions: {
|
||||||
|
memoryAutoSave: false,
|
||||||
|
identityAutoUpdate: false,
|
||||||
|
skillAutoInstall: false,
|
||||||
|
selfModification: false,
|
||||||
|
autoCompaction: false,
|
||||||
|
autoReflection: false,
|
||||||
|
},
|
||||||
|
approvalThreshold: {
|
||||||
|
importanceMax: 0,
|
||||||
|
riskMax: 'low',
|
||||||
|
},
|
||||||
|
notifyOnAction: true,
|
||||||
|
auditLogEnabled: true,
|
||||||
|
},
|
||||||
|
assisted: {
|
||||||
|
level: 'assisted',
|
||||||
|
allowedActions: {
|
||||||
|
memoryAutoSave: true,
|
||||||
|
identityAutoUpdate: false,
|
||||||
|
skillAutoInstall: false,
|
||||||
|
selfModification: false,
|
||||||
|
autoCompaction: true,
|
||||||
|
autoReflection: true,
|
||||||
|
},
|
||||||
|
approvalThreshold: {
|
||||||
|
importanceMax: 5,
|
||||||
|
riskMax: 'low',
|
||||||
|
},
|
||||||
|
notifyOnAction: true,
|
||||||
|
auditLogEnabled: true,
|
||||||
|
},
|
||||||
|
autonomous: {
|
||||||
|
level: 'autonomous',
|
||||||
|
allowedActions: {
|
||||||
|
memoryAutoSave: true,
|
||||||
|
identityAutoUpdate: true,
|
||||||
|
skillAutoInstall: true,
|
||||||
|
selfModification: false, // Always require approval for self-modification
|
||||||
|
autoCompaction: true,
|
||||||
|
autoReflection: true,
|
||||||
|
},
|
||||||
|
approvalThreshold: {
|
||||||
|
importanceMax: 7,
|
||||||
|
riskMax: 'medium',
|
||||||
|
},
|
||||||
|
notifyOnAction: false, // Only notify on high-impact actions
|
||||||
|
auditLogEnabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// === Storage ===
|
||||||
|
|
||||||
|
const AUTONOMY_CONFIG_KEY = 'zclaw-autonomy-config';
|
||||||
|
const AUDIT_LOG_KEY = 'zclaw-autonomy-audit-log';
|
||||||
|
|
||||||
|
// === Autonomy Manager ===
|
||||||
|
|
||||||
|
export class AutonomyManager {
|
||||||
|
private config: AutonomyConfig;
|
||||||
|
private auditLog: AuditLogEntry[] = [];
|
||||||
|
private pendingApprovals: Map<string, AutonomyDecision> = new Map();
|
||||||
|
|
||||||
|
constructor(config?: Partial<AutonomyConfig>) {
|
||||||
|
this.config = this.loadConfig();
|
||||||
|
if (config) {
|
||||||
|
this.config = { ...this.config, ...config };
|
||||||
|
}
|
||||||
|
this.loadAuditLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Decision Making ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate whether an action can be executed autonomously.
|
||||||
|
*/
|
||||||
|
evaluate(
|
||||||
|
action: ActionType,
|
||||||
|
context?: {
|
||||||
|
importance?: number;
|
||||||
|
riskOverride?: RiskLevel;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
): AutonomyDecision {
|
||||||
|
const importance = context?.importance ?? 5;
|
||||||
|
const baseRisk = ACTION_RISK_MAP[action];
|
||||||
|
const riskLevel = context?.riskOverride ?? baseRisk;
|
||||||
|
|
||||||
|
// High-risk actions ALWAYS require approval
|
||||||
|
const isHighRisk = riskLevel === 'high';
|
||||||
|
const isSelfModification = action === 'identity_update' || action === 'selfModification';
|
||||||
|
const isDeletion = action === 'memory_delete';
|
||||||
|
|
||||||
|
let allowed = false;
|
||||||
|
let requiresApproval = true;
|
||||||
|
let reason = '';
|
||||||
|
|
||||||
|
// Determine if action is allowed based on config
|
||||||
|
if (isHighRisk || isDeletion) {
|
||||||
|
// Always require approval for high-risk and deletion
|
||||||
|
allowed = false;
|
||||||
|
requiresApproval = true;
|
||||||
|
reason = `高风险操作 [${action}] 始终需要用户确认`;
|
||||||
|
} else if (isSelfModification && !this.config.allowedActions.selfModification) {
|
||||||
|
// Self-modification requires explicit permission
|
||||||
|
allowed = false;
|
||||||
|
requiresApproval = true;
|
||||||
|
reason = `身份修改 [${action}] 需要显式授权`;
|
||||||
|
} else {
|
||||||
|
// Check against thresholds
|
||||||
|
const importanceOk = importance <= this.config.approvalThreshold.importanceMax;
|
||||||
|
const riskOk = RISK_ORDER[riskLevel] <= RISK_ORDER[this.config.approvalThreshold.riskMax];
|
||||||
|
const actionAllowed = this.isActionAllowed(action);
|
||||||
|
|
||||||
|
if (actionAllowed && importanceOk && riskOk) {
|
||||||
|
allowed = true;
|
||||||
|
requiresApproval = false;
|
||||||
|
reason = `自动批准: 重要性=${importance}, 风险=${riskLevel}`;
|
||||||
|
} else if (actionAllowed) {
|
||||||
|
allowed = false;
|
||||||
|
requiresApproval = true;
|
||||||
|
reason = `需要审批: 重要性=${importance}(阈值${this.config.approvalThreshold.importanceMax}), 风险=${riskLevel}(阈值${this.config.approvalThreshold.riskMax})`;
|
||||||
|
} else {
|
||||||
|
allowed = false;
|
||||||
|
requiresApproval = true;
|
||||||
|
reason = `操作 [${action}] 未在允许列表中`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const decision: AutonomyDecision = {
|
||||||
|
action,
|
||||||
|
allowed,
|
||||||
|
requiresApproval,
|
||||||
|
reason,
|
||||||
|
riskLevel,
|
||||||
|
importance,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Log the decision
|
||||||
|
if (this.config.auditLogEnabled) {
|
||||||
|
this.logDecision(decision, context?.details ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return decision;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an action type is allowed by current config.
|
||||||
|
*/
|
||||||
|
private isActionAllowed(action: ActionType): boolean {
|
||||||
|
const actionMap: Record<ActionType, keyof AutonomyConfig['allowedActions'] | null> = {
|
||||||
|
memory_save: 'memoryAutoSave',
|
||||||
|
memory_delete: null, // Never auto-allowed
|
||||||
|
identity_update: 'identityAutoUpdate',
|
||||||
|
identity_rollback: null,
|
||||||
|
skill_install: 'skillAutoInstall',
|
||||||
|
skill_uninstall: null,
|
||||||
|
config_change: null,
|
||||||
|
workflow_trigger: 'autoCompaction',
|
||||||
|
hand_trigger: null,
|
||||||
|
llm_call: 'autoReflection',
|
||||||
|
reflection_run: 'autoReflection',
|
||||||
|
compaction_run: 'autoCompaction',
|
||||||
|
};
|
||||||
|
|
||||||
|
const configKey = actionMap[action];
|
||||||
|
if (!configKey) return false;
|
||||||
|
return this.config.allowedActions[configKey] ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Approval Workflow ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request approval for an action.
|
||||||
|
* Returns approval ID that can be used to approve/reject.
|
||||||
|
*/
|
||||||
|
requestApproval(decision: AutonomyDecision): string {
|
||||||
|
const approvalId = `approval_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
this.pendingApprovals.set(approvalId, decision);
|
||||||
|
|
||||||
|
// Store in localStorage for persistence
|
||||||
|
this.persistPendingApprovals();
|
||||||
|
|
||||||
|
console.log(`[AutonomyManager] Approval requested: ${approvalId} for ${decision.action}`);
|
||||||
|
return approvalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approve a pending action.
|
||||||
|
*/
|
||||||
|
approve(approvalId: string): boolean {
|
||||||
|
const decision = this.pendingApprovals.get(approvalId);
|
||||||
|
if (!decision) {
|
||||||
|
console.warn(`[AutonomyManager] Approval not found: ${approvalId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update decision
|
||||||
|
decision.allowed = true;
|
||||||
|
decision.requiresApproval = false;
|
||||||
|
decision.reason = `用户已批准 [${approvalId}]`;
|
||||||
|
|
||||||
|
// Remove from pending
|
||||||
|
this.pendingApprovals.delete(approvalId);
|
||||||
|
this.persistPendingApprovals();
|
||||||
|
|
||||||
|
// Update audit log
|
||||||
|
this.updateAuditLogOutcome(approvalId, 'success');
|
||||||
|
|
||||||
|
console.log(`[AutonomyManager] Approved: ${approvalId}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject a pending action.
|
||||||
|
*/
|
||||||
|
reject(approvalId: string): boolean {
|
||||||
|
const decision = this.pendingApprovals.get(approvalId);
|
||||||
|
if (!decision) {
|
||||||
|
console.warn(`[AutonomyManager] Approval not found: ${approvalId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from pending
|
||||||
|
this.pendingApprovals.delete(approvalId);
|
||||||
|
this.persistPendingApprovals();
|
||||||
|
|
||||||
|
// Update audit log
|
||||||
|
this.updateAuditLogOutcome(approvalId, 'failed');
|
||||||
|
|
||||||
|
console.log(`[AutonomyManager] Rejected: ${approvalId}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all pending approvals.
|
||||||
|
*/
|
||||||
|
getPendingApprovals(): Array<{ id: string; decision: AutonomyDecision }> {
|
||||||
|
return Array.from(this.pendingApprovals.entries()).map(([id, decision]) => ({
|
||||||
|
id,
|
||||||
|
decision,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Audit Log ===
|
||||||
|
|
||||||
|
private logDecision(decision: AutonomyDecision, context: Record<string, unknown>): void {
|
||||||
|
const entry: AuditLogEntry = {
|
||||||
|
id: `audit_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
action: decision.action,
|
||||||
|
decision,
|
||||||
|
context,
|
||||||
|
outcome: decision.allowed ? 'success' : 'failed',
|
||||||
|
timestamp: decision.timestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.auditLog.push(entry);
|
||||||
|
|
||||||
|
// Keep last 100 entries
|
||||||
|
if (this.auditLog.length > 100) {
|
||||||
|
this.auditLog = this.auditLog.slice(-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saveAuditLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateAuditLogOutcome(approvalId: string, outcome: 'success' | 'failed' | 'rolled_back'): void {
|
||||||
|
// Find the most recent entry for this action and update outcome
|
||||||
|
const entry = this.auditLog.find(e => e.decision.reason.includes(approvalId));
|
||||||
|
if (entry) {
|
||||||
|
entry.outcome = outcome;
|
||||||
|
this.saveAuditLog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rollback an action by its audit log ID.
|
||||||
|
*/
|
||||||
|
rollback(auditId: string): boolean {
|
||||||
|
const entry = this.auditLog.find(e => e.id === auditId);
|
||||||
|
if (!entry) {
|
||||||
|
console.warn(`[AutonomyManager] Audit entry not found: ${auditId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.outcome === 'rolled_back') {
|
||||||
|
console.warn(`[AutonomyManager] Already rolled back: ${auditId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as rolled back
|
||||||
|
entry.outcome = 'rolled_back';
|
||||||
|
entry.rolledBackAt = new Date().toISOString();
|
||||||
|
this.saveAuditLog();
|
||||||
|
|
||||||
|
console.log(`[AutonomyManager] Rolled back: ${auditId}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get audit log entries.
|
||||||
|
*/
|
||||||
|
getAuditLog(limit: number = 50): AuditLogEntry[] {
|
||||||
|
return this.auditLog.slice(-limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear audit log.
|
||||||
|
*/
|
||||||
|
clearAuditLog(): void {
|
||||||
|
this.auditLog = [];
|
||||||
|
this.saveAuditLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Config Management ===
|
||||||
|
|
||||||
|
getConfig(): AutonomyConfig {
|
||||||
|
return { ...this.config };
|
||||||
|
}
|
||||||
|
|
||||||
|
updateConfig(updates: Partial<AutonomyConfig>): void {
|
||||||
|
this.config = { ...this.config, ...updates };
|
||||||
|
this.saveConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
setLevel(level: AutonomyLevel): void {
|
||||||
|
this.config = { ...DEFAULT_AUTONOMY_CONFIGS[level], level };
|
||||||
|
this.saveConfig();
|
||||||
|
console.log(`[AutonomyManager] Level changed to: ${level}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Persistence ===
|
||||||
|
|
||||||
|
private loadConfig(): AutonomyConfig {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(AUTONOMY_CONFIG_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return { ...DEFAULT_AUTONOMY_CONFIGS.assisted, ...parsed };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
return DEFAULT_AUTONOMY_CONFIGS.assisted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveConfig(): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(AUTONOMY_CONFIG_KEY, JSON.stringify(this.config));
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadAuditLog(): void {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(AUDIT_LOG_KEY);
|
||||||
|
if (raw) {
|
||||||
|
this.auditLog = JSON.parse(raw);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.auditLog = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveAuditLog(): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(AUDIT_LOG_KEY, JSON.stringify(this.auditLog.slice(-100)));
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private persistPendingApprovals(): void {
|
||||||
|
try {
|
||||||
|
const pending = Array.from(this.pendingApprovals.entries());
|
||||||
|
localStorage.setItem('zclaw-pending-approvals', JSON.stringify(pending));
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Singleton ===
|
||||||
|
|
||||||
|
let _instance: AutonomyManager | null = null;
|
||||||
|
|
||||||
|
export function getAutonomyManager(config?: Partial<AutonomyConfig>): AutonomyManager {
|
||||||
|
if (!_instance) {
|
||||||
|
_instance = new AutonomyManager(config);
|
||||||
|
}
|
||||||
|
return _instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetAutonomyManager(): void {
|
||||||
|
_instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Helper Functions ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick check if an action can proceed autonomously.
|
||||||
|
*/
|
||||||
|
export function canAutoExecute(
|
||||||
|
action: ActionType,
|
||||||
|
importance: number = 5
|
||||||
|
): { canProceed: boolean; decision: AutonomyDecision } {
|
||||||
|
const manager = getAutonomyManager();
|
||||||
|
const decision = manager.evaluate(action, { importance });
|
||||||
|
return {
|
||||||
|
canProceed: decision.allowed && !decision.requiresApproval,
|
||||||
|
decision,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute an action with autonomy check.
|
||||||
|
* Returns the decision and whether the action was executed.
|
||||||
|
*/
|
||||||
|
export async function executeWithAutonomy<T>(
|
||||||
|
action: ActionType,
|
||||||
|
importance: number,
|
||||||
|
executor: () => Promise<T>,
|
||||||
|
onApprovalNeeded?: (decision: AutonomyDecision, approvalId: string) => void
|
||||||
|
): Promise<{ executed: boolean; result?: T; decision: AutonomyDecision; approvalId?: string }> {
|
||||||
|
const manager = getAutonomyManager();
|
||||||
|
const decision = manager.evaluate(action, { importance });
|
||||||
|
|
||||||
|
if (decision.allowed && !decision.requiresApproval) {
|
||||||
|
// Execute immediately
|
||||||
|
try {
|
||||||
|
const result = await executor();
|
||||||
|
return { executed: true, result, decision };
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[AutonomyManager] Action ${action} failed:`, error);
|
||||||
|
return { executed: false, decision };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Need approval
|
||||||
|
const approvalId = manager.requestApproval(decision);
|
||||||
|
onApprovalNeeded?.(decision, approvalId);
|
||||||
|
|
||||||
|
return { executed: false, decision, approvalId };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user