Skip to main content

agent_config/
validation.rs

1//! Side-effect-free drift validation reports.
2//!
3//! Status answers what is present. Validation answers whether that state is
4//! internally consistent enough for a caller to repair or mutate safely.
5
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use crate::error::AgentConfigError;
10use crate::plan::PlanTarget;
11use crate::status::{DriftIssue, InstallStatus, StatusReport, StatusWarning};
12use crate::util::{fs_atomic, md_block, ownership};
13
14/// Validation result for one hook, MCP server, or skill target.
15#[must_use]
16#[derive(Debug, Clone)]
17#[non_exhaustive]
18pub struct ValidationReport {
19    /// What install target was validated.
20    pub target: PlanTarget,
21    /// True when validation found no drift issues.
22    pub ok: bool,
23    /// Concrete drift issues in deterministic order.
24    pub issues: Vec<DriftIssue>,
25    /// Suggested safe next actions derived from the issues.
26    pub suggested_actions: Vec<SuggestedAction>,
27}
28
29/// Suggested follow-up for a validation report.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31#[non_exhaustive]
32pub enum SuggestedAction {
33    /// Reinstall the missing config, directory, or file entry.
34    Reinstall,
35    /// Uninstall using the owner currently recorded in the ledger.
36    UninstallWithOwner,
37    /// Remove a stale ownership ledger entry.
38    RemoveLedgerEntry,
39    /// Remove an unowned config, directory, or file entry.
40    RemoveConfigEntry,
41    /// Restore a backup before retrying mutation.
42    RestoreBackup,
43    /// Stop and inspect the files manually.
44    ManualReview,
45    /// No follow-up is needed.
46    NoAction,
47}
48
49impl ValidationReport {
50    pub(crate) fn from_issues(target: PlanTarget, mut issues: Vec<DriftIssue>) -> Self {
51        sort_issues(&mut issues);
52        let ok = issues.is_empty();
53        let suggested_actions = suggested_actions_for(&issues);
54        Self {
55            target,
56            ok,
57            issues,
58            suggested_actions,
59        }
60    }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64enum Presence {
65    Present,
66    Absent,
67    Malformed,
68    Unknown,
69}
70
71pub(crate) fn hook_report_from_status(
72    target: PlanTarget,
73    status: StatusReport,
74) -> ValidationReport {
75    let mut issues = Vec::new();
76    match &status.status {
77        InstallStatus::Absent | InstallStatus::InstalledOwned { .. } => {}
78        InstallStatus::InstalledOtherOwner { owner } => {
79            let expected = match &target {
80                PlanTarget::Hook { tag, .. } => tag.clone(),
81                _ => String::new(),
82            };
83            push_issue(
84                &mut issues,
85                DriftIssue::OwnerMismatch {
86                    expected,
87                    actual: Some(owner.clone()),
88                    path: status.ledger_path.clone(),
89                },
90            );
91        }
92        InstallStatus::PresentUnowned => {
93            push_issue(
94                &mut issues,
95                DriftIssue::ConfigOnly {
96                    path: primary_path(&status),
97                },
98            );
99        }
100        InstallStatus::LedgerOnly { owner } => {
101            push_issue(
102                &mut issues,
103                DriftIssue::LedgerOnly {
104                    path: status
105                        .ledger_path
106                        .clone()
107                        .unwrap_or_else(|| primary_path(&status)),
108                    owner: Some(owner.clone()),
109                },
110            );
111        }
112        InstallStatus::Drifted { issues: drift } => {
113            push_mapped_issues(&mut issues, drift);
114        }
115        InstallStatus::Unknown => {
116            push_issue(
117                &mut issues,
118                DriftIssue::UnexpectedDirectoryShape {
119                    path: primary_path(&status),
120                    reason: "status probe returned unknown".into(),
121                },
122            );
123        }
124    }
125    add_hook_ledger_issues(&mut issues, &target, &status);
126    add_markdown_fence_issues(&mut issues, &target, &status);
127    add_backup_issues(&mut issues, &status);
128    ValidationReport::from_issues(target, issues)
129}
130
131pub(crate) fn ledger_backed_report_from_status(
132    target: PlanTarget,
133    name: &str,
134    expected_owner: Option<&str>,
135    status: StatusReport,
136) -> Result<ValidationReport, AgentConfigError> {
137    let mut issues = ledger_backed_issues(name, expected_owner, &status)?;
138    add_backup_issues(&mut issues, &status);
139    Ok(ValidationReport::from_issues(target, issues))
140}
141
142pub(crate) fn skill_report_from_status(
143    target: PlanTarget,
144    name: &str,
145    expected_owner: Option<&str>,
146    status: StatusReport,
147) -> Result<ValidationReport, AgentConfigError> {
148    let mut issues = ledger_backed_issues(name, expected_owner, &status)?;
149    add_skill_shape_issues(&mut issues, &status)?;
150    add_backup_issues(&mut issues, &status);
151    Ok(ValidationReport::from_issues(target, issues))
152}
153
154pub(crate) fn malformed_ledger_report(
155    target: PlanTarget,
156    path: PathBuf,
157    reason: String,
158) -> ValidationReport {
159    ValidationReport::from_issues(target, vec![DriftIssue::MalformedLedger { path, reason }])
160}
161
162fn ledger_backed_issues(
163    name: &str,
164    expected_owner: Option<&str>,
165    status: &StatusReport,
166) -> Result<Vec<DriftIssue>, AgentConfigError> {
167    let mut issues = Vec::new();
168    if let InstallStatus::Drifted { issues: drift } = &status.status {
169        push_mapped_issues(&mut issues, drift);
170    }
171
172    let presence = presence_from_status(status);
173    let mut owner = None;
174    let mut ledger_malformed = false;
175
176    if let Some(ledger_path) = status.ledger_path.as_ref() {
177        match ownership::read_strict(ledger_path)? {
178            ownership::StrictLedgerRead::Missing => {}
179            ownership::StrictLedgerRead::Valid { entries } => {
180                owner = entries.get(name).cloned();
181            }
182            ownership::StrictLedgerRead::Malformed { reason } => {
183                ledger_malformed = true;
184                push_issue(
185                    &mut issues,
186                    DriftIssue::MalformedLedger {
187                        path: ledger_path.clone(),
188                        reason,
189                    },
190                );
191            }
192        }
193    }
194
195    if presence == Presence::Malformed || ledger_malformed {
196        return Ok(issues);
197    }
198
199    match (presence, owner.as_ref()) {
200        (Presence::Present, None) => {
201            push_issue(
202                &mut issues,
203                DriftIssue::ConfigOnly {
204                    path: primary_path(status),
205                },
206            );
207        }
208        (Presence::Absent, Some(owner)) => {
209            push_issue(
210                &mut issues,
211                DriftIssue::LedgerOnly {
212                    path: status
213                        .ledger_path
214                        .clone()
215                        .unwrap_or_else(|| primary_path(status)),
216                    owner: Some(owner.owner.clone()),
217                },
218            );
219        }
220        _ => {}
221    }
222
223    if let (Some(expected), Some(actual)) =
224        (expected_owner, owner.as_ref().map(|e| e.owner.as_str()))
225    {
226        if expected != actual {
227            push_issue(
228                &mut issues,
229                DriftIssue::OwnerMismatch {
230                    expected: expected.to_string(),
231                    actual: Some(actual.to_string()),
232                    path: status.ledger_path.clone(),
233                },
234            );
235        }
236    }
237
238    Ok(issues)
239}
240
241fn presence_from_status(status: &StatusReport) -> Presence {
242    match &status.status {
243        InstallStatus::InstalledOwned { .. }
244        | InstallStatus::InstalledOtherOwner { .. }
245        | InstallStatus::PresentUnowned => Presence::Present,
246        InstallStatus::Absent | InstallStatus::LedgerOnly { .. } => Presence::Absent,
247        InstallStatus::Drifted { issues } => {
248            if issues.iter().any(|issue| {
249                matches!(
250                    issue,
251                    DriftIssue::InvalidConfig { .. } | DriftIssue::MalformedConfig { .. }
252                )
253            }) {
254                Presence::Malformed
255            } else if issues.iter().any(|issue| {
256                matches!(
257                    issue,
258                    DriftIssue::MultipleEntries { .. }
259                        | DriftIssue::SkillIncomplete { .. }
260                        | DriftIssue::SkillMissingSkillMd { .. }
261                        | DriftIssue::UnexpectedDirectoryShape { .. }
262                        | DriftIssue::SkillAssetEscapesRoot { .. }
263                )
264            }) {
265                Presence::Present
266            } else {
267                Presence::Unknown
268            }
269        }
270        InstallStatus::Unknown => Presence::Unknown,
271    }
272}
273
274fn push_mapped_issues(out: &mut Vec<DriftIssue>, drift: &[DriftIssue]) {
275    for issue in drift {
276        match issue {
277            DriftIssue::InvalidConfig { path, reason } => {
278                push_issue(
279                    out,
280                    DriftIssue::MalformedConfig {
281                        path: path.clone(),
282                        reason: reason.clone(),
283                    },
284                );
285            }
286            DriftIssue::SkillIncomplete { dir, missing } => {
287                push_issue(
288                    out,
289                    DriftIssue::SkillMissingSkillMd {
290                        dir: dir.clone(),
291                        missing: missing.clone(),
292                    },
293                );
294            }
295            other => push_issue(out, other.clone()),
296        }
297    }
298}
299
300fn add_hook_ledger_issues(
301    issues: &mut Vec<DriftIssue>,
302    target: &PlanTarget,
303    status: &StatusReport,
304) {
305    let (PlanTarget::Hook { tag, .. }, Some(ledger_path)) = (target, status.ledger_path.as_ref())
306    else {
307        return;
308    };
309    match ownership::read_strict(ledger_path) {
310        Ok(ownership::StrictLedgerRead::Missing) => {}
311        Ok(ownership::StrictLedgerRead::Malformed { reason }) => {
312            push_issue(
313                issues,
314                DriftIssue::MalformedLedger {
315                    path: ledger_path.clone(),
316                    reason,
317                },
318            );
319        }
320        Ok(ownership::StrictLedgerRead::Valid { entries }) => {
321            let Some(hooks_dir) = ledger_path.parent() else {
322                return;
323            };
324            for (filename, entry) in entries {
325                if entry.owner != *tag {
326                    continue;
327                }
328                let script = hooks_dir.join(&filename);
329                if !script.exists() {
330                    push_issue(
331                        issues,
332                        DriftIssue::LedgerOnly {
333                            path: ledger_path.clone(),
334                            owner: Some(entry.owner.clone()),
335                        },
336                    );
337                    continue;
338                }
339                add_unix_executable_issue(issues, &script);
340            }
341        }
342        Err(e) => {
343            push_issue(
344                issues,
345                DriftIssue::MalformedLedger {
346                    path: ledger_path.clone(),
347                    reason: e.to_string(),
348                },
349            );
350        }
351    }
352}
353
354fn add_markdown_fence_issues(
355    issues: &mut Vec<DriftIssue>,
356    target: &PlanTarget,
357    status: &StatusReport,
358) {
359    let PlanTarget::Hook { tag, .. } = target else {
360        return;
361    };
362    let path = primary_path(status);
363    let text = match fs_atomic::read_to_string_or_empty(&path) {
364        Ok(text) => text,
365        Err(e) => {
366            push_issue(
367                issues,
368                DriftIssue::MalformedConfig {
369                    path,
370                    reason: format!("could not read hook markdown: {e}"),
371                },
372            );
373            return;
374        }
375    };
376    if md_block::malformed(&text, tag) {
377        push_issue(
378            issues,
379            DriftIssue::MalformedConfig {
380                path,
381                reason: "malformed agent-config markdown fence".into(),
382            },
383        );
384    }
385}
386
387#[cfg(unix)]
388fn add_unix_executable_issue(issues: &mut Vec<DriftIssue>, path: &Path) {
389    use std::os::unix::fs::PermissionsExt;
390
391    match fs::metadata(path) {
392        Ok(metadata) if metadata.permissions().mode() & 0o111 == 0 => {
393            push_issue(
394                issues,
395                DriftIssue::UnexpectedDirectoryShape {
396                    path: path.to_path_buf(),
397                    reason: "hook script is not executable".into(),
398                },
399            );
400        }
401        Ok(_) => {}
402        Err(e) => {
403            push_issue(
404                issues,
405                DriftIssue::UnexpectedDirectoryShape {
406                    path: path.to_path_buf(),
407                    reason: format!("could not inspect hook script permissions: {e}"),
408                },
409            );
410        }
411    }
412}
413
414#[cfg(not(unix))]
415fn add_unix_executable_issue(_issues: &mut Vec<DriftIssue>, _path: &Path) {}
416
417fn add_skill_shape_issues(
418    issues: &mut Vec<DriftIssue>,
419    status: &StatusReport,
420) -> Result<(), AgentConfigError> {
421    let dir = primary_path(status);
422    if !dir.exists() {
423        return Ok(());
424    }
425
426    let metadata = fs::symlink_metadata(&dir).map_err(|e| AgentConfigError::io(&dir, e))?;
427    if !metadata.is_dir() {
428        push_issue(
429            issues,
430            DriftIssue::UnexpectedDirectoryShape {
431                path: dir,
432                reason: "skill path exists but is not a directory".into(),
433            },
434        );
435        return Ok(());
436    }
437
438    let manifest = dir.join("SKILL.md");
439    if !manifest.is_file() {
440        push_issue(
441            issues,
442            DriftIssue::SkillMissingSkillMd {
443                dir: dir.clone(),
444                missing: manifest,
445            },
446        );
447    }
448
449    let canonical_root = match fs::canonicalize(&dir) {
450        Ok(path) => path,
451        Err(e) => {
452            push_issue(
453                issues,
454                DriftIssue::UnexpectedDirectoryShape {
455                    path: dir,
456                    reason: format!("could not canonicalize skill directory: {e}"),
457                },
458            );
459            return Ok(());
460        }
461    };
462    walk_skill_dir(&canonical_root, &canonical_root, issues)?;
463    Ok(())
464}
465
466fn walk_skill_dir(
467    dir: &Path,
468    canonical_root: &Path,
469    issues: &mut Vec<DriftIssue>,
470) -> Result<(), AgentConfigError> {
471    // `dir` is descended from `canonical_root` through real (non-symlink)
472    // directories, so non-symlink entries cannot escape — only canonicalize
473    // when an entry is a symlink.
474    for entry in fs::read_dir(dir).map_err(|e| AgentConfigError::io(dir, e))? {
475        let entry = entry.map_err(|e| AgentConfigError::io(dir, e))?;
476        let path = entry.path();
477        let metadata = fs::symlink_metadata(&path).map_err(|e| AgentConfigError::io(&path, e))?;
478
479        if metadata.file_type().is_symlink() {
480            match fs::canonicalize(&path) {
481                Ok(canonical) if !canonical.starts_with(canonical_root) => {
482                    push_issue(
483                        issues,
484                        DriftIssue::SkillAssetEscapesRoot {
485                            path: path.clone(),
486                            root: canonical_root.to_path_buf(),
487                        },
488                    );
489                }
490                Ok(_) => {}
491                Err(e) => {
492                    push_issue(
493                        issues,
494                        DriftIssue::UnexpectedDirectoryShape {
495                            path: path.clone(),
496                            reason: format!("could not canonicalize skill entry: {e}"),
497                        },
498                    );
499                }
500            }
501        }
502
503        if metadata.is_dir() && !metadata.file_type().is_symlink() {
504            walk_skill_dir(&path, canonical_root, issues)?;
505        }
506    }
507    Ok(())
508}
509
510fn add_backup_issues(issues: &mut Vec<DriftIssue>, status: &StatusReport) {
511    if let Some(config_path) = status.config_path.as_ref() {
512        let backup = fs_atomic::backup_path(config_path);
513        if backup.exists() {
514            let issue = if config_path.exists() {
515                DriftIssue::BackupCollision { path: backup }
516            } else {
517                DriftIssue::StaleBackup { path: backup }
518            };
519            push_issue(issues, issue);
520        }
521    }
522
523    for warning in &status.warnings {
524        if let StatusWarning::BackupExists { path } = warning {
525            let issue = if status.config_path.as_ref().is_some_and(|p| p.exists()) {
526                DriftIssue::BackupCollision { path: path.clone() }
527            } else {
528                DriftIssue::StaleBackup { path: path.clone() }
529            };
530            push_issue(issues, issue);
531        }
532    }
533}
534
535fn primary_path(status: &StatusReport) -> PathBuf {
536    status
537        .config_path
538        .clone()
539        .or_else(|| status.files.first().map(path_from_status))
540        .unwrap_or_default()
541}
542
543fn path_from_status(path_status: &crate::status::PathStatus) -> PathBuf {
544    match path_status {
545        crate::status::PathStatus::Missing { path }
546        | crate::status::PathStatus::Exists { path }
547        | crate::status::PathStatus::Invalid { path, .. } => path.clone(),
548    }
549}
550
551fn push_issue(issues: &mut Vec<DriftIssue>, issue: DriftIssue) {
552    if !issues.contains(&issue) {
553        issues.push(issue);
554    }
555}
556
557fn sort_issues(issues: &mut [DriftIssue]) {
558    // sort_by_cached_key extracts each key once instead of twice per comparison,
559    // and the stable sort preserves insertion order for fully-equal keys.
560    issues.sort_by_cached_key(|issue| (issue_rank(issue), issue_path(issue)));
561}
562
563fn issue_rank(issue: &DriftIssue) -> u8 {
564    match issue {
565        DriftIssue::MalformedConfig { .. } | DriftIssue::InvalidConfig { .. } => 0,
566        DriftIssue::MalformedLedger { .. } => 1,
567        DriftIssue::LedgerOnly { .. } => 2,
568        DriftIssue::ConfigOnly { .. } => 3,
569        DriftIssue::OwnerMismatch { .. } => 4,
570        DriftIssue::MultipleEntries { .. } => 5,
571        DriftIssue::UnexpectedDirectoryShape { .. } => 6,
572        DriftIssue::SkillMissingSkillMd { .. } | DriftIssue::SkillIncomplete { .. } => 7,
573        DriftIssue::SkillAssetEscapesRoot { .. } => 8,
574        DriftIssue::InstructionContentDrift { .. } => 9,
575        DriftIssue::BackupCollision { .. } => 10,
576        DriftIssue::MissingBackup { .. } => 11,
577        DriftIssue::StaleBackup { .. } => 12,
578        DriftIssue::UnsupportedButPresent { .. } => 13,
579    }
580}
581
582fn issue_path(issue: &DriftIssue) -> String {
583    let path = match issue {
584        DriftIssue::LedgerOnly { path, .. }
585        | DriftIssue::ConfigOnly { path }
586        | DriftIssue::MalformedConfig { path, .. }
587        | DriftIssue::MalformedLedger { path, .. }
588        | DriftIssue::BackupCollision { path }
589        | DriftIssue::MissingBackup { path }
590        | DriftIssue::StaleBackup { path }
591        | DriftIssue::UnexpectedDirectoryShape { path, .. }
592        | DriftIssue::SkillAssetEscapesRoot { path, .. }
593        | DriftIssue::UnsupportedButPresent { path }
594        | DriftIssue::InstructionContentDrift { path }
595        | DriftIssue::InvalidConfig { path, .. } => path.display().to_string(),
596        DriftIssue::OwnerMismatch { path, .. } => path
597            .as_ref()
598            .map(|p| p.display().to_string())
599            .unwrap_or_default(),
600        DriftIssue::SkillMissingSkillMd { missing, .. }
601        | DriftIssue::SkillIncomplete { missing, .. } => missing.display().to_string(),
602        DriftIssue::MultipleEntries { name, .. } => name.clone(),
603    };
604    path
605}
606
607fn suggested_actions_for(issues: &[DriftIssue]) -> Vec<SuggestedAction> {
608    let mut actions = Vec::new();
609    for issue in issues {
610        match issue {
611            DriftIssue::LedgerOnly { .. } => {
612                push_action(&mut actions, SuggestedAction::Reinstall);
613                push_action(&mut actions, SuggestedAction::RemoveLedgerEntry);
614            }
615            DriftIssue::ConfigOnly { .. } => {
616                push_action(&mut actions, SuggestedAction::RemoveConfigEntry);
617            }
618            DriftIssue::OwnerMismatch { .. } => {
619                push_action(&mut actions, SuggestedAction::UninstallWithOwner);
620            }
621            DriftIssue::BackupCollision { .. }
622            | DriftIssue::MissingBackup { .. }
623            | DriftIssue::StaleBackup { .. } => {
624                push_action(&mut actions, SuggestedAction::RestoreBackup);
625            }
626            DriftIssue::MalformedConfig { .. }
627            | DriftIssue::MalformedLedger { .. }
628            | DriftIssue::UnexpectedDirectoryShape { .. }
629            | DriftIssue::SkillMissingSkillMd { .. }
630            | DriftIssue::SkillAssetEscapesRoot { .. }
631            | DriftIssue::UnsupportedButPresent { .. }
632            | DriftIssue::InvalidConfig { .. }
633            | DriftIssue::SkillIncomplete { .. }
634            | DriftIssue::MultipleEntries { .. }
635            | DriftIssue::InstructionContentDrift { .. } => {
636                push_action(&mut actions, SuggestedAction::ManualReview);
637            }
638        }
639    }
640    if actions.is_empty() {
641        actions.push(SuggestedAction::NoAction);
642    }
643    actions
644}
645
646fn push_action(actions: &mut Vec<SuggestedAction>, action: SuggestedAction) {
647    if !actions.contains(&action) {
648        actions.push(action);
649    }
650}