diff --git a/scripts/claude-bridge.js b/scripts/claude-bridge.js new file mode 100644 index 0000000..dc0ffb3 --- /dev/null +++ b/scripts/claude-bridge.js @@ -0,0 +1,318 @@ +#!/usr/bin/env node +// Claude Desktop — Bridge zwischen Tauri-Backend und Claude CLI +// +// Kommunikation: stdin/stdout als NDJSON (eine JSON-Zeile pro Nachricht) +// +// Eingehend (von Tauri): +// { "command": "message", "id": "req-1", "message": "Fixe den Bug..." } +// { "command": "stop", "id": "req-2" } +// +// Ausgehend (an Tauri): +// { "type": "event", "event": "ready" } +// { "type": "event", "event": "agent-started", "payload": { "id": "...", "type": "Main" } } +// { "type": "event", "event": "text", "payload": { "text": "..." } } +// { "type": "event", "event": "tool-start", "payload": { "id": "...", "tool": "Read", "input": {...} } } +// { "type": "event", "event": "tool-end", "payload": { "id": "...", "success": true } } +// { "type": "event", "event": "result", "payload": { "cost": 0.01, "tokens": {...} } } +// { "type": "event", "event": "agent-stopped", "payload": { "id": "..." } } + +import { spawn } from 'node:child_process'; +import { createInterface } from 'node:readline'; +import { randomUUID } from 'node:crypto'; + +// Aktive Claude-Prozesse +const activeProcesses = new Map(); + +// Session-ID für --resume +let currentSessionId = null; + +// ============ Kommunikation mit Tauri ============ + +function sendToTauri(msg) { + process.stdout.write(JSON.stringify(msg) + '\n'); +} + +function sendEvent(event, payload = {}) { + sendToTauri({ type: 'event', event, payload }); +} + +function sendResponse(id, result) { + sendToTauri({ type: 'response', id, result }); +} + +function sendError(id, error) { + sendToTauri({ type: 'response', id, error }); +} + +// ============ Claude CLI aufrufen ============ + +function spawnClaude(message, requestId) { + const agentId = randomUUID(); + const args = [ + '-p', // Print-Modus (nicht interaktiv) + '--output-format', 'stream-json', // Streaming JSON Events + '--allowedTools', 'Read', 'Glob', 'Grep', 'Bash', 'Edit', 'Write', + 'WebFetch', 'WebSearch', 'Agent', + ]; + + // Bei Fortsetzung letzte Session verwenden + if (currentSessionId) { + args.push('--resume', currentSessionId); + } + + // Nachricht als Argument + args.push(message); + + // Claude CLI Pfad — npx oder global installiert + const claudePath = process.env.CLAUDE_PATH || 'claude'; + + const proc = spawn(claudePath, args, { + stdio: ['pipe', 'pipe', 'pipe'], + env: { + ...process.env, + // Keine interaktive UI + CLAUDE_CODE_HEADLESS: '1', + }, + }); + + activeProcesses.set(agentId, { proc, requestId }); + + sendEvent('agent-started', { + id: agentId, + type: 'Main', + task: message.substring(0, 100), + }); + + // NDJSON-Zeilen von stdout parsen + let fullText = ''; + let toolCounter = 0; + + const rl = createInterface({ input: proc.stdout }); + + rl.on('line', (line) => { + if (!line.trim()) return; + + try { + const event = JSON.parse(line); + handleClaudeEvent(event, agentId, requestId, { fullText: () => fullText, addText: (t) => { fullText += t; } }); + } catch { + // Nicht-JSON Zeile — als Text weiterleiten + fullText += line; + sendEvent('text', { text: line }); + } + }); + + // stderr für Debug-Infos + const stderrRl = createInterface({ input: proc.stderr }); + stderrRl.on('line', (line) => { + if (process.env.CLAUDE_DEBUG) { + process.stderr.write(`[claude-stderr] ${line}\n`); + } + }); + + proc.on('close', (code) => { + activeProcesses.delete(agentId); + + sendEvent('agent-stopped', { + id: agentId, + code, + }); + + if (activeProcesses.size === 0) { + sendEvent('all-stopped'); + } + }); + + proc.on('error', (err) => { + sendError(requestId, `Claude konnte nicht gestartet werden: ${err.message}`); + activeProcesses.delete(agentId); + }); + + return agentId; +} + +// ============ Claude Stream-JSON Events verarbeiten ============ + +function handleClaudeEvent(event, agentId, requestId, textState) { + // Das stream-json Format hat verschiedene Event-Typen: + // { "type": "assistant", "message": { "content": [...], ... } } + // { "type": "content_block_start", "content_block": { "type": "text", "text": "..." } } + // { "type": "content_block_delta", "delta": { "type": "text_delta", "text": "..." } } + // { "type": "content_block_start", "content_block": { "type": "tool_use", "name": "Read", ... } } + // { "type": "content_block_delta", "delta": { "type": "input_json_delta", ... } } + // { "type": "result", "result": "...", "cost_usd": 0.01, ... } + // { "type": "system", "subtype": "session_id", "session_id": "..." } + + const type = event.type; + + switch (type) { + case 'system': + if (event.subtype === 'session_id' && event.session_id) { + currentSessionId = event.session_id; + } + break; + + case 'assistant': + // Vollständige Nachricht — Inhalt extrahieren + if (event.message?.content) { + for (const block of event.message.content) { + if (block.type === 'text') { + sendEvent('text', { text: block.text }); + } + } + } + break; + + case 'content_block_start': + if (event.content_block?.type === 'text') { + // Text-Block startet + if (event.content_block.text) { + sendEvent('text', { text: event.content_block.text }); + } + } else if (event.content_block?.type === 'tool_use') { + // Tool-Aufruf startet + sendEvent('tool-start', { + id: event.content_block.id || randomUUID(), + tool: event.content_block.name, + input: event.content_block.input || {}, + }); + } + break; + + case 'content_block_delta': + if (event.delta?.type === 'text_delta' && event.delta.text) { + sendEvent('text', { text: event.delta.text }); + } + break; + + case 'content_block_stop': + // Block fertig — wenn es ein Tool war, Tool-Ende senden + break; + + case 'tool_result': + case 'tool_use_result': + sendEvent('tool-end', { + id: event.tool_use_id || event.id || '', + success: !event.is_error, + output: typeof event.content === 'string' + ? event.content + : JSON.stringify(event.content)?.substring(0, 500), + }); + break; + + case 'result': + // Endergebnis mit Kosten + sendEvent('result', { + text: event.result || '', + cost: event.cost_usd || event.cost || 0, + tokens: { + input: event.input_tokens || 0, + output: event.output_tokens || 0, + }, + session_id: event.session_id || currentSessionId, + duration_ms: event.duration_ms || 0, + }); + + // Session-ID für Fortsetzung merken + if (event.session_id) { + currentSessionId = event.session_id; + } + break; + + default: + // Unbekannte Events durchreichen + if (event.subagent_id || event.agent_id) { + // Sub-Agent Events + sendEvent('subagent-event', { + agentId: event.subagent_id || event.agent_id, + type, + data: event, + }); + } + break; + } +} + +// ============ Alle Prozesse stoppen ============ + +function stopAll() { + for (const [agentId, { proc }] of activeProcesses) { + try { + proc.kill('SIGTERM'); + // Nach 2 Sekunden hart killen + setTimeout(() => { + try { proc.kill('SIGKILL'); } catch {} + }, 2000); + } catch {} + } + activeProcesses.clear(); + sendEvent('all-stopped'); +} + +// ============ Eingehende Befehle verarbeiten ============ + +function handleCommand(msg) { + switch (msg.command) { + case 'message': + if (!msg.message) { + sendError(msg.id, 'Keine Nachricht angegeben'); + return; + } + const agentId = spawnClaude(msg.message, msg.id); + sendResponse(msg.id, { agentId, status: 'gestartet' }); + break; + + case 'stop': + stopAll(); + sendResponse(msg.id, { status: 'gestoppt' }); + break; + + case 'status': + const agents = []; + for (const [id, { proc }] of activeProcesses) { + agents.push({ + id, + running: !proc.killed, + pid: proc.pid, + }); + } + sendResponse(msg.id, { agents, sessionId: currentSessionId }); + break; + + case 'ping': + sendResponse(msg.id, { pong: true }); + break; + + default: + sendError(msg.id, `Unbekannter Befehl: ${msg.command}`); + } +} + +// ============ Main ============ + +// stdin zeilenweise lesen +const rl = createInterface({ input: process.stdin }); + +rl.on('line', (line) => { + if (!line.trim()) return; + try { + const msg = JSON.parse(line); + handleCommand(msg); + } catch (err) { + process.stderr.write(`Ungültige Eingabe: ${err.message}\n`); + } +}); + +// Sauber beenden +process.on('SIGTERM', () => { + stopAll(); + process.exit(0); +}); + +process.on('SIGINT', () => { + stopAll(); + process.exit(0); +}); + +// Bereit-Signal senden +sendEvent('ready', { version: '0.1.0', pid: process.pid }); diff --git a/shell.nix b/shell.nix index cdb71dd..13960cf 100644 --- a/shell.nix +++ b/shell.nix @@ -2,7 +2,11 @@ pkgs.mkShell { buildInputs = with pkgs; [ - # Rust (wir nutzen rustup, aber brauchen den Linker) + # Rust Toolchain aus nixpkgs + rustc + cargo + rustfmt + clippy gcc pkg-config openssl @@ -56,11 +60,9 @@ pkgs.mkShell { pkgs.openssl ]}:$LD_LIBRARY_PATH" - # Rust von rustup laden - source "$HOME/.cargo/env" 2>/dev/null || true - echo "🦀 Claude Desktop Entwicklungsumgebung geladen" echo " Rust: $(rustc --version 2>/dev/null || echo 'nicht gefunden')" + echo " Cargo: $(cargo --version 2>/dev/null || echo 'nicht gefunden')" echo " Node: $(node --version 2>/dev/null || echo 'nicht gefunden')" ''; } diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5ec2a9a..ba20d61 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -13,7 +13,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = [] } +tauri = { version = "2", features = ["tray-icon"] } tauri-plugin-shell = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src-tauri/src/audit.rs b/src-tauri/src/audit.rs index db957b6..e23ea06 100644 --- a/src-tauri/src/audit.rs +++ b/src-tauri/src/audit.rs @@ -2,7 +2,10 @@ // Protokolliert alle Änderungen an Einstellungen, Guard-Rails, Hooks, Skills, etc. use serde::{Deserialize, Serialize}; -use tauri::AppHandle; +use std::sync::{Arc, Mutex}; +use tauri::{AppHandle, Manager}; + +use crate::db; /// Kategorie der Änderung #[derive(Debug, Clone, Serialize, Deserialize)] @@ -105,17 +108,17 @@ pub struct AuditStats { /// Holt die letzten Audit-Einträge #[tauri::command] -pub async fn get_audit_log(limit: Option) -> Result, String> { +pub async fn get_audit_log(app: AppHandle, limit: Option) -> Result, String> { let limit = limit.unwrap_or(50); - - // TODO: Aus SQLite laden - - Ok(vec![]) + let state = app.state::>>(); + let db_lock = state.lock().unwrap(); + db_lock.load_audit_log(limit).map_err(|e| e.to_string()) } /// Fügt einen Audit-Eintrag hinzu #[tauri::command] pub async fn add_audit_entry( + app: AppHandle, category: String, action: String, item_id: String, @@ -159,21 +162,20 @@ pub async fn add_audit_entry( session_id: None, }; - // TODO: In SQLite speichern + // In SQLite speichern + let state = app.state::>>(); + let db_lock = state.lock().unwrap(); + db_lock.save_audit_entry(&entry).map_err(|e| e.to_string())?; - println!("📋 Audit-Eintrag hinzugefügt: {:?}", entry); + println!("📋 Audit: {:?} {:?} - {}", entry.action, entry.category, entry.item_name); Ok(()) } /// Holt Audit-Statistiken #[tauri::command] -pub async fn get_audit_stats() -> Result { - // TODO: Echte Implementierung - - Ok(AuditStats { - total: 0, - auto_corrected: 0, - today: 0, - }) +pub async fn get_audit_stats(app: AppHandle) -> Result { + let state = app.state::>>(); + let db_lock = state.lock().unwrap(); + db_lock.audit_stats().map_err(|e| e.to_string()) } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs new file mode 100644 index 0000000..56803aa --- /dev/null +++ b/src-tauri/src/db.rs @@ -0,0 +1,458 @@ +// Claude Desktop — SQLite Datenbankschicht +// Persistiert Guard-Rails, Audit-Log, Memory und Einstellungen + +use rusqlite::{params, Connection, Result as SqlResult}; +use std::path::Path; +use std::sync::{Arc, Mutex}; +use tauri::{AppHandle, Manager}; + +use crate::audit::{AuditAction, AuditCategory, AuditEntry, AuditStats}; +use crate::guard::{Permission, PermissionAction, PermissionType}; +use crate::memory::{ContextCategory, MemoryEntry, Pattern}; + +/// Datenbank-Wrapper +pub struct Database { + conn: Connection, +} + +/// Datenbank-Statistiken +#[derive(Debug, serde::Serialize)] +pub struct DbStats { + pub permissions: usize, + pub audit_entries: usize, + pub memory_entries: usize, + pub patterns: usize, + pub db_size_kb: u64, +} + +impl Database { + /// Öffnet oder erstellt die Datenbank + pub fn open(path: &Path) -> SqlResult { + let conn = Connection::open(path)?; + + // WAL-Modus für bessere Performance + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?; + + let db = Self { conn }; + db.create_tables()?; + Ok(db) + } + + /// Schema erstellen + fn create_tables(&self) -> SqlResult<()> { + self.conn.execute_batch( + " + -- Guard-Rails Permissions + CREATE TABLE IF NOT EXISTS permissions ( + id TEXT PRIMARY KEY, + pattern TEXT NOT NULL, + tool TEXT, + path_pattern TEXT, + action TEXT NOT NULL DEFAULT 'allow', + created_at TEXT NOT NULL, + use_count INTEGER DEFAULT 0, + last_used TEXT + ); + + -- Audit-Log + CREATE TABLE IF NOT EXISTS audit_log ( + id TEXT PRIMARY KEY, + timestamp TEXT NOT NULL, + category TEXT NOT NULL, + action TEXT NOT NULL, + item_id TEXT NOT NULL, + item_name TEXT NOT NULL, + old_value TEXT, + new_value TEXT, + reason TEXT, + auto_corrected INTEGER DEFAULT 0, + session_id TEXT + ); + CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp DESC); + CREATE INDEX IF NOT EXISTS idx_audit_category ON audit_log(category); + + -- Memory-Einträge + CREATE TABLE IF NOT EXISTS memory ( + id TEXT PRIMARY KEY, + category TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + sticky INTEGER DEFAULT 0, + auto_load INTEGER DEFAULT 0, + last_used TEXT, + use_count INTEGER DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_memory_category ON memory(category); + CREATE INDEX IF NOT EXISTS idx_memory_sticky ON memory(sticky) WHERE sticky = 1; + + -- Patterns (Vorgehensweisen) + CREATE TABLE IF NOT EXISTS patterns ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + trigger_text TEXT, + old_approach TEXT, + new_approach TEXT, + reason TEXT, + occurrence_count INTEGER DEFAULT 1, + auto_corrected INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + -- Einstellungen (Key-Value) + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + ", + )?; + Ok(()) + } + + // ============ Permissions ============ + + /// Speichert eine Permission + pub fn save_permission(&self, perm: &Permission) -> SqlResult<()> { + self.conn.execute( + "INSERT OR REPLACE INTO permissions (id, pattern, tool, path_pattern, action, created_at, use_count, last_used) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + perm.id, + perm.pattern, + perm.tool, + perm.path_pattern, + format!("{:?}", perm.action).to_lowercase(), + perm.created_at, + perm.use_count, + perm.last_used, + ], + )?; + Ok(()) + } + + /// Lädt alle permanenten Permissions + pub fn load_permissions(&self) -> SqlResult> { + let mut stmt = self.conn.prepare( + "SELECT id, pattern, tool, path_pattern, action, created_at, use_count, last_used FROM permissions" + )?; + + let perms = stmt.query_map([], |row| { + let action_str: String = row.get(4)?; + let action = match action_str.as_str() { + "deny" => PermissionAction::Deny, + _ => PermissionAction::Allow, + }; + + Ok(Permission { + id: row.get(0)?, + pattern: row.get(1)?, + tool: row.get(2)?, + path_pattern: row.get(3)?, + permission_type: PermissionType::Permanent, + action, + created_at: row.get(5)?, + use_count: row.get(6)?, + last_used: row.get(7)?, + }) + })?.collect::>>()?; + + Ok(perms) + } + + /// Löscht eine Permission + pub fn delete_permission(&self, id: &str) -> SqlResult<()> { + self.conn.execute("DELETE FROM permissions WHERE id = ?1", params![id])?; + Ok(()) + } + + // ============ Audit-Log ============ + + /// Speichert einen Audit-Eintrag + pub fn save_audit_entry(&self, entry: &AuditEntry) -> SqlResult<()> { + self.conn.execute( + "INSERT INTO audit_log (id, timestamp, category, action, item_id, item_name, old_value, new_value, reason, auto_corrected, session_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + params![ + entry.id, + entry.timestamp, + format!("{:?}", entry.category).to_lowercase(), + format!("{:?}", entry.action).to_lowercase(), + entry.item_id, + entry.item_name, + entry.old_value.as_ref().map(|v| v.to_string()), + entry.new_value.as_ref().map(|v| v.to_string()), + entry.reason, + entry.auto_corrected as i32, + entry.session_id, + ], + )?; + Ok(()) + } + + /// Lädt die letzten N Audit-Einträge + pub fn load_audit_log(&self, limit: usize) -> SqlResult> { + let mut stmt = self.conn.prepare( + "SELECT id, timestamp, category, action, item_id, item_name, old_value, new_value, reason, auto_corrected, session_id + FROM audit_log ORDER BY timestamp DESC LIMIT ?1" + )?; + + let entries = stmt.query_map(params![limit as i64], |row| { + let cat_str: String = row.get(2)?; + let act_str: String = row.get(3)?; + let old_val: Option = row.get(6)?; + let new_val: Option = row.get(7)?; + let auto_corr: i32 = row.get(9)?; + + Ok(AuditEntry { + id: row.get(0)?, + timestamp: row.get(1)?, + category: parse_audit_category(&cat_str), + action: parse_audit_action(&act_str), + item_id: row.get(4)?, + item_name: row.get(5)?, + old_value: old_val.and_then(|s| serde_json::from_str(&s).ok()), + new_value: new_val.and_then(|s| serde_json::from_str(&s).ok()), + reason: row.get(8)?, + auto_corrected: auto_corr != 0, + session_id: row.get(10)?, + }) + })?.collect::>>()?; + + Ok(entries) + } + + /// Audit-Statistiken + pub fn audit_stats(&self) -> SqlResult { + let total: usize = self.conn.query_row( + "SELECT COUNT(*) FROM audit_log", [], |row| row.get(0) + )?; + let auto_corrected: usize = self.conn.query_row( + "SELECT COUNT(*) FROM audit_log WHERE auto_corrected = 1", [], |row| row.get(0) + )?; + let today: usize = self.conn.query_row( + "SELECT COUNT(*) FROM audit_log WHERE timestamp LIKE ?1 || '%'", + params![chrono::Local::now().format("%Y-%m-%d").to_string()], + |row| row.get(0), + )?; + + Ok(AuditStats { total, auto_corrected, today }) + } + + // ============ Memory ============ + + /// Speichert einen Memory-Eintrag + pub fn save_memory_entry(&self, entry: &MemoryEntry) -> SqlResult<()> { + self.conn.execute( + "INSERT OR REPLACE INTO memory (id, category, key, value, sticky, auto_load, last_used, use_count) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + entry.id, + format!("{:?}", entry.category), + entry.key, + entry.value.to_string(), + entry.sticky as i32, + entry.auto_load as i32, + entry.last_used, + entry.use_count, + ], + )?; + Ok(()) + } + + /// Lädt alle Memory-Einträge + pub fn load_memory_entries(&self) -> SqlResult> { + let mut stmt = self.conn.prepare( + "SELECT id, category, key, value, sticky, auto_load, last_used, use_count FROM memory" + )?; + + let entries = stmt.query_map([], |row| { + let cat_str: String = row.get(1)?; + let val_str: String = row.get(3)?; + let sticky: i32 = row.get(4)?; + let auto_load: i32 = row.get(5)?; + + Ok(MemoryEntry { + id: row.get(0)?, + category: parse_context_category(&cat_str), + key: row.get(2)?, + value: serde_json::from_str(&val_str).unwrap_or(serde_json::Value::String(val_str)), + sticky: sticky != 0, + auto_load: auto_load != 0, + last_used: row.get(6)?, + use_count: row.get(7)?, + }) + })?.collect::>>()?; + + Ok(entries) + } + + /// Löscht einen Memory-Eintrag + pub fn delete_memory_entry(&self, id: &str) -> SqlResult<()> { + self.conn.execute("DELETE FROM memory WHERE id = ?1", params![id])?; + Ok(()) + } + + // ============ Patterns ============ + + /// Speichert ein Pattern + pub fn save_pattern(&self, pattern: &Pattern) -> SqlResult<()> { + self.conn.execute( + "INSERT OR REPLACE INTO patterns (id, name, description, trigger_text, old_approach, new_approach, reason, occurrence_count, auto_corrected, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + params![ + pattern.id, + pattern.name, + pattern.description, + pattern.trigger, + pattern.old_approach, + pattern.new_approach, + pattern.reason, + pattern.occurrence_count, + pattern.auto_corrected as i32, + pattern.created_at, + pattern.updated_at, + ], + )?; + Ok(()) + } + + /// Lädt alle Patterns + pub fn load_patterns(&self) -> SqlResult> { + let mut stmt = self.conn.prepare( + "SELECT id, name, description, trigger_text, old_approach, new_approach, reason, occurrence_count, auto_corrected, created_at, updated_at FROM patterns" + )?; + + let patterns = stmt.query_map([], |row| { + let auto_corr: i32 = row.get(8)?; + Ok(Pattern { + id: row.get(0)?, + name: row.get(1)?, + description: row.get(2)?, + trigger: row.get(3)?, + old_approach: row.get(4)?, + new_approach: row.get(5)?, + reason: row.get(6)?, + occurrence_count: row.get(7)?, + auto_corrected: auto_corr != 0, + created_at: row.get(9)?, + updated_at: row.get(10)?, + }) + })?.collect::>>()?; + + Ok(patterns) + } + + // ============ Settings ============ + + /// Speichert eine Einstellung + pub fn set_setting(&self, key: &str, value: &str) -> SqlResult<()> { + self.conn.execute( + "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?1, ?2, ?3)", + params![key, value, chrono::Local::now().to_rfc3339()], + )?; + Ok(()) + } + + /// Liest eine Einstellung + pub fn get_setting(&self, key: &str) -> SqlResult> { + let result = self.conn.query_row( + "SELECT value FROM settings WHERE key = ?1", + params![key], + |row| row.get(0), + ); + match result { + Ok(val) => Ok(Some(val)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e), + } + } + + // ============ Statistiken ============ + + /// DB-Statistiken + pub fn stats(&self) -> SqlResult { + let permissions: usize = self.conn.query_row( + "SELECT COUNT(*) FROM permissions", [], |row| row.get(0) + )?; + let audit_entries: usize = self.conn.query_row( + "SELECT COUNT(*) FROM audit_log", [], |row| row.get(0) + )?; + let memory_entries: usize = self.conn.query_row( + "SELECT COUNT(*) FROM memory", [], |row| row.get(0) + )?; + let patterns: usize = self.conn.query_row( + "SELECT COUNT(*) FROM patterns", [], |row| row.get(0) + )?; + + // DB-Größe ermitteln + let page_count: u64 = self.conn.query_row( + "PRAGMA page_count", [], |row| row.get(0) + )?; + let page_size: u64 = self.conn.query_row( + "PRAGMA page_size", [], |row| row.get(0) + )?; + let db_size_kb = (page_count * page_size) / 1024; + + Ok(DbStats { permissions, audit_entries, memory_entries, patterns, db_size_kb }) + } +} + +// ============ Hilfsfunktionen ============ + +fn parse_audit_category(s: &str) -> AuditCategory { + match s { + "guardrail" | "guard_rail" => AuditCategory::GuardRail, + "pattern" => AuditCategory::Pattern, + "hook" => AuditCategory::Hook, + "skill" => AuditCategory::Skill, + "setting" => AuditCategory::Setting, + "mcp" => AuditCategory::MCP, + "memory" => AuditCategory::Memory, + _ => AuditCategory::Setting, + } +} + +fn parse_audit_action(s: &str) -> AuditAction { + match s { + "create" => AuditAction::Create, + "update" => AuditAction::Update, + "delete" => AuditAction::Delete, + "enable" => AuditAction::Enable, + "disable" => AuditAction::Disable, + _ => AuditAction::Update, + } +} + +fn parse_context_category(s: &str) -> ContextCategory { + match s { + "Critical" => ContextCategory::Critical, + "Pattern" => ContextCategory::Pattern, + "Preference" => ContextCategory::Preference, + "GuardRail" => ContextCategory::GuardRail, + "Hook" => ContextCategory::Hook, + "Skill" => ContextCategory::Skill, + _ => ContextCategory::Pattern, + } +} + +// ============ Tauri Commands ============ + +pub type DbState = Arc>; + +/// DB initialisieren (falls Frontend es auslösen will) +#[tauri::command] +pub async fn init_database(app: AppHandle) -> Result { + let state = app.state::(); + let db = state.lock().unwrap(); + db.stats().map_err(|e| e.to_string()) +} + +/// DB-Statistiken abrufen +#[tauri::command] +pub async fn get_db_stats(app: AppHandle) -> Result { + let state = app.state::(); + let db = state.lock().unwrap(); + db.stats().map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/guard.rs b/src-tauri/src/guard.rs new file mode 100644 index 0000000..ddfbba4 --- /dev/null +++ b/src-tauri/src/guard.rs @@ -0,0 +1,470 @@ +// Claude Desktop — Guard-Rails System +// Risiko-Klassifikation und Freigabe-Management + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Manager}; + +/// Risiko-Level einer Aktion +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RiskLevel { + Safe, // Read, Glob, Grep → auto-approve + Moderate, // Write, Edit, Git commit → Hinweis in Statusbar + Critical, // Prod-Deploy, DB-Schema, Git push → Modal + Bestätigung + Blocked, // rm -rf, force push main → hart blockiert +} + +/// Typ der Freigabe +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PermissionType { + Session, // Gilt nur für aktuelle Session + Permanent, // Dauerhaft gespeichert +} + +/// Eine Freigabe-Regel +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Permission { + pub id: String, + pub pattern: String, // z.B. "npm install *", "git commit -m *" + pub tool: Option, // z.B. "Bash", "Edit", None = alle + pub path_pattern: Option, // z.B. "/var/www/dolibarr/*" + pub permission_type: PermissionType, + pub action: PermissionAction, + pub created_at: String, + pub use_count: u32, + pub last_used: Option, +} + +/// Aktion einer Regel +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PermissionAction { + Allow, + Deny, +} + +/// Anfrage zur Freigabe +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PermissionRequest { + pub id: String, + pub tool: String, + pub command: String, + pub args: Option, + pub path: Option, + pub risk_level: RiskLevel, + pub suggested_pattern: Option, +} + +/// Antwort auf Freigabe-Anfrage +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PermissionResponse { + pub request_id: String, + pub allowed: bool, + pub save_as: Option, + pub pattern: Option, +} + +/// Guard-Rails Manager +pub struct GuardRails { + permissions: Vec, + session_permissions: Vec, + blocked_patterns: Vec, +} + +impl Default for GuardRails { + fn default() -> Self { + Self::new() + } +} + +impl GuardRails { + pub fn new() -> Self { + Self { + permissions: vec![], + session_permissions: vec![], + blocked_patterns: vec![ + // Immer blockiert + "rm -rf /".to_string(), + "rm -rf /*".to_string(), + "rm -rf ~".to_string(), + "git push --force origin main".to_string(), + "git push --force origin master".to_string(), + "git push -f origin main".to_string(), + "DROP DATABASE".to_string(), + "DROP TABLE".to_string(), + "TRUNCATE TABLE".to_string(), + "> /dev/sda".to_string(), + "mkfs.".to_string(), + "dd if=/dev/zero".to_string(), + ":(){:|:&};:".to_string(), // Fork bomb + ], + } + } + + /// Klassifiziert das Risiko einer Aktion + pub fn classify_risk(&self, tool: &str, command: &str, path: Option<&str>) -> RiskLevel { + // Erst prüfen ob blockiert + if self.is_blocked(command) { + return RiskLevel::Blocked; + } + + // Tool-basierte Klassifikation + match tool { + // Sichere Tools + "Read" | "Glob" | "Grep" | "WebFetch" | "WebSearch" => RiskLevel::Safe, + + // Moderate Tools + "Write" | "Edit" | "NotebookEdit" => { + // Pfad-basierte Eskalation + if let Some(p) = path { + if self.is_production_path(p) { + RiskLevel::Critical + } else { + RiskLevel::Moderate + } + } else { + RiskLevel::Moderate + } + } + + // Bash braucht Command-Analyse + "Bash" => self.classify_bash_command(command, path), + + // Task/Agent - abhängig vom Typ + "Task" => RiskLevel::Moderate, + + // Unbekannt = Moderate + _ => RiskLevel::Moderate, + } + } + + /// Klassifiziert Bash-Befehle + fn classify_bash_command(&self, command: &str, path: Option<&str>) -> RiskLevel { + let cmd_lower = command.to_lowercase(); + + // Sichere Befehle + if cmd_lower.starts_with("ls ") + || cmd_lower.starts_with("pwd") + || cmd_lower.starts_with("echo ") + || cmd_lower.starts_with("cat ") + || cmd_lower.starts_with("head ") + || cmd_lower.starts_with("tail ") + || cmd_lower.starts_with("wc ") + || cmd_lower.starts_with("grep ") + || cmd_lower.starts_with("find ") + || cmd_lower.starts_with("which ") + || cmd_lower.starts_with("whoami") + || cmd_lower.starts_with("date") + || cmd_lower.starts_with("uname") + { + return RiskLevel::Safe; + } + + // Kritische Befehle + if cmd_lower.contains("--force") + || cmd_lower.contains(" -f ") + || cmd_lower.starts_with("sudo ") + || cmd_lower.starts_with("su ") + || cmd_lower.contains("systemctl") + || cmd_lower.contains("service ") + || cmd_lower.contains("docker ") + || cmd_lower.contains("kubectl") + || cmd_lower.starts_with("rm ") + || cmd_lower.starts_with("mv ") + || cmd_lower.contains("chmod ") + || cmd_lower.contains("chown ") + { + // Prod-Pfad macht es noch kritischer + if let Some(p) = path { + if self.is_production_path(p) { + return RiskLevel::Critical; + } + } + + // rm ohne -r ist nur Moderate + if cmd_lower.starts_with("rm ") && !cmd_lower.contains("-r") { + return RiskLevel::Moderate; + } + + return RiskLevel::Critical; + } + + // Moderate Befehle + if cmd_lower.starts_with("npm ") + || cmd_lower.starts_with("cargo ") + || cmd_lower.starts_with("git ") + || cmd_lower.starts_with("mkdir ") + || cmd_lower.starts_with("touch ") + || cmd_lower.starts_with("cp ") + { + // git push ist kritisch + if cmd_lower.contains("git push") { + return RiskLevel::Critical; + } + return RiskLevel::Moderate; + } + + // Default: Moderate + RiskLevel::Moderate + } + + /// Prüft ob ein Befehl blockiert ist + fn is_blocked(&self, command: &str) -> bool { + let cmd_lower = command.to_lowercase(); + self.blocked_patterns + .iter() + .any(|p| cmd_lower.contains(&p.to_lowercase())) + } + + /// Prüft ob ein Pfad in Produktion liegt + fn is_production_path(&self, path: &str) -> bool { + let prod_patterns = [ + "/var/www/prod", + "/var/www/production", + "/opt/prod", + "/home/prod", + "/srv/prod", + ]; + prod_patterns.iter().any(|p| path.starts_with(p)) + } + + /// Prüft ob eine Aktion erlaubt ist + pub fn check_permission(&self, tool: &str, command: &str, path: Option<&str>) -> Option<&Permission> { + // Erst Session-Permissions prüfen + for perm in &self.session_permissions { + if self.matches_permission(perm, tool, command, path) { + return Some(perm); + } + } + + // Dann permanente Permissions + for perm in &self.permissions { + if self.matches_permission(perm, tool, command, path) { + return Some(perm); + } + } + + None + } + + /// Prüft ob eine Permission matcht + fn matches_permission(&self, perm: &Permission, tool: &str, command: &str, path: Option<&str>) -> bool { + // Tool prüfen + if let Some(ref perm_tool) = perm.tool { + if perm_tool != tool { + return false; + } + } + + // Pfad prüfen + if let (Some(ref perm_path), Some(actual_path)) = (&perm.path_pattern, path) { + if !self.matches_pattern(perm_path, actual_path) { + return false; + } + } + + // Command/Pattern prüfen + self.matches_pattern(&perm.pattern, command) + } + + /// Einfacher Pattern-Matcher mit * Wildcard + fn matches_pattern(&self, pattern: &str, value: &str) -> bool { + if pattern == "*" { + return true; + } + + if pattern.contains('*') { + let parts: Vec<&str> = pattern.split('*').collect(); + if parts.len() == 2 { + let (prefix, suffix) = (parts[0], parts[1]); + return value.starts_with(prefix) && value.ends_with(suffix); + } + } + + pattern == value + } + + /// Fügt eine Permission hinzu + pub fn add_permission(&mut self, permission: Permission) { + match permission.permission_type { + PermissionType::Session => self.session_permissions.push(permission), + PermissionType::Permanent => self.permissions.push(permission), + } + } + + /// Entfernt eine Permission + pub fn remove_permission(&mut self, id: &str) { + self.permissions.retain(|p| p.id != id); + self.session_permissions.retain(|p| p.id != id); + } + + /// Session-Permissions löschen + pub fn clear_session(&mut self) { + self.session_permissions.clear(); + } + + /// Alle Permissions abrufen + pub fn get_all_permissions(&self) -> Vec<&Permission> { + self.permissions.iter().chain(self.session_permissions.iter()).collect() + } + + /// Schlägt ein Pattern vor + pub fn suggest_pattern(&self, _tool: &str, command: &str) -> String { + // Für npm install: npm install * + if command.starts_with("npm install ") { + return "npm install *".to_string(); + } + + // Für git commit: git commit -m * + if command.starts_with("git commit ") { + return "git commit *".to_string(); + } + + // Für cargo: cargo * + if command.starts_with("cargo ") { + let parts: Vec<&str> = command.split_whitespace().collect(); + if parts.len() >= 2 { + return format!("cargo {} *", parts[1]); + } + } + + // Default: exakter Befehl + command.to_string() + } +} + +// ============ Tauri Commands ============ + +use std::sync::{Arc, Mutex}; + +/// Globaler Guard-Rails State +pub type GuardState = Arc>; + +/// Prüft eine Aktion und gibt Risiko-Level zurück +#[tauri::command] +pub async fn check_action( + app: AppHandle, + tool: String, + command: String, + path: Option, +) -> Result { + let state = app.state::(); + let guard = state.lock().unwrap(); + + let risk = guard.classify_risk(&tool, &command, path.as_deref()); + + // Wenn blockiert, sofort ablehnen + if risk == RiskLevel::Blocked { + return Ok(serde_json::json!({ + "allowed": false, + "risk": "blocked", + "reason": "Diese Aktion ist aus Sicherheitsgründen blockiert." + })); + } + + // Permission prüfen + if let Some(perm) = guard.check_permission(&tool, &command, path.as_deref()) { + return Ok(serde_json::json!({ + "allowed": perm.action == PermissionAction::Allow, + "risk": format!("{:?}", risk).to_lowercase(), + "matched_rule": perm.id + })); + } + + // Kein Match - Frontend muss fragen + let suggested = guard.suggest_pattern(&tool, &command); + + Ok(serde_json::json!({ + "allowed": risk == RiskLevel::Safe, + "risk": format!("{:?}", risk).to_lowercase(), + "needs_confirmation": risk != RiskLevel::Safe, + "suggested_pattern": suggested + })) +} + +/// Fügt eine Freigabe hinzu +#[tauri::command] +pub async fn add_permission( + app: AppHandle, + pattern: String, + tool: Option, + path_pattern: Option, + permission_type: String, + action: String, +) -> Result { + let state = app.state::(); + let mut guard = state.lock().unwrap(); + + let perm_type = match permission_type.as_str() { + "session" => PermissionType::Session, + "permanent" => PermissionType::Permanent, + _ => return Err("Ungültiger Permission-Typ".to_string()), + }; + + let perm_action = match action.as_str() { + "allow" => PermissionAction::Allow, + "deny" => PermissionAction::Deny, + _ => return Err("Ungültige Aktion".to_string()), + }; + + let id = uuid::Uuid::new_v4().to_string(); + + let permission = Permission { + id: id.clone(), + pattern, + tool, + path_pattern, + permission_type: perm_type, + action: perm_action, + created_at: chrono::Local::now().to_rfc3339(), + use_count: 0, + last_used: None, + }; + + guard.add_permission(permission.clone()); + + // Bei Permanent in SQLite speichern + if perm_type == PermissionType::Permanent { + let db_state = app.state::>>(); + let db_lock = db_state.lock().unwrap(); + db_lock.save_permission(&permission).map_err(|e| e.to_string())?; + } + + println!("✅ Permission hinzugefügt: {}", id); + + Ok(id) +} + +/// Entfernt eine Freigabe +#[tauri::command] +pub async fn remove_permission(app: AppHandle, id: String) -> Result<(), String> { + let state = app.state::(); + let mut guard = state.lock().unwrap(); + + guard.remove_permission(&id); + + // Aus SQLite löschen + let db_state = app.state::>>(); + let db_lock = db_state.lock().unwrap(); + let _ = db_lock.delete_permission(&id); + + println!("🗑️ Permission entfernt: {}", id); + + Ok(()) +} + +/// Holt alle Freigaben +#[tauri::command] +pub async fn get_permissions(app: AppHandle) -> Result, String> { + let state = app.state::(); + let guard = state.lock().unwrap(); + + Ok(guard.get_all_permissions().into_iter().cloned().collect()) +} + +/// Holt blockierte Patterns +#[tauri::command] +pub async fn get_blocked_patterns(app: AppHandle) -> Result, String> { + let state = app.state::(); + let guard = state.lock().unwrap(); + + Ok(guard.blocked_patterns.clone()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e32becd..c3d28a8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,8 @@ use tauri::Manager; mod audit; mod claude; +mod db; +mod guard; mod memory; /// Initialisiert die App @@ -14,6 +16,7 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .manage(Arc::new(Mutex::new(claude::ClaudeState::default()))) + .manage(guard::GuardState::new(Mutex::new(guard::GuardRails::new()))) .invoke_handler(tauri::generate_handler![ // Claude SDK claude::send_message, @@ -28,21 +31,51 @@ pub fn run() { audit::get_audit_log, audit::add_audit_entry, audit::get_audit_stats, + // Guard-Rails + guard::check_action, + guard::add_permission, + guard::remove_permission, + guard::get_permissions, + guard::get_blocked_patterns, + // Datenbank + db::init_database, + db::get_db_stats, ]) .setup(|app| { let handle = app.handle().clone(); println!("🤖 Claude Desktop gestartet"); - // Gedächtnis-System beim Start laden + // Datenbank initialisieren + let app_dir = app.path().app_data_dir() + .expect("Konnte App-Datenverzeichnis nicht ermitteln"); + std::fs::create_dir_all(&app_dir).ok(); + let db_path = app_dir.join("claude-desktop.db"); + println!("📦 Datenbank: {:?}", db_path); + + let db = db::Database::open(&db_path) + .expect("Datenbank konnte nicht geöffnet werden"); + app.manage(Arc::new(Mutex::new(db))); + + // Guard-Rails: permanente Permissions aus DB laden + { + let db_state = handle.state::>>(); + let guard_state = handle.state::(); + let db_lock = db_state.lock().unwrap(); + let mut guard_lock = guard_state.lock().unwrap(); + if let Ok(perms) = db_lock.load_permissions() { + for p in perms { + guard_lock.add_permission(p); + } + println!("🛡️ {} permanente Permissions geladen", guard_lock.get_all_permissions().len()); + } + } + + // Gedächtnis-System laden tauri::async_runtime::spawn(async move { println!("🧠 Initialisiere Gedächtnis-System..."); - // TODO: memory::load_memory aufrufen }); - // Bridge optional beim Start starten (oder lazy bei erster Nachricht) - // let _ = claude::start_bridge(&app.handle()); - Ok(()) }) .run(tauri::generate_context!()) diff --git a/src-tauri/src/memory.rs b/src-tauri/src/memory.rs index 1c8ff64..53ad886 100644 --- a/src-tauri/src/memory.rs +++ b/src-tauri/src/memory.rs @@ -3,7 +3,10 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use tauri::AppHandle; +use std::sync::{Arc, Mutex}; +use tauri::{AppHandle, Manager}; + +use crate::db; /// Kategorien für Sticky Context (werden nie vergessen) #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -121,44 +124,69 @@ pub struct Pattern { pub async fn load_memory(app: AppHandle) -> Result { println!("🧠 Lade Gedächtnis-System..."); - // TODO: Aus lokaler SQLite laden - // TODO: Mit Remote claude-db synchronisieren + let state = app.state::>>(); + let db_lock = state.lock().unwrap(); + let entries = db_lock.load_memory_entries().map_err(|e| e.to_string())?; + + let mut by_category: HashMap = HashMap::new(); + let mut sticky_count = 0; + for entry in &entries { + let cat = format!("{:?}", entry.category); + *by_category.entry(cat).or_insert(0) += 1; + if entry.sticky { + sticky_count += 1; + } + } + + println!("🧠 {} Einträge geladen ({} sticky)", entries.len(), sticky_count); - // Placeholder-Statistiken Ok(MemoryStats { - total: 0, - sticky: 0, - by_category: HashMap::new(), + total: entries.len(), + sticky: sticky_count, + by_category, }) } /// Holt den Sticky-Kontext für Claude #[tauri::command] -pub async fn get_sticky_context() -> Result, String> { - // TODO: Echte Implementierung - Ok(vec![]) +pub async fn get_sticky_context(app: AppHandle) -> Result, String> { + let state = app.state::>>(); + let db_lock = state.lock().unwrap(); + let entries = db_lock.load_memory_entries().map_err(|e| e.to_string())?; + Ok(entries.into_iter().filter(|e| e.sticky).collect()) } /// Speichert eine neue Vorgehensweise #[tauri::command] -pub async fn save_pattern(pattern: Pattern) -> Result<(), String> { +pub async fn save_pattern(app: AppHandle, pattern: Pattern) -> Result<(), String> { println!("📝 Speichere Vorgehensweise: {}", pattern.name); - // TODO: In SQLite speichern - // TODO: Mit claude-db synchronisieren - - Ok(()) + let state = app.state::>>(); + let db_lock = state.lock().unwrap(); + db_lock.save_pattern(&pattern).map_err(|e| e.to_string()) } /// Erkennt ein Problem und schlägt Korrektur vor #[tauri::command] pub async fn detect_issue( + app: AppHandle, error_message: String, - context: String, + _context: String, ) -> Result, String> { - println!("🔍 Prüfe auf bekannte Probleme: {}", &error_message[..error_message.len().min(50)]); + let preview_len = error_message.len().min(50); + println!("🔍 Prüfe auf bekannte Probleme: {}", &error_message[..preview_len]); - // TODO: Pattern-Matching gegen bekannte Probleme + let state = app.state::>>(); + let db_lock = state.lock().unwrap(); + let patterns = db_lock.load_patterns().map_err(|e| e.to_string())?; + + // Einfaches Pattern-Matching: Trigger im Fehlertext suchen + let error_lower = error_message.to_lowercase(); + for pattern in patterns { + if error_lower.contains(&pattern.trigger.to_lowercase()) { + return Ok(Some(pattern)); + } + } Ok(None) } diff --git a/src/lib/components/AuditLog.svelte b/src/lib/components/AuditLog.svelte index ae91862..5301478 100644 --- a/src/lib/components/AuditLog.svelte +++ b/src/lib/components/AuditLog.svelte @@ -1,5 +1,6 @@