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