diff --git a/crates/zclaw-growth/src/storage/sqlite.rs b/crates/zclaw-growth/src/storage/sqlite.rs index de92514..3c2b723 100644 --- a/crates/zclaw-growth/src/storage/sqlite.rs +++ b/crates/zclaw-growth/src/storage/sqlite.rs @@ -163,6 +163,14 @@ impl SqliteStorage { .execute(&self.pool) .await; + // P2-24: Migration — content fingerprint for deduplication + let _ = sqlx::query("ALTER TABLE memories ADD COLUMN content_hash TEXT") + .execute(&self.pool) + .await; + let _ = sqlx::query("CREATE INDEX IF NOT EXISTS idx_content_hash ON memories(content_hash)") + .execute(&self.pool) + .await; + // Create metadata table sqlx::query( r#" @@ -426,12 +434,54 @@ impl VikingStorage for SqliteStorage { let last_accessed = entry.last_accessed.to_rfc3339(); let memory_type = entry.memory_type.to_string(); + // P2-24: Content-hash deduplication + let normalized_content = entry.content.trim().to_lowercase(); + let content_hash = { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + normalized_content.hash(&mut hasher); + format!("{:016x}", hasher.finish()) + }; + + // Check for existing entry with the same content hash (within same agent scope) + let agent_scope = entry.uri.split('/').nth(2).unwrap_or(""); + let existing: Option<(String, i32, i32)> = sqlx::query_as::<_, (String, i32, i32)>( + "SELECT uri, importance, access_count FROM memories WHERE content_hash = ? AND uri LIKE ? LIMIT 1" + ) + .bind(&content_hash) + .bind(format!("agent://{agent_scope}/%")) + .fetch_optional(&self.pool) + .await + .map_err(|e| ZclawError::StorageError(format!("Dedup check failed: {}", e)))?; + + if let Some((existing_uri, existing_importance, existing_access)) = existing { + // Merge: keep higher importance, bump access count, update last_accessed + let merged_importance = existing_importance.max(entry.importance as i32); + let merged_access = existing_access + 1; + sqlx::query( + "UPDATE memories SET importance = ?, access_count = ?, last_accessed = ? WHERE uri = ?" + ) + .bind(merged_importance) + .bind(merged_access) + .bind(&last_accessed) + .bind(&existing_uri) + .execute(&self.pool) + .await + .map_err(|e| ZclawError::StorageError(format!("Dedup merge failed: {}", e)))?; + + tracing::debug!( + "[SqliteStorage] Dedup: merged '{}' into existing '{}' (importance={}, access_count={})", + entry.uri, existing_uri, merged_importance, merged_access + ); + return Ok(()); + } + // Insert into main table sqlx::query( r#" INSERT OR REPLACE INTO memories - (uri, memory_type, content, keywords, importance, access_count, created_at, last_accessed, overview, abstract_summary) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (uri, memory_type, content, keywords, importance, access_count, created_at, last_accessed, overview, abstract_summary, content_hash) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "#, ) .bind(&entry.uri) @@ -444,6 +494,7 @@ impl VikingStorage for SqliteStorage { .bind(&last_accessed) .bind(&entry.overview) .bind(&entry.abstract_summary) + .bind(&content_hash) .execute(&self.pool) .await .map_err(|e| ZclawError::StorageError(format!("Failed to store memory: {}", e)))?; diff --git a/desktop/src/lib/kernel-hands.ts b/desktop/src/lib/kernel-hands.ts index 85214c6..6f52377 100644 --- a/desktop/src/lib/kernel-hands.ts +++ b/desktop/src/lib/kernel-hands.ts @@ -96,6 +96,13 @@ export function installHandMethods(ClientClass: { prototype: KernelClient }): vo input: params || {}, ...(autonomyLevel ? { autonomyLevel } : {}), }); + // P2-25: Audit hand execution + try { + const { logSecurityEvent } = await import('./security-audit'); + logSecurityEvent('hand_executed', `Hand "${name}" executed (runId: ${result.instance_id}, status: ${result.status})`, { + handId: name, runId: result.instance_id, status: result.status, autonomyLevel, + }); + } catch { /* audit failure is non-blocking */ } return { runId: result.instance_id, status: result.status }; }; @@ -116,7 +123,15 @@ export function installHandMethods(ClientClass: { prototype: KernelClient }): vo * Approve a hand execution */ proto.approveHand = async function (this: KernelClient, name: string, runId: string, approved: boolean, reason?: string): Promise<{ status: string }> { - return await invoke('hand_approve', { handName: name, runId, approved, reason }); + const result = await invoke<{ status: string }>('hand_approve', { handName: name, runId, approved, reason }); + // P2-25: Audit hand approval + try { + const { logSecurityEvent } = await import('./security-audit'); + logSecurityEvent(approved ? 'hand_approved' : 'hand_denied', + `Hand "${name}" ${approved ? 'approved' : 'denied'} (runId: ${runId})`, + { handId: name, runId, approved, reason }); + } catch { /* non-blocking */ } + return result; }; /** diff --git a/desktop/src/lib/kernel-skills.ts b/desktop/src/lib/kernel-skills.ts index 5f03a6b..5d7cf26 100644 --- a/desktop/src/lib/kernel-skills.ts +++ b/desktop/src/lib/kernel-skills.ts @@ -114,7 +114,12 @@ export function installSkillMethods(ClientClass: { prototype: KernelClient }): v const agent = convStore.currentAgent; const sessionKey = convStore.sessionKey; - return invoke('skill_execute', { + const result = await invoke<{ + success: boolean; + output?: unknown; + error?: string; + durationMs?: number; + }>('skill_execute', { id, context: { agentId: agent?.id || 'zclaw-main', @@ -123,5 +128,17 @@ export function installSkillMethods(ClientClass: { prototype: KernelClient }): v }, input: input || {}, }); + + // P2-25: Audit skill execution + try { + const { logSecurityEvent } = await import('./security-audit'); + logSecurityEvent( + 'skill_executed', + `Skill "${id}" ${result.success ? 'executed' : 'failed'} (duration: ${result.durationMs ?? '?'}ms)`, + { skillId: id, success: result.success, durationMs: result.durationMs, error: result.error }, + ); + } catch { /* audit failure is non-blocking */ } + + return result; }; } diff --git a/desktop/src/lib/security-audit.ts b/desktop/src/lib/security-audit.ts index fe8c50b..0e7ac18 100644 --- a/desktop/src/lib/security-audit.ts +++ b/desktop/src/lib/security-audit.ts @@ -41,7 +41,11 @@ export type SecurityEventType = | 'session_started' | 'session_ended' | 'rate_limit_exceeded' - | 'suspicious_activity'; + | 'suspicious_activity' + | 'hand_executed' + | 'hand_approved' + | 'hand_denied' + | 'skill_executed'; export type SecurityEventSeverity = 'info' | 'warning' | 'error' | 'critical'; @@ -190,6 +194,10 @@ function getDefaultSeverity(type: SecurityEventType): SecurityEventSeverity { session_ended: 'info', rate_limit_exceeded: 'warning', suspicious_activity: 'critical', + hand_executed: 'info', + hand_approved: 'info', + hand_denied: 'warning', + skill_executed: 'info', }; return severityMap[type] || 'info'; diff --git a/docs/test-results/DEFECT_LIST.md b/docs/test-results/DEFECT_LIST.md index 192658a..854557e 100644 --- a/docs/test-results/DEFECT_LIST.md +++ b/docs/test-results/DEFECT_LIST.md @@ -1,6 +1,6 @@ # ZCLAW 上线前功能审计 — 缺陷清单 -> **审计日期**: 2026-04-06 | **审计范围**: T1-T8 模块 | **基线**: V12 审计 | **最新编译状态**: ✅ cargo check 通过 +> **审计日期**: 2026-04-06 | **最后更新**: 2026-04-06 | **审计范围**: T1-T8 模块 | **基线**: V12 审计 | **最新编译状态**: ✅ cargo check 通过 ## 统计总览 @@ -8,9 +8,9 @@ |--------|---------|--------|--------|---------| | **P0** | 1 | 0 | 1 | **0** | | **P1** | 11 | 2 | 13 | **0** | -| **P2** | 25 | 2 | 25 | **2** | -| **P3** | 10 | 0 | 9 | **1** | -| **合计** | **47** | **4** | **48** | **3** | +| **P2** | 25 | 2 | 26 | **1** | +| **P3** | 10 | 0 | 10 | **0** | +| **合计** | **47** | **4** | **50** | **1** | --- @@ -26,7 +26,7 @@ | ID | 原V12 ID | 模块 | 描述 | 文件 | 状态 | |----|---------|------|------|------|------| -| P1-01 | M3-02 | T1 | Browser Hand 返回 pending_execution 不实际执行 | hands/browser.rs | 🔬 实验性(需 Fantoccini 桥接) | +| P1-01 | M3-02 | T1 | Browser Hand 返回 pending_execution 不实际执行 | hands/browser.rs | ✅ 已修复 (Fantoccini 0.21 集成于 desktop/src-tauri/src/browser/client.rs,Rust Hand 为有意 schema passthrough) | | P1-02 | M4-03 | T2 | Heartbeat 不自动初始化,需手动 heartbeat_init | heartbeat.rs | ✅ 已修复 | | P1-03 | TC-1-D01 | T1 | LLM API 并发 500 DATABASE_ERROR(4/5 并发失败) | saas/relay | ✅ 已修复 | | P1-04 | TC-4-D01 | T4 | GenerationPipeline 硬编码 model="default",SaaS relay 404 | zclaw-kernel/generation/mod.rs:416 | ✅ 已修复 | @@ -94,6 +94,8 @@ | P2-21 | M1-01 | GeminiDriver API Key 在 URL query 参数中 | ✅ 已修复 (P2-21: 前期暂停非国内模型支持,Gemini/OpenAI/Anthropic 标记为 suspended) | | P2-22 | M1-02 | ToolOutputGuard 只 warn 不 block 敏感信息 | ✅ 已修复 (sensitive patterns now return Err to block output) | | P2-23 | M1-03/04 | Mutex::unwrap() 在 async 中可能 panic | ✅ 已修复 (relay/service.rs unwrap_or_else(|e| e.into_inner())) | +| P2-24 | — | 记忆写入无去重,多轮对话产生内容相同的重复记忆 | 📋 待修复 (content_hash 去重方案) | +| P2-25 | — | 审计日志仅记录反思运行,Hand/Skill 执行无审计追踪 | 📋 待修复 (security-audit.ts 补全事件类型) | --- @@ -102,7 +104,7 @@ | ID | 原V12 ID | 模块 | 描述 | 状态 | |----|---------|------|------|------| | P3-01 | TC-2-D02 | T2 | memory_store entry ID 重复 (knowledge/knowledge) | ✅ 已修复 (使用 source 作为 category 避免重复) | -| P3-02 | M11-07 | T4 | 白板两套渲染实现未统一(SceneRenderer SVG + WhiteboardCanvas) | 📋 方案已制定 (docs/features/classroom/WHITEBOARD_UNIFICATION_PLAN.md) | +| P3-02 | M11-07 | T4 | 白板两套渲染实现未统一(SceneRenderer SVG + WhiteboardCanvas) | ✅ 已修复 (SceneRenderer 导入 WhiteboardCanvas,删除内联 SVG renderWhiteboardItem) | | P3-03 | M11-08 | T4 | HTML export 只渲染 title+duration,缺少 key_points | ✅ 已修复 (export_key_points 配置化渲染) | | P3-04 | M6-08 | T5 | get_progress() 百分比只有 0/50/100 三档 | ✅ 已修复 (PipelineRun.total_steps + 实际百分比计算) | | P3-05 | M7-05 | T6 | saveSaaSSession fire-and-forget,失败静默 | ✅ 已修复 (所有调用点添加 .catch() 错误日志) |