Skip to main content

safe_chains/targets/
mod.rs

1use std::path::{Path, PathBuf};
2
3use crate::verdict::{SafetyLevel, Verdict};
4
5pub mod claude;
6pub mod codex;
7pub mod copilot;
8pub mod cursor;
9pub mod droid;
10pub mod gemini;
11pub mod opencode;
12pub mod qwen;
13
14pub trait Target: Send + Sync {
15    fn name(&self) -> &'static str;
16
17    fn display_name(&self) -> &'static str;
18
19    fn detect_paths(&self, home: &Path) -> Vec<PathBuf>;
20
21    fn install(&self, home: &Path) -> Result<InstallOutcome, String>;
22
23    fn hook_format(&self) -> Option<&dyn HookFormat> {
24        None
25    }
26}
27
28pub trait HookFormat: Send + Sync {
29    fn parse_input(&self, stdin: &str) -> Result<HookInput, ParseError>;
30
31    fn render_response(&self, verdict: Verdict) -> HookResponse;
32}
33
34#[derive(Debug)]
35pub struct ParseError {
36    pub message: String,
37}
38
39impl std::fmt::Display for ParseError {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        f.write_str(&self.message)
42    }
43}
44
45impl std::error::Error for ParseError {}
46
47pub struct HookInput {
48    pub command: String,
49    pub cwd: Option<String>,
50}
51
52pub struct HookResponse {
53    pub stdout: String,
54    pub exit_code: i32,
55}
56
57pub enum InstallOutcome {
58    Installed { path: PathBuf },
59    AlreadyConfigured { path: PathBuf },
60    Skipped { reason: String },
61}
62
63impl InstallOutcome {
64    pub fn message(&self, target_display: &str) -> String {
65        match self {
66            InstallOutcome::Installed { path } => {
67                format!("{target_display}: installed → {}", path.display())
68            }
69            InstallOutcome::AlreadyConfigured { path } => {
70                format!("{target_display}: already configured at {}", path.display())
71            }
72            InstallOutcome::Skipped { reason } => {
73                format!("{target_display}: skipped — {reason}")
74            }
75        }
76    }
77}
78
79pub fn registry() -> Vec<Box<dyn Target>> {
80    vec![
81        Box::new(claude::ClaudeTarget),
82        Box::new(codex::CodexTarget),
83        Box::new(cursor::CursorTarget),
84        Box::new(gemini::GeminiTarget),
85        Box::new(copilot::CopilotTarget),
86        Box::new(qwen::QwenTarget),
87        Box::new(droid::DroidTarget),
88        Box::new(opencode::OpenCodeTarget),
89    ]
90}
91
92pub fn find(name: &str) -> Option<Box<dyn Target>> {
93    registry().into_iter().find(|t| t.name() == name)
94}
95
96pub fn detect_installed(home: &Path) -> Vec<Box<dyn Target>> {
97    registry()
98        .into_iter()
99        .filter(|t| t.detect_paths(home).iter().any(|p| p.exists()))
100        .collect()
101}
102
103pub fn allow_reason(verdict: Verdict) -> &'static str {
104    match verdict {
105        Verdict::Allowed(SafetyLevel::SafeWrite) => {
106            "All commands in chain are safe utilities (includes file writes)"
107        }
108        Verdict::Allowed(SafetyLevel::SafeRead) => {
109            "All commands in chain are safe utilities (includes code execution)"
110        }
111        _ => "All commands in chain are safe utilities",
112    }
113}