use gate4agent_node_protocol::SessionMode;
use serde_json::{json, Value};
use std::ffi::OsString;
use std::path::{Path, PathBuf};
fn off(v: Option<String>) -> bool {
matches!(v.as_deref(), Some("0") | Some("false") | Some("no") | Some("off"))
}
pub fn enabled(provider: &str) -> bool {
if off(std::env::var("HATCHERY_AUTO_PERMIT").ok()) {
return false;
}
!off(std::env::var(format!("HATCHERY_AUTO_PERMIT_{}", provider.to_ascii_uppercase())).ok())
}
pub fn launch_args(provider: &str, mode: SessionMode) -> Vec<OsString> {
if !enabled(provider) {
return Vec::new();
}
let v: &[&str] = match (provider, mode) {
("claude", SessionMode::Acp) => &[], ("claude", _) => &["--dangerously-skip-permissions"],
("codex", SessionMode::Acp) => &["-c", "approval_policy=\"never\"", "-c", "sandbox_mode=\"danger-full-access\""],
("codex", _) => &["--dangerously-bypass-approvals-and-sandbox"],
("grok", SessionMode::Acp) | ("kimi", SessionMode::Acp) => &[],
("grok", _) => &["--always-approve"],
("kimi", _) => &["--auto"],
_ => &[],
};
v.iter().map(OsString::from).collect()
}
pub fn acp_mode(provider: &str) -> Option<&'static str> {
if !enabled(provider) {
return None;
}
if let Some(m) = mode_override(provider) {
static LEAKED: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
return Some(LEAKED.get_or_init(|| Box::leak(m.into_boxed_str())));
}
match provider {
"claude" => Some("bypassPermissions"),
"codex" => Some("full-access"),
"grok" | "kimi" => Some("auto"),
_ => None,
}
}
pub fn acp_auto_approve(provider: &str) -> bool {
enabled(provider)
}
pub fn spawn_approval_level(provider: &str, mode: SessionMode) -> Option<gate4agent_types::ApprovalLevel> {
if !(enabled(provider) && provider == "claude" && mode == SessionMode::Acp) {
return None;
}
if mode_override(provider).is_some() {
return Some(gate4agent_types::ApprovalLevel::Moderate);
}
is_root().then_some(gate4agent_types::ApprovalLevel::Unmanaged)
}
fn mode_override(provider: &str) -> Option<String> {
let m = std::env::var(format!("HATCHERY_ACP_MODE_{}", provider.to_ascii_uppercase())).ok()?;
let m = m.trim();
(!m.is_empty() && m.len() <= 64 && m.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')).then(|| m.to_owned())
}
static ACP_SESSIONS: std::sync::Mutex<std::collections::BTreeMap<u64, &'static str>> =
std::sync::Mutex::new(std::collections::BTreeMap::new());
pub fn register_acp(instance_id: u64, provider: &str) {
let p: &'static str = match provider { "claude" => "claude", "codex" => "codex", "grok" => "grok", "kimi" => "kimi", _ => return };
if enabled(p) {
ACP_SESSIONS.lock().unwrap_or_else(|e| e.into_inner()).insert(instance_id, p);
}
}
pub fn forget_acp(instance_id: u64) {
ACP_SESSIONS.lock().unwrap_or_else(|e| e.into_inner()).remove(&instance_id);
}
pub fn acp_provider(instance_id: u64) -> Option<&'static str> {
ACP_SESSIONS.lock().unwrap_or_else(|e| e.into_inner()).get(&instance_id).copied()
}
pub fn pick_acp_mode<'a>(provider: &str, current: &str, available: impl Iterator<Item = &'a str> + Clone) -> Option<String> {
let preferred = acp_mode(provider)?;
let fallbacks = ["bypassPermissions", "full-access", "yolo", "auto", "agent-full-access", "dontAsk"];
let pick = std::iter::once(preferred)
.chain(fallbacks)
.find(|want| available.clone().any(|a| a == *want))?;
(pick != current).then(|| pick.to_owned())
}
pub fn prepare(provider: &str, homes: &[PathBuf], cwd: Option<&Path>) {
if !enabled(provider) {
return;
}
for home in homes {
let r = match provider {
"claude" => claude(home, cwd),
"codex" => codex(home, cwd),
_ => Ok(()), };
if let Err(e) = r {
tracing::warn!(%provider, home = %home.display(), error = %e, "auto-permit config not written");
}
}
}
fn claude(home: &Path, cwd: Option<&Path>) -> Result<(), String> {
let path = home.join(".claude.json");
let mut v: Value = std::fs::read(&path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_else(|| json!({}));
if !v.is_object() {
v = json!({});
}
patch_claude(&mut v, cwd);
let tmp = path.with_extension("json.hatchery-tmp");
std::fs::write(&tmp, serde_json::to_vec_pretty(&v).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
claude_settings(home)
}
fn claude_settings(home: &Path) -> Result<(), String> {
let dir = home.join(".claude");
if !dir.is_dir() {
return Ok(());
}
let path = dir.join("settings.json");
let mut v: Value = std::fs::read(&path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_else(|| json!({}));
if !v.is_object() {
v = json!({});
}
let before = v.clone();
patch_claude_settings(&mut v, is_root());
if v == before {
return Ok(());
}
let tmp = path.with_extension("json.hatchery-tmp");
std::fs::write(&tmp, serde_json::to_vec_pretty(&v).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
std::fs::rename(&tmp, &path).map_err(|e| e.to_string())
}
pub(crate) fn patch_claude_settings(v: &mut Value, root: bool) {
v["skipDangerousModePermissionPrompt"] = json!(true);
if root {
if !v["env"].is_object() {
v["env"] = json!({});
}
if v["env"].get("IS_SANDBOX").is_none() {
v["env"]["IS_SANDBOX"] = json!("1");
}
}
}
fn is_root() -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
std::fs::metadata("/proc/self").map(|m| m.uid() == 0).unwrap_or(false)
}
#[cfg(not(unix))]
{
false
}
}
pub(crate) fn patch_claude(v: &mut Value, cwd: Option<&Path>) {
let o = v.as_object_mut().expect("object");
o.insert("hasCompletedOnboarding".into(), json!(true));
o.entry("theme").or_insert(json!("dark"));
o.insert("bypassPermissionsModeAccepted".into(), json!(true));
o.insert("autoUpdates".into(), json!(true));
if let Some(cwd) = cwd {
let projects = o.entry("projects").or_insert(json!({}));
if !projects.is_object() {
*projects = json!({});
}
let p = projects
.as_object_mut()
.unwrap()
.entry(cwd.display().to_string())
.or_insert(json!({}));
if let Some(p) = p.as_object_mut() {
p.insert("hasTrustDialogAccepted".into(), json!(true));
p.insert("hasCompletedProjectOnboarding".into(), json!(true));
}
}
}
fn codex(home: &Path, cwd: Option<&Path>) -> Result<(), String> {
let dir = home.join(".codex");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let path = dir.join("config.toml");
let cur = std::fs::read_to_string(&path).unwrap_or_default();
let next = patch_codex(&cur, cwd);
if next != cur {
std::fs::write(&path, next).map_err(|e| e.to_string())?;
}
Ok(())
}
pub(crate) fn patch_codex(cur: &str, cwd: Option<&Path>) -> String {
let mut out = cur.to_owned();
let mut head = String::new();
for (k, val) in [("approval_policy", "\"never\""), ("sandbox_mode", "\"danger-full-access\"")] {
let has = cur.lines().take_while(|l| !l.trim_start().starts_with('[')).any(|l| l.trim_start().starts_with(k));
if !has {
head.push_str(&format!("{k} = {val}\n"));
}
}
if !head.is_empty() {
out = format!("{head}{out}");
}
if let Some(cwd) = cwd {
let table = format!("[projects.\"{}\"]", cwd.display());
if !out.contains(&table) {
if !out.ends_with('\n') && !out.is_empty() {
out.push('\n');
}
out.push_str(&format!("\n{table}\ntrust_level = \"trusted\"\n"));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn claude_patch_sets_trust_onboarding_bypass() {
let mut v = json!({"theme":"light","projects":{"/x":{"allowedTools":[]}}});
patch_claude(&mut v, Some(Path::new("/w")));
assert_eq!(v["projects"]["/w"]["hasTrustDialogAccepted"], true);
assert_eq!(v["hasCompletedOnboarding"], true);
assert_eq!(v["bypassPermissionsModeAccepted"], true);
assert_eq!(v["theme"], "light");
assert_eq!(v["projects"]["/x"]["allowedTools"], json!([]));
}
#[test]
fn codex_patch_idempotent() {
let a = patch_codex("model = \"o3\"\n[x]\ny=1\n", Some(Path::new("/w")));
assert!(a.starts_with("approval_policy = \"never\"\nsandbox_mode = \"danger-full-access\"\n"));
assert!(a.contains("[projects.\"/w\"]\ntrust_level = \"trusted\""));
assert_eq!(patch_codex(&a, Some(Path::new("/w"))), a);
}
#[test]
fn acp_mode_pick() {
let modes = ["default", "acceptEdits", "bypassPermissions"];
assert_eq!(pick_acp_mode("claude", "default", modes.iter().copied()).as_deref(), Some("bypassPermissions"));
assert_eq!(pick_acp_mode("claude", "bypassPermissions", modes.iter().copied()), None);
assert_eq!(pick_acp_mode("kimi", "default", ["default", "yolo"].iter().copied()).as_deref(), Some("yolo"));
}
#[test]
fn args_per_provider() {
assert_eq!(launch_args("claude", SessionMode::Pty), vec![OsString::from("--dangerously-skip-permissions")]);
assert_eq!(launch_args("kimi", SessionMode::Pty), vec![OsString::from("--auto")]);
assert!(launch_args("grok", SessionMode::Acp).is_empty());
}
}