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