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 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 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 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 #[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 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 session_id: None,
123 })
124 }
125
126 fn decision_pointer(&self) -> &'static str {
127 "/hookSpecificOutput/permissionDecision" }
129
130 fn render_response(&self, verdict: Verdict) -> HookResponse {
131 if verdict.is_allowed() {
132 let reason = allow_reason(verdict);
133 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 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 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 const EXE: &str = "/opt/homebrew/bin/safe-chains";
207
208 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 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 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 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 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}