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:
iven
2026-03-16 10:49:49 +08:00
parent 0b89329e19
commit 8e630882c7
3 changed files with 1416 additions and 0 deletions

View 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;