Skip to main content

skilllite_core/
observability.rs

1//! Observability: tracing init, audit log, security events.
2//!
3//! Uses config::ObservabilityConfig for SKILLLITE_QUIET, LOG_LEVEL, AUDIT_LOG, etc.
4
5use std::fs::OpenOptions;
6use std::io::Write;
7use std::path::Path;
8use std::sync::Mutex;
9
10use chrono::Utc;
11use serde_json::json;
12use tracing_subscriber::{prelude::*, EnvFilter};
13use uuid::Uuid;
14
15static SECURITY_EVENTS_PATH: Mutex<Option<String>> = Mutex::new(None);
16
17/// Tracing initialization mode.
18#[derive(Clone, Copy)]
19pub enum TracingMode {
20    /// Default: use SKILLLITE_LOG_LEVEL / SKILLLITE_QUIET from env
21    Default,
22    /// Chat: suppress agent-internal WARN (compaction, task planning) to keep UI clean
23    Chat,
24}
25
26/// Initialize tracing. Call at process startup.
27/// When SKILLLITE_QUIET=1 (or SKILLBOX_QUIET for compat), only WARN and above are logged.
28pub fn init_tracing(mode: TracingMode) {
29    let cfg = crate::config::ObservabilityConfig::from_env();
30    let mut level: String = if cfg.quiet {
31        "skilllite=warn".to_string()
32    } else {
33        cfg.log_level.clone()
34    };
35
36    // Chat mode: suppress agent-internal warnings (compaction, task planning) to avoid polluting the UI
37    if matches!(mode, TracingMode::Chat) {
38        level = format!("{},skilllite::agent=error", level);
39    }
40
41    let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&level));
42
43    let json = cfg.log_json;
44
45    let _ = if json {
46        tracing_subscriber::registry()
47            .with(filter)
48            .with(
49                tracing_subscriber::fmt::layer()
50                    .json()
51                    .with_target(true)
52                    .with_thread_ids(false),
53            )
54            .try_init()
55    } else {
56        tracing_subscriber::registry()
57            .with(filter)
58            .with(
59                tracing_subscriber::fmt::layer()
60                    .with_target(true)
61                    .with_thread_ids(false),
62            )
63            .try_init()
64    };
65}
66
67/// 解析审计日志实际写入路径。目录则按天存储 audit_YYYY-MM-DD.jsonl;.jsonl 文件则直接写入。
68fn get_audit_path() -> Option<String> {
69    let base = crate::config::ObservabilityConfig::from_env()
70        .audit_log
71        .clone()?;
72    if base.is_empty() {
73        return None;
74    }
75    let path = Path::new(&base);
76    let file_path = if base.ends_with(".jsonl") {
77        path.to_path_buf()
78    } else {
79        let today = chrono::Utc::now().format("%Y-%m-%d");
80        path.join(format!("audit_{}.jsonl", today))
81    };
82    let file_path_str = file_path.to_string_lossy().into_owned();
83    if let Some(parent) = file_path.parent() {
84        let _ = std::fs::create_dir_all(parent);
85    }
86    Some(file_path_str)
87}
88
89fn get_security_events_path() -> Option<String> {
90    {
91        let guard = SECURITY_EVENTS_PATH.lock().ok()?;
92        if let Some(ref p) = *guard {
93            return Some(p.clone());
94        }
95    }
96    let path = crate::config::ObservabilityConfig::from_env()
97        .security_events_log
98        .clone()?;
99    if path.is_empty() {
100        return None;
101    }
102    if let Some(parent) = Path::new(&path).parent() {
103        let _ = std::fs::create_dir_all(parent);
104    }
105    {
106        let mut guard = SECURITY_EVENTS_PATH.lock().ok()?;
107        *guard = Some(path.clone());
108    }
109    Some(path)
110}
111
112fn append_jsonl(path: &str, record: &serde_json::Value) {
113    if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(path) {
114        if let Ok(line) = serde_json::to_string(record) {
115            let _ = writeln!(f, "{}", line);
116            let _ = f.flush(); // 确保每条记录单独落盘,避免流式消费时行粘连
117        }
118    }
119}
120
121/// Audit: confirmation_requested (Rust-side L3 scan)
122pub fn audit_confirmation_requested(
123    skill_id: &str,
124    code_hash: &str,
125    issues_count: usize,
126    severity: &str,
127) {
128    if let Some(path) = get_audit_path() {
129        let record = json!({
130            "ts": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
131            "event": "confirmation_requested",
132            "skill_id": skill_id,
133            "code_hash": code_hash,
134            "issues_count": issues_count,
135            "severity": severity,
136            "source": "rust"
137        });
138        append_jsonl(&path, &record);
139    }
140}
141
142/// Audit: confirmation_response (Rust-side user/auto)
143pub fn audit_confirmation_response(skill_id: &str, approved: bool, source: &str) {
144    if let Some(path) = get_audit_path() {
145        let record = json!({
146            "ts": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
147            "event": "confirmation_response",
148            "skill_id": skill_id,
149            "approved": approved,
150            "source": source,
151            "source_layer": "rust"
152        });
153        append_jsonl(&path, &record);
154    }
155}
156
157/// Audit: execution_started (right before spawn — Python name: execution_started)
158///
159/// Also emits as "command_invoked" for backward compatibility.
160pub fn audit_execution_started(skill_id: &str, cmd: &str, args: &[&str], cwd: &str) {
161    if let Some(path) = get_audit_path() {
162        let record = json!({
163            "ts": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
164            "event": "execution_started",
165            "skill_id": skill_id,
166            "cmd": cmd,
167            "args": args,
168            "cwd": cwd,
169            "source": "rust"
170        });
171        append_jsonl(&path, &record);
172    }
173}
174
175/// Audit: command_invoked — alias for execution_started (backward compat)
176pub fn audit_command_invoked(skill_id: &str, cmd: &str, args: &[&str], cwd: &str) {
177    audit_execution_started(skill_id, cmd, args, cwd);
178}
179
180/// Audit: execution_completed (Rust-side)
181pub fn audit_execution_completed(
182    skill_id: &str,
183    exit_code: i32,
184    duration_ms: u64,
185    stdout_len: usize,
186) {
187    if let Some(path) = get_audit_path() {
188        let record = json!({
189            "ts": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
190            "event": "execution_completed",
191            "skill_id": skill_id,
192            "exit_code": exit_code,
193            "duration_ms": duration_ms,
194            "stdout_len": stdout_len,
195            "success": exit_code == 0,
196            "source": "rust"
197        });
198        append_jsonl(&path, &record);
199    }
200}
201
202/// Audit: skill_invocation (P0 可观测 - 记录谁在什么上下文调用了哪个 Skill、输入摘要、输出摘要)
203pub fn audit_skill_invocation(
204    skill_id: &str,
205    entry_point: &str,
206    cwd: &str,
207    input_json: &str,
208    output: &str,
209    exit_code: i32,
210    duration_ms: u64,
211) {
212    if let Some(path) = get_audit_path() {
213        let context = crate::config::loader::env_optional(
214            crate::config::env_keys::observability::SKILLLITE_AUDIT_CONTEXT,
215            &[],
216        )
217        .unwrap_or_else(|| "cli".to_string());
218        let input_summary = input_summary_bytes(input_json);
219        let output_summary = output_summary_bytes(output);
220        let record = json!({
221            "ts": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
222            "event": "skill_invocation",
223            "skill_id": skill_id,
224            "entry_point": entry_point,
225            "cwd": cwd,
226            "context": context,
227            "input_summary": input_summary,
228            "output_summary": output_summary,
229            "exit_code": exit_code,
230            "duration_ms": duration_ms,
231            "success": exit_code == 0,
232            "source": "rust"
233        });
234        append_jsonl(&path, &record);
235    }
236}
237
238fn input_summary_bytes(input: &str) -> serde_json::Value {
239    let preview: String = input.chars().take(100).collect();
240    let truncated = input.chars().count() > 100;
241    serde_json::json!({"len": input.len(), "preview": if truncated { format!("{}...", preview) } else { preview } })
242}
243
244fn output_summary_bytes(output: &str) -> serde_json::Value {
245    let preview: String = output.chars().take(100).collect();
246    let truncated = output.chars().count() > 100;
247    serde_json::json!({"len": output.len(), "preview": if truncated { format!("{}...", preview) } else { preview } })
248}
249
250/// Security event: network blocked
251pub fn security_blocked_network(skill_id: &str, blocked_target: &str, reason: &str) {
252    tracing::warn!(
253        skill_id = %skill_id,
254        blocked_target = %blocked_target,
255        reason = %reason,
256        "Security: blocked network request"
257    );
258    if let Some(path) = get_security_events_path() {
259        let record = json!({
260            "ts": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
261            "type": "security_blocked",
262            "category": "network",
263            "skill_id": skill_id,
264            "details": {
265                "blocked_target": blocked_target,
266                "reason": reason
267            }
268        });
269        append_jsonl(&path, &record);
270    }
271}
272
273/// Security event: scan found high/critical
274pub fn security_scan_high(skill_id: &str, severity: &str, issues: &serde_json::Value) {
275    if let Some(path) = get_security_events_path() {
276        let record = json!({
277            "ts": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
278            "type": "security_scan_high",
279            "category": "code_scan",
280            "skill_id": skill_id,
281            "details": {
282                "severity": severity,
283                "issues": issues
284            }
285        });
286        append_jsonl(&path, &record);
287    }
288}
289
290/// Security event: scan approved — user approved after high/critical scan
291pub fn security_scan_approved(skill_id: &str, scan_id: &str, issues_count: usize) {
292    tracing::info!(
293        skill_id = %skill_id,
294        scan_id = %scan_id,
295        issues_count = %issues_count,
296        "Security: scan approved by user"
297    );
298    if let Some(path) = get_security_events_path() {
299        let record = json!({
300            "ts": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
301            "type": "security_scan_approved",
302            "category": "code_scan",
303            "skill_id": skill_id,
304            "details": {
305                "scan_id": scan_id,
306                "issues_count": issues_count,
307                "decision": "approved"
308            }
309        });
310        append_jsonl(&path, &record);
311    }
312}
313
314/// Security event: scan rejected — user rejected after high/critical scan
315pub fn security_scan_rejected(skill_id: &str, scan_id: &str, issues_count: usize) {
316    tracing::info!(
317        skill_id = %skill_id,
318        scan_id = %scan_id,
319        issues_count = %issues_count,
320        "Security: scan rejected by user"
321    );
322    if let Some(path) = get_security_events_path() {
323        let record = json!({
324            "ts": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
325            "type": "security_scan_rejected",
326            "category": "code_scan",
327            "skill_id": skill_id,
328            "details": {
329                "scan_id": scan_id,
330                "issues_count": issues_count,
331                "decision": "rejected"
332            }
333        });
334        append_jsonl(&path, &record);
335    }
336}
337
338// ─── Edit audit events (agent layer) ────────────────────────────────────────
339//
340// 结构约定:
341// - path 提升到顶层,便于查询
342// - edit_id 每条唯一,用于去重与关联
343// - workspace/context 可选,用于多项目过滤
344
345fn edit_audit_context() -> serde_json::Value {
346    crate::config::loader::env_optional(
347        crate::config::env_keys::observability::SKILLLITE_AUDIT_CONTEXT,
348        &[],
349    )
350    .map(serde_json::Value::String)
351    .unwrap_or(serde_json::Value::Null)
352}
353
354/// Audit: edit_applied — agent wrote a file change via search_replace
355pub fn audit_edit_applied(
356    path: &str,
357    occurrences: usize,
358    first_changed_line: usize,
359    diff_excerpt: &str,
360    workspace: Option<&str>,
361) {
362    if let Some(audit) = get_audit_path() {
363        let record = json!({
364            "ts": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
365            "event": "edit_applied",
366            "category": "edit",
367            "source_layer": "agent",
368            "edit_id": Uuid::new_v4().to_string(),
369            "path": path,
370            "workspace": workspace.unwrap_or(""),
371            "context": edit_audit_context(),
372            "details": {
373                "occurrences": occurrences,
374                "first_changed_line": first_changed_line,
375                "diff_excerpt": diff_excerpt
376            }
377        });
378        append_jsonl(&audit, &record);
379    }
380}
381
382/// Audit: edit_previewed — agent computed a dry-run diff via preview_edit
383pub fn audit_edit_previewed(
384    path: &str,
385    occurrences: usize,
386    first_changed_line: usize,
387    diff_excerpt: &str,
388    workspace: Option<&str>,
389) {
390    if let Some(audit) = get_audit_path() {
391        let record = json!({
392            "ts": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
393            "event": "edit_previewed",
394            "category": "edit",
395            "source_layer": "agent",
396            "edit_id": Uuid::new_v4().to_string(),
397            "path": path,
398            "workspace": workspace.unwrap_or(""),
399            "context": edit_audit_context(),
400            "details": {
401                "occurrences": occurrences,
402                "first_changed_line": first_changed_line,
403                "diff_excerpt": diff_excerpt
404            }
405        });
406        append_jsonl(&audit, &record);
407    }
408}
409
410/// Audit: edit_inserted — agent inserted lines via insert_lines
411pub fn audit_edit_inserted(
412    path: &str,
413    line_num: usize,
414    lines_inserted: usize,
415    diff_excerpt: &str,
416    workspace: Option<&str>,
417) {
418    if let Some(audit) = get_audit_path() {
419        let record = json!({
420            "ts": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
421            "event": "edit_inserted",
422            "category": "edit",
423            "source_layer": "agent",
424            "edit_id": Uuid::new_v4().to_string(),
425            "path": path,
426            "workspace": workspace.unwrap_or(""),
427            "context": edit_audit_context(),
428            "details": {
429                "insert_after_line": line_num,
430                "lines_inserted": lines_inserted,
431                "diff_excerpt": diff_excerpt
432            }
433        });
434        append_jsonl(&audit, &record);
435    }
436}
437
438/// Audit: edit_failed — agent attempted an edit that failed (not found, non-unique, etc.)
439pub fn audit_edit_failed(path: &str, tool_name: &str, reason: &str, workspace: Option<&str>) {
440    if let Some(audit) = get_audit_path() {
441        let record = json!({
442            "ts": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
443            "event": "edit_failed",
444            "category": "edit",
445            "source_layer": "agent",
446            "edit_id": Uuid::new_v4().to_string(),
447            "path": path,
448            "reason": reason,
449            "tool": tool_name,
450            "workspace": workspace.unwrap_or(""),
451            "context": edit_audit_context(),
452            "details": {
453                "path": path,
454                "tool": tool_name,
455                "reason": reason
456            }
457        });
458        append_jsonl(&audit, &record);
459    }
460}
461
462// ─── Security events ────────────────────────────────────────────────────────
463
464// ─── Evolution audit events (EVO-5) ─────────────────────────────────────────
465
466/// Audit: evolution event — logged when evolution produces changes or rolls back.
467pub fn audit_evolution_event(event_type: &str, target_id: &str, reason: &str, txn_id: &str) {
468    if let Some(path) = get_audit_path() {
469        let record = json!({
470            "ts": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
471            "event": "evolution",
472            "category": "evolution",
473            "source_layer": "agent",
474            "details": {
475                "type": event_type,
476                "target_id": target_id,
477                "reason": reason,
478                "txn_id": txn_id
479            }
480        });
481        append_jsonl(&path, &record);
482    }
483}
484
485/// Security event: sandbox fallback (e.g. Seatbelt failed, using simple execution)
486pub fn security_sandbox_fallback(skill_id: &str, reason: &str) {
487    tracing::warn!(
488        skill_id = %skill_id,
489        reason = %reason,
490        "Security: sandbox fallback to simple execution"
491    );
492    if let Some(path) = get_security_events_path() {
493        let record = json!({
494            "ts": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
495            "type": "sandbox_fallback",
496            "category": "runtime",
497            "skill_id": skill_id,
498            "details": { "reason": reason }
499        });
500        append_jsonl(&path, &record);
501    }
502}