Skip to main content

safe_chains/targets/
mod.rs

1use 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    /// Surface explanatory context to the model on a non-approval *without*
35    /// changing the permission decision (the command still flows through the
36    /// tool's normal approval path, and the user's own allowlist still applies).
37    ///
38    /// The default abstains silently — same as today's empty deny body. A target
39    /// overrides this only when its hook schema has a verified field for
40    /// injecting model-visible context without a permission decision.
41    fn render_context(&self, _context: &str) -> HookResponse {
42        HookResponse {
43            stdout: String::new(),
44            exit_code: 0,
45        }
46    }
47
48    /// How this harness's hook must handle a GATED command (one safe-chains does not auto-approve),
49    /// derived from its capabilities (`docs/design/harness-capability-model.md`):
50    /// - `Defer` — stay silent; the harness's own per-command human review is the check (Claude).
51    /// - `Deny` — veto it; the harness has no human review and no escalate (Codex).
52    /// - `Ask` — escalate to an in-the-moment human prompt (Antigravity's `ask`).
53    fn gated_policy(&self) -> GatedPolicy {
54        GatedPolicy::Defer
55    }
56
57    /// The hook output that VETOES a gated command, for a `Deny` harness. Default abstains (so a
58    /// stray call can't fail open). The shape must be exactly what the harness supports, or a
59    /// harness that "continues on malformed output" (e.g. Codex) fails open.
60    fn render_deny(&self, _reason: &str) -> HookResponse {
61        HookResponse {
62            stdout: String::new(),
63            exit_code: 0,
64        }
65    }
66
67    /// The hook output that ESCALATES a gated command to a human prompt, for an `Ask` harness.
68    /// Default abstains. (Antigravity fails CLOSED on a malformed/absent decision, so an Ask target
69    /// must always emit a valid decision.)
70    fn render_ask(&self, _reason: &str) -> HookResponse {
71        HookResponse {
72            stdout: String::new(),
73            exit_code: 0,
74        }
75    }
76}
77
78/// How a harness's hook handles a gated command — see `HookFormat::gated_policy`.
79#[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    /// The project root, when the harness supplies one (HP-19) — a `*_PROJECT_DIR` env var
103    /// for most, `workspace_roots` in the payload for cursor. Absent for codex/copilot.
104    pub root: Option<String>,
105}
106
107/// Read a harness project-root env var from the hook process environment (set by the
108/// harness, not the agent's shell — see HARNESS-BEHAVIORS.md). Empty → `None`.
109pub(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}