From 38028b7ce514e1b0f506d4ba94ea0a2d0589d924 Mon Sep 17 00:00:00 2001 From: krimvp Date: Thu, 4 Jun 2026 17:00:25 +0200 Subject: [PATCH 1/7] feat(hooks): add Factory Droid integration Register RTK with Factory Droid so shell commands the agent runs are transparently rewritten to their token-saving `rtk` equivalents. - `rtk init --agent droid`: install a native PreToolUse hook into ~/.factory/settings.json (matcher "Execute"); supports global (-g) and project scope, the FACTORY_HOME override, idempotent install with backup + atomic write, and a clean uninstall round-trip. - `rtk hook droid`: process Droid's PreToolUse payload (BOM strip, stdin cap, JSON-parse fallback), mirroring the existing provider hooks. - Deny verdict steps aside (no output) so Droid's native deny handling fires, matching Claude/Cursor/Copilot and the PermissionVerdict::Deny contract. - Rewrites are auto-allowed: Droid only applies a hook's updatedInput when the decision is "allow", so we mirror run_cursor to avoid silently dropping the rewrite (and its savings) on the default verdict. --- README.md | 2 + src/hooks/constants.rs | 11 + src/hooks/hook_cmd.rs | 197 +++++++++++++++++ src/hooks/init.rs | 474 ++++++++++++++++++++++++++++++++++++++++- src/main.rs | 12 ++ 5 files changed, 695 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 193e4427db..dc11e8c85e 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ rtk init --agent kilocode # Kilo Code rtk init --agent antigravity # Google Antigravity rtk init -g --agent pi # Pi rtk init --agent hermes # Hermes +rtk init -g --agent droid # Factory Droid # 2. Restart your AI tool, then test git status # Automatically rewritten to rtk git status @@ -384,6 +385,7 @@ RTK supports 14 AI coding tools. Each integration rewrites shell commands to `rt | **Mistral Vibe** | Planned ([#800](https://github.com/rtk-ai/rtk/issues/800)) | Blocked on upstream | | **Kilo Code** | `rtk init --agent kilocode` | .kilocode/rules/rtk-rules.md (project-scoped) | | **Google Antigravity** | `rtk init --agent antigravity` | .agents/rules/antigravity-rtk-rules.md (project-scoped) | +| **Factory Droid** | `rtk init -g --agent droid` (or per-project) | PreToolUse hook in `~/.factory/settings.json` (matcher `Execute`) | For per-agent setup details, override controls, and graceful degradation, see the [Supported Agents guide](https://www.rtk-ai.app/guide/getting-started/supported-agents). The Hermes plugin source and tests live in `hooks/hermes/`; installed Hermes runtime files still live under `~/.hermes/plugins/rtk-rewrite/`. diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs index 23d2e10899..013d5a9812 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -12,6 +12,8 @@ pub const BEFORE_TOOL_KEY: &str = "BeforeTool"; pub const CLAUDE_HOOK_COMMAND: &str = "rtk hook claude"; /// Native Rust hook command for Cursor (replaces rtk-rewrite.sh). pub const CURSOR_HOOK_COMMAND: &str = "rtk hook cursor"; +/// Native Rust hook command for Factory Droid. +pub const DROID_HOOK_COMMAND: &str = "rtk hook droid"; pub const CONFIG_DIR: &str = ".config"; pub const OPENCODE_SUBDIR: &str = "opencode"; @@ -34,6 +36,15 @@ pub const PI_EXTENSIONS_SUBDIR: &str = "extensions"; pub const PI_PLUGIN_FILE: &str = "rtk.ts"; pub const PI_CODING_AGENT_DIR_ENV: &str = "PI_CODING_AGENT_DIR"; +/// Factory Droid config directory (overridable via FACTORY_HOME env var). +pub const DROID_DIR: &str = ".factory"; +/// Factory Droid settings file (where hooks live, per docs.factory.ai). +pub const DROID_SETTINGS_FILE: &str = "settings.json"; +/// Tool matcher used by Droid for shell command execution. +pub const DROID_EXECUTE_MATCHER: &str = "Execute"; +/// Environment variable Droid uses to override its config home directory. +pub const DROID_HOME_ENV: &str = "FACTORY_HOME"; + pub const HERMES_DIR: &str = ".hermes"; pub const HERMES_PLUGINS_SUBDIR: &str = "plugins"; pub const HERMES_PLUGIN_NAME: &str = "rtk-rewrite"; diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index ec63663d9e..b6539034d0 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -555,6 +555,101 @@ fn run_cursor_inner_with_rules( } } +// ── Factory Droid PreToolUse hook ────────────────────────────── +// +// Droid's PreToolUse payload (docs.factory.ai/reference/hooks-reference) is +// shaped like Claude Code's: `tool_name`, `tool_input.command`, and a +// `hookSpecificOutput` response with `permissionDecision` + `updatedInput`. +// Droid's shell-execution tool is matched as `Execute`; non-Execute payloads +// pass through silently. + +fn process_droid_payload(v: &Value) -> Option { + let tool_name = v.get("tool_name").and_then(|t| t.as_str()).unwrap_or(""); + // Accept both the documented `Execute` and the legacy `Bash` matchers so + // older Droid releases keep working. + if !matches!(tool_name, "Execute" | "Bash" | "") { + return None; + } + + let cmd = v + .pointer("/tool_input/command") + .and_then(|c| c.as_str()) + .filter(|c| !c.is_empty())?; + + let verdict = permissions::check_command(cmd); + droid_response_for_verdict(v, cmd, verdict) +} + +/// Build the Droid hook response for a known verdict. +/// +/// On `Deny` we step aside (emit no output) so Droid's native deny handling +/// fires, matching Claude/Cursor/Copilot and the `PermissionVerdict::Deny` +/// contract ("pass through to native deny handling"). RTK never emits its own +/// block here. +fn droid_response_for_verdict(v: &Value, cmd: &str, verdict: PermissionVerdict) -> Option { + if verdict == PermissionVerdict::Deny { + audit_log("deny", cmd, ""); + return None; + } + + let rewritten = get_rewritten(cmd)?; + + audit_log("rewrite", cmd, &rewritten); + + let updated_input = { + let mut ti = v.get("tool_input").cloned().unwrap_or_else(|| json!({})); + if let Some(obj) = ti.as_object_mut() { + obj.insert("command".into(), Value::String(rewritten)); + } + ti + }; + + // Droid (like Cursor) only applies a hook's `updatedInput` when the + // permission decision is "allow" — an "ask"/omitted decision makes Droid + // run the *original* command, so the rewrite (and its token savings) would + // be silently dropped. We already stepped aside on Deny above, so anything + // reaching here is safe to auto-allow in its rewritten `rtk …` form (it is + // the same command the agent chose, just wrapped). Mirrors `run_cursor`. + let decision = "allow"; + + Some(json!({ + "hookSpecificOutput": { + "hookEventName": PRE_TOOL_USE_KEY, + "permissionDecision": decision, + "permissionDecisionReason": "RTK auto-rewrite", + "updatedInput": updated_input + } + })) +} + +/// Run the Factory Droid PreToolUse hook natively. +pub fn run_droid() -> Result<()> { + let input = read_stdin_limited()?; + let input = strip_leading_bom(&input).trim(); + if input.is_empty() { + return Ok(()); + } + + let v: Value = match serde_json::from_str(input) { + Ok(v) => v, + Err(e) => { + let _ = writeln!(io::stderr(), "[rtk hook] Failed to parse JSON input: {e}"); + return Ok(()); + } + }; + + if let Some(output) = process_droid_payload(&v) { + let _ = writeln!(io::stdout(), "{output}"); + } + Ok(()) +} + +#[cfg(test)] +fn run_droid_inner(input: &str) -> Option { + let v: Value = serde_json::from_str(input).ok()?; + process_droid_payload(&v).map(|o| o.to_string()) +} + #[cfg(test)] mod tests { use super::*; @@ -1412,4 +1507,106 @@ mod tests { .unwrap(); assert_eq!(v["decision"], "deny"); } + + // --- Factory Droid hook --- + + fn droid_input(tool: &str, cmd: &str) -> String { + json!({ + "session_id": "abc123", + "hook_event_name": "PreToolUse", + "tool_name": tool, + "tool_input": { "command": cmd } + }) + .to_string() + } + + #[test] + fn test_droid_rewrites_execute_tool() { + let input = droid_input("Execute", "git status"); + let out = run_droid_inner(&input).expect("rewrite expected"); + let v: Value = serde_json::from_str(&out).unwrap(); + let updated = v + .pointer("/hookSpecificOutput/updatedInput/command") + .and_then(|c| c.as_str()) + .unwrap_or(""); + assert!( + updated.starts_with("rtk "), + "expected rtk-prefixed rewrite, got `{updated}`" + ); + assert_eq!( + v.pointer("/hookSpecificOutput/hookEventName") + .and_then(|c| c.as_str()), + Some("PreToolUse") + ); + // Default verdict (no rule) must still auto-allow: Droid only applies + // `updatedInput` when the decision is "allow", otherwise it runs the + // original command and the rewrite is silently dropped. + assert_eq!( + v.pointer("/hookSpecificOutput/permissionDecision") + .and_then(|c| c.as_str()), + Some("allow"), + "rewrites must be auto-allowed so Droid applies updatedInput" + ); + } + + #[test] + fn test_droid_ignores_non_execute_tool() { + // Droid fires PreToolUse for many tools (Edit, Create, Read…); we must + // only touch Execute (or legacy Bash) so other tools pass through. + let input = droid_input("Edit", "git status"); + assert!( + run_droid_inner(&input).is_none(), + "non-Execute tools must not produce output" + ); + } + + #[test] + fn test_droid_legacy_bash_matcher_still_works() { + let input = droid_input("Bash", "git status"); + assert!( + run_droid_inner(&input).is_some(), + "legacy Bash matcher should still rewrite" + ); + } + + #[test] + fn test_droid_deny_steps_aside() { + // A denied command must produce NO output so Droid's native deny + // handling fires — matching Claude/Cursor/Copilot. RTK must not emit + // its own `permissionDecision: deny` block (PermissionVerdict::Deny + // contract). Verdict is injected because check_command loads ambient + // rules that aren't present in the test environment. + let v: Value = serde_json::from_str(&droid_input("Execute", "git push --force")).unwrap(); + assert!( + droid_response_for_verdict(&v, "git push --force", PermissionVerdict::Deny).is_none(), + "deny must step aside (no output), not emit an RTK block" + ); + } + + #[test] + fn test_droid_allow_verdict_auto_allows() { + // An explicit allow rule auto-allows the rewritten command. + let v: Value = serde_json::from_str(&droid_input("Execute", "git status")).unwrap(); + let out = droid_response_for_verdict(&v, "git status", PermissionVerdict::Allow) + .expect("rewrite expected"); + assert_eq!( + out.pointer("/hookSpecificOutput/permissionDecision") + .and_then(|c| c.as_str()), + Some("allow") + ); + } + + #[test] + fn test_droid_empty_command_passthrough() { + let input = droid_input("Execute", ""); + assert!(run_droid_inner(&input).is_none()); + } + + #[test] + fn test_droid_no_rewrite_passthrough() { + // Commands rtk doesn't know about should not generate a hookSpecificOutput + // so Droid runs them unchanged. + let input = droid_input("Execute", "definitely-not-a-real-binary --foo"); + assert!(run_droid_inner(&input).is_none()); + } } diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 0f99edf6ee..c4a81f89d6 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -13,7 +13,8 @@ use crate::hooks::constants::{ }; use super::constants::{ - BEFORE_TOOL_KEY, CLAUDE_DIR, CLAUDE_HOOK_COMMAND, CODEX_DIR, CURSOR_HOOK_COMMAND, + BEFORE_TOOL_KEY, CLAUDE_DIR, CLAUDE_HOOK_COMMAND, CODEX_DIR, CURSOR_HOOK_COMMAND, DROID_DIR, + DROID_EXECUTE_MATCHER, DROID_HOME_ENV, DROID_HOOK_COMMAND, DROID_SETTINGS_FILE, GEMINI_HOOK_FILE, HERMES_DIR, HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_INIT_FILE, HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_JSON, HOOKS_SUBDIR, PI_CODING_AGENT_DIR_ENV, PI_DIR, PI_EXTENSIONS_SUBDIR, PI_LOCAL_DIR, PI_PLUGIN_FILE, @@ -2843,6 +2844,335 @@ fn resolve_hermes_home_from_env( .context("Cannot determine Hermes home directory. Set $HERMES_HOME or $HOME.") } +// ─── Factory Droid support ────────────────────────────────────────── + +/// Resolve Droid config directory, honouring `FACTORY_HOME` override. +/// +/// - Global: `$FACTORY_HOME` or `~/.factory`. +/// - Project: caller passes `.factory` relative to project root. +fn resolve_droid_dir() -> Result { + resolve_droid_dir_from_env(dirs::home_dir(), std::env::var_os(DROID_HOME_ENV)) +} + +fn resolve_droid_dir_from_env( + home_dir: Option, + factory_home: Option, +) -> Result { + if let Some(path) = factory_home.filter(|value| !value.is_empty()) { + return Ok(PathBuf::from(path)); + } + home_dir + .map(|home| home.join(DROID_DIR)) + .context("Cannot determine Droid config directory. Set $FACTORY_HOME or $HOME.") +} + +/// Install Factory Droid PreToolUse hook into `settings.json`. +/// +/// - Global (`-g`): writes to `~/.factory/settings.json` (or `$FACTORY_HOME/settings.json`). +/// - Project: writes to `/.factory/settings.json` so the hook can be committed. +pub fn run_droid_mode(global: bool, ctx: InitContext) -> Result<()> { + let droid_dir = if global { + resolve_droid_dir()? + } else { + std::env::current_dir() + .context("Failed to read current directory")? + .join(DROID_DIR) + }; + run_droid_mode_at(&droid_dir, global, ctx) +} + +fn run_droid_mode_at(droid_dir: &Path, global: bool, ctx: InitContext) -> Result<()> { + let InitContext { dry_run, .. } = ctx; + + if !dry_run { + fs::create_dir_all(droid_dir).with_context(|| { + format!( + "Failed to create Droid config directory: {}", + droid_dir.display() + ) + })?; + } + + let settings_path = droid_dir.join(DROID_SETTINGS_FILE); + let patched = patch_droid_settings_json(&settings_path, ctx)?; + + if dry_run { + print_dry_run_footer(); + } else { + let scope = if global { "global" } else { "project" }; + println!("\nFactory Droid hook registered ({scope}).\n"); + println!(" Command: {}", DROID_HOOK_COMMAND); + println!(" Settings: {}", settings_path.display()); + if patched { + println!(" settings.json: RTK PreToolUse entry added"); + } else { + println!(" settings.json: RTK PreToolUse entry already present"); + } + println!(" Restart Droid. Test with: git status\n"); + } + + Ok(()) +} + +/// Insert RTK PreToolUse entry into Droid settings.json. +/// Returns true if the file was modified. +fn patch_droid_settings_json(path: &Path, ctx: InitContext) -> Result { + let InitContext { verbose, dry_run } = ctx; + let mut root = if path.exists() { + let content = fs::read_to_string(path) + .with_context(|| format!("Failed to read {}", path.display()))?; + if content.trim().is_empty() { + serde_json::json!({}) + } else { + serde_json::from_str(&content) + .with_context(|| format!("Failed to parse {} as JSON", path.display()))? + } + } else { + serde_json::json!({}) + }; + + if droid_hook_already_present(&root) { + if verbose > 0 { + eprintln!("Droid settings.json: RTK hook already present"); + } + return Ok(false); + } + + insert_droid_hook_entry(&mut root)?; + + let serialized = + serde_json::to_string_pretty(&root).context("Failed to serialize Droid settings.json")?; + + if dry_run { + println!( + "[dry-run] would patch Droid settings.json: {}", + path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", serialized); + } + return Ok(true); + } + + if path.exists() { + let backup_path = path.with_extension("json.bak"); + fs::copy(path, &backup_path) + .with_context(|| format!("Failed to backup to {}", backup_path.display()))?; + if verbose > 0 { + eprintln!("Backup: {}", backup_path.display()); + } + } + + atomic_write(path, &serialized)?; + Ok(true) +} + +/// Check if RTK PreToolUse Execute hook is already in Droid settings.json. +fn droid_hook_already_present(root: &serde_json::Value) -> bool { + let pre = match root + .get("hooks") + .and_then(|h| h.get(PRE_TOOL_USE_KEY)) + .and_then(|p| p.as_array()) + { + Some(arr) => arr, + None => return false, + }; + + pre.iter().any(|matcher_entry| { + let hooks = matcher_entry + .get("hooks") + .and_then(|h| h.as_array()) + .map(|arr| arr.as_slice()) + .unwrap_or(&[]); + hooks.iter().any(|hook| { + hook.get("command") + .and_then(|c| c.as_str()) + .is_some_and(|cmd| cmd == DROID_HOOK_COMMAND) + }) + }) +} + +/// Insert the RTK Execute matcher into Droid settings.json. +fn insert_droid_hook_entry(root: &mut serde_json::Value) -> Result<()> { + let root_obj = match root.as_object_mut() { + Some(obj) => obj, + None => { + *root = serde_json::json!({}); + root.as_object_mut().expect("just-created json object") + } + }; + + let hooks = root_obj + .entry("hooks") + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + .context("hooks value is not an object")?; + + let pre_tool_use = hooks + .entry(PRE_TOOL_USE_KEY) + .or_insert_with(|| serde_json::json!([])) + .as_array_mut() + .context("PreToolUse value is not an array")?; + + // Reuse the existing Execute matcher group if one exists, otherwise create + // a new one so we don't trample user-supplied hooks on the same matcher. + for entry in pre_tool_use.iter_mut() { + let matcher = entry + .get("matcher") + .and_then(|m| m.as_str()) + .unwrap_or_default(); + if matcher == DROID_EXECUTE_MATCHER { + if let Some(hook_array) = entry.get_mut("hooks").and_then(|h| h.as_array_mut()) { + hook_array.push(serde_json::json!({ + "type": "command", + "command": DROID_HOOK_COMMAND + })); + return Ok(()); + } + } + } + + pre_tool_use.push(serde_json::json!({ + "matcher": DROID_EXECUTE_MATCHER, + "hooks": [ + { "type": "command", "command": DROID_HOOK_COMMAND } + ] + })); + Ok(()) +} + +/// Uninstall Factory Droid integration: strip RTK hook entry from settings.json. +pub fn uninstall_droid(global: bool, ctx: InitContext) -> Result<()> { + let InitContext { dry_run, .. } = ctx; + let droid_dir = if global { + resolve_droid_dir()? + } else { + std::env::current_dir() + .context("Failed to read current directory")? + .join(DROID_DIR) + }; + let removed = uninstall_droid_at(&droid_dir, ctx)?; + + if removed.is_empty() { + println!("RTK Droid support was not installed (nothing to remove)"); + } else { + let header = if dry_run { + "[dry-run] would uninstall RTK for Factory Droid:" + } else { + "RTK uninstalled for Factory Droid:" + }; + println!("{}", header); + for item in removed { + println!(" - {}", item); + } + } + + if dry_run { + print_dry_run_footer(); + } + Ok(()) +} + +fn uninstall_droid_at(droid_dir: &Path, ctx: InitContext) -> Result> { + let InitContext { verbose, dry_run } = ctx; + let mut removed = Vec::new(); + + let settings_path = droid_dir.join(DROID_SETTINGS_FILE); + if !settings_path.exists() { + return Ok(removed); + } + + let content = fs::read_to_string(&settings_path) + .with_context(|| format!("Failed to read {}", settings_path.display()))?; + if content.trim().is_empty() { + return Ok(removed); + } + + let mut root: serde_json::Value = serde_json::from_str(&content) + .with_context(|| format!("Failed to parse {} as JSON", settings_path.display()))?; + + if !remove_droid_hook_from_json(&mut root) { + return Ok(removed); + } + + if dry_run { + println!( + "[dry-run] would remove RTK entry from Droid settings.json: {}", + settings_path.display() + ); + } else { + let backup_path = settings_path.with_extension("json.bak"); + fs::copy(&settings_path, &backup_path).ok(); + + let serialized = serde_json::to_string_pretty(&root) + .context("Failed to serialize Droid settings.json")?; + atomic_write(&settings_path, &serialized)?; + + if verbose > 0 { + eprintln!("Removed RTK hook from Droid settings.json"); + } + } + removed.push(format!("Droid settings.json: {}", settings_path.display())); + Ok(removed) +} + +fn remove_droid_hook_from_json(root: &mut serde_json::Value) -> bool { + let hooks_obj = match root.get_mut("hooks").and_then(|h| h.as_object_mut()) { + Some(o) => o, + None => return false, + }; + + let pre_tool_use = match hooks_obj + .get_mut(PRE_TOOL_USE_KEY) + .and_then(|p| p.as_array_mut()) + { + Some(arr) => arr, + None => return false, + }; + + let mut modified = false; + + for entry in pre_tool_use.iter_mut() { + if let Some(hook_arr) = entry.get_mut("hooks").and_then(|h| h.as_array_mut()) { + let original = hook_arr.len(); + hook_arr.retain(|hook| { + hook.get("command") + .and_then(|c| c.as_str()) + .is_none_or(|cmd| cmd != DROID_HOOK_COMMAND) + }); + if hook_arr.len() < original { + modified = true; + } + } + } + + // Drop matcher entries that lost all their hooks. + let before = pre_tool_use.len(); + pre_tool_use.retain(|entry| { + entry + .get("hooks") + .and_then(|h| h.as_array()) + .map(|a| !a.is_empty()) + .unwrap_or(true) + }); + if pre_tool_use.len() < before { + modified = true; + } + + // Drop empty PreToolUse array, then empty hooks object. + if pre_tool_use.is_empty() { + hooks_obj.remove(PRE_TOOL_USE_KEY); + modified = true; + } + if hooks_obj.is_empty() { + if let Some(obj) = root.as_object_mut() { + obj.remove("hooks"); + } + } + + modified +} + fn codex_rtk_md_ref(codex_dir: &Path) -> String { format!("@{}", codex_dir.join(RTK_MD).display()) } @@ -5153,6 +5483,148 @@ mod tests { assert_eq!(missing_falls_back, home_dir.join(".hermes")); } + // --- Factory Droid --- + + #[test] + fn test_resolve_droid_dir_prefers_factory_home() { + let factory_home = OsString::from("/custom/factory"); + let resolved = + resolve_droid_dir_from_env(Some(PathBuf::from("/tmp/home")), Some(factory_home)) + .unwrap(); + assert_eq!(resolved, PathBuf::from("/custom/factory")); + } + + #[test] + fn test_resolve_droid_dir_falls_back_to_home() { + let home = PathBuf::from("/tmp/home"); + let resolved = + resolve_droid_dir_from_env(Some(home.clone()), Some(OsString::new())).unwrap(); + assert_eq!(resolved, home.join(".factory")); + } + + #[test] + fn test_insert_droid_hook_entry_empty() { + let mut root = serde_json::json!({}); + insert_droid_hook_entry(&mut root).unwrap(); + let pre = root["hooks"]["PreToolUse"].as_array().unwrap(); + assert_eq!(pre.len(), 1); + assert_eq!(pre[0]["matcher"], "Execute"); + let hooks = pre[0]["hooks"].as_array().unwrap(); + assert_eq!(hooks[0]["type"], "command"); + assert_eq!(hooks[0]["command"], DROID_HOOK_COMMAND); + } + + #[test] + fn test_insert_droid_hook_entry_reuses_execute_group() { + let mut root = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Execute", + "hooks": [{ "type": "command", "command": "echo user-hook" }] + }] + } + }); + insert_droid_hook_entry(&mut root).unwrap(); + let pre = root["hooks"]["PreToolUse"].as_array().unwrap(); + assert_eq!(pre.len(), 1, "must not duplicate the matcher entry"); + let hooks = pre[0]["hooks"].as_array().unwrap(); + assert_eq!(hooks.len(), 2); + assert_eq!(hooks[0]["command"], "echo user-hook"); + assert_eq!(hooks[1]["command"], DROID_HOOK_COMMAND); + } + + #[test] + fn test_droid_hook_already_present_detects_rtk() { + let root = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Execute", + "hooks": [{ "type": "command", "command": DROID_HOOK_COMMAND }] + }] + } + }); + assert!(droid_hook_already_present(&root)); + } + + #[test] + fn test_droid_hook_already_present_false_for_other_command() { + let root = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Execute", + "hooks": [{ "type": "command", "command": "echo unrelated" }] + }] + } + }); + assert!(!droid_hook_already_present(&root)); + } + + #[test] + fn test_remove_droid_hook_keeps_other_hooks() { + let mut root = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Execute", + "hooks": [ + { "type": "command", "command": "echo user-hook" }, + { "type": "command", "command": DROID_HOOK_COMMAND } + ] + }] + } + }); + assert!(remove_droid_hook_from_json(&mut root)); + let hooks = root["hooks"]["PreToolUse"][0]["hooks"].as_array().unwrap(); + assert_eq!(hooks.len(), 1); + assert_eq!(hooks[0]["command"], "echo user-hook"); + } + + #[test] + fn test_remove_droid_hook_drops_empty_matcher() { + let mut root = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Execute", + "hooks": [ + { "type": "command", "command": DROID_HOOK_COMMAND } + ] + }] + } + }); + assert!(remove_droid_hook_from_json(&mut root)); + assert!( + root.get("hooks").is_none(), + "hooks key should be removed when empty" + ); + } + + #[test] + fn test_droid_install_then_uninstall_round_trip() { + let temp = TempDir::new().unwrap(); + let droid_dir = temp.path().join(".factory"); + let ctx = InitContext { + verbose: 0, + dry_run: false, + }; + + run_droid_mode_at(&droid_dir, true, ctx).unwrap(); + let settings = droid_dir.join("settings.json"); + assert!(settings.exists(), "settings.json should be created"); + + // Second run is a no-op (idempotent). + run_droid_mode_at(&droid_dir, true, ctx).unwrap(); + let content = fs::read_to_string(&settings).unwrap(); + let v: serde_json::Value = serde_json::from_str(&content).unwrap(); + let hooks = v["hooks"]["PreToolUse"][0]["hooks"].as_array().unwrap(); + assert_eq!(hooks.len(), 1, "install must be idempotent"); + + // Uninstall wipes the entry and the (now empty) hooks key. + let removed = uninstall_droid_at(&droid_dir, ctx).unwrap(); + assert!(!removed.is_empty()); + let post = fs::read_to_string(&settings).unwrap(); + let v: serde_json::Value = serde_json::from_str(&post).unwrap(); + assert!(v.get("hooks").is_none()); + } + #[test] fn test_uninstall_codex_at_is_idempotent() { let temp = TempDir::new().unwrap(); diff --git a/src/main.rs b/src/main.rs index 0cb5beb509..c3963f6f9d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,6 +50,8 @@ pub enum AgentTarget { Pi, /// Hermes CLI Hermes, + /// Factory Droid CLI + Droid, } #[derive(Parser)] @@ -853,6 +855,8 @@ enum HookCommands { Gemini, /// Process Copilot preToolUse hook (VS Code + Copilot CLI, reads JSON from stdin) Copilot, + /// Process Factory Droid PreToolUse hook (reads JSON from stdin) + Droid, /// Check how a command would be rewritten by the hook engine (dry-run) Check { /// Target agent @@ -1520,6 +1524,8 @@ where { if agent == Some(AgentTarget::Hermes) { uninstall_hermes(ctx) + } else if agent == Some(AgentTarget::Droid) { + hooks::init::uninstall_droid(global, ctx) } else { let cursor = agent == Some(AgentTarget::Cursor); let pi = agent == Some(AgentTarget::Pi); @@ -2008,6 +2014,8 @@ fn run_cli() -> Result { hooks::init::run_antigravity_mode(ctx)?; } else if agent == Some(AgentTarget::Hermes) { hooks::init::run_hermes_mode(ctx)?; + } else if agent == Some(AgentTarget::Droid) { + hooks::init::run_droid_mode(global, ctx)?; } else { let install_opencode = opencode; let install_claude = !opencode; @@ -2367,6 +2375,10 @@ fn run_cli() -> Result { hooks::hook_cmd::run_copilot()?; 0 } + HookCommands::Droid => { + hooks::hook_cmd::run_droid()?; + 0 + } HookCommands::Check { agent: _, command } => { use crate::discover::registry::rewrite_command; let raw = command.join(" "); From 9dd82113adeccdb9597a1f871922f92335643a7b Mon Sep 17 00:00:00 2001 From: krimvp Date: Thu, 4 Jun 2026 18:32:15 +0200 Subject: [PATCH 2/7] fix(hooks): don't force-allow Droid rewrites Droid 0.140.0 applies a PreToolUse hook's updatedInput regardless of the permission decision (verified empirically + via binary decompile), so the forced permissionDecision:"allow" was unnecessary. Worse, Droid resolves the decision as the first hook result carrying one (no deny-over-allow priority), so an unconditional allow could suppress another PreToolUse hook's deny/ask and bypass Droid's native prompt. Now omit permissionDecision by default and only assert allow on an explicit allow rule, mirroring process_claude_payload. The rewrite still lands via Droid's decision-independent "updated input result" path. Updated the inaccurate comment and the test that required allow. --- src/hooks/hook_cmd.rs | 57 ++++++++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 23 deletions(-) diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index b6539034d0..8d8c1f4a11 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -604,22 +604,32 @@ fn droid_response_for_verdict(v: &Value, cmd: &str, verdict: PermissionVerdict) ti }; - // Droid (like Cursor) only applies a hook's `updatedInput` when the - // permission decision is "allow" — an "ask"/omitted decision makes Droid - // run the *original* command, so the rewrite (and its token savings) would - // be silently dropped. We already stepped aside on Deny above, so anything - // reaching here is safe to auto-allow in its rewritten `rtk …` form (it is - // the same command the agent chose, just wrapped). Mirrors `run_cursor`. - let decision = "allow"; - - Some(json!({ - "hookSpecificOutput": { - "hookEventName": PRE_TOOL_USE_KEY, - "permissionDecision": decision, - "permissionDecisionReason": "RTK auto-rewrite", - "updatedInput": updated_input - } - })) + // Wire format mirrors Claude's `hookSpecificOutput`; the allow policy mirrors + // `process_claude_payload`, NOT Cursor. Verified empirically against Droid + // 0.140.0: Droid applies a hook's `updatedInput` regardless of the permission + // decision — beyond the `case "allow"` branch it has an "updated input result" + // path that applies any hook's `updatedInput` even when no decision is set. So + // forcing "allow" is unnecessary for the rewrite to land, and would be harmful: + // Droid resolves the decision as `find(first result with a permissionDecision)` + // (no deny-over-allow priority), so an unconditional "allow" can suppress + // another PreToolUse hook's deny/ask and bypasses Droid's native permission + // prompt. We therefore only assert "allow" on an explicit allow rule; otherwise + // we omit the decision so the rewrite still applies while the user's other + // hooks and Droid's native prompt stay in control. + let mut hook_output = json!({ + "hookEventName": PRE_TOOL_USE_KEY, + "permissionDecisionReason": "RTK auto-rewrite", + "updatedInput": updated_input + }); + + if verdict == PermissionVerdict::Allow { + hook_output + .as_object_mut() + .unwrap() + .insert("permissionDecision".into(), json!("allow")); + } + + Some(json!({ "hookSpecificOutput": hook_output })) } /// Run the Factory Droid PreToolUse hook natively. @@ -1538,14 +1548,15 @@ mod tests { .and_then(|c| c.as_str()), Some("PreToolUse") ); - // Default verdict (no rule) must still auto-allow: Droid only applies - // `updatedInput` when the decision is "allow", otherwise it runs the - // original command and the rewrite is silently dropped. - assert_eq!( + // Default verdict (no rule) must OMIT permissionDecision. Verified + // against Droid 0.140.0: `updatedInput` is applied regardless of the + // decision, so omitting it lets the rewrite land while preserving + // Droid's native prompt and other hooks' deny/ask (Droid picks the + // first hook result that sets a decision). Mirrors the Claude handler. + assert!( v.pointer("/hookSpecificOutput/permissionDecision") - .and_then(|c| c.as_str()), - Some("allow"), - "rewrites must be auto-allowed so Droid applies updatedInput" + .is_none(), + "default verdict must not force a permission decision" ); } From 53efe76ee4c8ea0d4915431d38d0c7a0981ff8ad Mon Sep 17 00:00:00 2001 From: krimvp Date: Sat, 4 Jul 2026 21:19:04 +0000 Subject: [PATCH 3/7] refactor(hooks): route Droid hook through shared decision flow Adopt the decide_from_verdict/HookDecision flow introduced on develop so the Droid handler gains the same safety behavior as Claude/Cursor/Copilot: commands with substitution or file redirects now Defer (no rewrite) instead of being rewritten, and deny/allow/ask mapping is shared instead of reimplemented. Also removes an unwrap() in the response builder. Claude-Session: https://claude.ai/code/session_01Vp4p5YroP2cfFvThhQE4Rv --- src/hooks/hook_cmd.rs | 80 +++++++++++++++++++++++++++++-------------- 1 file changed, 55 insertions(+), 25 deletions(-) diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 8d8c1f4a11..547f8a5d4f 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -576,23 +576,25 @@ fn process_droid_payload(v: &Value) -> Option { .and_then(|c| c.as_str()) .filter(|c| !c.is_empty())?; - let verdict = permissions::check_command(cmd); - droid_response_for_verdict(v, cmd, verdict) + droid_response_from_decision(v, cmd, decide_hook_action(cmd, permissions::Host::Claude)) } -/// Build the Droid hook response for a known verdict. +/// Build the Droid hook response for a decision from the shared flow. /// /// On `Deny` we step aside (emit no output) so Droid's native deny handling -/// fires, matching Claude/Cursor/Copilot and the `PermissionVerdict::Deny` -/// contract ("pass through to native deny handling"). RTK never emits its own -/// block here. -fn droid_response_for_verdict(v: &Value, cmd: &str, verdict: PermissionVerdict) -> Option { - if verdict == PermissionVerdict::Deny { - audit_log("deny", cmd, ""); - return None; - } - - let rewritten = get_rewritten(cmd)?; +/// fires, matching Claude/Cursor/Copilot. `Defer` (command substitution, file +/// redirects, or no rewrite available) also stays silent so Droid runs the +/// command unchanged. RTK never emits its own block here. +fn droid_response_from_decision(v: &Value, cmd: &str, decision: HookDecision) -> Option { + let (rewritten, allow) = match decision { + HookDecision::Deny => { + audit_log("deny", cmd, ""); + return None; + } + HookDecision::Defer => return None, + HookDecision::AllowRewrite(r) => (r, true), + HookDecision::AskRewrite(r) => (r, false), + }; audit_log("rewrite", cmd, &rewritten); @@ -622,11 +624,8 @@ fn droid_response_for_verdict(v: &Value, cmd: &str, verdict: PermissionVerdict) "updatedInput": updated_input }); - if verdict == PermissionVerdict::Allow { - hook_output - .as_object_mut() - .unwrap() - .insert("permissionDecision".into(), json!("allow")); + if allow { + hook_output["permissionDecision"] = json!("allow"); } Some(json!({ "hookSpecificOutput": hook_output })) @@ -1584,27 +1583,58 @@ mod tests { fn test_droid_deny_steps_aside() { // A denied command must produce NO output so Droid's native deny // handling fires — matching Claude/Cursor/Copilot. RTK must not emit - // its own `permissionDecision: deny` block (PermissionVerdict::Deny - // contract). Verdict is injected because check_command loads ambient - // rules that aren't present in the test environment. + // its own `permissionDecision: deny` block. Decision is injected + // because decide_hook_action loads ambient rules that aren't present + // in the test environment. let v: Value = serde_json::from_str(&droid_input("Execute", "git push --force")).unwrap(); assert!( - droid_response_for_verdict(&v, "git push --force", PermissionVerdict::Deny).is_none(), + droid_response_from_decision(&v, "git push --force", HookDecision::Deny).is_none(), "deny must step aside (no output), not emit an RTK block" ); } #[test] - fn test_droid_allow_verdict_auto_allows() { + fn test_droid_allow_decision_auto_allows() { // An explicit allow rule auto-allows the rewritten command. let v: Value = serde_json::from_str(&droid_input("Execute", "git status")).unwrap(); - let out = droid_response_for_verdict(&v, "git status", PermissionVerdict::Allow) - .expect("rewrite expected"); + let out = droid_response_from_decision( + &v, + "git status", + HookDecision::AllowRewrite("rtk git status".to_string()), + ) + .expect("rewrite expected"); assert_eq!( out.pointer("/hookSpecificOutput/permissionDecision") .and_then(|c| c.as_str()), Some("allow") ); + assert_eq!( + out.pointer("/hookSpecificOutput/updatedInput/command") + .and_then(|c| c.as_str()), + Some("rtk git status") + ); + } + + #[test] + fn test_droid_substitution_defers() { + // Commands with substitution can't be attested — the shared decision + // flow defers so Droid runs the original command unchanged. + for cmd in ["git status `rm -rf /tmp/x`", "git status $(rm -rf /tmp/x)"] { + let input = droid_input("Execute", cmd); + assert!( + run_droid_inner(&input).is_none(), + "substitution must defer (no output) for {cmd}" + ); + } + } + + #[test] + fn test_droid_file_redirect_defers() { + let input = droid_input("Execute", "git log > /tmp/out.txt"); + assert!( + run_droid_inner(&input).is_none(), + "file redirects must defer (no output)" + ); } #[test] From da36ba3935a4c476edaedd3e447c9c58e1bc0f09 Mon Sep 17 00:00:00 2001 From: krimvp Date: Sat, 4 Jul 2026 21:32:49 +0000 Subject: [PATCH 4/7] fix(hooks): install Droid hook into canonical hooks.json Adversarial verification against Droid v0.164.0 (shipped code + docs) found two broken assumptions in the original integration: - Droid's canonical hooks file is hooks.json (root event map, no wrapper); the hooks key of settings.json is only a fallback that hooks.json shadows per event key. Droid's own /hooks UI writes hooks.json, so an RTK entry in settings.json silently dies as soon as the user adds any PreToolUse hook there. Install now targets the file whose PreToolUse array Droid actually reads (live hooks.json > live settings.json fallback > create canonical hooks.json), migrates stale copies, and uninstall sweeps root hooks.json, legacy hooks/hooks.json, and settings.json. - The config-home env var is FACTORY_HOME_OVERRIDE (replaces $HOME, with .factory appended), not FACTORY_HOME pointing at the config dir. Also corrects the 'legacy Bash matcher' comment (Droid never had a Bash tool; the acceptance is purely defensive) and records that the omit-permissionDecision rewrite path is confirmed in v0.164.0: decisions resolve as find(first result with a decision) and updatedInput applies via a separate path even when no decision is set. Claude-Session: https://claude.ai/code/session_01Vp4p5YroP2cfFvThhQE4Rv --- README.md | 4 +- src/hooks/constants.rs | 16 +- src/hooks/hook_cmd.rs | 35 +-- src/hooks/init.rs | 508 ++++++++++++++++++++++++++++++++--------- 4 files changed, 432 insertions(+), 131 deletions(-) diff --git a/README.md b/README.md index dc11e8c85e..62ca1afcba 100644 --- a/README.md +++ b/README.md @@ -366,7 +366,7 @@ rtk init -g ## Supported AI Tools -RTK supports 14 AI coding tools. Each integration rewrites shell commands to `rtk` equivalents for 60-90% token savings where the agent supports command interception. +RTK supports 15 AI coding tools. Each integration rewrites shell commands to `rtk` equivalents for 60-90% token savings where the agent supports command interception. | Tool | Install | Method | |------|---------|--------| @@ -385,7 +385,7 @@ RTK supports 14 AI coding tools. Each integration rewrites shell commands to `rt | **Mistral Vibe** | Planned ([#800](https://github.com/rtk-ai/rtk/issues/800)) | Blocked on upstream | | **Kilo Code** | `rtk init --agent kilocode` | .kilocode/rules/rtk-rules.md (project-scoped) | | **Google Antigravity** | `rtk init --agent antigravity` | .agents/rules/antigravity-rtk-rules.md (project-scoped) | -| **Factory Droid** | `rtk init -g --agent droid` (or per-project) | PreToolUse hook in `~/.factory/settings.json` (matcher `Execute`) | +| **Factory Droid** | `rtk init -g --agent droid` (or per-project) | PreToolUse hook in `~/.factory/hooks.json` (matcher `Execute`) | For per-agent setup details, override controls, and graceful degradation, see the [Supported Agents guide](https://www.rtk-ai.app/guide/getting-started/supported-agents). The Hermes plugin source and tests live in `hooks/hermes/`; installed Hermes runtime files still live under `~/.hermes/plugins/rtk-rewrite/`. diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs index 013d5a9812..4caaf94473 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -36,14 +36,22 @@ pub const PI_EXTENSIONS_SUBDIR: &str = "extensions"; pub const PI_PLUGIN_FILE: &str = "rtk.ts"; pub const PI_CODING_AGENT_DIR_ENV: &str = "PI_CODING_AGENT_DIR"; -/// Factory Droid config directory (overridable via FACTORY_HOME env var). +/// Factory Droid config directory, joined onto the resolved home directory. pub const DROID_DIR: &str = ".factory"; -/// Factory Droid settings file (where hooks live, per docs.factory.ai). +/// Canonical Droid hooks file (Droid's own /hooks UI reads and writes this). +pub const DROID_HOOKS_FILE: &str = "hooks.json"; +/// Legacy nested hooks location (`.factory/hooks/hooks.json`), still read by +/// Droid when the root `hooks.json` is absent. +pub const DROID_HOOKS_SUBDIR: &str = "hooks"; +/// Droid settings file. Its `hooks` key is a fallback config surface: Droid +/// merges `hooks.json` OVER it per event key, so a `PreToolUse` entry here is +/// silently ignored once `hooks.json` defines `PreToolUse`. pub const DROID_SETTINGS_FILE: &str = "settings.json"; /// Tool matcher used by Droid for shell command execution. pub const DROID_EXECUTE_MATCHER: &str = "Execute"; -/// Environment variable Droid uses to override its config home directory. -pub const DROID_HOME_ENV: &str = "FACTORY_HOME"; +/// Environment variable Droid uses to override its HOME directory (the +/// `.factory` segment is appended to it): `$FACTORY_HOME_OVERRIDE/.factory`. +pub const DROID_HOME_ENV: &str = "FACTORY_HOME_OVERRIDE"; pub const HERMES_DIR: &str = ".hermes"; pub const HERMES_PLUGINS_SUBDIR: &str = "plugins"; diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 547f8a5d4f..22c01f07f7 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -565,8 +565,10 @@ fn run_cursor_inner_with_rules( fn process_droid_payload(v: &Value) -> Option { let tool_name = v.get("tool_name").and_then(|t| t.as_str()).unwrap_or(""); - // Accept both the documented `Execute` and the legacy `Bash` matchers so - // older Droid releases keep working. + // `Execute` is Droid's shell tool. The installed matcher already gates + // invocations to Execute; also tolerate a missing tool_name and accept + // `Bash` defensively for Claude-shaped payloads (Droid itself has no Bash + // tool — verified against Droid v0.164.0). if !matches!(tool_name, "Execute" | "Bash" | "") { return None; } @@ -607,17 +609,18 @@ fn droid_response_from_decision(v: &Value, cmd: &str, decision: HookDecision) -> }; // Wire format mirrors Claude's `hookSpecificOutput`; the allow policy mirrors - // `process_claude_payload`, NOT Cursor. Verified empirically against Droid - // 0.140.0: Droid applies a hook's `updatedInput` regardless of the permission - // decision — beyond the `case "allow"` branch it has an "updated input result" - // path that applies any hook's `updatedInput` even when no decision is set. So - // forcing "allow" is unnecessary for the rewrite to land, and would be harmful: - // Droid resolves the decision as `find(first result with a permissionDecision)` - // (no deny-over-allow priority), so an unconditional "allow" can suppress - // another PreToolUse hook's deny/ask and bypasses Droid's native permission - // prompt. We therefore only assert "allow" on an explicit allow rule; otherwise - // we omit the decision so the rewrite still applies while the user's other - // hooks and Droid's native prompt stay in control. + // `process_claude_payload`, NOT Cursor. Verified against Droid 0.140.0 and + // re-verified in the shipped v0.164.0 code: Droid applies a hook's + // `updatedInput` regardless of the permission decision — beyond the + // `case "allow"` branch it has an "updated input result" path that applies any + // hook's `updatedInput` even when no decision is set. So forcing "allow" is + // unnecessary for the rewrite to land, and would be harmful: Droid resolves + // the decision as `find(first result with a permissionDecision)` (no + // deny-over-allow priority), so an unconditional "allow" can suppress another + // PreToolUse hook's deny/ask and bypasses Droid's native permission prompt. + // We therefore only assert "allow" on an explicit allow rule; otherwise we + // omit the decision so the rewrite still applies while the user's other hooks + // and Droid's native prompt stay in control. let mut hook_output = json!({ "hookEventName": PRE_TOOL_USE_KEY, "permissionDecisionReason": "RTK auto-rewrite", @@ -1571,11 +1574,13 @@ mod tests { } #[test] - fn test_droid_legacy_bash_matcher_still_works() { + fn test_droid_bash_tool_name_accepted_defensively() { + // Droid has no Bash tool, but Claude-shaped payloads are accepted + // defensively; the installed matcher gates invocations to Execute. let input = droid_input("Bash", "git status"); assert!( run_droid_inner(&input).is_some(), - "legacy Bash matcher should still rewrite" + "Bash tool name should still rewrite" ); } diff --git a/src/hooks/init.rs b/src/hooks/init.rs index c4a81f89d6..f09d91a827 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -14,11 +14,11 @@ use crate::hooks::constants::{ use super::constants::{ BEFORE_TOOL_KEY, CLAUDE_DIR, CLAUDE_HOOK_COMMAND, CODEX_DIR, CURSOR_HOOK_COMMAND, DROID_DIR, - DROID_EXECUTE_MATCHER, DROID_HOME_ENV, DROID_HOOK_COMMAND, DROID_SETTINGS_FILE, - GEMINI_HOOK_FILE, HERMES_DIR, HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_INIT_FILE, - HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_JSON, HOOKS_SUBDIR, - PI_CODING_AGENT_DIR_ENV, PI_DIR, PI_EXTENSIONS_SUBDIR, PI_LOCAL_DIR, PI_PLUGIN_FILE, - PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON, + DROID_EXECUTE_MATCHER, DROID_HOME_ENV, DROID_HOOKS_FILE, DROID_HOOKS_SUBDIR, + DROID_HOOK_COMMAND, DROID_SETTINGS_FILE, GEMINI_HOOK_FILE, HERMES_DIR, HERMES_PLUGINS_SUBDIR, + HERMES_PLUGIN_INIT_FILE, HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_JSON, + HOOKS_SUBDIR, PI_CODING_AGENT_DIR_ENV, PI_DIR, PI_EXTENSIONS_SUBDIR, PI_LOCAL_DIR, + PI_PLUGIN_FILE, PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON, }; use super::integrity; @@ -2846,9 +2846,11 @@ fn resolve_hermes_home_from_env( // ─── Factory Droid support ────────────────────────────────────────── -/// Resolve Droid config directory, honouring `FACTORY_HOME` override. +/// Resolve Droid config directory, honouring `FACTORY_HOME_OVERRIDE`. /// -/// - Global: `$FACTORY_HOME` or `~/.factory`. +/// Droid resolves its home as `$FACTORY_HOME_OVERRIDE || $HOME`, then joins +/// `.factory` onto it (verified against Droid v0.164.0). +/// - Global: `$FACTORY_HOME_OVERRIDE/.factory` or `~/.factory`. /// - Project: caller passes `.factory` relative to project root. fn resolve_droid_dir() -> Result { resolve_droid_dir_from_env(dirs::home_dir(), std::env::var_os(DROID_HOME_ENV)) @@ -2856,20 +2858,138 @@ fn resolve_droid_dir() -> Result { fn resolve_droid_dir_from_env( home_dir: Option, - factory_home: Option, + factory_home_override: Option, ) -> Result { - if let Some(path) = factory_home.filter(|value| !value.is_empty()) { - return Ok(PathBuf::from(path)); - } - home_dir + factory_home_override + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or(home_dir) .map(|home| home.join(DROID_DIR)) - .context("Cannot determine Droid config directory. Set $FACTORY_HOME or $HOME.") + .context("Cannot determine Droid config directory. Set $FACTORY_HOME_OVERRIDE or $HOME.") +} + +/// How hook events are stored in a Droid config file. +#[derive(Clone, Copy, PartialEq, Eq)] +enum DroidLayout { + /// `hooks.json`: the event map (`PreToolUse`, …) is the file's root object. + Root, + /// `settings.json`: the event map lives under a top-level `hooks` key. + Nested, +} + +struct DroidHookFile { + path: PathBuf, + layout: DroidLayout, +} + +/// Every file Droid may read PreToolUse hooks from, in its own precedence +/// order: root `hooks.json`, legacy `hooks/hooks.json` (only read when the +/// root file is absent), then the `hooks` key of `settings.json` (merged +/// under `hooks.json` per event key). +fn droid_hook_file_candidates(droid_dir: &Path) -> [DroidHookFile; 3] { + [ + DroidHookFile { + path: droid_dir.join(DROID_HOOKS_FILE), + layout: DroidLayout::Root, + }, + DroidHookFile { + path: droid_dir.join(DROID_HOOKS_SUBDIR).join(DROID_HOOKS_FILE), + layout: DroidLayout::Root, + }, + DroidHookFile { + path: droid_dir.join(DROID_SETTINGS_FILE), + layout: DroidLayout::Nested, + }, + ] +} + +/// Read a Droid config file as JSON. `Ok(None)` when the file doesn't exist; +/// an empty file parses as `{}`. +fn read_droid_json(path: &Path) -> Result> { + if !path.exists() { + return Ok(None); + } + let content = + fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?; + if content.trim().is_empty() { + return Ok(Some(serde_json::json!({}))); + } + serde_json::from_str(&content) + .map(Some) + .with_context(|| format!("Failed to parse {} as JSON", path.display())) +} + +/// The JSON object holding hook events for the given layout, if present. +fn droid_events(root: &serde_json::Value, layout: DroidLayout) -> &serde_json::Value { + match layout { + DroidLayout::Root => root, + DroidLayout::Nested => root.get("hooks").unwrap_or(&serde_json::Value::Null), + } +} + +fn droid_has_pre_tool_use(root: &serde_json::Value, layout: DroidLayout) -> bool { + droid_events(root, layout) + .get(PRE_TOOL_USE_KEY) + .and_then(|p| p.as_array()) + .is_some_and(|arr| !arr.is_empty()) +} + +/// Pick the file whose `PreToolUse` hooks Droid will actually run. +/// +/// Droid loads root `hooks.json` (falling back to legacy `hooks/hooks.json` +/// when the root file is absent) and merges it OVER the `hooks` key of +/// `settings.json`, per event key (verified against Droid v0.164.0). +/// Installing into a shadowed file silently does nothing, and adding +/// `PreToolUse` to `hooks.json` would shadow a user's live `settings.json` +/// hooks. Hence: +/// 1. the live `hooks.json`, when it already defines `PreToolUse`; +/// 2. else `settings.json`, when its `hooks.PreToolUse` is live; +/// 3. else the live `hooks.json`, when one exists; +/// 4. else create the canonical root `hooks.json` (where Droid's own +/// `/hooks` UI writes). +fn resolve_droid_install_target(droid_dir: &Path) -> Result { + let root = droid_dir.join(DROID_HOOKS_FILE); + let legacy = droid_dir.join(DROID_HOOKS_SUBDIR).join(DROID_HOOKS_FILE); + let settings = droid_dir.join(DROID_SETTINGS_FILE); + + let live_hooks_json = if root.exists() { + Some(root.clone()) + } else if legacy.exists() { + Some(legacy) + } else { + None + }; + + if let Some(path) = &live_hooks_json { + if let Some(json) = read_droid_json(path)? { + if droid_has_pre_tool_use(&json, DroidLayout::Root) { + return Ok(DroidHookFile { + path: path.clone(), + layout: DroidLayout::Root, + }); + } + } + } + + if let Some(json) = read_droid_json(&settings)? { + if droid_has_pre_tool_use(&json, DroidLayout::Nested) { + return Ok(DroidHookFile { + path: settings, + layout: DroidLayout::Nested, + }); + } + } + + Ok(DroidHookFile { + path: live_hooks_json.unwrap_or(root), + layout: DroidLayout::Root, + }) } -/// Install Factory Droid PreToolUse hook into `settings.json`. +/// Install Factory Droid PreToolUse hook. /// -/// - Global (`-g`): writes to `~/.factory/settings.json` (or `$FACTORY_HOME/settings.json`). -/// - Project: writes to `/.factory/settings.json` so the hook can be committed. +/// - Global (`-g`): under `~/.factory` (or `$FACTORY_HOME_OVERRIDE/.factory`). +/// - Project: under `/.factory` so the hook can be committed. pub fn run_droid_mode(global: bool, ctx: InitContext) -> Result<()> { let droid_dir = if global { resolve_droid_dir()? @@ -2884,29 +3004,45 @@ pub fn run_droid_mode(global: bool, ctx: InitContext) -> Result<()> { fn run_droid_mode_at(droid_dir: &Path, global: bool, ctx: InitContext) -> Result<()> { let InitContext { dry_run, .. } = ctx; + let target = resolve_droid_install_target(droid_dir)?; + if !dry_run { - fs::create_dir_all(droid_dir).with_context(|| { - format!( - "Failed to create Droid config directory: {}", - droid_dir.display() - ) + let dir = target.path.parent().unwrap_or(droid_dir); + fs::create_dir_all(dir).with_context(|| { + format!("Failed to create Droid config directory: {}", dir.display()) })?; } - let settings_path = droid_dir.join(DROID_SETTINGS_FILE); - let patched = patch_droid_settings_json(&settings_path, ctx)?; + let patched = patch_droid_hook_file(&target, ctx)?; + + // Migrate stale copies (e.g. an earlier RTK install into settings.json + // that hooks.json now shadows) so exactly one live entry remains. + // Best-effort: a corrupt secondary file must not block the install. + for candidate in droid_hook_file_candidates(droid_dir) { + if candidate.path == target.path { + continue; + } + match remove_droid_hook_from_file(&candidate, ctx) { + Ok(true) => println!( + " Removed stale RTK entry from {}", + candidate.path.display() + ), + Ok(false) => {} + Err(e) => eprintln!("rtk: warning: {e:#}"), + } + } if dry_run { print_dry_run_footer(); } else { let scope = if global { "global" } else { "project" }; println!("\nFactory Droid hook registered ({scope}).\n"); - println!(" Command: {}", DROID_HOOK_COMMAND); - println!(" Settings: {}", settings_path.display()); + println!(" Command: {}", DROID_HOOK_COMMAND); + println!(" Hooks file: {}", target.path.display()); if patched { - println!(" settings.json: RTK PreToolUse entry added"); + println!(" RTK PreToolUse entry added"); } else { - println!(" settings.json: RTK PreToolUse entry already present"); + println!(" RTK PreToolUse entry already present"); } println!(" Restart Droid. Test with: git status\n"); } @@ -2914,40 +3050,27 @@ fn run_droid_mode_at(droid_dir: &Path, global: bool, ctx: InitContext) -> Result Ok(()) } -/// Insert RTK PreToolUse entry into Droid settings.json. +/// Insert RTK PreToolUse entry into a Droid hook file. /// Returns true if the file was modified. -fn patch_droid_settings_json(path: &Path, ctx: InitContext) -> Result { +fn patch_droid_hook_file(file: &DroidHookFile, ctx: InitContext) -> Result { let InitContext { verbose, dry_run } = ctx; - let mut root = if path.exists() { - let content = fs::read_to_string(path) - .with_context(|| format!("Failed to read {}", path.display()))?; - if content.trim().is_empty() { - serde_json::json!({}) - } else { - serde_json::from_str(&content) - .with_context(|| format!("Failed to parse {} as JSON", path.display()))? - } - } else { - serde_json::json!({}) - }; + let path = &file.path; + let mut root = read_droid_json(path)?.unwrap_or_else(|| serde_json::json!({})); - if droid_hook_already_present(&root) { + if droid_hook_already_present(&root, file.layout) { if verbose > 0 { - eprintln!("Droid settings.json: RTK hook already present"); + eprintln!("{}: RTK hook already present", path.display()); } return Ok(false); } - insert_droid_hook_entry(&mut root)?; + insert_droid_hook_entry(&mut root, file.layout)?; let serialized = - serde_json::to_string_pretty(&root).context("Failed to serialize Droid settings.json")?; + serde_json::to_string_pretty(&root).context("Failed to serialize Droid hook file")?; if dry_run { - println!( - "[dry-run] would patch Droid settings.json: {}", - path.display() - ); + println!("[dry-run] would patch Droid hook file: {}", path.display()); if verbose > 0 { println!("[dry-run] content:\n{}", serialized); } @@ -2967,11 +3090,10 @@ fn patch_droid_settings_json(path: &Path, ctx: InitContext) -> Result { Ok(true) } -/// Check if RTK PreToolUse Execute hook is already in Droid settings.json. -fn droid_hook_already_present(root: &serde_json::Value) -> bool { - let pre = match root - .get("hooks") - .and_then(|h| h.get(PRE_TOOL_USE_KEY)) +/// Check if the RTK PreToolUse Execute hook is already in a Droid hook file. +fn droid_hook_already_present(root: &serde_json::Value, layout: DroidLayout) -> bool { + let pre = match droid_events(root, layout) + .get(PRE_TOOL_USE_KEY) .and_then(|p| p.as_array()) { Some(arr) => arr, @@ -2992,8 +3114,8 @@ fn droid_hook_already_present(root: &serde_json::Value) -> bool { }) } -/// Insert the RTK Execute matcher into Droid settings.json. -fn insert_droid_hook_entry(root: &mut serde_json::Value) -> Result<()> { +/// Insert the RTK Execute matcher into a Droid hook file. +fn insert_droid_hook_entry(root: &mut serde_json::Value, layout: DroidLayout) -> Result<()> { let root_obj = match root.as_object_mut() { Some(obj) => obj, None => { @@ -3002,13 +3124,16 @@ fn insert_droid_hook_entry(root: &mut serde_json::Value) -> Result<()> { } }; - let hooks = root_obj - .entry("hooks") - .or_insert_with(|| serde_json::json!({})) - .as_object_mut() - .context("hooks value is not an object")?; + let events = match layout { + DroidLayout::Root => root_obj, + DroidLayout::Nested => root_obj + .entry("hooks") + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + .context("hooks value is not an object")?, + }; - let pre_tool_use = hooks + let pre_tool_use = events .entry(PRE_TOOL_USE_KEY) .or_insert_with(|| serde_json::json!([])) .as_array_mut() @@ -3074,55 +3199,61 @@ pub fn uninstall_droid(global: bool, ctx: InitContext) -> Result<()> { } fn uninstall_droid_at(droid_dir: &Path, ctx: InitContext) -> Result> { - let InitContext { verbose, dry_run } = ctx; let mut removed = Vec::new(); - - let settings_path = droid_dir.join(DROID_SETTINGS_FILE); - if !settings_path.exists() { - return Ok(removed); + for candidate in droid_hook_file_candidates(droid_dir) { + if remove_droid_hook_from_file(&candidate, ctx)? { + removed.push(format!("Droid hook file: {}", candidate.path.display())); + } } + Ok(removed) +} - let content = fs::read_to_string(&settings_path) - .with_context(|| format!("Failed to read {}", settings_path.display()))?; - if content.trim().is_empty() { - return Ok(removed); - } +/// Strip the RTK entry from one Droid hook file. Returns true if the file +/// held an RTK entry (and was rewritten, unless dry-run). +fn remove_droid_hook_from_file(file: &DroidHookFile, ctx: InitContext) -> Result { + let InitContext { verbose, dry_run } = ctx; + let path = &file.path; - let mut root: serde_json::Value = serde_json::from_str(&content) - .with_context(|| format!("Failed to parse {} as JSON", settings_path.display()))?; + let mut root = match read_droid_json(path)? { + Some(v) => v, + None => return Ok(false), + }; - if !remove_droid_hook_from_json(&mut root) { - return Ok(removed); + if !remove_droid_hook_from_json(&mut root, file.layout) { + return Ok(false); } if dry_run { println!( - "[dry-run] would remove RTK entry from Droid settings.json: {}", - settings_path.display() + "[dry-run] would remove RTK entry from Droid hook file: {}", + path.display() ); } else { - let backup_path = settings_path.with_extension("json.bak"); - fs::copy(&settings_path, &backup_path).ok(); + let backup_path = path.with_extension("json.bak"); + fs::copy(path, &backup_path).ok(); - let serialized = serde_json::to_string_pretty(&root) - .context("Failed to serialize Droid settings.json")?; - atomic_write(&settings_path, &serialized)?; + let serialized = + serde_json::to_string_pretty(&root).context("Failed to serialize Droid hook file")?; + atomic_write(path, &serialized)?; if verbose > 0 { - eprintln!("Removed RTK hook from Droid settings.json"); + eprintln!("Removed RTK hook from {}", path.display()); } } - removed.push(format!("Droid settings.json: {}", settings_path.display())); - Ok(removed) + Ok(true) } -fn remove_droid_hook_from_json(root: &mut serde_json::Value) -> bool { - let hooks_obj = match root.get_mut("hooks").and_then(|h| h.as_object_mut()) { +fn remove_droid_hook_from_json(root: &mut serde_json::Value, layout: DroidLayout) -> bool { + let events_obj = match layout { + DroidLayout::Root => root.as_object_mut(), + DroidLayout::Nested => root.get_mut("hooks").and_then(|h| h.as_object_mut()), + }; + let events_obj = match events_obj { Some(o) => o, None => return false, }; - let pre_tool_use = match hooks_obj + let pre_tool_use = match events_obj .get_mut(PRE_TOOL_USE_KEY) .and_then(|p| p.as_array_mut()) { @@ -3159,12 +3290,18 @@ fn remove_droid_hook_from_json(root: &mut serde_json::Value) -> bool { modified = true; } - // Drop empty PreToolUse array, then empty hooks object. + // Drop the PreToolUse key once empty; in settings.json also drop the + // then-empty `hooks` object. if pre_tool_use.is_empty() { - hooks_obj.remove(PRE_TOOL_USE_KEY); + events_obj.remove(PRE_TOOL_USE_KEY); modified = true; } - if hooks_obj.is_empty() { + if layout == DroidLayout::Nested + && root + .get("hooks") + .and_then(|h| h.as_object()) + .is_some_and(|o| o.is_empty()) + { if let Some(obj) = root.as_object_mut() { obj.remove("hooks"); } @@ -5486,12 +5623,14 @@ mod tests { // --- Factory Droid --- #[test] - fn test_resolve_droid_dir_prefers_factory_home() { - let factory_home = OsString::from("/custom/factory"); + fn test_resolve_droid_dir_prefers_home_override() { + // FACTORY_HOME_OVERRIDE replaces the HOME directory; `.factory` is + // appended to it (mirrors Droid's own resolution, v0.164.0). + let override_home = OsString::from("/custom/home"); let resolved = - resolve_droid_dir_from_env(Some(PathBuf::from("/tmp/home")), Some(factory_home)) + resolve_droid_dir_from_env(Some(PathBuf::from("/tmp/home")), Some(override_home)) .unwrap(); - assert_eq!(resolved, PathBuf::from("/custom/factory")); + assert_eq!(resolved, PathBuf::from("/custom/home/.factory")); } #[test] @@ -5503,9 +5642,9 @@ mod tests { } #[test] - fn test_insert_droid_hook_entry_empty() { + fn test_insert_droid_hook_entry_empty_nested() { let mut root = serde_json::json!({}); - insert_droid_hook_entry(&mut root).unwrap(); + insert_droid_hook_entry(&mut root, DroidLayout::Nested).unwrap(); let pre = root["hooks"]["PreToolUse"].as_array().unwrap(); assert_eq!(pre.len(), 1); assert_eq!(pre[0]["matcher"], "Execute"); @@ -5514,6 +5653,21 @@ mod tests { assert_eq!(hooks[0]["command"], DROID_HOOK_COMMAND); } + #[test] + fn test_insert_droid_hook_entry_empty_root() { + // hooks.json holds the event map at the file root — no `hooks` wrapper. + let mut root = serde_json::json!({}); + insert_droid_hook_entry(&mut root, DroidLayout::Root).unwrap(); + assert!( + root.get("hooks").is_none(), + "no hooks wrapper in hooks.json" + ); + let pre = root["PreToolUse"].as_array().unwrap(); + assert_eq!(pre.len(), 1); + assert_eq!(pre[0]["matcher"], "Execute"); + assert_eq!(pre[0]["hooks"][0]["command"], DROID_HOOK_COMMAND); + } + #[test] fn test_insert_droid_hook_entry_reuses_execute_group() { let mut root = serde_json::json!({ @@ -5524,7 +5678,7 @@ mod tests { }] } }); - insert_droid_hook_entry(&mut root).unwrap(); + insert_droid_hook_entry(&mut root, DroidLayout::Nested).unwrap(); let pre = root["hooks"]["PreToolUse"].as_array().unwrap(); assert_eq!(pre.len(), 1, "must not duplicate the matcher entry"); let hooks = pre[0]["hooks"].as_array().unwrap(); @@ -5543,7 +5697,10 @@ mod tests { }] } }); - assert!(droid_hook_already_present(&root)); + assert!(droid_hook_already_present(&root, DroidLayout::Nested)); + // The same document read with the Root layout must NOT match: in + // hooks.json a top-level `hooks` key is not the event map. + assert!(!droid_hook_already_present(&root, DroidLayout::Root)); } #[test] @@ -5556,7 +5713,7 @@ mod tests { }] } }); - assert!(!droid_hook_already_present(&root)); + assert!(!droid_hook_already_present(&root, DroidLayout::Nested)); } #[test] @@ -5572,7 +5729,7 @@ mod tests { }] } }); - assert!(remove_droid_hook_from_json(&mut root)); + assert!(remove_droid_hook_from_json(&mut root, DroidLayout::Nested)); let hooks = root["hooks"]["PreToolUse"][0]["hooks"].as_array().unwrap(); assert_eq!(hooks.len(), 1); assert_eq!(hooks[0]["command"], "echo user-hook"); @@ -5590,13 +5747,99 @@ mod tests { }] } }); - assert!(remove_droid_hook_from_json(&mut root)); + assert!(remove_droid_hook_from_json(&mut root, DroidLayout::Nested)); assert!( root.get("hooks").is_none(), "hooks key should be removed when empty" ); } + #[test] + fn test_remove_droid_hook_root_layout() { + let mut root = serde_json::json!({ + "PreToolUse": [{ + "matcher": "Execute", + "hooks": [{ "type": "command", "command": DROID_HOOK_COMMAND }] + }], + "PostToolUse": [{ "matcher": "Edit", "hooks": [] }] + }); + assert!(remove_droid_hook_from_json(&mut root, DroidLayout::Root)); + assert!(root.get("PreToolUse").is_none()); + assert!( + root.get("PostToolUse").is_some(), + "unrelated events must survive" + ); + } + + #[test] + fn test_droid_target_defaults_to_hooks_json() { + // Fresh setup: the canonical hooks.json is created (Droid's own + // /hooks UI location), not the settings.json fallback. + let temp = TempDir::new().unwrap(); + let droid_dir = temp.path().join(".factory"); + let target = resolve_droid_install_target(&droid_dir).unwrap(); + assert_eq!(target.path, droid_dir.join("hooks.json")); + assert!(target.layout == DroidLayout::Root); + } + + #[test] + fn test_droid_target_prefers_hooks_json_with_pre_tool_use() { + // hooks.json defines PreToolUse: it shadows settings.json's + // PreToolUse entirely, so RTK must ride the hooks.json array. + let temp = TempDir::new().unwrap(); + let droid_dir = temp.path().join(".factory"); + fs::create_dir_all(&droid_dir).unwrap(); + fs::write( + droid_dir.join("hooks.json"), + r#"{"PreToolUse":[{"matcher":"Execute","hooks":[{"type":"command","command":"echo user"}]}]}"#, + ) + .unwrap(); + fs::write( + droid_dir.join("settings.json"), + r#"{"hooks":{"PreToolUse":[{"matcher":"Execute","hooks":[{"type":"command","command":"echo shadowed"}]}]}}"#, + ) + .unwrap(); + let target = resolve_droid_install_target(&droid_dir).unwrap(); + assert_eq!(target.path, droid_dir.join("hooks.json")); + } + + #[test] + fn test_droid_target_uses_settings_when_its_pre_tool_use_is_live() { + // hooks.json exists but has no PreToolUse key, so settings.json's + // PreToolUse is live; adding PreToolUse to hooks.json would shadow + // (silently disable) the user's settings hooks. + let temp = TempDir::new().unwrap(); + let droid_dir = temp.path().join(".factory"); + fs::create_dir_all(&droid_dir).unwrap(); + fs::write(droid_dir.join("hooks.json"), r#"{"PostToolUse":[]}"#).unwrap(); + fs::write( + droid_dir.join("settings.json"), + r#"{"hooks":{"PreToolUse":[{"matcher":"Execute","hooks":[{"type":"command","command":"echo user"}]}]}}"#, + ) + .unwrap(); + let target = resolve_droid_install_target(&droid_dir).unwrap(); + assert_eq!(target.path, droid_dir.join("settings.json")); + assert!(target.layout == DroidLayout::Nested); + } + + #[test] + fn test_droid_target_uses_legacy_hooks_json_when_root_absent() { + // Droid still reads .factory/hooks/hooks.json when the root file is + // absent; creating a root hooks.json would shadow the whole legacy + // file, so RTK patches the legacy one. + let temp = TempDir::new().unwrap(); + let droid_dir = temp.path().join(".factory"); + let legacy_dir = droid_dir.join("hooks"); + fs::create_dir_all(&legacy_dir).unwrap(); + fs::write( + legacy_dir.join("hooks.json"), + r#"{"PreToolUse":[{"matcher":"Execute","hooks":[{"type":"command","command":"echo user"}]}]}"#, + ) + .unwrap(); + let target = resolve_droid_install_target(&droid_dir).unwrap(); + assert_eq!(target.path, legacy_dir.join("hooks.json")); + } + #[test] fn test_droid_install_then_uninstall_round_trip() { let temp = TempDir::new().unwrap(); @@ -5607,22 +5850,67 @@ mod tests { }; run_droid_mode_at(&droid_dir, true, ctx).unwrap(); - let settings = droid_dir.join("settings.json"); - assert!(settings.exists(), "settings.json should be created"); + let hooks_json = droid_dir.join("hooks.json"); + assert!(hooks_json.exists(), "hooks.json should be created"); // Second run is a no-op (idempotent). run_droid_mode_at(&droid_dir, true, ctx).unwrap(); - let content = fs::read_to_string(&settings).unwrap(); + let content = fs::read_to_string(&hooks_json).unwrap(); let v: serde_json::Value = serde_json::from_str(&content).unwrap(); - let hooks = v["hooks"]["PreToolUse"][0]["hooks"].as_array().unwrap(); + let hooks = v["PreToolUse"][0]["hooks"].as_array().unwrap(); assert_eq!(hooks.len(), 1, "install must be idempotent"); - // Uninstall wipes the entry and the (now empty) hooks key. + // Uninstall wipes the entry. let removed = uninstall_droid_at(&droid_dir, ctx).unwrap(); assert!(!removed.is_empty()); - let post = fs::read_to_string(&settings).unwrap(); + let post = fs::read_to_string(&hooks_json).unwrap(); let v: serde_json::Value = serde_json::from_str(&post).unwrap(); - assert!(v.get("hooks").is_none()); + assert!(v.get("PreToolUse").is_none()); + } + + #[test] + fn test_droid_install_migrates_stale_settings_entry() { + // Simulate an old RTK install in settings.json now shadowed by a + // user-created hooks.json: install must move RTK into hooks.json and + // strip the dead settings.json copy. + let temp = TempDir::new().unwrap(); + let droid_dir = temp.path().join(".factory"); + fs::create_dir_all(&droid_dir).unwrap(); + fs::write( + droid_dir.join("hooks.json"), + r#"{"PreToolUse":[{"matcher":"Execute","hooks":[{"type":"command","command":"echo user"}]}]}"#, + ) + .unwrap(); + fs::write( + droid_dir.join("settings.json"), + format!( + r#"{{"model":"custom","hooks":{{"PreToolUse":[{{"matcher":"Execute","hooks":[{{"type":"command","command":"{}"}}]}}]}}}}"#, + DROID_HOOK_COMMAND + ), + ) + .unwrap(); + + let ctx = InitContext { + verbose: 0, + dry_run: false, + }; + run_droid_mode_at(&droid_dir, true, ctx).unwrap(); + + let hooks: serde_json::Value = + serde_json::from_str(&fs::read_to_string(droid_dir.join("hooks.json")).unwrap()) + .unwrap(); + assert!( + droid_hook_already_present(&hooks, DroidLayout::Root), + "RTK entry must now live in hooks.json" + ); + let settings: serde_json::Value = + serde_json::from_str(&fs::read_to_string(droid_dir.join("settings.json")).unwrap()) + .unwrap(); + assert!( + !droid_hook_already_present(&settings, DroidLayout::Nested), + "stale settings.json entry must be removed" + ); + assert_eq!(settings["model"], "custom", "user settings must survive"); } #[test] From 70ed82a7b7af743369daba99a9450cdbf5888049 Mon Sep 17 00:00:00 2001 From: krimvp Date: Sun, 5 Jul 2026 23:12:56 +0200 Subject: [PATCH 5/7] fix(hooks): source Droid verdicts from Droid's own permission lists The Droid hook consulted Claude Code's settings (Host::Claude), leaking one agent's permission config into another: a Bash(...) allow rule in ~/.claude/settings.json could auto-allow commands in Droid, bypassing its native prompt. Add permissions::Host::Droid backed by ~/.factory/settings.json (honoring $FACTORY_HOME_OVERRIDE as a home-dir override, matching the shipped Droid CLI): - commandAllowlist -> Allow: the rewrite carries permissionDecision "allow", exactly the verdict Droid would emit natively. - commandDenylist (always confirm) and commandBlocklist (never runs) -> Deny: RTK steps aside entirely so Droid's native confirm/block fires on the original command. Rewriting first would dodge Droid's own pattern matching ("rtk git log" no longer matches a "git log" denylist entry). - Built-in defaults are mirrored from the shipped Droid CLI (verified against v0.153.1); a user list replaces its default outright, same as Droid's own accessor. Also trims the wire-format comment per review feedback. Verified live against droid 0.153.1: git status/git log rewrite and auto-run at --auto low via the default allowlist; a denylisted git log is left untouched and natively refused; uninstall round-trip clean. Claude-Session: https://claude.ai/code/session_015CvKLxKMnmcvwY5CjHVTF5 --- src/hooks/hook_cmd.rs | 130 ++++++++++++++++++++++++------ src/hooks/permissions.rs | 166 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 269 insertions(+), 27 deletions(-) diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 22c01f07f7..cc6bcd7c30 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -561,9 +561,17 @@ fn run_cursor_inner_with_rules( // shaped like Claude Code's: `tool_name`, `tool_input.command`, and a // `hookSpecificOutput` response with `permissionDecision` + `updatedInput`. // Droid's shell-execution tool is matched as `Execute`; non-Execute payloads -// pass through silently. +// pass through silently. Verdicts come from Droid's own permission lists +// (`commandAllowlist`/`commandDenylist`/`commandBlocklist` in settings.json), +// never from another agent's settings. fn process_droid_payload(v: &Value) -> Option { + let cmd = droid_execute_command(v)?; + droid_response_from_decision(v, cmd, decide_hook_action(cmd, permissions::Host::Droid)) +} + +/// Extract the shell command when the payload targets Droid's Execute tool. +fn droid_execute_command(v: &Value) -> Option<&str> { let tool_name = v.get("tool_name").and_then(|t| t.as_str()).unwrap_or(""); // `Execute` is Droid's shell tool. The installed matcher already gates // invocations to Execute; also tolerate a missing tool_name and accept @@ -573,12 +581,9 @@ fn process_droid_payload(v: &Value) -> Option { return None; } - let cmd = v - .pointer("/tool_input/command") + v.pointer("/tool_input/command") .and_then(|c| c.as_str()) - .filter(|c| !c.is_empty())?; - - droid_response_from_decision(v, cmd, decide_hook_action(cmd, permissions::Host::Claude)) + .filter(|c| !c.is_empty()) } /// Build the Droid hook response for a decision from the shared flow. @@ -608,19 +613,12 @@ fn droid_response_from_decision(v: &Value, cmd: &str, decision: HookDecision) -> ti }; - // Wire format mirrors Claude's `hookSpecificOutput`; the allow policy mirrors - // `process_claude_payload`, NOT Cursor. Verified against Droid 0.140.0 and - // re-verified in the shipped v0.164.0 code: Droid applies a hook's - // `updatedInput` regardless of the permission decision — beyond the - // `case "allow"` branch it has an "updated input result" path that applies any - // hook's `updatedInput` even when no decision is set. So forcing "allow" is - // unnecessary for the rewrite to land, and would be harmful: Droid resolves - // the decision as `find(first result with a permissionDecision)` (no - // deny-over-allow priority), so an unconditional "allow" can suppress another - // PreToolUse hook's deny/ask and bypasses Droid's native permission prompt. - // We therefore only assert "allow" on an explicit allow rule; otherwise we - // omit the decision so the rewrite still applies while the user's other hooks - // and Droid's native prompt stay in control. + // Wire format mirrors Claude's `hookSpecificOutput`. Droid applies + // `updatedInput` even without a permission decision, and resolves decisions + // first-result-wins (verified against the shipped Droid CLI, v0.140.0–0.164.0), + // so "allow" is asserted only on an explicit allowlist match — forcing it + // unconditionally would suppress other hooks' deny/ask and bypass Droid's + // native permission prompt. let mut hook_output = json!({ "hookEventName": PRE_TOOL_USE_KEY, "permissionDecisionReason": "RTK auto-rewrite", @@ -656,10 +654,24 @@ pub fn run_droid() -> Result<()> { Ok(()) } +/// Hermetic test path: default Droid config (built-in lists, no settings file). #[cfg(test)] fn run_droid_inner(input: &str) -> Option { + let (deny, ask, allow) = permissions::droid_rules_from_settings(None); + run_droid_inner_with_rules(input, &deny, &ask, &allow) +} + +#[cfg(test)] +fn run_droid_inner_with_rules( + input: &str, + deny_rules: &[String], + ask_rules: &[String], + allow_rules: &[String], +) -> Option { let v: Value = serde_json::from_str(input).ok()?; - process_droid_payload(&v).map(|o| o.to_string()) + let cmd = droid_execute_command(&v)?; + let verdict = permissions::check_command_with_rules(cmd, deny_rules, ask_rules, allow_rules); + droid_response_from_decision(&v, cmd, decide_from_verdict(cmd, verdict)).map(|o| o.to_string()) } #[cfg(test)] @@ -1534,6 +1546,9 @@ mod tests { #[test] fn test_droid_rewrites_execute_tool() { + // `git status` is on Droid's built-in commandAllowlist (auto-run), so + // the rewrite carries `permissionDecision: allow` — exactly the + // verdict Droid would have emitted natively for the original command. let input = droid_input("Execute", "git status"); let out = run_droid_inner(&input).expect("rewrite expected"); let v: Value = serde_json::from_str(&out).unwrap(); @@ -1550,15 +1565,78 @@ mod tests { .and_then(|c| c.as_str()), Some("PreToolUse") ); - // Default verdict (no rule) must OMIT permissionDecision. Verified - // against Droid 0.140.0: `updatedInput` is applied regardless of the - // decision, so omitting it lets the rewrite land while preserving - // Droid's native prompt and other hooks' deny/ask (Droid picks the - // first hook result that sets a decision). Mirrors the Claude handler. + assert_eq!( + v.pointer("/hookSpecificOutput/permissionDecision") + .and_then(|c| c.as_str()), + Some("allow"), + "default-allowlisted command must auto-allow" + ); + } + + #[test] + fn test_droid_unlisted_command_omits_decision() { + // Not on any Droid list → rewrite lands via Droid's "updated input + // result" path with NO decision, leaving Droid's native prompt and + // other hooks' deny/ask in control. + let input = droid_input("Execute", "cargo build"); + let out = run_droid_inner(&input).expect("rewrite expected"); + let v: Value = serde_json::from_str(&out).unwrap(); + assert!( + v.pointer("/hookSpecificOutput/updatedInput/command") + .and_then(|c| c.as_str()) + .is_some_and(|c| c.starts_with("rtk ")), + "expected rtk-prefixed rewrite" + ); + assert!( + v.pointer("/hookSpecificOutput/permissionDecision") + .is_none(), + "unlisted command must not force a permission decision" + ); + } + + #[test] + fn test_droid_denylisted_command_steps_aside() { + // A commandDenylist match must produce NO output: rewriting would + // dodge Droid's own pattern match (`rtk git log` no longer matches a + // `git log` denylist entry), silently dropping the user's + // always-confirm rule. Stepping aside keeps Droid's native + // confirmation on the original command. + let settings = json!({ "commandDenylist": ["git log"] }); + let (deny, ask, allow) = permissions::droid_rules_from_settings(Some(&settings)); + let input = droid_input("Execute", "git log --oneline"); + assert!( + run_droid_inner_with_rules(&input, &deny, &ask, &allow).is_none(), + "denylisted command must step aside (no output)" + ); + } + + #[test] + fn test_droid_blocklisted_command_steps_aside() { + // Same contract for commandBlocklist (never runs): step aside so + // Droid's Execute-level block fires on the original command. + let settings = json!({ "commandBlocklist": ["git status"] }); + let (deny, ask, allow) = permissions::droid_rules_from_settings(Some(&settings)); + let input = droid_input("Execute", "git status"); + assert!( + run_droid_inner_with_rules(&input, &deny, &ask, &allow).is_none(), + "blocklisted command must step aside (no output)" + ); + } + + #[test] + fn test_droid_user_allowlist_replaces_defaults() { + // A user commandAllowlist replaces Droid's built-in default outright, + // so `git status` (default-allowed) drops back to no-decision. + let settings = json!({ "commandAllowlist": ["cargo test"] }); + let (deny, ask, allow) = permissions::droid_rules_from_settings(Some(&settings)); + let input = droid_input("Execute", "git status"); + let out = run_droid_inner_with_rules(&input, &deny, &ask, &allow) + .expect("rewrite still expected"); + let v: Value = serde_json::from_str(&out).unwrap(); assert!( v.pointer("/hookSpecificOutput/permissionDecision") .is_none(), - "default verdict must not force a permission decision" + "user allowlist replaced the default — no auto-allow for git status" ); } diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index a4f04b78bd..dfb11010d9 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -1,4 +1,7 @@ -use super::constants::{CLAUDE_DIR, CURSOR_DIR, GEMINI_DIR, SETTINGS_JSON, SETTINGS_LOCAL_JSON}; +use super::constants::{ + CLAUDE_DIR, CURSOR_DIR, DROID_DIR, DROID_HOME_ENV, DROID_SETTINGS_FILE, GEMINI_DIR, + SETTINGS_JSON, SETTINGS_LOCAL_JSON, +}; use crate::core::stream::exec_capture; use crate::discover::lexer::split_for_permissions; use serde_json::Value; @@ -32,6 +35,7 @@ pub enum Host { Claude, Cursor, Gemini, + Droid, } pub fn check_command_for(cmd: &str, host: Host) -> PermissionVerdict { @@ -39,6 +43,7 @@ pub fn check_command_for(cmd: &str, host: Host) -> PermissionVerdict { Host::Claude => load_permission_rules(), Host::Cursor => load_cursor_rules(), Host::Gemini => load_gemini_rules(), + Host::Droid => load_droid_rules(), }; check_command_with_rules(cmd, &deny_rules, &ask_rules, &allow_rules) } @@ -271,6 +276,103 @@ fn load_gemini_rules() -> (Vec, Vec, Vec) { (Vec::new(), ask, allow) } +// Droid's built-in permission lists, mirrored from the shipped Droid CLI +// (verified against v0.153.1). A user list in `settings.json` REPLACES the +// matching default outright (`settings.commandAllowlist || DEFAULT`), so RTK +// applies the same fallback. The default `commandBlocklist` is empty. +const DROID_DEFAULT_ALLOWLIST: &[&str] = &[ + "ls", + "pwd", + "dir", + "git status", + "git diff", + "git log", + "git show", + "git blame", + "git ls-files", +]; +const DROID_DEFAULT_DENYLIST: &[&str] = &[ + "rm -rf /", + "rm -rf /*", + "rm -rf .", + "rm -rf ~", + "rm -rf ~/*", + "rm -rf $HOME", + "rm -r /", + "rm -r /*", + "rm -r ~", + "rm -r ~/*", + "mkfs", + "mkfs.ext4", + "mkfs.ext3", + "mkfs.vfat", + "mkfs.ntfs", + "dd if=/dev/zero of=/dev", + "dd of=/dev", + "shutdown", + "reboot", + "halt", + "poweroff", + "init 0", + "init 6", + ":(){ :|: & };:", + ":() { :|:& };:", + "chmod -R 777 /", + "chmod -R 000 /", + "chown -R", + "Format-Volume", + "format.com", + "powershell Remove-Item -Recurse -Force", +]; + +// Global config only, honoring $FACTORY_HOME_OVERRIDE like the installer. +fn droid_settings() -> Option { + let home = std::env::var_os(DROID_HOME_ENV) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(dirs::home_dir)?; + read_json(&home.join(DROID_DIR).join(DROID_SETTINGS_FILE)) +} + +/// Load Droid's own permission lists from `settings.json`. +/// +/// Mapping: `commandAllowlist` → allow; `commandDenylist` (always confirm) +/// and `commandBlocklist` (never runs) → deny. Both map to deny — not ask — +/// so RTK steps aside entirely and Droid's native confirm/block fires on the +/// original command. Rewriting first would dodge Droid's own pattern +/// matching (`git push --force` → `rtk git push --force` no longer matches a +/// `git push --force` list entry). +fn load_droid_rules() -> (Vec, Vec, Vec) { + droid_rules_from_settings(droid_settings().as_ref()) +} + +pub(crate) fn droid_rules_from_settings( + settings: Option<&Value>, +) -> (Vec, Vec, Vec) { + let list = |key: &str, default: &[&str]| -> Vec { + settings + .and_then(|s| s.get(key)) + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|rule| !rule.is_empty()) + .map(String::from) + .collect() + }) + .unwrap_or_else(|| default.iter().map(|rule| rule.to_string()).collect()) + }; + + let mut deny = list("commandBlocklist", &[]); + deny.extend(list("commandDenylist", DROID_DEFAULT_DENYLIST)); + ( + deny, + Vec::new(), + list("commandAllowlist", DROID_DEFAULT_ALLOWLIST), + ) +} + /// Locate the project root by walking up from CWD looking for `.claude/`. /// /// Falls back to `git rev-parse --show-toplevel` if not found via directory walk. @@ -951,6 +1053,68 @@ mod tests { // --- Per-host rule extraction --- + #[test] + fn test_droid_defaults_when_no_settings() { + let (deny, ask, allow) = droid_rules_from_settings(None); + assert!(ask.is_empty(), "Droid has no ask-shaped list"); + assert!(allow.iter().any(|r| r == "git status")); + assert!(deny.iter().any(|r| r == "rm -rf /")); + // Default blocklist is empty — deny is exactly the default denylist. + assert_eq!(deny.len(), DROID_DEFAULT_DENYLIST.len()); + } + + #[test] + fn test_droid_user_lists_replace_defaults() { + let settings = serde_json::json!({ + "commandAllowlist": ["cargo test"], + "commandDenylist": ["git push"], + }); + let (deny, _, allow) = droid_rules_from_settings(Some(&settings)); + assert_eq!(allow, vec!["cargo test"]); + // User denylist replaces the default denylist outright. + assert_eq!(deny, vec!["git push"]); + } + + #[test] + fn test_droid_blocklist_and_denylist_both_deny() { + let settings = serde_json::json!({ + "commandBlocklist": ["curl:*"], + "commandDenylist": ["git push"], + }); + let (deny, ask, _) = droid_rules_from_settings(Some(&settings)); + assert_eq!(deny, vec!["curl:*", "git push"]); + assert!(ask.is_empty()); + } + + #[test] + fn test_droid_malformed_entries_filtered() { + let settings = serde_json::json!({ + "commandAllowlist": [" git status ", "", 42, null, {"nested": true}], + }); + let (_, _, allow) = droid_rules_from_settings(Some(&settings)); + assert_eq!(allow, vec!["git status"]); + } + + #[test] + fn test_droid_default_allowlist_verdicts() { + let (deny, ask, allow) = droid_rules_from_settings(None); + // Read-only commands Droid auto-runs natively get Allow… + assert_eq!( + check_command_with_rules("git status --short", &deny, &ask, &allow), + PermissionVerdict::Allow + ); + // …anything else stays Default so Droid's native flow decides… + assert_eq!( + check_command_with_rules("cargo build", &deny, &ask, &allow), + PermissionVerdict::Default + ); + // …and the default denylist yields Deny (RTK steps aside). + assert_eq!( + check_command_with_rules("shutdown -h now", &deny, &ask, &allow), + PermissionVerdict::Deny + ); + } + #[test] fn test_wrapped_rules_cursor_shell_only() { let v = serde_json::json!([ From 0a032aab10f215de3ed5a046d914fcd12388bf43 Mon Sep 17 00:00:00 2001 From: krimvp Date: Sun, 5 Jul 2026 23:12:56 +0200 Subject: [PATCH 6/7] docs(agents): add Factory Droid to supported-agents guide Propagates the README Droid documentation to docs/guide/getting-started/supported-agents.md per review feedback: frontmatter description, agents table row, and an installation section covering hooks.json placement and Droid-native permission handling. Claude-Session: https://claude.ai/code/session_015CvKLxKMnmcvwY5CjHVTF5 --- .../guide/getting-started/supported-agents.md | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/guide/getting-started/supported-agents.md b/docs/guide/getting-started/supported-agents.md index d5a0ad87c7..64567a7762 100644 --- a/docs/guide/getting-started/supported-agents.md +++ b/docs/guide/getting-started/supported-agents.md @@ -1,6 +1,6 @@ --- title: Supported Agents -description: How to integrate RTK with Claude Code, Cursor, Copilot, Cline, Windsurf, Codex, OpenCode, Hermes, Kilo Code, and Antigravity +description: How to integrate RTK with Claude Code, Cursor, Copilot, Cline, Windsurf, Codex, OpenCode, Hermes, Kilo Code, Antigravity, and Factory Droid sidebar: order: 3 --- @@ -37,6 +37,7 @@ Agent runs "cargo test" | OpenClaw | TypeScript plugin (`before_tool_call`) | Yes | | Pi | TypeScript extension (`tool_call` event) | Yes | | Hermes | Python plugin (`terminal` command mutation) | Yes | +| Factory Droid | Shell hook (`PreToolUse`, matcher `Execute`) | Yes | | Cline / Roo Code | Rules file (prompt-level) | N/A | | Windsurf | Rules file (prompt-level) | N/A | | Codex CLI | AGENTS.md instructions | N/A | @@ -137,6 +138,26 @@ Creates `~/.hermes/plugins/rtk-rewrite/` and enables it through `plugins.enabled The plugin fails open. If `rtk` is missing at load time, the hook is not registered. If `rtk rewrite` errors, the tool is not `terminal`, the payload has no string `command`, or the plugin raises an exception, Hermes runs the original command unchanged. The same `rtk rewrite` limitations apply: already-prefixed `rtk` commands, compound shell commands, heredocs, and commands without filters are not rewritten. +### Factory Droid + +```bash +rtk init -g --agent droid # user-scoped (~/.factory/hooks.json) +rtk init --agent droid # project-scoped (.factory/hooks.json, commit to share) +``` + +Installs a `PreToolUse` hook (matcher `Execute`) into Droid's canonical `hooks.json` — falling back to the `hooks` key of `settings.json` only when that file already carries live `PreToolUse` hooks. Respects `$FACTORY_HOME_OVERRIDE`. + +RTK enforces Droid's own permission lists from `~/.factory/settings.json`, never another agent's settings: commands matching `commandAllowlist` (or Droid's built-in read-only defaults) are rewritten and auto-allowed; commands matching `commandDenylist` or `commandBlocklist` are left untouched so Droid's native confirmation or block fires on the original command. + +Uninstall: + +```bash +rtk init --uninstall -g --agent droid +rtk init --uninstall --agent droid +``` + +Removes only RTK's hook entry; other hooks and settings are untouched. + ### Cline / Roo Code ```bash From 3d407426b5ca63ca7e6bd0f31a572e9b0342b05b Mon Sep 17 00:00:00 2001 From: krimvp Date: Mon, 6 Jul 2026 23:13:39 +0200 Subject: [PATCH 7/7] fix(hooks): make Droid verdicts deny-only from explicit lists in all scopes Drop the hardcoded mirrors of Droid's built-in command lists and stop emitting permissionDecision entirely. RTK now steps aside only on explicit commandDenylist/commandBlocklist entries, unioned across all four settings scopes (user + project, settings.json + settings.local.json); every other command is rewritten via updatedInput and the decision is left to Droid's native flow. Verified against Droid CLI 0.153.1: updatedInput is applied after Droid's own permission evaluation of the original command, so native allowlist decisions are unaffected and deny entries fire natively. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SpSp3Arj7fG1XJYhXoCMGC --- .../guide/getting-started/supported-agents.md | 2 +- src/hooks/hook_cmd.rs | 104 +++++---- src/hooks/permissions.rs | 199 ++++++++---------- 3 files changed, 142 insertions(+), 163 deletions(-) diff --git a/docs/guide/getting-started/supported-agents.md b/docs/guide/getting-started/supported-agents.md index 64567a7762..1c594d1c5a 100644 --- a/docs/guide/getting-started/supported-agents.md +++ b/docs/guide/getting-started/supported-agents.md @@ -147,7 +147,7 @@ rtk init --agent droid # project-scoped (.factory/hooks.json, commit to sh Installs a `PreToolUse` hook (matcher `Execute`) into Droid's canonical `hooks.json` — falling back to the `hooks` key of `settings.json` only when that file already carries live `PreToolUse` hooks. Respects `$FACTORY_HOME_OVERRIDE`. -RTK enforces Droid's own permission lists from `~/.factory/settings.json`, never another agent's settings: commands matching `commandAllowlist` (or Droid's built-in read-only defaults) are rewritten and auto-allowed; commands matching `commandDenylist` or `commandBlocklist` are left untouched so Droid's native confirmation or block fires on the original command. +RTK honors Droid's own permission lists, never another agent's settings. Commands matching an explicit `commandDenylist` or `commandBlocklist` entry — read from all four settings scopes (`~/.factory/settings.json`, `~/.factory/settings.local.json`, `.factory/settings.json`, `.factory/settings.local.json`) — are left untouched so Droid's native confirmation or block fires on the original command. Every other command is rewritten via `updatedInput` with **no** permission decision: Droid's native flow (allowlist, autonomy level, other hooks) decides on the rewritten command. To auto-run rewritten read-only commands, add `rtk`-prefixed entries (e.g. `rtk git status`) to your `commandAllowlist`. Uninstall: diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index cc6bcd7c30..40451b93c0 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -557,13 +557,10 @@ fn run_cursor_inner_with_rules( // ── Factory Droid PreToolUse hook ────────────────────────────── // -// Droid's PreToolUse payload (docs.factory.ai/reference/hooks-reference) is -// shaped like Claude Code's: `tool_name`, `tool_input.command`, and a -// `hookSpecificOutput` response with `permissionDecision` + `updatedInput`. -// Droid's shell-execution tool is matched as `Execute`; non-Execute payloads -// pass through silently. Verdicts come from Droid's own permission lists -// (`commandAllowlist`/`commandDenylist`/`commandBlocklist` in settings.json), -// never from another agent's settings. +// Payload is shaped like Claude Code's (docs.factory.ai/reference/hooks-reference); +// the shell tool is matched as `Execute`. RTK steps aside on Droid's explicit +// deny lists and otherwise rewrites via `updatedInput` with no +// `permissionDecision` — the verdict stays with Droid's native flow. fn process_droid_payload(v: &Value) -> Option { let cmd = droid_execute_command(v)?; @@ -588,19 +585,18 @@ fn droid_execute_command(v: &Value) -> Option<&str> { /// Build the Droid hook response for a decision from the shared flow. /// -/// On `Deny` we step aside (emit no output) so Droid's native deny handling -/// fires, matching Claude/Cursor/Copilot. `Defer` (command substitution, file -/// redirects, or no rewrite available) also stays silent so Droid runs the -/// command unchanged. RTK never emits its own block here. +/// On `Deny` and `Defer`, stay silent so Droid handles the original command. +/// Rewrites land via `updatedInput` alone — never a `permissionDecision`: +/// RTK can't reproduce the verdict Droid would emit for a command it renames +/// to `rtk …` (updatedInput-without-decision verified on Droid v0.140–0.164). fn droid_response_from_decision(v: &Value, cmd: &str, decision: HookDecision) -> Option { - let (rewritten, allow) = match decision { + let rewritten = match decision { HookDecision::Deny => { audit_log("deny", cmd, ""); return None; } HookDecision::Defer => return None, - HookDecision::AllowRewrite(r) => (r, true), - HookDecision::AskRewrite(r) => (r, false), + HookDecision::AllowRewrite(r) | HookDecision::AskRewrite(r) => r, }; audit_log("rewrite", cmd, &rewritten); @@ -613,23 +609,13 @@ fn droid_response_from_decision(v: &Value, cmd: &str, decision: HookDecision) -> ti }; - // Wire format mirrors Claude's `hookSpecificOutput`. Droid applies - // `updatedInput` even without a permission decision, and resolves decisions - // first-result-wins (verified against the shipped Droid CLI, v0.140.0–0.164.0), - // so "allow" is asserted only on an explicit allowlist match — forcing it - // unconditionally would suppress other hooks' deny/ask and bypass Droid's - // native permission prompt. - let mut hook_output = json!({ - "hookEventName": PRE_TOOL_USE_KEY, - "permissionDecisionReason": "RTK auto-rewrite", - "updatedInput": updated_input - }); - - if allow { - hook_output["permissionDecision"] = json!("allow"); - } - - Some(json!({ "hookSpecificOutput": hook_output })) + Some(json!({ + "hookSpecificOutput": { + "hookEventName": PRE_TOOL_USE_KEY, + "permissionDecisionReason": "RTK auto-rewrite", + "updatedInput": updated_input + } + })) } /// Run the Factory Droid PreToolUse hook natively. @@ -654,10 +640,10 @@ pub fn run_droid() -> Result<()> { Ok(()) } -/// Hermetic test path: default Droid config (built-in lists, no settings file). +/// Hermetic test path: no Droid settings (empty rules). #[cfg(test)] fn run_droid_inner(input: &str) -> Option { - let (deny, ask, allow) = permissions::droid_rules_from_settings(None); + let (deny, ask, allow) = permissions::droid_rules_from_settings(&[]); run_droid_inner_with_rules(input, &deny, &ask, &allow) } @@ -1546,9 +1532,8 @@ mod tests { #[test] fn test_droid_rewrites_execute_tool() { - // `git status` is on Droid's built-in commandAllowlist (auto-run), so - // the rewrite carries `permissionDecision: allow` — exactly the - // verdict Droid would have emitted natively for the original command. + // Rewrites land via `updatedInput` with no decision — Droid's native + // flow decides on the rewritten command. let input = droid_input("Execute", "git status"); let out = run_droid_inner(&input).expect("rewrite expected"); let v: Value = serde_json::from_str(&out).unwrap(); @@ -1565,11 +1550,10 @@ mod tests { .and_then(|c| c.as_str()), Some("PreToolUse") ); - assert_eq!( + assert!( v.pointer("/hookSpecificOutput/permissionDecision") - .and_then(|c| c.as_str()), - Some("allow"), - "default-allowlisted command must auto-allow" + .is_none(), + "RTK must never assert a permission decision for Droid" ); } @@ -1602,7 +1586,7 @@ mod tests { // always-confirm rule. Stepping aside keeps Droid's native // confirmation on the original command. let settings = json!({ "commandDenylist": ["git log"] }); - let (deny, ask, allow) = permissions::droid_rules_from_settings(Some(&settings)); + let (deny, ask, allow) = permissions::droid_rules_from_settings(&[settings]); let input = droid_input("Execute", "git log --oneline"); assert!( run_droid_inner_with_rules(&input, &deny, &ask, &allow).is_none(), @@ -1615,7 +1599,7 @@ mod tests { // Same contract for commandBlocklist (never runs): step aside so // Droid's Execute-level block fires on the original command. let settings = json!({ "commandBlocklist": ["git status"] }); - let (deny, ask, allow) = permissions::droid_rules_from_settings(Some(&settings)); + let (deny, ask, allow) = permissions::droid_rules_from_settings(&[settings]); let input = droid_input("Execute", "git status"); assert!( run_droid_inner_with_rules(&input, &deny, &ask, &allow).is_none(), @@ -1624,11 +1608,11 @@ mod tests { } #[test] - fn test_droid_user_allowlist_replaces_defaults() { - // A user commandAllowlist replaces Droid's built-in default outright, - // so `git status` (default-allowed) drops back to no-decision. - let settings = json!({ "commandAllowlist": ["cargo test"] }); - let (deny, ask, allow) = permissions::droid_rules_from_settings(Some(&settings)); + fn test_droid_allowlist_never_auto_allows() { + // Even an allowlisted command gets no decision — RTK can't reproduce + // Droid's allow once the program is renamed to `rtk`. + let settings = json!({ "commandAllowlist": ["git status"] }); + let (deny, ask, allow) = permissions::droid_rules_from_settings(&[settings]); let input = droid_input("Execute", "git status"); let out = run_droid_inner_with_rules(&input, &deny, &ask, &allow) .expect("rewrite still expected"); @@ -1636,7 +1620,21 @@ mod tests { assert!( v.pointer("/hookSpecificOutput/permissionDecision") .is_none(), - "user allowlist replaced the default — no auto-allow for git status" + "allowlisted command must not auto-allow" + ); + } + + #[test] + fn test_droid_project_scope_deny_steps_aside() { + // A deny entry in any scope (here: project) must step aside — + // global-only reads would let the rewrite dodge it. + let user = json!({}); + let project = json!({ "commandDenylist": ["git log"] }); + let (deny, ask, allow) = permissions::droid_rules_from_settings(&[user, project]); + let input = droid_input("Execute", "git log --oneline"); + assert!( + run_droid_inner_with_rules(&input, &deny, &ask, &allow).is_none(), + "project-scope deny entry must step aside (no output)" ); } @@ -1677,8 +1675,8 @@ mod tests { } #[test] - fn test_droid_allow_decision_auto_allows() { - // An explicit allow rule auto-allows the rewritten command. + fn test_droid_allow_decision_emits_no_permission_decision() { + // Defensive: even an AllowRewrite decision carries the rewrite only. let v: Value = serde_json::from_str(&droid_input("Execute", "git status")).unwrap(); let out = droid_response_from_decision( &v, @@ -1686,10 +1684,10 @@ mod tests { HookDecision::AllowRewrite("rtk git status".to_string()), ) .expect("rewrite expected"); - assert_eq!( + assert!( out.pointer("/hookSpecificOutput/permissionDecision") - .and_then(|c| c.as_str()), - Some("allow") + .is_none(), + "no permission decision may be emitted" ); assert_eq!( out.pointer("/hookSpecificOutput/updatedInput/command") diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index dfb11010d9..da94c207d0 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -276,101 +276,62 @@ fn load_gemini_rules() -> (Vec, Vec, Vec) { (Vec::new(), ask, allow) } -// Droid's built-in permission lists, mirrored from the shipped Droid CLI -// (verified against v0.153.1). A user list in `settings.json` REPLACES the -// matching default outright (`settings.commandAllowlist || DEFAULT`), so RTK -// applies the same fallback. The default `commandBlocklist` is empty. -const DROID_DEFAULT_ALLOWLIST: &[&str] = &[ - "ls", - "pwd", - "dir", - "git status", - "git diff", - "git log", - "git show", - "git blame", - "git ls-files", -]; -const DROID_DEFAULT_DENYLIST: &[&str] = &[ - "rm -rf /", - "rm -rf /*", - "rm -rf .", - "rm -rf ~", - "rm -rf ~/*", - "rm -rf $HOME", - "rm -r /", - "rm -r /*", - "rm -r ~", - "rm -r ~/*", - "mkfs", - "mkfs.ext4", - "mkfs.ext3", - "mkfs.vfat", - "mkfs.ntfs", - "dd if=/dev/zero of=/dev", - "dd of=/dev", - "shutdown", - "reboot", - "halt", - "poweroff", - "init 0", - "init 6", - ":(){ :|: & };:", - ":() { :|:& };:", - "chmod -R 777 /", - "chmod -R 000 /", - "chown -R", - "Format-Volume", - "format.com", - "powershell Remove-Item -Recurse -Force", -]; - -// Global config only, honoring $FACTORY_HOME_OVERRIDE like the installer. -fn droid_settings() -> Option { - let home = std::env::var_os(DROID_HOME_ENV) +// All four Droid settings scopes: user (honoring $FACTORY_HOME_OVERRIDE) and +// project `.factory/`, each with settings.json + settings.local.json +// (docs.factory.ai/cli/configuration/settings). Missing files are skipped. +fn droid_settings_scopes() -> Vec { + let mut dirs_to_read = Vec::new(); + if let Some(home) = std::env::var_os(DROID_HOME_ENV) .filter(|value| !value.is_empty()) .map(PathBuf::from) - .or_else(dirs::home_dir)?; - read_json(&home.join(DROID_DIR).join(DROID_SETTINGS_FILE)) + .or_else(dirs::home_dir) + { + dirs_to_read.push(home.join(DROID_DIR)); + } + if let Some(root) = find_project_root() { + dirs_to_read.push(root.join(DROID_DIR)); + } + + let mut scopes = Vec::new(); + for dir in dirs_to_read { + for file in [DROID_SETTINGS_FILE, SETTINGS_LOCAL_JSON] { + if let Some(v) = read_json(&dir.join(file)) { + scopes.push(v); + } + } + } + scopes } -/// Load Droid's own permission lists from `settings.json`. -/// -/// Mapping: `commandAllowlist` → allow; `commandDenylist` (always confirm) -/// and `commandBlocklist` (never runs) → deny. Both map to deny — not ask — -/// so RTK steps aside entirely and Droid's native confirm/block fires on the -/// original command. Rewriting first would dodge Droid's own pattern -/// matching (`git push --force` → `rtk git push --force` no longer matches a -/// `git push --force` list entry). +/// Deny-only: `commandDenylist`/`commandBlocklist` → deny, so RTK steps aside +/// and Droid's native confirm/block fires on the original command (rewriting +/// first would dodge Droid's own pattern matching). Entries are unioned across +/// scopes — a spurious step-aside is safe, a missed entry reopens the dodge. +/// No allow rules: RTK never asserts a decision for a command it renames to +/// `rtk …`, and Droid's built-in defaults are not mirrored (they would drift). fn load_droid_rules() -> (Vec, Vec, Vec) { - droid_rules_from_settings(droid_settings().as_ref()) + droid_rules_from_settings(&droid_settings_scopes()) } pub(crate) fn droid_rules_from_settings( - settings: Option<&Value>, + scopes: &[Value], ) -> (Vec, Vec, Vec) { - let list = |key: &str, default: &[&str]| -> Vec { - settings - .and_then(|s| s.get(key)) - .and_then(Value::as_array) - .map(|arr| { + let mut deny = Vec::new(); + for settings in scopes { + for key in ["commandBlocklist", "commandDenylist"] { + let Some(arr) = settings.get(key).and_then(Value::as_array) else { + continue; + }; + deny.extend( arr.iter() .filter_map(Value::as_str) .map(str::trim) .filter(|rule| !rule.is_empty()) - .map(String::from) - .collect() - }) - .unwrap_or_else(|| default.iter().map(|rule| rule.to_string()).collect()) - }; - - let mut deny = list("commandBlocklist", &[]); - deny.extend(list("commandDenylist", DROID_DEFAULT_DENYLIST)); - ( - deny, - Vec::new(), - list("commandAllowlist", DROID_DEFAULT_ALLOWLIST), - ) + .map(String::from), + ); + } + } + (deny, Vec::new(), Vec::new()) } /// Locate the project root by walking up from CWD looking for `.claude/`. @@ -1054,25 +1015,25 @@ mod tests { // --- Per-host rule extraction --- #[test] - fn test_droid_defaults_when_no_settings() { - let (deny, ask, allow) = droid_rules_from_settings(None); + fn test_droid_no_settings_yields_no_rules() { + // No hardcoded defaults: without explicit settings there are no rules. + let (deny, ask, allow) = droid_rules_from_settings(&[]); + assert!(deny.is_empty(), "no built-in denylist may be mirrored"); assert!(ask.is_empty(), "Droid has no ask-shaped list"); - assert!(allow.iter().any(|r| r == "git status")); - assert!(deny.iter().any(|r| r == "rm -rf /")); - // Default blocklist is empty — deny is exactly the default denylist. - assert_eq!(deny.len(), DROID_DEFAULT_DENYLIST.len()); + assert!(allow.is_empty(), "RTK never asserts allow for Droid"); } #[test] - fn test_droid_user_lists_replace_defaults() { - let settings = serde_json::json!({ - "commandAllowlist": ["cargo test"], - "commandDenylist": ["git push"], - }); - let (deny, _, allow) = droid_rules_from_settings(Some(&settings)); - assert_eq!(allow, vec!["cargo test"]); - // User denylist replaces the default denylist outright. - assert_eq!(deny, vec!["git push"]); + fn test_droid_deny_lists_union_across_scopes() { + // Project/local deny entries must be honored, not just global ones, + // or the rtk-rename rewrite dodges them. + let user = serde_json::json!({ "commandDenylist": ["git push"] }); + let user_local = serde_json::json!({ "commandBlocklist": ["curl:*"] }); + let project = serde_json::json!({ "commandDenylist": ["docker *"] }); + let (deny, ask, allow) = droid_rules_from_settings(&[user, user_local, project]); + assert_eq!(deny, vec!["git push", "curl:*", "docker *"]); + assert!(ask.is_empty()); + assert!(allow.is_empty()); } #[test] @@ -1081,38 +1042,58 @@ mod tests { "commandBlocklist": ["curl:*"], "commandDenylist": ["git push"], }); - let (deny, ask, _) = droid_rules_from_settings(Some(&settings)); + let (deny, ask, allow) = droid_rules_from_settings(std::slice::from_ref(&settings)); assert_eq!(deny, vec!["curl:*", "git push"]); assert!(ask.is_empty()); + assert!(allow.is_empty()); + } + + #[test] + fn test_droid_allowlist_never_read() { + // commandAllowlist is not consulted — the decision stays with Droid. + let settings = serde_json::json!({ + "commandAllowlist": ["git status", "cargo test"], + }); + let (deny, ask, allow) = droid_rules_from_settings(std::slice::from_ref(&settings)); + assert!(deny.is_empty()); + assert!(ask.is_empty()); + assert!(allow.is_empty()); } #[test] fn test_droid_malformed_entries_filtered() { let settings = serde_json::json!({ - "commandAllowlist": [" git status ", "", 42, null, {"nested": true}], + "commandDenylist": [" git push ", "", 42, null, {"nested": true}], }); - let (_, _, allow) = droid_rules_from_settings(Some(&settings)); - assert_eq!(allow, vec!["git status"]); + let (deny, _, _) = droid_rules_from_settings(std::slice::from_ref(&settings)); + assert_eq!(deny, vec!["git push"]); } #[test] - fn test_droid_default_allowlist_verdicts() { - let (deny, ask, allow) = droid_rules_from_settings(None); - // Read-only commands Droid auto-runs natively get Allow… + fn test_droid_verdicts_deny_or_default_only() { + // Without settings everything is Default — Droid decides natively. + let (deny, ask, allow) = droid_rules_from_settings(&[]); assert_eq!( check_command_with_rules("git status --short", &deny, &ask, &allow), - PermissionVerdict::Allow + PermissionVerdict::Default ); - // …anything else stays Default so Droid's native flow decides… assert_eq!( - check_command_with_rules("cargo build", &deny, &ask, &allow), + check_command_with_rules("shutdown -h now", &deny, &ask, &allow), PermissionVerdict::Default ); - // …and the default denylist yields Deny (RTK steps aside). + + // An explicit deny entry from any scope yields Deny (step-aside)… + let project = serde_json::json!({ "commandDenylist": ["git push"] }); + let (deny, ask, allow) = droid_rules_from_settings(std::slice::from_ref(&project)); assert_eq!( - check_command_with_rules("shutdown -h now", &deny, &ask, &allow), + check_command_with_rules("git push origin main", &deny, &ask, &allow), PermissionVerdict::Deny ); + // …while unlisted commands stay Default. + assert_eq!( + check_command_with_rules("cargo build", &deny, &ask, &allow), + PermissionVerdict::Default + ); } #[test]