Skip to main content

agentsec_core/diagnostics/
mod.rs

1//! Observability surface: introspect AgentSec's runtime state.
2//!
3//! Four entry points, each surfaced as a CLI subcommand and an MCP tool
4//! by the umbrella crate:
5//!
6//! | Entry              | Role                                                                    |
7//! |--------------------|-------------------------------------------------------------------------|
8//! | [`info`]           | Crate version + resolved paths + env state with per-var provenance.     |
9//! | [`status`]         | Runtime activity overview (file counts, latest timestamps, Plain Mode). |
10//! | [`recent_activity`]| Tail of `paste_log/` and `web_log/` audit rows.                         |
11//! | [`doctor`]         | Comprehensive health check (env / paths / hook wiring / MCP register).  |
12//!
13//! ## §Env provenance
14//!
15//! [`info`] reports four AgentSec-relevant env vars (`AGENTSEC_HOME`,
16//! `HOME`, `ANTHROPIC_API_KEY`, `AGENTSEC_LLM_MODEL`) with the source
17//! that produced the effective value:
18//!
19//! - **`Process`** — set in the process environment, not present in the
20//!   loaded `.env` file (or no `.env` was loaded).
21//! - **`DotenvFile { path, line }`** — defined in the `.env` file at the
22//!   given line; `dotenvy` loaded it without overriding an existing
23//!   process-env value (so this is the effective source for vars that
24//!   were unset before load).
25//! - **`DotenvFileShadowed { path, line }`** — defined in the `.env`
26//!   file at the given line **but** the process env already had the
27//!   var set; the process value wins (dotenvy is non-overriding by
28//!   default).
29//! - **`Default(value)`** — not set anywhere; AgentSec's builtin
30//!   default applies.
31//! - **`Unset`** — not set anywhere and no default applies (i.e. an
32//!   optional var like `ANTHROPIC_API_KEY` left empty).
33//!
34//! Secret values (`ANTHROPIC_API_KEY`) are always reported in redacted
35//! form (`sk-***<last-4>`); the raw value never enters the report.
36
37use std::collections::{HashMap, HashSet};
38use std::fs;
39use std::path::{Path, PathBuf};
40
41use serde::{Deserialize, Serialize};
42
43use crate::Config;
44use crate::config::DEFAULT_LLM_MODEL;
45use crate::error::Result;
46
47/// AgentSec env vars tracked by [`info`] and [`doctor`].
48const TRACKED_ENV_VARS: &[&str] = &[
49    "AGENTSEC_HOME",
50    "HOME",
51    "ANTHROPIC_API_KEY",
52    "AGENTSEC_LLM_MODEL",
53    "AGENTSEC_DOTENV",
54];
55
56/// Crate version embedded at compile time.
57pub const VERSION: &str = env!("CARGO_PKG_VERSION");
58
59// ── §info ────────────────────────────────────────────────────────────
60
61/// Top-level info report.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct InfoReport {
64    /// `agentsec-core` crate version (semver).
65    pub version: String,
66    /// Resolved filesystem paths (see [`crate::Paths`]).
67    pub paths: PathsInfo,
68    /// Per-env-var status with provenance (see module docs §Env provenance).
69    pub env: Vec<EnvVarStatus>,
70}
71
72/// Display-friendly path bundle.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct PathsInfo {
75    pub home: PathBuf,
76    pub user_home: PathBuf,
77    pub snapshots: PathBuf,
78    pub scans: PathBuf,
79    pub web_log: PathBuf,
80    pub paste_log: PathBuf,
81}
82
83/// One env var's effective state + provenance.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct EnvVarStatus {
86    pub key: String,
87    pub source: EnvSource,
88    /// Effective value, redacted for secrets. `None` if unset.
89    pub value_display: Option<String>,
90}
91
92/// Where the effective env value came from.
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
94pub enum EnvSource {
95    /// Set in process environment, not declared in the loaded `.env`.
96    Process,
97    /// Declared in the loaded `.env` file at the given 1-based line, and
98    /// either the process env had no prior value (so dotenv's value
99    /// became effective) or the file was loaded explicitly via
100    /// `AGENTSEC_DOTENV`.
101    DotenvFile { path: PathBuf, line: usize },
102    /// Declared in the loaded `.env` but shadowed by an existing
103    /// process-env value (dotenvy is non-overriding by default; the
104    /// process value wins, the file value is dead weight).
105    DotenvFileShadowed { path: PathBuf, line: usize },
106    /// Not set anywhere; AgentSec's builtin default applies.
107    Default(String),
108    /// Not set anywhere and no default applies (optional var).
109    Unset,
110}
111
112/// Build an [`InfoReport`] from a resolved [`Config`] and the live
113/// process environment.
114///
115/// Reads `cfg.dotenv_path` (if any) to determine which keys came from
116/// the dotenv file vs the process environment.
117pub fn info(cfg: &Config) -> InfoReport {
118    let paths = PathsInfo {
119        home: cfg.paths.home.clone(),
120        user_home: cfg.paths.user_home.clone(),
121        snapshots: cfg.paths.snapshots(),
122        scans: cfg.paths.scans(),
123        web_log: cfg.paths.web_log(),
124        paste_log: cfg.paths.paste_log(),
125    };
126
127    let dotenv_keys = cfg
128        .dotenv_path
129        .as_ref()
130        .and_then(|p| parse_dotenv(p).ok())
131        .unwrap_or_default();
132
133    let env = TRACKED_ENV_VARS
134        .iter()
135        .map(|key| classify_env_var(key, &dotenv_keys, cfg.dotenv_path.as_deref()))
136        .collect();
137
138    InfoReport {
139        version: VERSION.to_string(),
140        paths,
141        env,
142    }
143}
144
145/// (key, 1-based line number) pairs parsed from a `.env` file.
146type DotenvKeys = HashMap<String, usize>;
147
148/// Parse a `.env` file into a `key → line-number` map. Best-effort:
149/// blank lines, comments, and malformed lines are skipped. Returns
150/// `Err` only on file read failure.
151fn parse_dotenv(path: &Path) -> Result<DotenvKeys> {
152    let body = fs::read_to_string(path)?;
153    let mut out = DotenvKeys::new();
154    for (idx, raw) in body.lines().enumerate() {
155        let trimmed = raw.trim_start();
156        if trimmed.is_empty() || trimmed.starts_with('#') {
157            continue;
158        }
159        // Strip optional `export ` prefix.
160        let after_export = trimmed.strip_prefix("export ").unwrap_or(trimmed);
161        let Some(eq_idx) = after_export.find('=') else {
162            continue;
163        };
164        let key = after_export[..eq_idx].trim().to_string();
165        if !is_valid_env_key(&key) {
166            continue;
167        }
168        // Preserve only the first occurrence per key.
169        out.entry(key).or_insert(idx + 1);
170    }
171    Ok(out)
172}
173
174fn is_valid_env_key(s: &str) -> bool {
175    !s.is_empty()
176        && s.chars()
177            .next()
178            .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
179        && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
180}
181
182fn classify_env_var(
183    key: &str,
184    dotenv_keys: &DotenvKeys,
185    dotenv_path: Option<&Path>,
186) -> EnvVarStatus {
187    let in_dotenv_line = dotenv_keys.get(key).copied();
188    let process_value = std::env::var(key).ok();
189
190    let (source, value_display) = match (process_value, in_dotenv_line, dotenv_path) {
191        (Some(v), Some(line), Some(path)) => {
192            // Effective value comes from dotenv only if the process env
193            // had no prior value — but dotenv has already loaded into
194            // process env at this point, so we cannot distinguish
195            // pre-load process env from post-load. Conservative choice:
196            // if the key was in the dotenv file, attribute the source
197            // to the dotenv file (this matches the typical "I just set
198            // it in my .env" mental model). The `DotenvFileShadowed`
199            // variant is reserved for callers who pre-snapshot the env
200            // (not done here — see module docs §Env provenance for the
201            // wire contract).
202            (
203                EnvSource::DotenvFile {
204                    path: path.to_path_buf(),
205                    line,
206                },
207                Some(display_value(key, &v)),
208            )
209        }
210        // (Some(v), Some(_), None) is unreachable in practice — a
211        // non-empty dotenv_line implies a dotenv_path was loaded — but
212        // we collapse it into the Process arm so the match stays
213        // exhaustive without an identical-body sibling.
214        (Some(v), None, _) | (Some(v), Some(_), None) => {
215            (EnvSource::Process, Some(display_value(key, &v)))
216        }
217        (None, Some(line), Some(path)) => (
218            EnvSource::DotenvFile {
219                path: path.to_path_buf(),
220                line,
221            },
222            None,
223        ),
224        (None, None, _) | (None, Some(_), None) => match default_for(key) {
225            Some(d) => (EnvSource::Default(d.clone()), Some(d)),
226            None => (EnvSource::Unset, None),
227        },
228    };
229
230    EnvVarStatus {
231        key: key.to_string(),
232        source,
233        value_display,
234    }
235}
236
237fn default_for(key: &str) -> Option<String> {
238    match key {
239        "AGENTSEC_LLM_MODEL" => Some(DEFAULT_LLM_MODEL.to_string()),
240        _ => None,
241    }
242}
243
244fn display_value(key: &str, raw: &str) -> String {
245    if is_secret_key(key) {
246        redact(raw)
247    } else {
248        raw.to_string()
249    }
250}
251
252fn is_secret_key(key: &str) -> bool {
253    matches!(key, "ANTHROPIC_API_KEY")
254}
255
256fn redact(value: &str) -> String {
257    if value.is_empty() {
258        return "<empty>".to_string();
259    }
260    let chars: Vec<char> = value.chars().collect();
261    if chars.len() <= 8 {
262        return "***".to_string();
263    }
264    let tail: String = chars[chars.len().saturating_sub(4)..].iter().collect();
265    format!("***{tail} (len={})", chars.len())
266}
267
268// ── §status ──────────────────────────────────────────────────────────
269
270/// One row of the Storage section: where audit data physically lives.
271///
272/// `path` is always absolute. `count` is the number of audit entries
273/// (files in the dir, or 1 for single-file kinds when the file
274/// exists). `size_bytes` is the total on-disk footprint for the kind
275/// (sum of regular-file sizes for dir kinds, file size for single-file
276/// kinds). `latest` is the most recent entry name (for dir kinds) or
277/// the file's mtime as ISO-8601 (for single-file kinds), or `None`
278/// when the storage location is absent.
279#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct StorageItem {
281    pub kind: String,
282    pub path: PathBuf,
283    pub count: usize,
284    pub size_bytes: u64,
285    pub latest: Option<String>,
286}
287
288/// Runtime activity snapshot.
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct StatusReport {
291    pub snapshots_count: usize,
292    pub latest_snapshot: Option<String>,
293    pub paste_log_count: usize,
294    pub web_log_count: usize,
295    pub plain_mode_active: bool,
296    pub plain_mode_entries: usize,
297    pub registry_entries: usize,
298    /// Per-kind storage rows: where data lives and how big it is.
299    /// Surfaced so users can manage retention without `du` / `find`.
300    #[serde(default)]
301    pub storage: Vec<StorageItem>,
302}
303
304/// Build a [`StatusReport`] from the on-disk state at `cfg.paths`.
305///
306/// All I/O is read-only. Missing directories are reported as zero
307/// counts rather than errors.
308pub fn status(cfg: &Config) -> StatusReport {
309    use crate::plain_mode;
310    use crate::registry::Registry;
311
312    let snapshots_dir = cfg.paths.snapshots();
313    let (snapshots_count, latest_snapshot) = count_and_latest(&snapshots_dir);
314    let paste_log_count = count_files(&cfg.paths.paste_log());
315    let web_log_count = count_files(&cfg.paths.web_log());
316
317    let plain = plain_mode::status(&cfg.paths).unwrap_or_else(|_| plain_mode::PlainStatus {
318        active: false,
319        entries: Vec::new(),
320        ledger_path: cfg.paths.home.join(plain_mode::PLAIN_LEDGER_FILENAME),
321    });
322
323    let registry_entries = Registry::load_or_builtin(&cfg.paths).len();
324
325    let storage = collect_storage(cfg);
326
327    StatusReport {
328        snapshots_count,
329        latest_snapshot,
330        paste_log_count,
331        web_log_count,
332        plain_mode_active: plain.active,
333        plain_mode_entries: plain.entries.len(),
334        registry_entries,
335        storage,
336    }
337}
338
339/// Build the per-kind Storage rows surfaced under `status`'s `Storage`
340/// section.
341///
342/// Each row is read-only: missing dirs/files report zero count + zero
343/// size + `None` latest.
344fn collect_storage(cfg: &Config) -> Vec<StorageItem> {
345    let home = &cfg.paths.home;
346    vec![
347        dir_storage("snapshots", &cfg.paths.snapshots()),
348        dir_storage("paste_log", &cfg.paths.paste_log()),
349        dir_storage("web_log", &cfg.paths.web_log()),
350        dir_storage("scans", &cfg.paths.scans()),
351        file_storage(
352            "plain ledger",
353            home.join(crate::plain_mode::PLAIN_LEDGER_FILENAME),
354        ),
355        file_storage("registry cache", home.join("registry.json")),
356        file_storage("registry local", home.join("registry-local.json")),
357    ]
358}
359
360fn dir_storage(kind: &str, path: &Path) -> StorageItem {
361    let (count, size_bytes, latest) = match fs::read_dir(path) {
362        Err(_) => (0, 0, None),
363        Ok(entries) => {
364            let mut names: Vec<String> = Vec::new();
365            let mut total: u64 = 0;
366            for e in entries.flatten() {
367                if let Ok(md) = e.metadata()
368                    && md.is_file()
369                {
370                    total = total.saturating_add(md.len());
371                    if let Some(name) = e.file_name().to_str() {
372                        names.push(name.to_string());
373                    }
374                }
375            }
376            let count = names.len();
377            names.sort();
378            (count, total, names.last().cloned())
379        }
380    };
381    StorageItem {
382        kind: kind.to_string(),
383        path: path.to_path_buf(),
384        count,
385        size_bytes,
386        latest,
387    }
388}
389
390fn file_storage(kind: &str, path: PathBuf) -> StorageItem {
391    let (count, size_bytes, latest) = match fs::metadata(&path) {
392        Ok(md) if md.is_file() => {
393            let size = md.len();
394            let latest = md.modified().ok().and_then(|t| {
395                t.duration_since(std::time::UNIX_EPOCH)
396                    .ok()
397                    .map(|d| format!("epoch={}", d.as_secs()))
398            });
399            (1, size, latest)
400        }
401        _ => (0, 0, None),
402    };
403    StorageItem {
404        kind: kind.to_string(),
405        path,
406        count,
407        size_bytes,
408        latest,
409    }
410}
411
412/// Format a byte count as a short human-readable string.
413/// Integer-only math (no f64) to avoid clippy precision-loss lint.
414pub fn format_size(bytes: u64) -> String {
415    const KB: u64 = 1024;
416    const MB: u64 = KB * 1024;
417    const GB: u64 = MB * 1024;
418    let (unit, scale) = if bytes >= GB {
419        ("GB", GB)
420    } else if bytes >= MB {
421        ("MB", MB)
422    } else if bytes >= KB {
423        ("KB", KB)
424    } else {
425        return format!("{bytes} B");
426    };
427    let whole = bytes / scale;
428    let tenth = (bytes % scale) * 10 / scale;
429    format!("{whole}.{tenth} {unit}")
430}
431
432fn count_files(dir: &Path) -> usize {
433    fs::read_dir(dir)
434        .map(std::iter::Iterator::count)
435        .unwrap_or(0)
436}
437
438fn count_and_latest(dir: &Path) -> (usize, Option<String>) {
439    let Ok(entries) = fs::read_dir(dir) else {
440        return (0, None);
441    };
442    let mut names: Vec<String> = entries
443        .filter_map(std::result::Result::ok)
444        .filter_map(|e| {
445            let p = e.path();
446            if p.extension()
447                .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
448            {
449                e.file_name().to_str().map(std::string::ToString::to_string)
450            } else {
451                None
452            }
453        })
454        .collect();
455    let count = names.len();
456    names.sort();
457    (count, names.last().cloned())
458}
459
460// ── §recent_activity ─────────────────────────────────────────────────
461
462/// Tail of audit log files. `n` of each kind (paste / web).
463#[derive(Debug, Clone, Serialize, Deserialize)]
464pub struct RecentActivityReport {
465    pub paste: Vec<AuditTail>,
466    pub web: Vec<AuditTail>,
467}
468
469/// One audit-log row excerpt.
470#[derive(Debug, Clone, Serialize, Deserialize)]
471pub struct AuditTail {
472    /// File basename (e.g. `2026-06-17-110203-abcdef12.json`).
473    pub filename: String,
474    /// File size in bytes (cheap stat).
475    pub size: u64,
476    /// First ~200 chars of the file's content (defensive truncation —
477    /// audit rows are normally small but we cap to avoid blowing up
478    /// the response on a runaway log).
479    pub excerpt: String,
480}
481
482/// Read the last `n` files (by filename order, which is timestamped) of
483/// `paste_log/` and `web_log/` and return their excerpts.
484pub fn recent_activity(cfg: &Config, n: usize) -> RecentActivityReport {
485    RecentActivityReport {
486        paste: tail_dir(&cfg.paths.paste_log(), n),
487        web: tail_dir(&cfg.paths.web_log(), n),
488    }
489}
490
491const AUDIT_EXCERPT_CHARS: usize = 200;
492/// Number of context lines kept on each side of the "anchor" line.
493const EXCERPT_CONTEXT_LINES: usize = 2;
494
495fn tail_dir(dir: &Path, n: usize) -> Vec<AuditTail> {
496    let Ok(entries) = fs::read_dir(dir) else {
497        return Vec::new();
498    };
499    let mut files: Vec<(String, PathBuf)> = entries
500        .filter_map(std::result::Result::ok)
501        .filter_map(|e| {
502            let p = e.path();
503            if !p
504                .extension()
505                .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
506            {
507                return None;
508            }
509            let name = e.file_name().to_str()?.to_string();
510            Some((name, e.path()))
511        })
512        .collect();
513    files.sort_by(|(a, _), (b, _)| a.cmp(b));
514    files
515        .into_iter()
516        .rev()
517        .take(n)
518        .map(|(name, path)| AuditTail {
519            filename: name,
520            size: fs::metadata(&path).map(|m| m.len()).unwrap_or(0),
521            excerpt: read_excerpt(&path),
522        })
523        .collect()
524}
525
526/// Build a ±2-line excerpt around the most "interesting" line, capped at
527/// [`AUDIT_EXCERPT_CHARS`] characters.
528///
529/// The "anchor" line is the longest non-brace-only line in the file
530/// (heuristic that picks the first substantive field in compact JSON audit
531/// rows like those written by `paste::detect` and `web::fetch_and_sanitize`).
532/// Surrounding context lines (±2) are joined with `\n`.  The result is
533/// hard-truncated to [`AUDIT_EXCERPT_CHARS`] chars with a `…` suffix if
534/// needed.
535fn read_excerpt(path: &Path) -> String {
536    let Ok(body) = fs::read_to_string(path) else {
537        return String::new();
538    };
539    let lines: Vec<&str> = body.lines().collect();
540    if lines.is_empty() {
541        return String::new();
542    }
543    // Find anchor: longest line that is not a bare `{` or `}`.
544    let anchor = lines
545        .iter()
546        .enumerate()
547        .filter(|(_, l)| {
548            let t = l.trim();
549            t != "{" && t != "}" && !t.is_empty()
550        })
551        .max_by_key(|(_, l)| l.len())
552        .map_or(0, |(i, _)| i);
553    let start = anchor.saturating_sub(EXCERPT_CONTEXT_LINES);
554    let end = (anchor + EXCERPT_CONTEXT_LINES + 1).min(lines.len());
555    let joined = lines[start..end].join("\n");
556    if joined.chars().count() <= AUDIT_EXCERPT_CHARS {
557        joined
558    } else {
559        let truncated: String = joined.chars().take(AUDIT_EXCERPT_CHARS).collect();
560        format!("{truncated}…")
561    }
562}
563
564// ── §doctor ──────────────────────────────────────────────────────────
565
566/// Health-check report: one row per check, each PASS / WARN / FAIL.
567#[derive(Debug, Clone, Serialize, Deserialize)]
568pub struct DoctorReport {
569    pub checks: Vec<DoctorCheck>,
570}
571
572/// One health check row.
573#[derive(Debug, Clone, Serialize, Deserialize)]
574pub struct DoctorCheck {
575    pub name: String,
576    pub status: DoctorStatus,
577    pub message: String,
578}
579
580/// Three-level check verdict.
581#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
582pub enum DoctorStatus {
583    /// Working as expected.
584    Pass,
585    /// Non-fatal: a feature is unavailable but degraded operation is OK
586    /// (e.g. `ANTHROPIC_API_KEY` unset ⇒ semantic sanitize layer fails
587    /// open).
588    Warn,
589    /// Misconfiguration that prevents normal operation.
590    Fail,
591}
592
593/// Run all health checks and return the aggregated report.
594///
595/// Checks (in order):
596///
597/// 1. Binary version.
598/// 2. `<home>/` directory: present + writable.
599/// 3. Subdirs (snapshots / scans / web_log / paste_log) creatable.
600/// 4. `AGENTSEC_DOTENV` resolution.
601/// 5. `ANTHROPIC_API_KEY` set (WARN if unset).
602/// 6. Claude Code MCP server registration (`agentsec` in
603///    `~/.claude.json` top-level or any project block).
604/// 7. Claude Code hook wiring (`UserPromptSubmit` /
605///    `SessionStart` entries in `~/.claude/settings.json`).
606pub fn doctor(cfg: &Config) -> DoctorReport {
607    let mut checks = Vec::new();
608
609    checks.push(DoctorCheck {
610        name: "binary version".into(),
611        status: DoctorStatus::Pass,
612        message: format!("agentsec-core {VERSION}"),
613    });
614
615    checks.push(check_home_writable(cfg));
616    checks.extend(check_subdirs(cfg));
617    checks.push(check_dotenv(cfg));
618    checks.push(check_api_key(cfg));
619    checks.extend(check_mcp_registration(cfg));
620    checks.extend(check_hook_wiring(cfg));
621
622    DoctorReport { checks }
623}
624
625fn check_home_writable(cfg: &Config) -> DoctorCheck {
626    let home = &cfg.paths.home;
627    if let Err(e) = fs::create_dir_all(home) {
628        return DoctorCheck {
629            name: "home dir".into(),
630            status: DoctorStatus::Fail,
631            message: format!("cannot create {}: {e}", home.display()),
632        };
633    }
634    let probe = home.join(".agentsec-doctor-probe");
635    match fs::write(&probe, b"probe") {
636        Ok(()) => {
637            let _ = fs::remove_file(&probe);
638            DoctorCheck {
639                name: "home dir".into(),
640                status: DoctorStatus::Pass,
641                message: format!("{} is writable", home.display()),
642            }
643        }
644        Err(e) => DoctorCheck {
645            name: "home dir".into(),
646            status: DoctorStatus::Fail,
647            message: format!("{} is not writable: {e}", home.display()),
648        },
649    }
650}
651
652fn check_subdirs(cfg: &Config) -> Vec<DoctorCheck> {
653    let p = &cfg.paths;
654    vec![
655        check_subdir_creatable("snapshots dir", &p.snapshots()),
656        check_subdir_creatable("scans dir", &p.scans()),
657        check_subdir_creatable("web_log dir", &p.web_log()),
658        check_subdir_creatable("paste_log dir", &p.paste_log()),
659    ]
660}
661
662fn check_subdir_creatable(name: &str, path: &Path) -> DoctorCheck {
663    match fs::create_dir_all(path) {
664        Ok(()) => DoctorCheck {
665            name: name.to_string(),
666            status: DoctorStatus::Pass,
667            message: format!("{} present", path.display()),
668        },
669        Err(e) => DoctorCheck {
670            name: name.to_string(),
671            status: DoctorStatus::Fail,
672            message: format!("cannot create {}: {e}", path.display()),
673        },
674    }
675}
676
677fn check_dotenv(cfg: &Config) -> DoctorCheck {
678    match &cfg.dotenv_path {
679        Some(p) if p.exists() => DoctorCheck {
680            name: "dotenv".into(),
681            status: DoctorStatus::Pass,
682            message: format!("loaded {}", p.display()),
683        },
684        Some(p) => DoctorCheck {
685            name: "dotenv".into(),
686            status: DoctorStatus::Warn,
687            message: format!("recorded dotenv path missing on disk: {}", p.display()),
688        },
689        None => DoctorCheck {
690            name: "dotenv".into(),
691            status: DoctorStatus::Warn,
692            message: "no .env loaded (process env only)".into(),
693        },
694    }
695}
696
697fn check_api_key(cfg: &Config) -> DoctorCheck {
698    if cfg.llm.api_key.is_some() {
699        DoctorCheck {
700            name: "anthropic api key".into(),
701            status: DoctorStatus::Pass,
702            message: "set; semantic sanitize layer enabled".into(),
703        }
704    } else {
705        DoctorCheck {
706            name: "anthropic api key".into(),
707            status: DoctorStatus::Warn,
708            message: "unset; semantic sanitize layer is no-op (regex layer still active)".into(),
709        }
710    }
711}
712
713/// Returns one PASS row per registration site found (or one FAIL row if
714/// none).
715fn check_mcp_registration(cfg: &Config) -> Vec<DoctorCheck> {
716    let path = cfg.paths.user_home.join(".claude.json");
717    if !path.exists() {
718        return vec![DoctorCheck {
719            name: "mcp registration".into(),
720            status: DoctorStatus::Warn,
721            message: format!(
722                "{} not found; cannot verify Claude Code MCP wiring",
723                path.display()
724            ),
725        }];
726    }
727    let body = match fs::read_to_string(&path) {
728        Ok(b) => b,
729        Err(e) => {
730            return vec![DoctorCheck {
731                name: "mcp registration".into(),
732                status: DoctorStatus::Warn,
733                message: format!("cannot read {}: {e}", path.display()),
734            }];
735        }
736    };
737    let json: serde_json::Value = match serde_json::from_str(&body) {
738        Ok(v) => v,
739        Err(e) => {
740            return vec![DoctorCheck {
741                name: "mcp registration".into(),
742                status: DoctorStatus::Warn,
743                message: format!("{} is not valid JSON: {e}", path.display()),
744            }];
745        }
746    };
747
748    let mut sites = Vec::new();
749    if json
750        .get("mcpServers")
751        .and_then(|v| v.get("agentsec"))
752        .is_some()
753    {
754        sites.push("user-scope (.claude.json:mcpServers.agentsec)".to_string());
755    }
756    if let Some(projects) = json.get("projects").and_then(|v| v.as_object()) {
757        for (proj, val) in projects {
758            if val
759                .get("mcpServers")
760                .and_then(|v| v.get("agentsec"))
761                .is_some()
762            {
763                sites.push(format!("project-scope ({proj})"));
764            }
765        }
766    }
767
768    if sites.is_empty() {
769        vec![DoctorCheck {
770            name: "mcp registration".into(),
771            status: DoctorStatus::Fail,
772            message: "agentsec not registered anywhere in .claude.json; run `claude mcp add agentsec -s user -- agentsec mcp`".into(),
773        }]
774    } else {
775        sites
776            .into_iter()
777            .map(|site| DoctorCheck {
778                name: "mcp registration".into(),
779                status: DoctorStatus::Pass,
780                message: format!("registered at {site}"),
781            })
782            .collect()
783    }
784}
785
786/// Returns one row per hook (UserPromptSubmit / SessionStart) reporting
787/// whether `agentsec hook <name>` is wired into
788/// `~/.claude/settings.json`.
789fn check_hook_wiring(cfg: &Config) -> Vec<DoctorCheck> {
790    let path = cfg.paths.user_home.join(".claude/settings.json");
791    if !path.exists() {
792        return vec![DoctorCheck {
793            name: "hook wiring".into(),
794            status: DoctorStatus::Warn,
795            message: format!(
796                "{} not found; cannot verify Claude Code hook wiring",
797                path.display()
798            ),
799        }];
800    }
801    let body = match fs::read_to_string(&path) {
802        Ok(b) => b,
803        Err(e) => {
804            return vec![DoctorCheck {
805                name: "hook wiring".into(),
806                status: DoctorStatus::Warn,
807                message: format!("cannot read {}: {e}", path.display()),
808            }];
809        }
810    };
811    let json: serde_json::Value = match serde_json::from_str(&body) {
812        Ok(v) => v,
813        Err(e) => {
814            return vec![DoctorCheck {
815                name: "hook wiring".into(),
816                status: DoctorStatus::Warn,
817                message: format!("{} is not valid JSON: {e}", path.display()),
818            }];
819        }
820    };
821
822    let wired = collect_wired_hooks(&json);
823    let expected = ["user-prompt-submit", "session-start"];
824    expected
825        .iter()
826        .map(|name| {
827            let full = format!("agentsec hook {name}");
828            if wired.contains((*name).to_string().as_str()) {
829                DoctorCheck {
830                    name: format!("hook: {name}"),
831                    status: DoctorStatus::Pass,
832                    message: format!("`{full}` is wired in settings.json"),
833                }
834            } else {
835                DoctorCheck {
836                    name: format!("hook: {name}"),
837                    status: DoctorStatus::Warn,
838                    message: format!(
839                        "`{full}` not wired; add it under hooks.{} in {}",
840                        hook_key_for(name),
841                        path.display()
842                    ),
843                }
844            }
845        })
846        .collect()
847}
848
849/// Scan `hooks.<HookKey>[].hooks[].command` for `agentsec hook <name>`
850/// substrings; return the set of hook short-names actually wired.
851pub(crate) fn collect_wired_hooks(json: &serde_json::Value) -> HashSet<String> {
852    let mut out = HashSet::new();
853    let Some(hooks) = json.get("hooks").and_then(|v| v.as_object()) else {
854        return out;
855    };
856    for matcher_list in hooks.values() {
857        let Some(matchers) = matcher_list.as_array() else {
858            continue;
859        };
860        for matcher in matchers {
861            let Some(inner) = matcher.get("hooks").and_then(|v| v.as_array()) else {
862                continue;
863            };
864            for hook in inner {
865                if let Some(cmd) = hook.get("command").and_then(|v| v.as_str())
866                    && let Some(name) = extract_hook_name(cmd)
867                {
868                    out.insert(name);
869                }
870            }
871        }
872    }
873    out
874}
875
876pub(crate) fn extract_hook_name(cmd: &str) -> Option<String> {
877    // Looking for `agentsec hook <name>` (allow leading path components
878    // like `~/.cargo/bin/agentsec hook <name>`).
879    let needle = "agentsec hook ";
880    let idx = cmd.find(needle)?;
881    let after = &cmd[idx + needle.len()..];
882    let name = after.split_whitespace().next()?;
883    Some(name.to_string())
884}
885
886pub(crate) fn hook_key_for(name: &str) -> &'static str {
887    match name {
888        "user-prompt-submit" => "UserPromptSubmit",
889        "session-start" => "SessionStart",
890        _ => "(unknown)",
891    }
892}
893
894#[cfg(test)]
895mod tests {
896    use super::*;
897    use crate::Paths;
898
899    fn cfg_for(tmp: &tempfile::TempDir, dotenv: Option<PathBuf>) -> Config {
900        Config {
901            paths: Paths {
902                home: tmp.path().to_path_buf(),
903                user_home: tmp.path().to_path_buf(),
904            },
905            llm: crate::LlmConfig {
906                api_key: None,
907                model: DEFAULT_LLM_MODEL.into(),
908            },
909            paste: crate::config::PasteConfig::default(),
910            web: crate::config::WebConfig::default(),
911            dotenv_path: dotenv,
912        }
913    }
914
915    #[test]
916    fn parse_dotenv_picks_up_keys_with_line_numbers() {
917        let tmp = tempfile::tempdir().unwrap();
918        let p = tmp.path().join(".env");
919        fs::write(
920            &p,
921            "# comment\nFOO=bar\nexport BAZ=qux\n\n# another\nQUUX=zap\n",
922        )
923        .unwrap();
924        let keys = parse_dotenv(&p).unwrap();
925        assert_eq!(keys.get("FOO"), Some(&2));
926        assert_eq!(keys.get("BAZ"), Some(&3));
927        assert_eq!(keys.get("QUUX"), Some(&6));
928    }
929
930    #[test]
931    fn redact_short_secret_is_three_stars() {
932        assert_eq!(redact("short"), "***");
933    }
934
935    #[test]
936    fn redact_long_secret_shows_last_four_and_length() {
937        let out = redact("sk-ant-12345-very-long-api-key-zzzz");
938        assert!(out.starts_with("***"));
939        assert!(out.contains("zzzz"));
940        assert!(out.contains("len="));
941    }
942
943    #[test]
944    fn doctor_runs_without_panicking_on_empty_tempdir() {
945        let tmp = tempfile::tempdir().unwrap();
946        let cfg = cfg_for(&tmp, None);
947        let report = doctor(&cfg);
948        assert!(!report.checks.is_empty());
949        // home dir check should PASS (tempdir is writable).
950        let home_check = report.checks.iter().find(|c| c.name == "home dir").unwrap();
951        assert_eq!(home_check.status, DoctorStatus::Pass);
952    }
953
954    #[test]
955    fn status_reports_zero_for_empty_tempdir() {
956        let tmp = tempfile::tempdir().unwrap();
957        let cfg = cfg_for(&tmp, None);
958        let s = status(&cfg);
959        assert_eq!(s.snapshots_count, 0);
960        assert_eq!(s.paste_log_count, 0);
961        assert_eq!(s.web_log_count, 0);
962        assert!(!s.plain_mode_active);
963        // builtin registry has >0 entries
964        assert!(s.registry_entries > 0);
965    }
966
967    #[test]
968    fn status_storage_section_has_all_kinds() {
969        let tmp = tempfile::tempdir().unwrap();
970        let cfg = cfg_for(&tmp, None);
971        let s = status(&cfg);
972        // 7 known kinds: snapshots, paste_log, web_log, scans, plain ledger,
973        // registry cache, registry local.
974        let kinds: Vec<&str> = s.storage.iter().map(|i| i.kind.as_str()).collect();
975        assert!(kinds.contains(&"snapshots"));
976        assert!(kinds.contains(&"paste_log"));
977        assert!(kinds.contains(&"web_log"));
978        assert!(kinds.contains(&"scans"));
979        assert!(kinds.contains(&"plain ledger"));
980        assert!(kinds.contains(&"registry cache"));
981        assert!(kinds.contains(&"registry local"));
982        // Empty tempdir: all dirs missing → count/size are zero.
983        for item in &s.storage {
984            assert_eq!(
985                item.count, 0,
986                "kind {} count must be 0 on empty home",
987                item.kind
988            );
989            assert_eq!(
990                item.size_bytes, 0,
991                "kind {} size must be 0 on empty home",
992                item.kind
993            );
994            // Paths are absolute (under tempdir/home).
995            assert!(
996                item.path.is_absolute(),
997                "kind {} path must be absolute",
998                item.kind
999            );
1000        }
1001    }
1002
1003    #[test]
1004    fn status_storage_picks_up_paste_log_files() {
1005        use std::fs;
1006        let tmp = tempfile::tempdir().unwrap();
1007        let cfg = cfg_for(&tmp, None);
1008        let paste_dir = cfg.paths.paste_log();
1009        fs::create_dir_all(&paste_dir).unwrap();
1010        fs::write(paste_dir.join("aaa.json"), "{}").unwrap();
1011        fs::write(paste_dir.join("bbb.json"), "{\"x\":1}").unwrap();
1012        let s = status(&cfg);
1013        let paste = s.storage.iter().find(|i| i.kind == "paste_log").unwrap();
1014        assert_eq!(paste.count, 2);
1015        assert!(
1016            paste.size_bytes >= 9,
1017            "two small JSON files should sum to ≥9 bytes"
1018        );
1019        assert_eq!(paste.latest.as_deref(), Some("bbb.json"));
1020    }
1021
1022    #[test]
1023    fn format_size_reports_units() {
1024        use crate::diagnostics::format_size;
1025        assert_eq!(format_size(0), "0 B");
1026        assert_eq!(format_size(512), "512 B");
1027        assert_eq!(format_size(1024), "1.0 KB");
1028        assert_eq!(format_size(1536), "1.5 KB");
1029        assert_eq!(format_size(1024 * 1024), "1.0 MB");
1030        assert_eq!(format_size(1024 * 1024 * 1024), "1.0 GB");
1031    }
1032
1033    #[test]
1034    fn info_reports_env_with_default_for_unset_model() {
1035        let tmp = tempfile::tempdir().unwrap();
1036        let cfg = cfg_for(&tmp, None);
1037        // Ensure AGENTSEC_LLM_MODEL is unset for this test; we can't
1038        // mutate process env safely in unit tests, so we rely on the
1039        // test runner's env not setting it.
1040        let report = info(&cfg);
1041        let model = report
1042            .env
1043            .iter()
1044            .find(|e| e.key == "AGENTSEC_LLM_MODEL")
1045            .unwrap();
1046        // Either unset (→ Default) or actually set in CI env; both are
1047        // acceptable. The point is the row exists with a source.
1048        match &model.source {
1049            EnvSource::Default(v) => assert_eq!(v, DEFAULT_LLM_MODEL),
1050            EnvSource::Process | EnvSource::DotenvFile { .. } => { /* CI / dev env override */ }
1051            other => panic!("unexpected source for AGENTSEC_LLM_MODEL: {other:?}"),
1052        }
1053    }
1054
1055    #[test]
1056    fn doctor_flags_missing_claude_json_with_warn_not_fail() {
1057        let tmp = tempfile::tempdir().unwrap();
1058        let cfg = cfg_for(&tmp, None);
1059        let report = doctor(&cfg);
1060        let mcp = report
1061            .checks
1062            .iter()
1063            .find(|c| c.name == "mcp registration")
1064            .unwrap();
1065        // tempdir has no .claude.json ⇒ Warn (can't verify), not Fail.
1066        assert_eq!(mcp.status, DoctorStatus::Warn);
1067    }
1068
1069    #[test]
1070    fn extract_hook_name_handles_full_path() {
1071        assert_eq!(
1072            extract_hook_name("/Users/x/.cargo/bin/agentsec hook user-prompt-submit"),
1073            Some("user-prompt-submit".to_string())
1074        );
1075        assert_eq!(
1076            extract_hook_name("agentsec hook session-start"),
1077            Some("session-start".to_string())
1078        );
1079        assert_eq!(extract_hook_name("python3 something.py"), None);
1080    }
1081}