Skip to main content

hatchery_node/
auto_permit.rs

1//! Auto-permit at spawn: pre-answer provider interactive prompts (trust workspace,
2//! onboarding/theme, permission modes) so node sessions start ready and in auto/yolo.
3//!
4//! Default ON. Disable with `HATCHERY_AUTO_PERMIT=0` (all providers) or
5//! `HATCHERY_AUTO_PERMIT_<PROVIDER>=0` (e.g. `HATCHERY_AUTO_PERMIT_CODEX=0`).
6//! Auto-update is left enabled (we never set an updater-disable switch).
7
8use gate4agent_node_protocol::SessionMode;
9use serde_json::{json, Value};
10use std::ffi::OsString;
11use std::path::{Path, PathBuf};
12
13fn off(v: Option<String>) -> bool {
14    matches!(v.as_deref(), Some("0") | Some("false") | Some("no") | Some("off"))
15}
16
17pub fn enabled(provider: &str) -> bool {
18    if off(std::env::var("HATCHERY_AUTO_PERMIT").ok()) {
19        return false;
20    }
21    !off(std::env::var(format!("HATCHERY_AUTO_PERMIT_{}", provider.to_ascii_uppercase())).ok())
22}
23
24/// Launch flags for the auto/yolo mode (PTY and ACP).
25pub fn launch_args(provider: &str, mode: SessionMode) -> Vec<OsString> {
26    if !enabled(provider) {
27        return Vec::new();
28    }
29    let v: &[&str] = match (provider, mode) {
30        ("claude", SessionMode::Acp) => &[],           // ACP: permission mode set via session/set_mode
31        ("claude", _) => &["--dangerously-skip-permissions"],
32        ("codex", SessionMode::Acp) => &["-c", "approval_policy=\"never\"", "-c", "sandbox_mode=\"danger-full-access\""],
33        ("codex", _) => &["--dangerously-bypass-approvals-and-sandbox"],
34        // ACP launch overlays carry only codex `-c` args; grok/kimi ACP use mode + auto-approve.
35        ("grok", SessionMode::Acp) | ("kimi", SessionMode::Acp) => &[],
36        ("grok", _) => &["--always-approve"],
37        ("kimi", _) => &["--auto"],
38        _ => &[],
39    };
40    v.iter().map(OsString::from).collect()
41}
42
43/// ACP permission mode id to request right after session/new (or auto-approve).
44pub fn acp_mode(provider: &str) -> Option<&'static str> {
45    if !enabled(provider) {
46        return None;
47    }
48    // `HATCHERY_ACP_MODE_<PROVIDER>` picks another mode (e.g. `default`, where the agent asks
49    // for every tool and the node's auto-approve answers): a probe of the approval path.
50    if let Some(m) = mode_override(provider) {
51        static LEAKED: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
52        return Some(LEAKED.get_or_init(|| Box::leak(m.into_boxed_str())));
53    }
54    match provider {
55        "claude" => Some("bypassPermissions"),
56        "codex" => Some("full-access"),
57        "grok" | "kimi" => Some("auto"),
58        _ => None,
59    }
60}
61
62/// True: answer every ACP session/request_permission with the allow option.
63pub fn acp_auto_approve(provider: &str) -> bool {
64    enabled(provider)
65}
66
67/// Approval level for a spawn, when auto-permit must deviate from the FullAuto default:
68/// claude's ACP agent offers no `bypassPermissions` under root, so FullAuto would fail the
69/// spawn; Unmanaged starts it and the node then switches to its `auto` mode and approves
70/// every permission request itself.
71pub fn spawn_approval_level(provider: &str, mode: SessionMode) -> Option<gate4agent_types::ApprovalLevel> {
72    if !(enabled(provider) && provider == "claude" && mode == SessionMode::Acp) {
73        return None;
74    }
75    // With a mode override (approval probe) the transport defers every permission request to
76    // the node, which answers it through ResolveInteraction ("acp permission auto-approved").
77    if mode_override(provider).is_some() {
78        return Some(gate4agent_types::ApprovalLevel::Moderate);
79    }
80    is_root().then_some(gate4agent_types::ApprovalLevel::Unmanaged)
81}
82
83fn mode_override(provider: &str) -> Option<String> {
84    let m = std::env::var(format!("HATCHERY_ACP_MODE_{}", provider.to_ascii_uppercase())).ok()?;
85    let m = m.trim();
86    (!m.is_empty() && m.len() <= 64 && m.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')).then(|| m.to_owned())
87}
88
89static ACP_SESSIONS: std::sync::Mutex<std::collections::BTreeMap<u64, &'static str>> =
90    std::sync::Mutex::new(std::collections::BTreeMap::new());
91
92/// Remember an auto-permitted ACP runtime (instance id -> provider).
93pub fn register_acp(instance_id: u64, provider: &str) {
94    let p: &'static str = match provider { "claude" => "claude", "codex" => "codex", "grok" => "grok", "kimi" => "kimi", _ => return };
95    if enabled(p) {
96        ACP_SESSIONS.lock().unwrap_or_else(|e| e.into_inner()).insert(instance_id, p);
97    }
98}
99
100pub fn forget_acp(instance_id: u64) {
101    ACP_SESSIONS.lock().unwrap_or_else(|e| e.into_inner()).remove(&instance_id);
102}
103
104pub fn acp_provider(instance_id: u64) -> Option<&'static str> {
105    ACP_SESSIONS.lock().unwrap_or_else(|e| e.into_inner()).get(&instance_id).copied()
106}
107
108/// Mode to switch to: the provider's preferred auto mode, else any advertised mode whose id
109/// reads as a no-ask mode. None when already there or nothing fits.
110pub fn pick_acp_mode<'a>(provider: &str, current: &str, available: impl Iterator<Item = &'a str> + Clone) -> Option<String> {
111    let preferred = acp_mode(provider)?;
112    let fallbacks = ["bypassPermissions", "full-access", "yolo", "auto", "agent-full-access", "dontAsk"];
113    let pick = std::iter::once(preferred)
114        .chain(fallbacks)
115        .find(|want| available.clone().any(|a| a == *want))?;
116    (pick != current).then(|| pick.to_owned())
117}
118
119/// Pre-write provider config in each candidate home so prompts never appear.
120pub fn prepare(provider: &str, homes: &[PathBuf], cwd: Option<&Path>) {
121    if !enabled(provider) {
122        return;
123    }
124    for home in homes {
125        let r = match provider {
126            "claude" => claude(home, cwd),
127            "codex" => codex(home, cwd),
128            _ => Ok(()), // grok/kimi: trust+approve via flags; no workspace trust prompt
129        };
130        if let Err(e) = r {
131            tracing::warn!(%provider, home = %home.display(), error = %e, "auto-permit config not written");
132        }
133    }
134}
135
136fn claude(home: &Path, cwd: Option<&Path>) -> Result<(), String> {
137    let path = home.join(".claude.json");
138    let mut v: Value = std::fs::read(&path)
139        .ok()
140        .and_then(|b| serde_json::from_slice(&b).ok())
141        .unwrap_or_else(|| json!({}));
142    if !v.is_object() {
143        v = json!({});
144    }
145    patch_claude(&mut v, cwd);
146    let tmp = path.with_extension("json.hatchery-tmp");
147    std::fs::write(&tmp, serde_json::to_vec_pretty(&v).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
148    std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
149    claude_settings(home)
150}
151
152/// `~/.claude/settings.json`: skip the bypass-mode warning; under root, declare the sandbox
153/// (`env.IS_SANDBOX`) so claude accepts bypass mode (PTY children get a minimal env).
154fn claude_settings(home: &Path) -> Result<(), String> {
155    let dir = home.join(".claude");
156    if !dir.is_dir() {
157        return Ok(());
158    }
159    let path = dir.join("settings.json");
160    let mut v: Value = std::fs::read(&path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_else(|| json!({}));
161    if !v.is_object() {
162        v = json!({});
163    }
164    let before = v.clone();
165    patch_claude_settings(&mut v, is_root());
166    if v == before {
167        return Ok(());
168    }
169    let tmp = path.with_extension("json.hatchery-tmp");
170    std::fs::write(&tmp, serde_json::to_vec_pretty(&v).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
171    std::fs::rename(&tmp, &path).map_err(|e| e.to_string())
172}
173
174pub(crate) fn patch_claude_settings(v: &mut Value, root: bool) {
175    v["skipDangerousModePermissionPrompt"] = json!(true);
176    if root {
177        if !v["env"].is_object() {
178            v["env"] = json!({});
179        }
180        if v["env"].get("IS_SANDBOX").is_none() {
181            v["env"]["IS_SANDBOX"] = json!("1");
182        }
183    }
184}
185
186fn is_root() -> bool {
187    #[cfg(unix)]
188    {
189        use std::os::unix::fs::MetadataExt;
190        std::fs::metadata("/proc/self").map(|m| m.uid() == 0).unwrap_or(false)
191    }
192    #[cfg(not(unix))]
193    {
194        false
195    }
196}
197
198pub(crate) fn patch_claude(v: &mut Value, cwd: Option<&Path>) {
199    let o = v.as_object_mut().expect("object");
200    o.insert("hasCompletedOnboarding".into(), json!(true));
201    o.entry("theme").or_insert(json!("dark"));
202    o.insert("bypassPermissionsModeAccepted".into(), json!(true));
203    o.insert("autoUpdates".into(), json!(true));
204    if let Some(cwd) = cwd {
205        let projects = o.entry("projects").or_insert(json!({}));
206        if !projects.is_object() {
207            *projects = json!({});
208        }
209        let p = projects
210            .as_object_mut()
211            .unwrap()
212            .entry(cwd.display().to_string())
213            .or_insert(json!({}));
214        if let Some(p) = p.as_object_mut() {
215            p.insert("hasTrustDialogAccepted".into(), json!(true));
216            p.insert("hasCompletedProjectOnboarding".into(), json!(true));
217        }
218    }
219}
220
221fn codex(home: &Path, cwd: Option<&Path>) -> Result<(), String> {
222    let dir = home.join(".codex");
223    std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
224    let path = dir.join("config.toml");
225    let cur = std::fs::read_to_string(&path).unwrap_or_default();
226    let next = patch_codex(&cur, cwd);
227    if next != cur {
228        std::fs::write(&path, next).map_err(|e| e.to_string())?;
229    }
230    Ok(())
231}
232
233pub(crate) fn patch_codex(cur: &str, cwd: Option<&Path>) -> String {
234    let mut out = cur.to_owned();
235    // Top-level keys must precede any [table]; prepend only if missing.
236    let mut head = String::new();
237    for (k, val) in [("approval_policy", "\"never\""), ("sandbox_mode", "\"danger-full-access\"")] {
238        let has = cur.lines().take_while(|l| !l.trim_start().starts_with('[')).any(|l| l.trim_start().starts_with(k));
239        if !has {
240            head.push_str(&format!("{k} = {val}\n"));
241        }
242    }
243    if !head.is_empty() {
244        out = format!("{head}{out}");
245    }
246    if let Some(cwd) = cwd {
247        let table = format!("[projects.\"{}\"]", cwd.display());
248        if !out.contains(&table) {
249            if !out.ends_with('\n') && !out.is_empty() {
250                out.push('\n');
251            }
252            out.push_str(&format!("\n{table}\ntrust_level = \"trusted\"\n"));
253        }
254    }
255    out
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn claude_patch_sets_trust_onboarding_bypass() {
264        let mut v = json!({"theme":"light","projects":{"/x":{"allowedTools":[]}}});
265        patch_claude(&mut v, Some(Path::new("/w")));
266        assert_eq!(v["projects"]["/w"]["hasTrustDialogAccepted"], true);
267        assert_eq!(v["hasCompletedOnboarding"], true);
268        assert_eq!(v["bypassPermissionsModeAccepted"], true);
269        assert_eq!(v["theme"], "light");
270        assert_eq!(v["projects"]["/x"]["allowedTools"], json!([]));
271    }
272
273    #[test]
274    fn codex_patch_idempotent() {
275        let a = patch_codex("model = \"o3\"\n[x]\ny=1\n", Some(Path::new("/w")));
276        assert!(a.starts_with("approval_policy = \"never\"\nsandbox_mode = \"danger-full-access\"\n"));
277        assert!(a.contains("[projects.\"/w\"]\ntrust_level = \"trusted\""));
278        assert_eq!(patch_codex(&a, Some(Path::new("/w"))), a);
279    }
280
281    #[test]
282    fn acp_mode_pick() {
283        let modes = ["default", "acceptEdits", "bypassPermissions"];
284        assert_eq!(pick_acp_mode("claude", "default", modes.iter().copied()).as_deref(), Some("bypassPermissions"));
285        assert_eq!(pick_acp_mode("claude", "bypassPermissions", modes.iter().copied()), None);
286        assert_eq!(pick_acp_mode("kimi", "default", ["default", "yolo"].iter().copied()).as_deref(), Some("yolo"));
287    }
288
289    #[test]
290    fn args_per_provider() {
291        assert_eq!(launch_args("claude", SessionMode::Pty), vec![OsString::from("--dangerously-skip-permissions")]);
292        assert_eq!(launch_args("kimi", SessionMode::Pty), vec![OsString::from("--auto")]);
293        assert!(launch_args("grok", SessionMode::Acp).is_empty());
294    }
295}
296