safe_chains/targets/
mod.rs1use std::path::{Path, PathBuf};
2
3use crate::verdict::{SafetyLevel, Verdict};
4
5pub mod agy;
6pub mod claude;
7pub mod codex;
8pub mod copilot;
9pub mod cursor;
10pub mod droid;
11pub mod gemini;
12pub mod opencode;
13pub mod qwen;
14
15pub trait Target: Send + Sync {
16 fn name(&self) -> &'static str;
17
18 fn display_name(&self) -> &'static str;
19
20 fn detect_paths(&self, home: &Path) -> Vec<PathBuf>;
21
22 fn install(&self, home: &Path) -> Result<InstallOutcome, String>;
23
24 fn hook_format(&self) -> Option<&dyn HookFormat> {
25 None
26 }
27}
28
29pub trait HookFormat: Send + Sync {
30 fn parse_input(&self, stdin: &str) -> Result<HookInput, ParseError>;
31
32 fn render_response(&self, verdict: Verdict) -> HookResponse;
33
34 fn render_context(&self, _context: &str) -> HookResponse {
42 HookResponse {
43 stdout: String::new(),
44 exit_code: 0,
45 }
46 }
47
48 fn gated_policy(&self) -> GatedPolicy {
54 GatedPolicy::Defer
55 }
56
57 fn render_deny(&self, _reason: &str) -> HookResponse {
61 HookResponse {
62 stdout: String::new(),
63 exit_code: 0,
64 }
65 }
66
67 fn render_ask(&self, _reason: &str) -> HookResponse {
71 HookResponse {
72 stdout: String::new(),
73 exit_code: 0,
74 }
75 }
76}
77
78#[derive(Clone, Copy, PartialEq, Eq, Debug)]
80pub enum GatedPolicy {
81 Defer,
82 Deny,
83 Ask,
84}
85
86#[derive(Debug)]
87pub struct ParseError {
88 pub message: String,
89}
90
91impl std::fmt::Display for ParseError {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 f.write_str(&self.message)
94 }
95}
96
97impl std::error::Error for ParseError {}
98
99pub struct HookInput {
100 pub command: String,
101 pub cwd: Option<String>,
102 pub root: Option<String>,
105}
106
107pub(crate) fn env_root(var: &str) -> Option<String> {
110 std::env::var(var).ok().filter(|s| !s.is_empty())
111}
112
113pub struct HookResponse {
114 pub stdout: String,
115 pub exit_code: i32,
116}
117
118pub enum InstallOutcome {
119 Installed { path: PathBuf },
120 AlreadyConfigured { path: PathBuf },
121 Skipped { reason: String },
122}
123
124impl InstallOutcome {
125 pub fn message(&self, target_display: &str) -> String {
126 match self {
127 InstallOutcome::Installed { path } => {
128 format!("{target_display}: installed → {}", path.display())
129 }
130 InstallOutcome::AlreadyConfigured { path } => {
131 format!("{target_display}: already configured at {}", path.display())
132 }
133 InstallOutcome::Skipped { reason } => {
134 format!("{target_display}: skipped — {reason}")
135 }
136 }
137 }
138}
139
140pub fn registry() -> Vec<Box<dyn Target>> {
141 vec![
142 Box::new(claude::ClaudeTarget),
143 Box::new(codex::CodexTarget),
144 Box::new(agy::AntigravityTarget),
145 Box::new(cursor::CursorTarget),
146 Box::new(gemini::GeminiTarget),
147 Box::new(copilot::CopilotTarget),
148 Box::new(qwen::QwenTarget),
149 Box::new(droid::DroidTarget),
150 Box::new(opencode::OpenCodeTarget),
151 ]
152}
153
154pub fn find(name: &str) -> Option<Box<dyn Target>> {
155 registry().into_iter().find(|t| t.name() == name)
156}
157
158pub fn detect_installed(home: &Path) -> Vec<Box<dyn Target>> {
159 registry()
160 .into_iter()
161 .filter(|t| t.detect_paths(home).iter().any(|p| p.exists()))
162 .collect()
163}
164
165pub fn allow_reason(verdict: Verdict) -> &'static str {
166 match verdict {
167 Verdict::Allowed(SafetyLevel::SafeWrite) => {
168 "All commands in chain are safe utilities (includes file writes)"
169 }
170 Verdict::Allowed(SafetyLevel::SafeRead) => {
171 "All commands in chain are safe utilities (includes code execution)"
172 }
173 _ => "All commands in chain are safe utilities",
174 }
175}