1use crate::diagnostics::{
23 DoctorReport, DoctorStatus, EnvSource, InfoReport, RecentActivityReport, StatusReport,
24};
25use crate::scan::unknown::{UnknownVerdict, UnknownVerdictKind};
26use crate::scan::{ScanOutcome, ScanReport, diff::DiffReport};
27use std::fmt::Write;
28
29pub fn render(outcome: &ScanOutcome) -> String {
32 let mut s = String::new();
33 write_header(&mut s, &outcome.report);
34 write_inventory(&mut s, &outcome.report);
35 write_diff(&mut s, outcome);
36 write_footer(&mut s, outcome);
37 s
38}
39
40fn write_header(s: &mut String, report: &ScanReport) {
41 let _ = writeln!(s, "# AgentSec scan — {}", report.scanned_at.to_rfc3339());
42 let _ = writeln!(s);
43 let _ = writeln!(s, "Total paths inventoried: **{}**", report.paths.len());
44 let _ = writeln!(s);
45}
46
47fn write_inventory(s: &mut String, report: &ScanReport) {
48 let _ = writeln!(s, "## Inventory");
49 let _ = writeln!(s);
50 if report.paths.is_empty() {
51 let _ = writeln!(s, "_no targets present._");
52 let _ = writeln!(s);
53 return;
54 }
55 let _ = writeln!(s, "| category | path | size | sha256 |");
56 let _ = writeln!(s, "|---|---|---|---|");
57 for p in &report.paths {
58 let _ = writeln!(
59 s,
60 "| {} | `{}` | {} | `{}` |",
61 p.category,
62 p.path.display(),
63 p.size,
64 &p.sha256[..16]
65 );
66 }
67 let _ = writeln!(s);
68}
69
70fn write_diff(s: &mut String, outcome: &ScanOutcome) {
71 let Some(diff) = &outcome.diff else {
72 let _ = writeln!(s, "## Diff vs previous snapshot");
73 let _ = writeln!(s);
74 let _ = writeln!(s, "_no previous snapshot — this is the baseline._");
75 let _ = writeln!(s);
76 return;
77 };
78 let _ = writeln!(s, "## Diff vs previous snapshot");
79 let _ = writeln!(s);
80 if diff.is_empty() {
81 let _ = writeln!(s, "_no changes._");
82 let _ = writeln!(s);
83 return;
84 }
85 if !diff.added.is_empty() {
86 let _ = writeln!(s, "### Added ({})", diff.added.len());
87 for e in &diff.added {
88 let _ = writeln!(s, "- `{}` ({} bytes)", e.path.display(), e.size);
89 }
90 let _ = writeln!(s);
91 }
92 if !diff.modified.is_empty() {
93 let _ = writeln!(s, "### Modified ({})", diff.modified.len());
94 for c in &diff.modified {
95 let _ = writeln!(
96 s,
97 "- `{}` ({} → {} bytes, sha256 `{}` → `{}`)",
98 c.path.display(),
99 c.prev_size,
100 c.curr_size,
101 &c.prev_sha256[..8],
102 &c.curr_sha256[..8]
103 );
104 }
105 let _ = writeln!(s);
106 }
107 if !diff.removed.is_empty() {
108 let _ = writeln!(s, "### Removed ({})", diff.removed.len());
109 for e in &diff.removed {
110 let _ = writeln!(s, "- `{}`", e.path.display());
111 }
112 let _ = writeln!(s);
113 }
114}
115
116const CRITICAL_CATEGORIES: &[&str] = &[
119 "local_config", "settings", "settings_local",
122 "settings_project",
123 "mcp_project", ];
125
126fn is_critical_category(category: &str) -> bool {
127 CRITICAL_CATEGORIES.contains(&category)
128}
129
130pub fn render_diff(diff: &DiffReport, baseline_hint: Option<&str>) -> String {
145 let mut s = String::new();
146 let header = match baseline_hint {
147 Some(p) => format!("# AgentSec diff vs `{p}`\n\n"),
148 None => "# AgentSec diff vs latest snapshot\n\n".to_string(),
149 };
150 s.push_str(&header);
151 if diff.is_empty() {
152 let _ = writeln!(s, "_no changes._");
153 return s;
154 }
155 if !diff.added.is_empty() {
156 let _ = writeln!(s, "### Added ({})", diff.added.len());
157 let mut sorted: Vec<_> = diff.added.iter().collect();
159 sorted.sort_by(|a, b| {
160 let a_crit = is_critical_category(&a.category);
161 let b_crit = is_critical_category(&b.category);
162 b_crit.cmp(&a_crit).then_with(|| a.path.cmp(&b.path))
163 });
164 for e in sorted {
165 let prefix = if is_critical_category(&e.category) {
166 "🚨 **CRITICAL** "
167 } else {
168 ""
169 };
170 let _ = writeln!(s, "- {prefix}`{}` ({} bytes)", e.path.display(), e.size);
171 }
172 let _ = writeln!(s);
173 }
174 if !diff.modified.is_empty() {
175 let _ = writeln!(s, "### Modified ({})", diff.modified.len());
176 let mut sorted_modified: Vec<_> = diff.modified.iter().collect();
183 sorted_modified.sort_by(|a, b| {
184 let a_crit = is_critical_path(&a.path.to_string_lossy());
185 let b_crit = is_critical_path(&b.path.to_string_lossy());
186 b_crit.cmp(&a_crit).then_with(|| a.path.cmp(&b.path))
187 });
188 for c in sorted_modified {
189 let path_str = c.path.to_string_lossy();
190 let prefix = if is_critical_path(&path_str) {
191 "🚨 **CRITICAL** "
192 } else {
193 ""
194 };
195 let _ = writeln!(
196 s,
197 "- {prefix}`{}` ({} → {} bytes, sha256 `{}` → `{}`)",
198 c.path.display(),
199 c.prev_size,
200 c.curr_size,
201 &c.prev_sha256[..8],
202 &c.curr_sha256[..8]
203 );
204 }
205 let _ = writeln!(s);
206 }
207 if !diff.removed.is_empty() {
208 let _ = writeln!(s, "### Removed ({})", diff.removed.len());
209 let mut sorted: Vec<_> = diff.removed.iter().collect();
210 sorted.sort_by(|a, b| {
211 let a_crit = is_critical_category(&a.category);
212 let b_crit = is_critical_category(&b.category);
213 b_crit.cmp(&a_crit).then_with(|| a.path.cmp(&b.path))
214 });
215 for e in sorted {
216 let prefix = if is_critical_category(&e.category) {
217 "🚨 **CRITICAL** "
218 } else {
219 ""
220 };
221 let _ = writeln!(s, "- {prefix}`{}`", e.path.display());
222 }
223 let _ = writeln!(s);
224 }
225 s
226}
227
228fn is_critical_path(path_str: &str) -> bool {
232 path_str.ends_with(".claude.json")
233 || path_str.contains("settings.json")
234 || path_str.ends_with(".mcp.json")
235}
236
237pub fn render_blacklist(verdicts: &[UnknownVerdict], registry_size: usize) -> String {
247 let mut s = String::new();
248 let _ = writeln!(s, "# AgentSec BlackList check");
249 let _ = writeln!(s);
250 let _ = writeln!(s, "Registry: {registry_size} known-good entries.");
251 let _ = writeln!(s);
252 if verdicts.is_empty() {
253 let _ = writeln!(s, "_no MCP servers found in .mcp.json / .claude.json._");
254 return s;
255 }
256 let mut good = Vec::new();
257 let mut likely = Vec::new();
258 let mut informational = Vec::new();
259 let mut unk = Vec::new();
260 for v in verdicts {
261 match &v.verdict {
262 UnknownVerdictKind::KnownGood => good.push(v),
263 UnknownVerdictKind::LikelyTyposquat { .. } => likely.push(v),
264 UnknownVerdictKind::InformationalTyposquat { .. } => informational.push(v),
265 UnknownVerdictKind::Unknown => unk.push(v),
266 }
267 }
268 if !good.is_empty() {
269 let _ = writeln!(s, "### Known-good ({})", good.len());
270 for v in good {
271 let paths_line = v.paths.join(", ");
272 let _ = writeln!(
273 s,
274 "- `{}` ×{} — {} (from `{}`)",
275 v.name, v.count, v.reason, paths_line
276 );
277 }
278 let _ = writeln!(s);
279 }
280 if !likely.is_empty() {
281 let _ = writeln!(s, "### Likely typosquat (warn) ({})", likely.len());
282 for v in likely {
283 let _ = writeln!(s, "- `{}` ×{} — {}", v.name, v.count, v.reason);
284 for p in &v.paths {
285 let _ = writeln!(s, " - `{p}`");
286 }
287 }
288 let _ = writeln!(s);
289 }
290 if !informational.is_empty() {
291 let _ = writeln!(
292 s,
293 "### Informational typosquat (info) ({})",
294 informational.len()
295 );
296 for v in informational {
297 let _ = writeln!(s, "- `{}` ×{} — {}", v.name, v.count, v.reason);
298 for p in &v.paths {
299 let _ = writeln!(s, " - `{p}`");
300 }
301 }
302 let _ = writeln!(s);
303 }
304 if !unk.is_empty() {
305 let _ = writeln!(s, "### Unknown ({})", unk.len());
306 for v in unk {
307 let paths_line = v.paths.join(", ");
308 let _ = writeln!(s, "- `{}` ×{} (from `{}`)", v.name, v.count, paths_line);
309 }
310 let _ = writeln!(s);
311 }
312 s
313}
314
315pub fn render_info(report: &InfoReport) -> String {
325 let mut s = String::new();
326 let _ = writeln!(s, "# AgentSec — info");
327 let _ = writeln!(s);
328 let _ = writeln!(s, "## Version");
329 let _ = writeln!(s);
330 let _ = writeln!(s, "- agentsec-core: `{}`", report.version);
331 let _ = writeln!(s);
332 let _ = writeln!(s, "## Paths");
333 let _ = writeln!(s);
334 let p = &report.paths;
335 let _ = writeln!(s, "- home: `{}`", p.home.display());
336 let _ = writeln!(s, "- user_home: `{}`", p.user_home.display());
337 let _ = writeln!(s, "- snapshots: `{}`", p.snapshots.display());
338 let _ = writeln!(s, "- scans: `{}`", p.scans.display());
339 let _ = writeln!(s, "- web_log: `{}`", p.web_log.display());
340 let _ = writeln!(s, "- paste_log: `{}`", p.paste_log.display());
341 let _ = writeln!(s);
342 let _ = writeln!(s, "## Environment");
343 let _ = writeln!(s);
344 let _ = writeln!(s, "| Key | Source | Value |");
345 let _ = writeln!(s, "|---|---|---|");
346 for ev in &report.env {
347 let source = render_env_source(&ev.source);
348 let value = ev.value_display.clone().unwrap_or_else(|| "—".into());
349 let _ = writeln!(s, "| `{}` | {source} | `{value}` |", ev.key);
350 }
351 s
352}
353
354fn render_env_source(src: &EnvSource) -> String {
355 match src {
356 EnvSource::Process => "process env".into(),
357 EnvSource::DotenvFile { path, line } => {
358 format!("dotenv: `{}:{}`", path.display(), line)
359 }
360 EnvSource::DotenvFileShadowed { path, line } => {
361 format!(
362 "dotenv `{}:{}` (shadowed by process env)",
363 path.display(),
364 line
365 )
366 }
367 EnvSource::Default(_) => "builtin default".into(),
368 EnvSource::Unset => "unset".into(),
369 }
370}
371
372pub fn render_status(report: &StatusReport) -> String {
374 let mut s = String::new();
375 let _ = writeln!(s, "# AgentSec — status");
376 let _ = writeln!(s);
377 let _ = writeln!(s, "| Metric | Value |");
378 let _ = writeln!(s, "|---|---|");
379 let _ = writeln!(s, "| snapshots | {} |", report.snapshots_count);
380 let _ = writeln!(
381 s,
382 "| latest snapshot | {} |",
383 report.latest_snapshot.as_deref().unwrap_or("—")
384 );
385 let _ = writeln!(s, "| paste_log entries | {} |", report.paste_log_count);
386 let _ = writeln!(s, "| web_log entries | {} |", report.web_log_count);
387 let _ = writeln!(
388 s,
389 "| plain mode active | {} |",
390 if report.plain_mode_active {
391 "yes"
392 } else {
393 "no"
394 }
395 );
396 let _ = writeln!(s, "| plain mode entries | {} |", report.plain_mode_entries);
397 let _ = writeln!(s, "| registry entries | {} |", report.registry_entries);
398
399 if !report.storage.is_empty() {
400 let _ = writeln!(s);
401 let _ = writeln!(s, "## Storage");
402 let _ = writeln!(s);
403 let _ = writeln!(s, "| Kind | Path | Count | Size | Latest |");
404 let _ = writeln!(s, "|---|---|---|---|---|");
405 for item in &report.storage {
406 let _ = writeln!(
407 s,
408 "| {} | `{}` | {} | {} | {} |",
409 item.kind,
410 item.path.display(),
411 item.count,
412 crate::diagnostics::format_size(item.size_bytes),
413 item.latest.as_deref().unwrap_or("—"),
414 );
415 }
416 }
417
418 s
419}
420
421pub fn render_recent(report: &RecentActivityReport) -> String {
423 let mut s = String::new();
424 let _ = writeln!(s, "# AgentSec — recent activity");
425 let _ = writeln!(s);
426 write_audit_section(&mut s, "paste_log", &report.paste);
427 write_audit_section(&mut s, "web_log", &report.web);
428 s
429}
430
431fn write_audit_section(s: &mut String, label: &str, rows: &[crate::diagnostics::AuditTail]) {
432 let _ = writeln!(s, "## {label} ({} rows)", rows.len());
433 let _ = writeln!(s);
434 if rows.is_empty() {
435 let _ = writeln!(s, "_empty._");
436 let _ = writeln!(s);
437 return;
438 }
439 for row in rows {
440 let _ = writeln!(s, "### `{}` ({} bytes)", row.filename, row.size);
441 let _ = writeln!(s);
442 let _ = writeln!(s, "```");
443 let _ = writeln!(s, "{}", row.excerpt);
444 let _ = writeln!(s, "```");
445 let _ = writeln!(s);
446 }
447}
448
449pub fn render_doctor(report: &DoctorReport) -> String {
451 let mut s = String::new();
452 let _ = writeln!(s, "# AgentSec — doctor");
453 let _ = writeln!(s);
454 let mut pass = 0usize;
455 let mut warn = 0usize;
456 let mut fail = 0usize;
457 for c in &report.checks {
458 match c.status {
459 DoctorStatus::Pass => pass += 1,
460 DoctorStatus::Warn => warn += 1,
461 DoctorStatus::Fail => fail += 1,
462 }
463 }
464 let _ = writeln!(
465 s,
466 "Summary: **{pass} PASS / {warn} WARN / {fail} FAIL** ({} checks)",
467 report.checks.len()
468 );
469 let _ = writeln!(s);
470 let _ = writeln!(s, "| Status | Check | Detail |");
471 let _ = writeln!(s, "|---|---|---|");
472 for c in &report.checks {
473 let badge = match c.status {
474 DoctorStatus::Pass => "✅ PASS",
475 DoctorStatus::Warn => "⚠️ WARN",
476 DoctorStatus::Fail => "❌ FAIL",
477 };
478 let detail = c.message.replace('\n', " ").replace('|', "\\|");
479 let _ = writeln!(s, "| {badge} | {} | {detail} |", c.name);
480 }
481 s
482}
483
484pub fn diff_summary_line(diff: &DiffReport) -> String {
499 if diff.is_empty() {
500 return "no changes".to_string();
501 }
502 let mut parts = Vec::new();
503 if !diff.added.is_empty() {
504 parts.push(format!("{} added", diff.added.len()));
505 }
506 if !diff.modified.is_empty() {
507 parts.push(format!("{} modified", diff.modified.len()));
508 }
509 if !diff.removed.is_empty() {
510 parts.push(format!("{} removed", diff.removed.len()));
511 }
512 parts.join(", ")
513}
514
515fn write_footer(s: &mut String, outcome: &ScanOutcome) {
516 let _ = writeln!(
517 s,
518 "_snapshot written to: `{}`_",
519 outcome.snapshot_path.display()
520 );
521}