safe_chains/targets/
droid.rs1use 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" }
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 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 #[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 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 session_id: None,
122 })
123 }
124
125 fn decision_pointer(&self) -> &'static str {
126 "/hookSpecificOutput/permissionDecision" }
128
129 fn render_response(&self, verdict: Verdict) -> HookResponse {
130 if verdict.is_allowed() {
131 let reason = allow_reason(verdict);
132 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 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 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 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 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 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 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}