use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::Config;
use crate::config::DEFAULT_LLM_MODEL;
use crate::error::Result;
const TRACKED_ENV_VARS: &[&str] = &[
"AGENTSEC_HOME",
"HOME",
"ANTHROPIC_API_KEY",
"AGENTSEC_LLM_MODEL",
"AGENTSEC_DOTENV",
];
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InfoReport {
pub version: String,
pub paths: PathsInfo,
pub env: Vec<EnvVarStatus>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PathsInfo {
pub home: PathBuf,
pub user_home: PathBuf,
pub snapshots: PathBuf,
pub scans: PathBuf,
pub web_log: PathBuf,
pub paste_log: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnvVarStatus {
pub key: String,
pub source: EnvSource,
pub value_display: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum EnvSource {
Process,
DotenvFile { path: PathBuf, line: usize },
DotenvFileShadowed { path: PathBuf, line: usize },
Default(String),
Unset,
}
pub fn info(cfg: &Config) -> InfoReport {
let paths = PathsInfo {
home: cfg.paths.home.clone(),
user_home: cfg.paths.user_home.clone(),
snapshots: cfg.paths.snapshots(),
scans: cfg.paths.scans(),
web_log: cfg.paths.web_log(),
paste_log: cfg.paths.paste_log(),
};
let dotenv_keys = cfg
.dotenv_path
.as_ref()
.and_then(|p| parse_dotenv(p).ok())
.unwrap_or_default();
let env = TRACKED_ENV_VARS
.iter()
.map(|key| classify_env_var(key, &dotenv_keys, cfg.dotenv_path.as_deref()))
.collect();
InfoReport {
version: VERSION.to_string(),
paths,
env,
}
}
type DotenvKeys = HashMap<String, usize>;
fn parse_dotenv(path: &Path) -> Result<DotenvKeys> {
let body = fs::read_to_string(path)?;
let mut out = DotenvKeys::new();
for (idx, raw) in body.lines().enumerate() {
let trimmed = raw.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let after_export = trimmed.strip_prefix("export ").unwrap_or(trimmed);
let Some(eq_idx) = after_export.find('=') else {
continue;
};
let key = after_export[..eq_idx].trim().to_string();
if !is_valid_env_key(&key) {
continue;
}
out.entry(key).or_insert(idx + 1);
}
Ok(out)
}
fn is_valid_env_key(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn classify_env_var(
key: &str,
dotenv_keys: &DotenvKeys,
dotenv_path: Option<&Path>,
) -> EnvVarStatus {
let in_dotenv_line = dotenv_keys.get(key).copied();
let process_value = std::env::var(key).ok();
let (source, value_display) = match (process_value, in_dotenv_line, dotenv_path) {
(Some(v), Some(line), Some(path)) => {
(
EnvSource::DotenvFile {
path: path.to_path_buf(),
line,
},
Some(display_value(key, &v)),
)
}
(Some(v), None, _) | (Some(v), Some(_), None) => {
(EnvSource::Process, Some(display_value(key, &v)))
}
(None, Some(line), Some(path)) => (
EnvSource::DotenvFile {
path: path.to_path_buf(),
line,
},
None,
),
(None, None, _) | (None, Some(_), None) => match default_for(key) {
Some(d) => (EnvSource::Default(d.clone()), Some(d)),
None => (EnvSource::Unset, None),
},
};
EnvVarStatus {
key: key.to_string(),
source,
value_display,
}
}
fn default_for(key: &str) -> Option<String> {
match key {
"AGENTSEC_LLM_MODEL" => Some(DEFAULT_LLM_MODEL.to_string()),
_ => None,
}
}
fn display_value(key: &str, raw: &str) -> String {
if is_secret_key(key) {
redact(raw)
} else {
raw.to_string()
}
}
fn is_secret_key(key: &str) -> bool {
matches!(key, "ANTHROPIC_API_KEY")
}
fn redact(value: &str) -> String {
if value.is_empty() {
return "<empty>".to_string();
}
let chars: Vec<char> = value.chars().collect();
if chars.len() <= 8 {
return "***".to_string();
}
let tail: String = chars[chars.len().saturating_sub(4)..].iter().collect();
format!("***{tail} (len={})", chars.len())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StorageItem {
pub kind: String,
pub path: PathBuf,
pub count: usize,
pub size_bytes: u64,
pub latest: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatusReport {
pub snapshots_count: usize,
pub latest_snapshot: Option<String>,
pub paste_log_count: usize,
pub web_log_count: usize,
pub plain_mode_active: bool,
pub plain_mode_entries: usize,
pub registry_entries: usize,
#[serde(default)]
pub storage: Vec<StorageItem>,
}
pub fn status(cfg: &Config) -> StatusReport {
use crate::plain_mode;
use crate::registry::Registry;
let snapshots_dir = cfg.paths.snapshots();
let (snapshots_count, latest_snapshot) = count_and_latest(&snapshots_dir);
let paste_log_count = count_files(&cfg.paths.paste_log());
let web_log_count = count_files(&cfg.paths.web_log());
let plain = plain_mode::status(&cfg.paths).unwrap_or_else(|_| plain_mode::PlainStatus {
active: false,
entries: Vec::new(),
ledger_path: cfg.paths.home.join(plain_mode::PLAIN_LEDGER_FILENAME),
});
let registry_entries = Registry::load_or_builtin(&cfg.paths).len();
let storage = collect_storage(cfg);
StatusReport {
snapshots_count,
latest_snapshot,
paste_log_count,
web_log_count,
plain_mode_active: plain.active,
plain_mode_entries: plain.entries.len(),
registry_entries,
storage,
}
}
fn collect_storage(cfg: &Config) -> Vec<StorageItem> {
let home = &cfg.paths.home;
vec![
dir_storage("snapshots", &cfg.paths.snapshots()),
dir_storage("paste_log", &cfg.paths.paste_log()),
dir_storage("web_log", &cfg.paths.web_log()),
dir_storage("scans", &cfg.paths.scans()),
file_storage(
"plain ledger",
home.join(crate::plain_mode::PLAIN_LEDGER_FILENAME),
),
file_storage("registry cache", home.join("registry.json")),
file_storage("registry local", home.join("registry-local.json")),
]
}
fn dir_storage(kind: &str, path: &Path) -> StorageItem {
let (count, size_bytes, latest) = match fs::read_dir(path) {
Err(_) => (0, 0, None),
Ok(entries) => {
let mut names: Vec<String> = Vec::new();
let mut total: u64 = 0;
for e in entries.flatten() {
if let Ok(md) = e.metadata()
&& md.is_file()
{
total = total.saturating_add(md.len());
if let Some(name) = e.file_name().to_str() {
names.push(name.to_string());
}
}
}
let count = names.len();
names.sort();
(count, total, names.last().cloned())
}
};
StorageItem {
kind: kind.to_string(),
path: path.to_path_buf(),
count,
size_bytes,
latest,
}
}
fn file_storage(kind: &str, path: PathBuf) -> StorageItem {
let (count, size_bytes, latest) = match fs::metadata(&path) {
Ok(md) if md.is_file() => {
let size = md.len();
let latest = md.modified().ok().and_then(|t| {
t.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|d| format!("epoch={}", d.as_secs()))
});
(1, size, latest)
}
_ => (0, 0, None),
};
StorageItem {
kind: kind.to_string(),
path,
count,
size_bytes,
latest,
}
}
pub fn format_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
let (unit, scale) = if bytes >= GB {
("GB", GB)
} else if bytes >= MB {
("MB", MB)
} else if bytes >= KB {
("KB", KB)
} else {
return format!("{bytes} B");
};
let whole = bytes / scale;
let tenth = (bytes % scale) * 10 / scale;
format!("{whole}.{tenth} {unit}")
}
fn count_files(dir: &Path) -> usize {
fs::read_dir(dir)
.map(std::iter::Iterator::count)
.unwrap_or(0)
}
fn count_and_latest(dir: &Path) -> (usize, Option<String>) {
let Ok(entries) = fs::read_dir(dir) else {
return (0, None);
};
let mut names: Vec<String> = entries
.filter_map(std::result::Result::ok)
.filter_map(|e| {
let p = e.path();
if p.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
{
e.file_name().to_str().map(std::string::ToString::to_string)
} else {
None
}
})
.collect();
let count = names.len();
names.sort();
(count, names.last().cloned())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecentActivityReport {
pub paste: Vec<AuditTail>,
pub web: Vec<AuditTail>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditTail {
pub filename: String,
pub size: u64,
pub excerpt: String,
}
pub fn recent_activity(cfg: &Config, n: usize) -> RecentActivityReport {
RecentActivityReport {
paste: tail_dir(&cfg.paths.paste_log(), n),
web: tail_dir(&cfg.paths.web_log(), n),
}
}
const AUDIT_EXCERPT_CHARS: usize = 200;
const EXCERPT_CONTEXT_LINES: usize = 2;
fn tail_dir(dir: &Path, n: usize) -> Vec<AuditTail> {
let Ok(entries) = fs::read_dir(dir) else {
return Vec::new();
};
let mut files: Vec<(String, PathBuf)> = entries
.filter_map(std::result::Result::ok)
.filter_map(|e| {
let p = e.path();
if !p
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
{
return None;
}
let name = e.file_name().to_str()?.to_string();
Some((name, e.path()))
})
.collect();
files.sort_by(|(a, _), (b, _)| a.cmp(b));
files
.into_iter()
.rev()
.take(n)
.map(|(name, path)| AuditTail {
filename: name,
size: fs::metadata(&path).map(|m| m.len()).unwrap_or(0),
excerpt: read_excerpt(&path),
})
.collect()
}
fn read_excerpt(path: &Path) -> String {
let Ok(body) = fs::read_to_string(path) else {
return String::new();
};
let lines: Vec<&str> = body.lines().collect();
if lines.is_empty() {
return String::new();
}
let anchor = lines
.iter()
.enumerate()
.filter(|(_, l)| {
let t = l.trim();
t != "{" && t != "}" && !t.is_empty()
})
.max_by_key(|(_, l)| l.len())
.map_or(0, |(i, _)| i);
let start = anchor.saturating_sub(EXCERPT_CONTEXT_LINES);
let end = (anchor + EXCERPT_CONTEXT_LINES + 1).min(lines.len());
let joined = lines[start..end].join("\n");
if joined.chars().count() <= AUDIT_EXCERPT_CHARS {
joined
} else {
let truncated: String = joined.chars().take(AUDIT_EXCERPT_CHARS).collect();
format!("{truncated}…")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DoctorReport {
pub checks: Vec<DoctorCheck>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DoctorCheck {
pub name: String,
pub status: DoctorStatus,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum DoctorStatus {
Pass,
Warn,
Fail,
}
pub fn doctor(cfg: &Config) -> DoctorReport {
let mut checks = Vec::new();
checks.push(DoctorCheck {
name: "binary version".into(),
status: DoctorStatus::Pass,
message: format!("agentsec-core {VERSION}"),
});
checks.push(check_home_writable(cfg));
checks.extend(check_subdirs(cfg));
checks.push(check_dotenv(cfg));
checks.push(check_api_key(cfg));
checks.extend(check_mcp_registration(cfg));
checks.extend(check_hook_wiring(cfg));
DoctorReport { checks }
}
fn check_home_writable(cfg: &Config) -> DoctorCheck {
let home = &cfg.paths.home;
if let Err(e) = fs::create_dir_all(home) {
return DoctorCheck {
name: "home dir".into(),
status: DoctorStatus::Fail,
message: format!("cannot create {}: {e}", home.display()),
};
}
let probe = home.join(".agentsec-doctor-probe");
match fs::write(&probe, b"probe") {
Ok(()) => {
let _ = fs::remove_file(&probe);
DoctorCheck {
name: "home dir".into(),
status: DoctorStatus::Pass,
message: format!("{} is writable", home.display()),
}
}
Err(e) => DoctorCheck {
name: "home dir".into(),
status: DoctorStatus::Fail,
message: format!("{} is not writable: {e}", home.display()),
},
}
}
fn check_subdirs(cfg: &Config) -> Vec<DoctorCheck> {
let p = &cfg.paths;
vec![
check_subdir_creatable("snapshots dir", &p.snapshots()),
check_subdir_creatable("scans dir", &p.scans()),
check_subdir_creatable("web_log dir", &p.web_log()),
check_subdir_creatable("paste_log dir", &p.paste_log()),
]
}
fn check_subdir_creatable(name: &str, path: &Path) -> DoctorCheck {
match fs::create_dir_all(path) {
Ok(()) => DoctorCheck {
name: name.to_string(),
status: DoctorStatus::Pass,
message: format!("{} present", path.display()),
},
Err(e) => DoctorCheck {
name: name.to_string(),
status: DoctorStatus::Fail,
message: format!("cannot create {}: {e}", path.display()),
},
}
}
fn check_dotenv(cfg: &Config) -> DoctorCheck {
match &cfg.dotenv_path {
Some(p) if p.exists() => DoctorCheck {
name: "dotenv".into(),
status: DoctorStatus::Pass,
message: format!("loaded {}", p.display()),
},
Some(p) => DoctorCheck {
name: "dotenv".into(),
status: DoctorStatus::Warn,
message: format!("recorded dotenv path missing on disk: {}", p.display()),
},
None => DoctorCheck {
name: "dotenv".into(),
status: DoctorStatus::Warn,
message: "no .env loaded (process env only)".into(),
},
}
}
fn check_api_key(cfg: &Config) -> DoctorCheck {
if cfg.llm.api_key.is_some() {
DoctorCheck {
name: "anthropic api key".into(),
status: DoctorStatus::Pass,
message: "set; semantic sanitize layer enabled".into(),
}
} else {
DoctorCheck {
name: "anthropic api key".into(),
status: DoctorStatus::Warn,
message: "unset; semantic sanitize layer is no-op (regex layer still active)".into(),
}
}
}
fn check_mcp_registration(cfg: &Config) -> Vec<DoctorCheck> {
let path = cfg.paths.user_home.join(".claude.json");
if !path.exists() {
return vec![DoctorCheck {
name: "mcp registration".into(),
status: DoctorStatus::Warn,
message: format!(
"{} not found; cannot verify Claude Code MCP wiring",
path.display()
),
}];
}
let body = match fs::read_to_string(&path) {
Ok(b) => b,
Err(e) => {
return vec![DoctorCheck {
name: "mcp registration".into(),
status: DoctorStatus::Warn,
message: format!("cannot read {}: {e}", path.display()),
}];
}
};
let json: serde_json::Value = match serde_json::from_str(&body) {
Ok(v) => v,
Err(e) => {
return vec![DoctorCheck {
name: "mcp registration".into(),
status: DoctorStatus::Warn,
message: format!("{} is not valid JSON: {e}", path.display()),
}];
}
};
let mut sites = Vec::new();
if json
.get("mcpServers")
.and_then(|v| v.get("agentsec"))
.is_some()
{
sites.push("user-scope (.claude.json:mcpServers.agentsec)".to_string());
}
if let Some(projects) = json.get("projects").and_then(|v| v.as_object()) {
for (proj, val) in projects {
if val
.get("mcpServers")
.and_then(|v| v.get("agentsec"))
.is_some()
{
sites.push(format!("project-scope ({proj})"));
}
}
}
if sites.is_empty() {
vec![DoctorCheck {
name: "mcp registration".into(),
status: DoctorStatus::Fail,
message: "agentsec not registered anywhere in .claude.json; run `claude mcp add agentsec -s user -- agentsec mcp`".into(),
}]
} else {
sites
.into_iter()
.map(|site| DoctorCheck {
name: "mcp registration".into(),
status: DoctorStatus::Pass,
message: format!("registered at {site}"),
})
.collect()
}
}
fn check_hook_wiring(cfg: &Config) -> Vec<DoctorCheck> {
let path = cfg.paths.user_home.join(".claude/settings.json");
if !path.exists() {
return vec![DoctorCheck {
name: "hook wiring".into(),
status: DoctorStatus::Warn,
message: format!(
"{} not found; cannot verify Claude Code hook wiring",
path.display()
),
}];
}
let body = match fs::read_to_string(&path) {
Ok(b) => b,
Err(e) => {
return vec![DoctorCheck {
name: "hook wiring".into(),
status: DoctorStatus::Warn,
message: format!("cannot read {}: {e}", path.display()),
}];
}
};
let json: serde_json::Value = match serde_json::from_str(&body) {
Ok(v) => v,
Err(e) => {
return vec![DoctorCheck {
name: "hook wiring".into(),
status: DoctorStatus::Warn,
message: format!("{} is not valid JSON: {e}", path.display()),
}];
}
};
let wired = collect_wired_hooks(&json);
let expected = ["user-prompt-submit", "session-start"];
expected
.iter()
.map(|name| {
let full = format!("agentsec hook {name}");
if wired.contains((*name).to_string().as_str()) {
DoctorCheck {
name: format!("hook: {name}"),
status: DoctorStatus::Pass,
message: format!("`{full}` is wired in settings.json"),
}
} else {
DoctorCheck {
name: format!("hook: {name}"),
status: DoctorStatus::Warn,
message: format!(
"`{full}` not wired; add it under hooks.{} in {}",
hook_key_for(name),
path.display()
),
}
}
})
.collect()
}
pub(crate) fn collect_wired_hooks(json: &serde_json::Value) -> HashSet<String> {
let mut out = HashSet::new();
let Some(hooks) = json.get("hooks").and_then(|v| v.as_object()) else {
return out;
};
for matcher_list in hooks.values() {
let Some(matchers) = matcher_list.as_array() else {
continue;
};
for matcher in matchers {
let Some(inner) = matcher.get("hooks").and_then(|v| v.as_array()) else {
continue;
};
for hook in inner {
if let Some(cmd) = hook.get("command").and_then(|v| v.as_str())
&& let Some(name) = extract_hook_name(cmd)
{
out.insert(name);
}
}
}
}
out
}
pub(crate) fn extract_hook_name(cmd: &str) -> Option<String> {
let needle = "agentsec hook ";
let idx = cmd.find(needle)?;
let after = &cmd[idx + needle.len()..];
let name = after.split_whitespace().next()?;
Some(name.to_string())
}
pub(crate) fn hook_key_for(name: &str) -> &'static str {
match name {
"user-prompt-submit" => "UserPromptSubmit",
"session-start" => "SessionStart",
_ => "(unknown)",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Paths;
fn cfg_for(tmp: &tempfile::TempDir, dotenv: Option<PathBuf>) -> Config {
Config {
paths: Paths {
home: tmp.path().to_path_buf(),
user_home: tmp.path().to_path_buf(),
},
llm: crate::LlmConfig {
api_key: None,
model: DEFAULT_LLM_MODEL.into(),
},
paste: crate::config::PasteConfig::default(),
web: crate::config::WebConfig::default(),
dotenv_path: dotenv,
}
}
#[test]
fn parse_dotenv_picks_up_keys_with_line_numbers() {
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join(".env");
fs::write(
&p,
"# comment\nFOO=bar\nexport BAZ=qux\n\n# another\nQUUX=zap\n",
)
.unwrap();
let keys = parse_dotenv(&p).unwrap();
assert_eq!(keys.get("FOO"), Some(&2));
assert_eq!(keys.get("BAZ"), Some(&3));
assert_eq!(keys.get("QUUX"), Some(&6));
}
#[test]
fn redact_short_secret_is_three_stars() {
assert_eq!(redact("short"), "***");
}
#[test]
fn redact_long_secret_shows_last_four_and_length() {
let out = redact("sk-ant-12345-very-long-api-key-zzzz");
assert!(out.starts_with("***"));
assert!(out.contains("zzzz"));
assert!(out.contains("len="));
}
#[test]
fn doctor_runs_without_panicking_on_empty_tempdir() {
let tmp = tempfile::tempdir().unwrap();
let cfg = cfg_for(&tmp, None);
let report = doctor(&cfg);
assert!(!report.checks.is_empty());
let home_check = report.checks.iter().find(|c| c.name == "home dir").unwrap();
assert_eq!(home_check.status, DoctorStatus::Pass);
}
#[test]
fn status_reports_zero_for_empty_tempdir() {
let tmp = tempfile::tempdir().unwrap();
let cfg = cfg_for(&tmp, None);
let s = status(&cfg);
assert_eq!(s.snapshots_count, 0);
assert_eq!(s.paste_log_count, 0);
assert_eq!(s.web_log_count, 0);
assert!(!s.plain_mode_active);
assert!(s.registry_entries > 0);
}
#[test]
fn status_storage_section_has_all_kinds() {
let tmp = tempfile::tempdir().unwrap();
let cfg = cfg_for(&tmp, None);
let s = status(&cfg);
let kinds: Vec<&str> = s.storage.iter().map(|i| i.kind.as_str()).collect();
assert!(kinds.contains(&"snapshots"));
assert!(kinds.contains(&"paste_log"));
assert!(kinds.contains(&"web_log"));
assert!(kinds.contains(&"scans"));
assert!(kinds.contains(&"plain ledger"));
assert!(kinds.contains(&"registry cache"));
assert!(kinds.contains(&"registry local"));
for item in &s.storage {
assert_eq!(
item.count, 0,
"kind {} count must be 0 on empty home",
item.kind
);
assert_eq!(
item.size_bytes, 0,
"kind {} size must be 0 on empty home",
item.kind
);
assert!(
item.path.is_absolute(),
"kind {} path must be absolute",
item.kind
);
}
}
#[test]
fn status_storage_picks_up_paste_log_files() {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
let cfg = cfg_for(&tmp, None);
let paste_dir = cfg.paths.paste_log();
fs::create_dir_all(&paste_dir).unwrap();
fs::write(paste_dir.join("aaa.json"), "{}").unwrap();
fs::write(paste_dir.join("bbb.json"), "{\"x\":1}").unwrap();
let s = status(&cfg);
let paste = s.storage.iter().find(|i| i.kind == "paste_log").unwrap();
assert_eq!(paste.count, 2);
assert!(
paste.size_bytes >= 9,
"two small JSON files should sum to ≥9 bytes"
);
assert_eq!(paste.latest.as_deref(), Some("bbb.json"));
}
#[test]
fn format_size_reports_units() {
use crate::diagnostics::format_size;
assert_eq!(format_size(0), "0 B");
assert_eq!(format_size(512), "512 B");
assert_eq!(format_size(1024), "1.0 KB");
assert_eq!(format_size(1536), "1.5 KB");
assert_eq!(format_size(1024 * 1024), "1.0 MB");
assert_eq!(format_size(1024 * 1024 * 1024), "1.0 GB");
}
#[test]
fn info_reports_env_with_default_for_unset_model() {
let tmp = tempfile::tempdir().unwrap();
let cfg = cfg_for(&tmp, None);
let report = info(&cfg);
let model = report
.env
.iter()
.find(|e| e.key == "AGENTSEC_LLM_MODEL")
.unwrap();
match &model.source {
EnvSource::Default(v) => assert_eq!(v, DEFAULT_LLM_MODEL),
EnvSource::Process | EnvSource::DotenvFile { .. } => { }
other => panic!("unexpected source for AGENTSEC_LLM_MODEL: {other:?}"),
}
}
#[test]
fn doctor_flags_missing_claude_json_with_warn_not_fail() {
let tmp = tempfile::tempdir().unwrap();
let cfg = cfg_for(&tmp, None);
let report = doctor(&cfg);
let mcp = report
.checks
.iter()
.find(|c| c.name == "mcp registration")
.unwrap();
assert_eq!(mcp.status, DoctorStatus::Warn);
}
#[test]
fn extract_hook_name_handles_full_path() {
assert_eq!(
extract_hook_name("/Users/x/.cargo/bin/agentsec hook user-prompt-submit"),
Some("user-prompt-submit".to_string())
);
assert_eq!(
extract_hook_name("agentsec hook session-start"),
Some("session-start".to_string())
);
assert_eq!(extract_hook_name("python3 something.py"), None);
}
}