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