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    /// Surface explanatory context to the model on a non-approval *without*
34    /// changing the permission decision (the command still flows through the
35    /// tool's normal approval path, and the user's own allowlist still applies).
36    ///
37    /// The default abstains silently — same as today's empty deny body. A target
38    /// overrides this only when its hook schema has a verified field for
39    /// injecting model-visible context without a permission decision.
40    fn render_context(&self, _context: &str) -> HookResponse {
41        HookResponse {
42            stdout: String::new(),
43            exit_code: 0,
44        }
45    }
46}
47
48#[derive(Debug)]
49pub struct ParseError {
50    pub message: String,
51}
52
53impl std::fmt::Display for ParseError {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        f.write_str(&self.message)
56    }
57}
58
59impl std::error::Error for ParseError {}
60
61pub struct HookInput {
62    pub command: String,
63    pub cwd: Option<String>,
64}
65
66pub struct HookResponse {
67    pub stdout: String,
68    pub exit_code: i32,
69}
70
71pub enum InstallOutcome {
72    Installed { path: PathBuf },
73    AlreadyConfigured { path: PathBuf },
74    Skipped { reason: String },
75}
76
77impl InstallOutcome {
78    pub fn message(&self, target_display: &str) -> String {
79        match self {
80            InstallOutcome::Installed { path } => {
81                format!("{target_display}: installed → {}", path.display())
82            }
83            InstallOutcome::AlreadyConfigured { path } => {
84                format!("{target_display}: already configured at {}", path.display())
85            }
86            InstallOutcome::Skipped { reason } => {
87                format!("{target_display}: skipped — {reason}")
88            }
89        }
90    }
91}
92
93pub fn registry() -> Vec<Box<dyn Target>> {
94    vec![
95        Box::new(claude::ClaudeTarget),
96        Box::new(codex::CodexTarget),
97        Box::new(cursor::CursorTarget),
98        Box::new(gemini::GeminiTarget),
99        Box::new(copilot::CopilotTarget),
100        Box::new(qwen::QwenTarget),
101        Box::new(droid::DroidTarget),
102        Box::new(opencode::OpenCodeTarget),
103    ]
104}
105
106pub fn find(name: &str) -> Option<Box<dyn Target>> {
107    registry().into_iter().find(|t| t.name() == name)
108}
109
110pub fn detect_installed(home: &Path) -> Vec<Box<dyn Target>> {
111    registry()
112        .into_iter()
113        .filter(|t| t.detect_paths(home).iter().any(|p| p.exists()))
114        .collect()
115}
116
117pub fn allow_reason(verdict: Verdict) -> &'static str {
118    match verdict {
119        Verdict::Allowed(SafetyLevel::SafeWrite) => {
120            "All commands in chain are safe utilities (includes file writes)"
121        }
122        Verdict::Allowed(SafetyLevel::SafeRead) => {
123            "All commands in chain are safe utilities (includes code execution)"
124        }
125        _ => "All commands in chain are safe utilities",
126    }
127}