use std::path::Path;
use crate::core::mcp_config::managed_mcp_server_names;
pub fn preseed_managed_trust(claude_config_dir: &Path, workspace: &Path) -> anyhow::Result<()> {
use serde_json::Value;
let claude_json = claude_config_dir.join(".claude.json");
let mut config: Value = match std::fs::read_to_string(&claude_json) {
Ok(text) => match serde_json::from_str::<Value>(&text) {
Ok(val) if val.is_object() => val,
Ok(_) => {
tracing::warn!(
"skipping managed trust pre-seed: {} is valid JSON but not an object",
claude_json.display()
);
return Ok(());
}
Err(err) => {
let corrupt_path = claude_json.with_extension("json.corrupt");
match std::fs::rename(&claude_json, &corrupt_path) {
Ok(()) => tracing::warn!(
"managed trust pre-seed: {} is not valid JSON ({err}); \
quarantined to {} — proceeding with fresh config",
claude_json.display(),
corrupt_path.display()
),
Err(rename_err) => tracing::warn!(
"managed trust pre-seed: {} is not valid JSON ({err}); \
quarantine rename failed ({rename_err}) — proceeding with fresh config",
claude_json.display()
),
}
Value::Object(serde_json::Map::new())
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Value::Object(serde_json::Map::new()),
Err(err) => {
tracing::warn!(
"skipping managed trust pre-seed: failed to read {}: {err}",
claude_json.display()
);
return Ok(());
}
};
let workspace_key = workspace.to_string_lossy().to_string();
let enabled_mcp = Value::Array(
managed_mcp_server_names(&config)
.into_iter()
.map(Value::String)
.collect(),
);
let projects = config
.as_object_mut()
.expect("config is an object")
.entry("projects")
.or_insert_with(|| Value::Object(serde_json::Map::new()));
if !projects.is_object() {
*projects = Value::Object(serde_json::Map::new());
}
let projects = projects.as_object_mut().expect("projects is an object");
let entry = projects
.entry(workspace_key)
.or_insert_with(|| Value::Object(serde_json::Map::new()));
if !entry.is_object() {
*entry = Value::Object(serde_json::Map::new());
}
let entry = entry.as_object_mut().expect("project entry is an object");
let already_seeded = entry.get("hasTrustDialogAccepted") == Some(&Value::Bool(true))
&& entry.get("hasCompletedProjectOnboarding") == Some(&Value::Bool(true))
&& entry
.get("projectOnboardingSeenCount")
.and_then(|v| v.as_i64())
.is_some_and(|n| n >= 1)
&& entry.get("enabledMcpjsonServers") == Some(&enabled_mcp);
if already_seeded {
return Ok(());
}
entry.insert("hasTrustDialogAccepted".to_string(), Value::Bool(true));
entry.insert(
"hasCompletedProjectOnboarding".to_string(),
Value::Bool(true),
);
entry
.entry("projectOnboardingSeenCount")
.or_insert_with(|| Value::from(1));
entry.insert("enabledMcpjsonServers".to_string(), enabled_mcp);
trusty_common::claude_config::write_json_atomic(&claude_json, &config)
.map_err(|e| anyhow::anyhow!("failed to write {}: {e}", claude_json.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_preseed_managed_trust_marks_directory() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().join("claude-config");
std::fs::create_dir_all(&cfg).unwrap();
let workspace = tmp.path().join("projects").join("my-repo").join("repo");
std::fs::create_dir_all(&workspace).unwrap();
preseed_managed_trust(&cfg, &workspace).unwrap();
let text = std::fs::read_to_string(cfg.join(".claude.json")).unwrap();
let val: serde_json::Value = serde_json::from_str(&text).unwrap();
let key = workspace.to_string_lossy().to_string();
let proj = val["projects"][&key].as_object().unwrap();
assert_eq!(
proj.get("hasTrustDialogAccepted"),
Some(&serde_json::Value::Bool(true)),
"hasTrustDialogAccepted must be true"
);
assert_eq!(
proj.get("hasCompletedProjectOnboarding"),
Some(&serde_json::Value::Bool(true)),
"hasCompletedProjectOnboarding must be true"
);
assert!(
proj.get("projectOnboardingSeenCount")
.and_then(|v| v.as_i64())
.is_some_and(|n| n >= 1),
"projectOnboardingSeenCount must be >= 1"
);
}
#[test]
fn test_preseed_managed_trust_enables_mcp_servers() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().join("claude-config");
std::fs::create_dir_all(&cfg).unwrap();
let workspace = tmp.path().join("repo");
std::fs::create_dir_all(&workspace).unwrap();
preseed_managed_trust(&cfg, &workspace).unwrap();
let text = std::fs::read_to_string(cfg.join(".claude.json")).unwrap();
let val: serde_json::Value = serde_json::from_str(&text).unwrap();
let key = workspace.to_string_lossy().to_string();
let servers = val["projects"][&key]["enabledMcpjsonServers"]
.as_array()
.expect("enabledMcpjsonServers must be an array");
let names: Vec<&str> = servers.iter().filter_map(|v| v.as_str()).collect();
assert!(
names.contains(&"trusty-memory"),
"trusty-memory must be in enabledMcpjsonServers; got {names:?}"
);
assert!(
names.contains(&"trusty-search"),
"trusty-search must be in enabledMcpjsonServers; got {names:?}"
);
assert!(
names.contains(&"trusty-review"),
"trusty-review must be in enabledMcpjsonServers (WI-8); got {names:?}"
);
}
#[test]
fn test_preseed_managed_trust_syncs_tm_mcp_added_server() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().join("claude-config");
std::fs::create_dir_all(&cfg).unwrap();
let workspace = tmp.path().join("repo");
std::fs::create_dir_all(&workspace).unwrap();
crate::core::mcp_config::add_server(
&cfg,
"my-custom",
serde_json::json!({"type": "stdio", "command": "my-custom", "args": []}),
)
.unwrap();
preseed_managed_trust(&cfg, &workspace).unwrap();
let val: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(cfg.join(".claude.json")).unwrap())
.unwrap();
let key = workspace.to_string_lossy().to_string();
let names: Vec<&str> = val["projects"][&key]["enabledMcpjsonServers"]
.as_array()
.expect("enabledMcpjsonServers array")
.iter()
.filter_map(|v| v.as_str())
.collect();
assert!(
names.contains(&"my-custom"),
"tm mcp add-ed server must be trusted after preseed; got {names:?}"
);
assert!(names.contains(&"trusty-memory"));
assert!(names.contains(&"trusty-review"));
assert!(names.contains(&"trusty-search"));
}
#[test]
fn test_preseed_managed_trust_is_idempotent() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().join("claude-config");
std::fs::create_dir_all(&cfg).unwrap();
let workspace = tmp.path().join("my-repo");
std::fs::create_dir_all(&workspace).unwrap();
preseed_managed_trust(&cfg, &workspace).unwrap();
let after_first = std::fs::read_to_string(cfg.join(".claude.json")).unwrap();
preseed_managed_trust(&cfg, &workspace).unwrap();
let after_second = std::fs::read_to_string(cfg.join(".claude.json")).unwrap();
assert_eq!(
after_first, after_second,
"preseed_managed_trust must be idempotent: two calls must produce identical output"
);
}
#[test]
fn test_preseed_managed_trust_preserves_other_keys() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().join("claude-config");
std::fs::create_dir_all(&cfg).unwrap();
let workspace = tmp.path().join("repo");
std::fs::create_dir_all(&workspace).unwrap();
std::fs::write(
cfg.join(".claude.json"),
r#"{"someOAuthToken":"keep-me","otherField":99}"#,
)
.unwrap();
preseed_managed_trust(&cfg, &workspace).unwrap();
let text = std::fs::read_to_string(cfg.join(".claude.json")).unwrap();
let val: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(
val.get("someOAuthToken").and_then(|v| v.as_str()),
Some("keep-me"),
"someOAuthToken must be preserved after trust seed"
);
assert_eq!(
val.get("otherField").and_then(|v| v.as_i64()),
Some(99),
"otherField must be preserved after trust seed"
);
}
#[serial_test::serial]
#[test]
fn test_preseed_managed_trust_no_home_write() {
struct HomeGuard(Option<String>);
impl Drop for HomeGuard {
fn drop(&mut self) {
match self.0 {
Some(ref p) => unsafe { std::env::set_var("HOME", p) },
None => unsafe { std::env::remove_var("HOME") },
}
}
}
let root = TempDir::new().unwrap();
let cfg = root.path().join("claude-config");
std::fs::create_dir_all(&cfg).unwrap();
let workspace = root.path().join("repo");
std::fs::create_dir_all(&workspace).unwrap();
let fake_home = TempDir::new().unwrap();
let _home_guard = {
let prev = std::env::var("HOME").ok();
unsafe { std::env::set_var("HOME", fake_home.path()) };
HomeGuard(prev)
};
preseed_managed_trust(&cfg, &workspace).unwrap();
assert!(
cfg.join(".claude.json").exists(),
"preseed_managed_trust must write .claude.json inside claude_config_dir"
);
assert!(
!root.path().join(".claude.json").exists(),
"preseed_managed_trust must NOT write .claude.json outside claude_config_dir \
(isolation invariant)"
);
assert!(
!root.path().join(".claude").exists(),
"preseed_managed_trust must NOT create .claude/ outside claude_config_dir \
(isolation invariant)"
);
assert!(
!fake_home.path().join(".claude.json").exists(),
"preseed_managed_trust must NOT write .claude.json to $HOME \
(isolation invariant)"
);
assert!(
!fake_home.path().join(".claude").exists(),
"preseed_managed_trust must NOT create $HOME/.claude/ \
(isolation invariant)"
);
}
#[test]
fn test_preseed_managed_trust_quarantines_malformed_json() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().join("claude-config");
std::fs::create_dir_all(&cfg).unwrap();
let workspace = tmp.path().join("repo");
std::fs::create_dir_all(&workspace).unwrap();
std::fs::write(cfg.join(".claude.json"), b"{ this is not valid json !!!").unwrap();
preseed_managed_trust(&cfg, &workspace).unwrap();
assert!(
cfg.join(".claude.json.corrupt").exists(),
".claude.json.corrupt quarantine file must exist after malformed-JSON quarantine"
);
let text = std::fs::read_to_string(cfg.join(".claude.json"))
.expect(".claude.json must exist after quarantine + fresh seed");
let val: serde_json::Value =
serde_json::from_str(&text).expect(".claude.json must be valid JSON after fresh seed");
let key = workspace.to_string_lossy().to_string();
let proj = val["projects"][&key]
.as_object()
.expect("projects.<workspace> must be an object");
assert_eq!(
proj.get("hasTrustDialogAccepted"),
Some(&serde_json::Value::Bool(true)),
"hasTrustDialogAccepted must be true after quarantine + fresh seed"
);
assert_eq!(
proj.get("hasCompletedProjectOnboarding"),
Some(&serde_json::Value::Bool(true)),
"hasCompletedProjectOnboarding must be true after quarantine + fresh seed"
);
}
}