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