Skip to main content

safe_chains/targets/
droid.rs

1use std::path::{Path, PathBuf};
2
3use serde::Deserialize;
4use serde_json::{Map, Value, json};
5
6use super::{HookFormat, HookInput, HookResponse, InstallOutcome, ParseError, Target, allow_reason};
7use crate::verdict::Verdict;
8
9pub struct DroidTarget;
10
11impl Target for DroidTarget {
12    fn name(&self) -> &'static str {
13        "droid"
14    }
15
16    fn display_name(&self) -> &'static str {
17        "Factory Droid"
18    }
19
20    #[cfg(test)]
21    fn sample_envelope(&self, tool: &str, command: &str) -> Option<String> {
22        Some(format!(r#"{{"tool_name":"{tool}","tool_input":{{"command":"{command}"}}}}"#))
23    }
24
25    fn shell_tool_name(&self) -> &'static str {
26        "Execute" // droid's shell tool is not `Bash`
27    }
28
29    fn detect_paths(&self, home: &Path) -> Vec<PathBuf> {
30        vec![home.join(".factory")]
31    }
32
33    fn install(&self, home: &Path) -> Result<InstallOutcome, String> {
34        let exe = std::env::current_exe().ok().and_then(|p| p.canonicalize().ok());
35        self.install_with(home, exe.as_deref())
36    }
37
38    fn hook_format(&self) -> Option<&dyn HookFormat> {
39        Some(&DroidHookFormat)
40    }
41}
42
43impl DroidTarget {
44    /// `install`'s body with the hook binary's path injected: the real install passes the
45    /// canonicalized `current_exe` (falling back to the bare invocation when discovery fails),
46    /// while the tests pass a fixed path — under `cargo test` the running binary is
47    /// `safe_chains-<hash>`, which `has_safe_chains_hook` only recognizes when some directory in
48    /// the checkout's path is literally named "safe-chains".
49    fn install_with(&self, home: &Path, exe: Option<&Path>) -> Result<InstallOutcome, String> {
50        let dir = home.join(".factory");
51        if !dir.exists() {
52            return Ok(InstallOutcome::Skipped {
53                reason: format!("~/.factory not found at {} (Factory Droid not installed)", dir.display()),
54            });
55        }
56
57        let path = dir.join("settings.json");
58        // Droid docs require absolute paths for hook commands. The
59        // caller resolves the running binary's absolute path and we
60        // embed it in the config; falls back to bare "safe-chains
61        // hook droid" if discovery fails (and the install message warns).
62        let resolved = exe
63            .map(|p| format!("{} hook droid", p.display()))
64            .unwrap_or_else(|| "safe-chains hook droid".to_string());
65        let binary = resolved.as_str();
66
67        if path.exists() {
68            let contents = std::fs::read_to_string(&path).map_err(|e| format!("Could not read {}: {e}", path.display()))?;
69            let mut settings: Value = serde_json::from_str(&contents).map_err(|e| format!("Could not parse {}: {e}", path.display()))?;
70
71            if has_safe_chains_hook(&settings) {
72                return Ok(InstallOutcome::AlreadyConfigured { path });
73            }
74
75            add_hook(&mut settings, binary)?;
76            let output = serde_json::to_string_pretty(&settings).expect("serializing valid JSON");
77            std::fs::write(&path, format!("{output}\n")).map_err(|e| format!("Could not write {}: {e}", path.display()))?;
78            Ok(InstallOutcome::Installed { path })
79        } else {
80            let mut settings = Value::Object(Map::new());
81            add_hook(&mut settings, binary)?;
82            let output = serde_json::to_string_pretty(&settings).expect("serializing valid JSON");
83            std::fs::write(&path, format!("{output}\n")).map_err(|e| format!("Could not write {}: {e}", path.display()))?;
84            Ok(InstallOutcome::Installed { path })
85        }
86    }
87}
88
89struct DroidHookFormat;
90
91#[derive(Deserialize)]
92struct ToolInput {
93    command: String,
94}
95
96#[derive(Deserialize)]
97struct DroidHookEnvelope {
98    /// Optional so a harness that omits it still works; when present and naming another tool we
99    /// abstain (see parse_input).
100    #[serde(default)]
101    tool_name: Option<String>,
102    tool_input: ToolInput,
103    #[serde(default)]
104    cwd: Option<String>,
105}
106
107impl HookFormat for DroidHookFormat {
108    fn parse_input(&self, stdin: &str) -> Result<HookInput, ParseError> {
109        let envelope: DroidHookEnvelope = serde_json::from_str(stdin).map_err(|e| ParseError { message: e.to_string() })?;
110        // Self-filter on the tool: the hook can be delivered for a non-shell call by a
111        // hand-edited matcher, and deciding on one grants or vetoes a tool never analysed.
112        if let Some(name) = &envelope.tool_name
113            && name != "Execute"
114        {
115            return Err(ParseError { message: format!("not a shell tool: {name}") });
116        }
117        Ok(HookInput {
118            command: envelope.tool_input.command,
119            cwd: envelope.cwd,
120            root: super::env_root("FACTORY_PROJECT_DIR"),
121            // No scratchpad layout researched for this harness yet (see docs/design/agent-scratchpad.md).
122            session_id: None,
123        })
124    }
125
126    fn decision_pointer(&self) -> &'static str {
127        "/hookSpecificOutput/permissionDecision" // mirrors Claude's nesting
128    }
129
130    fn render_response(&self, verdict: Verdict) -> HookResponse {
131        if verdict.is_allowed() {
132            let reason = allow_reason(verdict);
133            // Droid mirrors Claude Code's hookSpecificOutput envelope.
134            let body = json!({
135                "hookSpecificOutput": {
136                    "hookEventName": "PreToolUse",
137                    "permissionDecision": "allow",
138                    "permissionDecisionReason": reason,
139                }
140            });
141            HookResponse { stdout: serde_json::to_string(&body).unwrap_or_default(), exit_code: 0 }
142        } else {
143            HookResponse { stdout: String::new(), exit_code: 0 }
144        }
145    }
146
147    fn render_context(&self, context: &str) -> HookResponse {
148        // Droid mirrors Claude Code's hookSpecificOutput envelope, including
149        // additionalContext (injects model-visible text, no permission decision).
150        let body = json!({
151            "hookSpecificOutput": {
152                "hookEventName": "PreToolUse",
153                "additionalContext": context,
154            }
155        });
156        HookResponse { stdout: serde_json::to_string(&body).unwrap_or_default(), exit_code: 0 }
157    }
158}
159
160fn hook_entry(binary: &str) -> Value {
161    // Droid's bash tool name is `Execute`, not `Bash`. timeout is in
162    // seconds (different from Qwen/Gemini ms).
163    json!({
164        "matcher": "Execute",
165        "hooks": [{
166            "type": "command",
167            "command": binary,
168            "timeout": 60,
169        }]
170    })
171}
172
173fn has_safe_chains_hook(settings: &Value) -> bool {
174    settings
175        .get("hooks")
176        .and_then(|h| h.get("PreToolUse"))
177        .and_then(|arr| arr.as_array())
178        .is_some_and(|entries| {
179            entries.iter().any(|entry| {
180                entry.get("hooks").and_then(|h| h.as_array()).is_some_and(|hooks| {
181                    hooks
182                        .iter()
183                        .any(|hook| hook.get("command").and_then(|c| c.as_str()).is_some_and(|cmd| cmd.contains("safe-chains")))
184                })
185            })
186        })
187}
188
189fn add_hook(settings: &mut Value, binary: &str) -> Result<(), String> {
190    super::append_hook_entry(settings, "hooks", "PreToolUse", hook_entry(binary))
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::verdict::SafetyLevel;
197
198    fn target() -> DroidTarget {
199        DroidTarget
200    }
201
202    /// Fixed hook-binary path for the install tests, so they pass wherever the checkout lives:
203    /// under `cargo test` the running binary is `safe_chains-<hash>`, which `has_safe_chains_hook`
204    /// only recognizes when some directory in the checkout's path is literally named
205    /// "safe-chains".
206    const EXE: &str = "/opt/homebrew/bin/safe-chains";
207
208    /// Verbatim shape from the Factory Droid hooks reference. Note
209    /// tool_name is "Execute", not "Bash".
210    const DROID_DOCS_SAMPLE: &str = r#"{
211        "session_id": "abc123",
212        "transcript_path": "/Users/me/.factory/projects/p/uuid.jsonl",
213        "cwd": "/Users/me/project",
214        "permission_mode": "off",
215        "hook_event_name": "PreToolUse",
216        "tool_name": "Execute",
217        "tool_input": {"command": "ls -la"}
218    }"#;
219
220    #[test]
221    fn install_no_factory_dir_skips() {
222        let dir = tempfile::tempdir().unwrap();
223        let outcome = target().install(dir.path()).unwrap();
224        assert!(matches!(outcome, InstallOutcome::Skipped { .. }));
225    }
226
227    #[test]
228    fn install_creates_settings_file() {
229        let dir = tempfile::tempdir().unwrap();
230        std::fs::create_dir(dir.path().join(".factory")).unwrap();
231        let outcome = target().install_with(dir.path(), Some(Path::new(EXE))).unwrap();
232        assert!(matches!(outcome, InstallOutcome::Installed { .. }));
233        let contents = std::fs::read_to_string(dir.path().join(".factory/settings.json")).unwrap();
234        let settings: Value = serde_json::from_str(&contents).unwrap();
235        assert!(has_safe_chains_hook(&settings));
236    }
237
238    #[test]
239    fn install_uses_execute_matcher_not_bash() {
240        // Droid's bash tool is `Execute` — wiring a `Bash` matcher
241        // wouldn't fire on shell calls.
242        let dir = tempfile::tempdir().unwrap();
243        std::fs::create_dir(dir.path().join(".factory")).unwrap();
244        target().install(dir.path()).unwrap();
245        let contents = std::fs::read_to_string(dir.path().join(".factory/settings.json")).unwrap();
246        assert!(contents.contains("\"matcher\": \"Execute\""));
247    }
248
249    #[test]
250    fn install_uses_absolute_path_to_binary() {
251        // Droid docs explicitly require absolute paths for hook
252        // commands. We resolve via env::current_exe.
253        let dir = tempfile::tempdir().unwrap();
254        std::fs::create_dir(dir.path().join(".factory")).unwrap();
255        target().install(dir.path()).unwrap();
256        let contents = std::fs::read_to_string(dir.path().join(".factory/settings.json")).unwrap();
257        let settings: Value = serde_json::from_str(&contents).unwrap();
258        let cmd = settings.pointer("/hooks/PreToolUse/0/hooks/0/command").and_then(|s| s.as_str()).unwrap_or("");
259        // Either an absolute path or the fallback bare invocation.
260        assert!(cmd.starts_with('/') || cmd == "safe-chains hook droid", "unexpected command: {cmd}",);
261        assert!(cmd.ends_with(" hook droid") || cmd == "safe-chains hook droid");
262    }
263
264    #[test]
265    fn install_idempotent() {
266        let dir = tempfile::tempdir().unwrap();
267        std::fs::create_dir(dir.path().join(".factory")).unwrap();
268        target().install_with(dir.path(), Some(Path::new(EXE))).unwrap();
269        let outcome = target().install_with(dir.path(), Some(Path::new(EXE))).unwrap();
270        assert!(matches!(outcome, InstallOutcome::AlreadyConfigured { .. }));
271    }
272
273    #[test]
274    fn install_idempotent_outside_a_safe_chains_directory() {
275        // The regression this file's fix is for: detection must survive a binary path in which no
276        // DIRECTORY is named "safe-chains" — the FILE name alone carries it. Under `cargo test`
277        // the running binary is `safe_chains-<hash>` (underscore), so the real install only
278        // round-tripped when the checkout itself sat inside a "safe-chains" directory.
279        let dir = tempfile::tempdir().unwrap();
280        std::fs::create_dir(dir.path().join(".factory")).unwrap();
281        let exe = Path::new("/usr/local/bin/safe-chains");
282        let first = target().install_with(dir.path(), Some(exe)).unwrap();
283        assert!(matches!(first, InstallOutcome::Installed { .. }));
284        let second = target().install_with(dir.path(), Some(exe)).unwrap();
285        assert!(matches!(second, InstallOutcome::AlreadyConfigured { .. }));
286    }
287
288    #[test]
289    fn parse_input_extracts_command() {
290        let parsed = DroidHookFormat.parse_input(DROID_DOCS_SAMPLE).unwrap();
291        assert_eq!(parsed.command, "ls -la");
292        assert_eq!(parsed.cwd.as_deref(), Some("/Users/me/project"));
293    }
294
295    #[test]
296    fn parse_input_rejects_garbage() {
297        assert!(DroidHookFormat.parse_input("not json").is_err());
298        assert!(DroidHookFormat.parse_input("{}").is_err());
299    }
300
301    #[test]
302    fn render_response_emits_claude_shaped_envelope() {
303        let r = DroidHookFormat.render_response(Verdict::Allowed(SafetyLevel::Inert));
304        let v: Value = serde_json::from_str(&r.stdout).unwrap();
305        assert_eq!(v.pointer("/hookSpecificOutput/permissionDecision").and_then(|d| d.as_str()), Some("allow"),);
306    }
307
308    #[test]
309    fn render_response_deny_emits_empty_body() {
310        let r = DroidHookFormat.render_response(Verdict::Denied);
311        assert_eq!(r.stdout, "");
312    }
313}