use serde::{Deserialize, Serialize};
use std::collections::HashMap;
const DEFAULT_CONFIG: &str = include_str!("../config.default.toml");
#[derive(Debug, Deserialize, Serialize)]
pub struct Config {
#[serde(default)]
pub settings: Settings,
#[serde(default)]
pub commands: Commands,
#[serde(default)]
pub wrappers: WrapperConfig,
#[serde(default)]
pub git: GitConfig,
#[serde(default)]
pub cargo: CargoConfig,
#[serde(default)]
pub kubectl: KubectlConfig,
#[serde(default)]
pub gh: GhConfig,
#[serde(skip)]
pub project_overlay_path: Option<std::path::PathBuf>,
}
#[derive(Debug, Deserialize, Serialize, Default)]
pub struct Settings {
#[serde(default)]
pub escalate_deny: bool,
}
#[derive(Debug, Deserialize, Serialize, Default)]
pub struct Commands {
#[serde(default)]
pub allow: Vec<String>,
#[serde(default)]
pub ask: Vec<String>,
#[serde(default)]
pub deny: Vec<String>,
}
#[derive(Debug, Deserialize, Serialize, Default)]
pub struct WrapperConfig {
#[serde(default)]
pub allow_floor: Vec<String>,
#[serde(default)]
pub ask_floor: Vec<String>,
}
#[derive(Debug, Deserialize, Serialize, Default)]
pub struct GitConfig {
#[serde(default)]
pub read_only: Vec<String>,
#[serde(default)]
pub allowed_with_config: Vec<String>,
#[serde(default)]
pub config_env: HashMap<String, String>,
#[serde(default)]
pub force_push_flags: Vec<String>,
}
#[derive(Debug, Deserialize, Serialize, Default)]
pub struct CargoConfig {
#[serde(default)]
pub safe_subcommands: Vec<String>,
#[serde(default)]
pub allowed_with_config: Vec<String>,
#[serde(default)]
pub config_env: HashMap<String, String>,
}
#[derive(Debug, Deserialize, Serialize, Default)]
pub struct KubectlConfig {
#[serde(default)]
pub read_only: Vec<String>,
#[serde(default)]
pub mutating: Vec<String>,
#[serde(default)]
pub allowed_with_config: Vec<String>,
#[serde(default)]
pub config_env: HashMap<String, String>,
}
#[derive(Debug, Deserialize, Serialize, Default)]
pub struct GhConfig {
#[serde(default)]
pub read_only: Vec<String>,
#[serde(default)]
pub mutating: Vec<String>,
#[serde(default)]
pub allowed_with_config: Vec<String>,
#[serde(default)]
pub config_env: HashMap<String, String>,
}
#[derive(Debug, Deserialize, Default)]
struct ConfigOverlay {
#[serde(default)]
settings: SettingsOverlay,
#[serde(default)]
commands: CommandsOverlay,
#[serde(default)]
wrappers: WrappersOverlay,
#[serde(default)]
git: GitOverlay,
#[serde(default)]
cargo: CargoOverlay,
#[serde(default)]
kubectl: KubectlOverlay,
#[serde(default)]
gh: GhOverlay,
}
#[derive(Debug, Deserialize, Default)]
struct SettingsOverlay {
escalate_deny: Option<bool>,
}
#[derive(Debug, Deserialize, Default)]
struct WrappersOverlay {
#[serde(default)]
replace: bool,
#[serde(default)]
allow_floor: Vec<String>,
#[serde(default)]
ask_floor: Vec<String>,
#[serde(default)]
remove_allow_floor: Vec<String>,
#[serde(default)]
remove_ask_floor: Vec<String>,
}
#[derive(Debug, Deserialize, Default)]
struct CommandsOverlay {
#[serde(default)]
replace: bool,
#[serde(default)]
allow: Vec<String>,
#[serde(default)]
ask: Vec<String>,
#[serde(default)]
deny: Vec<String>,
#[serde(default)]
remove_allow: Vec<String>,
#[serde(default)]
remove_ask: Vec<String>,
#[serde(default)]
remove_deny: Vec<String>,
}
#[derive(Debug, Deserialize, Default)]
struct GitOverlay {
#[serde(default)]
replace: bool,
#[serde(default)]
read_only: Vec<String>,
#[serde(default)]
allowed_with_config: Vec<String>,
config_env: Option<HashMap<String, String>>,
#[serde(default)]
force_push_flags: Vec<String>,
#[serde(default)]
remove_read_only: Vec<String>,
#[serde(default)]
remove_allowed_with_config: Vec<String>,
#[serde(default)]
remove_force_push_flags: Vec<String>,
}
#[derive(Debug, Deserialize, Default)]
struct CargoOverlay {
#[serde(default)]
replace: bool,
#[serde(default)]
safe_subcommands: Vec<String>,
#[serde(default)]
allowed_with_config: Vec<String>,
config_env: Option<HashMap<String, String>>,
#[serde(default)]
remove_safe_subcommands: Vec<String>,
#[serde(default)]
remove_allowed_with_config: Vec<String>,
}
#[derive(Debug, Deserialize, Default)]
struct KubectlOverlay {
#[serde(default)]
replace: bool,
#[serde(default)]
read_only: Vec<String>,
#[serde(default)]
mutating: Vec<String>,
#[serde(default)]
allowed_with_config: Vec<String>,
config_env: Option<HashMap<String, String>>,
#[serde(default)]
remove_read_only: Vec<String>,
#[serde(default)]
remove_mutating: Vec<String>,
#[serde(default)]
remove_allowed_with_config: Vec<String>,
}
#[derive(Debug, Deserialize, Default)]
struct GhOverlay {
#[serde(default)]
replace: bool,
#[serde(default)]
read_only: Vec<String>,
#[serde(default)]
mutating: Vec<String>,
#[serde(default)]
allowed_with_config: Vec<String>,
config_env: Option<HashMap<String, String>>,
#[serde(default)]
remove_read_only: Vec<String>,
#[serde(default)]
remove_mutating: Vec<String>,
#[serde(default)]
remove_allowed_with_config: Vec<String>,
}
fn merge_list(base: &mut Vec<String>, add: Vec<String>, remove: &[String], replace: bool) {
if replace {
*base = add;
} else {
base.retain(|item| !remove.contains(item));
for item in add {
if !base.contains(&item) {
base.push(item);
}
}
}
}
impl Config {
pub fn default_config() -> Self {
toml::from_str(DEFAULT_CONFIG).expect("embedded default config must parse")
}
pub fn load() -> Self {
let mut config = Self::default_config();
if let Some(overlay) = Self::load_overlay() {
config.apply_overlay(overlay);
}
if let Some((overlay, path)) = Self::load_project_overlay() {
config.apply_overlay(overlay);
config.project_overlay_path = Some(path);
}
config
}
fn load_overlay() -> Option<ConfigOverlay> {
let home = std::env::var_os("HOME")?;
let path = std::path::Path::new(&home).join(".config/cc-toolgate/config.toml");
load_overlay_from_path(&path, "config parse error")
}
fn load_project_overlay() -> Option<(ConfigOverlay, std::path::PathBuf)> {
let cwd = std::env::current_dir().ok()?;
let git_root = find_git_root(&cwd)?;
let path = git_root.join(".claude/cc-toolgate.toml");
let mut overlay = load_overlay_from_path(&path, "project config parse error")?;
strip_project_overlay_dangerous_fields(&mut overlay, &path);
Some((overlay, path))
}
fn apply_overlay(&mut self, overlay: ConfigOverlay) {
if let Some(v) = overlay.settings.escalate_deny {
self.settings.escalate_deny = v;
}
let c = overlay.commands;
merge_list(
&mut self.commands.allow,
c.allow,
&c.remove_allow,
c.replace,
);
merge_list(&mut self.commands.ask, c.ask, &c.remove_ask, c.replace);
merge_list(&mut self.commands.deny, c.deny, &c.remove_deny, c.replace);
let w = overlay.wrappers;
merge_list(
&mut self.wrappers.allow_floor,
w.allow_floor,
&w.remove_allow_floor,
w.replace,
);
merge_list(
&mut self.wrappers.ask_floor,
w.ask_floor,
&w.remove_ask_floor,
w.replace,
);
let g = overlay.git;
merge_list(
&mut self.git.read_only,
g.read_only,
&g.remove_read_only,
g.replace,
);
merge_list(
&mut self.git.allowed_with_config,
g.allowed_with_config,
&g.remove_allowed_with_config,
g.replace,
);
merge_list(
&mut self.git.force_push_flags,
g.force_push_flags,
&g.remove_force_push_flags,
g.replace,
);
if let Some(v) = g.config_env {
self.git.config_env = v;
}
let ca = overlay.cargo;
merge_list(
&mut self.cargo.safe_subcommands,
ca.safe_subcommands,
&ca.remove_safe_subcommands,
ca.replace,
);
merge_list(
&mut self.cargo.allowed_with_config,
ca.allowed_with_config,
&ca.remove_allowed_with_config,
ca.replace,
);
if let Some(v) = ca.config_env {
self.cargo.config_env = v;
}
let k = overlay.kubectl;
merge_list(
&mut self.kubectl.read_only,
k.read_only,
&k.remove_read_only,
k.replace,
);
merge_list(
&mut self.kubectl.mutating,
k.mutating,
&k.remove_mutating,
k.replace,
);
merge_list(
&mut self.kubectl.allowed_with_config,
k.allowed_with_config,
&k.remove_allowed_with_config,
k.replace,
);
if let Some(v) = k.config_env {
self.kubectl.config_env = v;
}
let gh = overlay.gh;
merge_list(
&mut self.gh.read_only,
gh.read_only,
&gh.remove_read_only,
gh.replace,
);
merge_list(
&mut self.gh.mutating,
gh.mutating,
&gh.remove_mutating,
gh.replace,
);
merge_list(
&mut self.gh.allowed_with_config,
gh.allowed_with_config,
&gh.remove_allowed_with_config,
gh.replace,
);
if let Some(v) = gh.config_env {
self.gh.config_env = v;
}
}
#[cfg(test)]
fn apply_overlay_str(&mut self, toml_str: &str) {
let overlay: ConfigOverlay = toml::from_str(toml_str).unwrap();
self.apply_overlay(overlay);
}
}
fn strip_project_overlay_dangerous_fields(overlay: &mut ConfigOverlay, path: &std::path::Path) {
let mut stripped = false;
if overlay.commands.replace
|| !overlay.commands.remove_allow.is_empty()
|| !overlay.commands.remove_ask.is_empty()
|| !overlay.commands.remove_deny.is_empty()
{
stripped = true;
}
overlay.commands.replace = false;
overlay.commands.remove_allow.clear();
overlay.commands.remove_ask.clear();
overlay.commands.remove_deny.clear();
if overlay.wrappers.replace
|| !overlay.wrappers.remove_allow_floor.is_empty()
|| !overlay.wrappers.remove_ask_floor.is_empty()
{
stripped = true;
}
overlay.wrappers.replace = false;
overlay.wrappers.remove_allow_floor.clear();
overlay.wrappers.remove_ask_floor.clear();
if overlay.git.replace
|| !overlay.git.remove_read_only.is_empty()
|| !overlay.git.remove_allowed_with_config.is_empty()
|| !overlay.git.remove_force_push_flags.is_empty()
{
stripped = true;
}
overlay.git.replace = false;
overlay.git.remove_read_only.clear();
overlay.git.remove_allowed_with_config.clear();
overlay.git.remove_force_push_flags.clear();
if overlay.cargo.replace
|| !overlay.cargo.remove_safe_subcommands.is_empty()
|| !overlay.cargo.remove_allowed_with_config.is_empty()
{
stripped = true;
}
overlay.cargo.replace = false;
overlay.cargo.remove_safe_subcommands.clear();
overlay.cargo.remove_allowed_with_config.clear();
if overlay.kubectl.replace
|| !overlay.kubectl.remove_read_only.is_empty()
|| !overlay.kubectl.remove_mutating.is_empty()
|| !overlay.kubectl.remove_allowed_with_config.is_empty()
{
stripped = true;
}
overlay.kubectl.replace = false;
overlay.kubectl.remove_read_only.clear();
overlay.kubectl.remove_mutating.clear();
overlay.kubectl.remove_allowed_with_config.clear();
if overlay.gh.replace
|| !overlay.gh.remove_read_only.is_empty()
|| !overlay.gh.remove_mutating.is_empty()
|| !overlay.gh.remove_allowed_with_config.is_empty()
{
stripped = true;
}
overlay.gh.replace = false;
overlay.gh.remove_read_only.clear();
overlay.gh.remove_mutating.clear();
overlay.gh.remove_allowed_with_config.clear();
if stripped {
eprintln!(
"cc-toolgate: project overlay at {} attempted to use replace/remove — stripped for security",
path.display()
);
}
}
fn load_overlay_from_path(path: &std::path::Path, err_label: &str) -> Option<ConfigOverlay> {
let content = std::fs::read_to_string(path).ok()?;
match toml::from_str(&content) {
Ok(overlay) => Some(overlay),
Err(e) => {
eprintln!("cc-toolgate: {err_label}: {e}");
None
}
}
}
fn find_git_root(start: &std::path::Path) -> Option<std::path::PathBuf> {
let mut current = Some(start);
while let Some(dir) = current {
if dir.join(".git").exists() {
return Some(dir.to_path_buf());
}
current = dir.parent();
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_parses() {
let config = Config::default_config();
assert!(!config.commands.allow.is_empty());
assert!(!config.commands.ask.is_empty());
assert!(!config.commands.deny.is_empty());
assert!(!config.git.read_only.is_empty());
assert!(!config.cargo.safe_subcommands.is_empty());
assert!(!config.kubectl.read_only.is_empty());
assert!(!config.gh.read_only.is_empty());
}
#[test]
fn default_config_has_expected_commands() {
let config = Config::default_config();
assert!(config.commands.allow.contains(&"ls".to_string()));
assert!(config.commands.ask.contains(&"rm".to_string()));
assert!(config.commands.deny.contains(&"shred".to_string()));
}
#[test]
fn default_escalate_deny_is_false() {
let config = Config::default_config();
assert!(!config.settings.escalate_deny);
}
#[test]
fn default_git_env_gate_disabled() {
let config = Config::default_config();
assert!(config.git.config_env.is_empty());
assert!(config.git.allowed_with_config.is_empty());
}
#[test]
fn overlay_extends_allow_list() {
let mut config = Config::default_config();
config.apply_overlay_str(
r#"
[commands]
allow = ["my-tool"]
"#,
);
assert!(config.commands.allow.contains(&"ls".to_string()));
assert!(config.commands.allow.contains(&"my-tool".to_string()));
}
#[test]
fn overlay_removes_from_allow_list() {
let mut config = Config::default_config();
config.apply_overlay_str(
r#"
[commands]
remove_allow = ["cat", "find"]
"#,
);
assert!(!config.commands.allow.contains(&"cat".to_string()));
assert!(!config.commands.allow.contains(&"find".to_string()));
assert!(config.commands.allow.contains(&"ls".to_string()));
}
#[test]
fn default_wrappers_populated() {
let config = Config::default_config();
assert!(config.wrappers.allow_floor.contains(&"xargs".to_string()));
assert!(config.wrappers.allow_floor.contains(&"env".to_string()));
assert!(config.wrappers.ask_floor.contains(&"sudo".to_string()));
assert!(config.wrappers.ask_floor.contains(&"doas".to_string()));
assert!(!config.commands.allow.contains(&"xargs".to_string()));
assert!(!config.commands.allow.contains(&"env".to_string()));
assert!(!config.commands.ask.contains(&"sudo".to_string()));
}
#[test]
fn overlay_removes_from_wrappers() {
let mut config = Config::default_config();
config.apply_overlay_str(
r#"
[wrappers]
remove_allow_floor = ["xargs"]
"#,
);
assert!(!config.wrappers.allow_floor.contains(&"xargs".to_string()));
assert!(config.wrappers.allow_floor.contains(&"env".to_string()));
}
#[test]
fn overlay_extends_wrappers() {
let mut config = Config::default_config();
config.apply_overlay_str(
r#"
[wrappers]
allow_floor = ["my-wrapper"]
"#,
);
assert!(
config
.wrappers
.allow_floor
.contains(&"my-wrapper".to_string())
);
assert!(config.wrappers.allow_floor.contains(&"xargs".to_string()));
}
#[test]
fn overlay_replace_commands() {
let mut config = Config::default_config();
config.apply_overlay_str(
r#"
[commands]
replace = true
allow = ["ls", "cat"]
ask = ["rm"]
deny = ["shred"]
"#,
);
assert_eq!(config.commands.allow, vec!["ls", "cat"]);
assert_eq!(config.commands.ask, vec!["rm"]);
assert_eq!(config.commands.deny, vec!["shred"]);
}
#[test]
fn overlay_git_env_gate() {
let mut config = Config::default_config();
config.apply_overlay_str(
r#"
[git]
allowed_with_config = ["commit", "add", "push"]
[git.config_env]
GIT_CONFIG_GLOBAL = "~/.gitconfig.ai"
"#,
);
assert_eq!(
config.git.config_env.get("GIT_CONFIG_GLOBAL").unwrap(),
"~/.gitconfig.ai"
);
assert_eq!(
config.git.allowed_with_config,
vec!["commit", "add", "push"]
);
assert!(config.git.read_only.contains(&"status".to_string()));
assert!(config.git.read_only.contains(&"log".to_string()));
}
#[test]
fn overlay_escalate_deny() {
let mut config = Config::default_config();
config.apply_overlay_str(
r#"
[settings]
escalate_deny = true
"#,
);
assert!(config.settings.escalate_deny);
}
#[test]
fn overlay_omitted_settings_unchanged() {
let mut config = Config::default_config();
config.apply_overlay_str(
r#"
[commands]
allow = ["my-tool"]
"#,
);
assert!(!config.settings.escalate_deny);
}
#[test]
fn overlay_no_duplicates() {
let mut config = Config::default_config();
config.apply_overlay_str(
r#"
[commands]
allow = ["ls"]
"#,
);
let count = config.commands.allow.iter().filter(|s| *s == "ls").count();
assert_eq!(count, 1);
}
#[test]
fn overlay_remove_and_add() {
let mut config = Config::default_config();
config.apply_overlay_str(
r#"
[commands]
remove_deny = ["eval"]
ask = ["eval"]
"#,
);
assert!(!config.commands.deny.contains(&"eval".to_string()));
assert!(config.commands.ask.contains(&"eval".to_string()));
}
#[test]
fn overlay_replace_git() {
let mut config = Config::default_config();
config.apply_overlay_str(
r#"
[git]
replace = true
read_only = ["status", "log"]
force_push_flags = ["--force"]
"#,
);
assert_eq!(config.git.read_only, vec!["status", "log"]);
assert_eq!(config.git.force_push_flags, vec!["--force"]);
assert!(config.git.allowed_with_config.is_empty());
}
#[test]
fn overlay_unrelated_sections_untouched() {
let mut config = Config::default_config();
let original_kubectl_read_only = config.kubectl.read_only.clone();
config.apply_overlay_str(
r#"
[git]
allowed_with_config = ["push"]
config_env_var = "GIT_CONFIG_GLOBAL"
"#,
);
assert_eq!(config.kubectl.read_only, original_kubectl_read_only);
}
#[test]
fn empty_overlay_changes_nothing() {
let original = Config::default_config();
let mut config = Config::default_config();
config.apply_overlay_str("");
assert_eq!(config.commands.allow.len(), original.commands.allow.len());
assert_eq!(config.git.read_only.len(), original.git.read_only.len());
}
fn scratch_dir(tag: &str) -> std::path::PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("cc-toolgate-test-{tag}-{nanos}"));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn find_git_root_finds_dot_git_in_ancestor() {
let root = scratch_dir("find-root");
std::fs::create_dir(root.join(".git")).unwrap();
let deep = root.join("a/b/c");
std::fs::create_dir_all(&deep).unwrap();
assert_eq!(find_git_root(&deep), Some(root.clone()));
assert_eq!(find_git_root(&root), Some(root.clone()));
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn project_overlay_file_parses_and_extends_allow() {
let root = scratch_dir("project-overlay");
std::fs::create_dir(root.join(".git")).unwrap();
std::fs::create_dir(root.join(".claude")).unwrap();
std::fs::write(
root.join(".claude/cc-toolgate.toml"),
r#"
[commands]
allow = ["my-project-tool"]
"#,
)
.unwrap();
let path = root.join(".claude/cc-toolgate.toml");
let overlay = load_overlay_from_path(&path, "test").expect("parses");
let mut config = Config::default_config();
config.apply_overlay(overlay);
assert!(
config
.commands
.allow
.contains(&"my-project-tool".to_string())
);
std::fs::remove_dir_all(&root).ok();
}
fn require_nextest() {
assert!(
std::env::var("NEXTEST").is_ok(),
"this test mutates process CWD/HOME and requires nextest (cargo nextest run)"
);
}
#[test]
fn config_load_applies_project_overlay() {
require_nextest();
let root = scratch_dir("load-project");
std::fs::create_dir(root.join(".git")).unwrap();
std::fs::create_dir(root.join(".claude")).unwrap();
std::fs::write(
root.join(".claude/cc-toolgate.toml"),
r#"
[commands]
allow = ["my-test-script"]
"#,
)
.unwrap();
let fake_home = root.join("fakehome");
std::fs::create_dir_all(&fake_home).unwrap();
unsafe { std::env::set_var("HOME", &fake_home) };
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&root).unwrap();
let config = Config::load();
std::env::set_current_dir(&original_dir).unwrap();
assert!(
config.commands.allow.contains(&"ls".to_string()),
"default 'ls' should still be in allow list"
);
assert!(
config
.commands
.allow
.contains(&"my-test-script".to_string()),
"project overlay should have added 'my-test-script' to allow list"
);
let expected_path = root.join(".claude/cc-toolgate.toml");
assert_eq!(
config.project_overlay_path,
Some(expected_path),
"project_overlay_path should record the overlay file"
);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn config_load_project_overlay_replace_is_stripped() {
require_nextest();
let root = scratch_dir("load-replace-stripped");
std::fs::create_dir(root.join(".git")).unwrap();
std::fs::create_dir(root.join(".claude")).unwrap();
std::fs::write(
root.join(".claude/cc-toolgate.toml"),
r#"
[commands]
replace = true
allow = ["only-this"]
ask = ["only-ask"]
deny = ["only-deny"]
"#,
)
.unwrap();
let fake_home = root.join("fakehome");
std::fs::create_dir_all(&fake_home).unwrap();
unsafe { std::env::set_var("HOME", &fake_home) };
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&root).unwrap();
let config = Config::load();
std::env::set_current_dir(&original_dir).unwrap();
assert!(
config.commands.allow.contains(&"ls".to_string()),
"replace should be stripped; default 'ls' must still be in allow list"
);
assert!(
config.commands.allow.contains(&"only-this".to_string()),
"additive allow items from project overlay should still be applied"
);
assert!(
config.commands.ask.contains(&"only-ask".to_string()),
"additive ask items from project overlay should still be applied"
);
assert!(
config.commands.deny.contains(&"only-deny".to_string()),
"additive deny items from project overlay should still be applied"
);
assert!(
!config.git.read_only.is_empty(),
"git read_only should be unaffected by a commands-only overlay"
);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn config_load_project_overlay_remove_deny_is_stripped() {
require_nextest();
let root = scratch_dir("load-remove-deny-stripped");
std::fs::create_dir(root.join(".git")).unwrap();
std::fs::create_dir(root.join(".claude")).unwrap();
std::fs::write(
root.join(".claude/cc-toolgate.toml"),
r#"
[commands]
remove_deny = ["shred"]
"#,
)
.unwrap();
let fake_home = root.join("fakehome");
std::fs::create_dir_all(&fake_home).unwrap();
unsafe { std::env::set_var("HOME", &fake_home) };
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&root).unwrap();
let config = Config::load();
std::env::set_current_dir(&original_dir).unwrap();
assert!(
config.commands.deny.contains(&"shred".to_string()),
"remove_deny must be stripped; 'shred' should remain in deny list"
);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn strip_project_overlay_dangerous_fields_clears_all_sections() {
let path = std::path::PathBuf::from("/fake/path/.claude/cc-toolgate.toml");
let mut overlay = ConfigOverlay {
commands: CommandsOverlay {
replace: true,
remove_allow: vec!["cat".into()],
remove_ask: vec!["rm".into()],
remove_deny: vec!["shred".into()],
allow: vec!["my-tool".into()],
..Default::default()
},
wrappers: WrappersOverlay {
replace: true,
remove_allow_floor: vec!["xargs".into()],
remove_ask_floor: vec!["sudo".into()],
..Default::default()
},
git: GitOverlay {
replace: true,
remove_read_only: vec!["status".into()],
remove_allowed_with_config: vec!["push".into()],
remove_force_push_flags: vec!["--force".into()],
read_only: vec!["log".into()],
..Default::default()
},
cargo: CargoOverlay {
replace: true,
remove_safe_subcommands: vec!["build".into()],
remove_allowed_with_config: vec!["publish".into()],
..Default::default()
},
kubectl: KubectlOverlay {
replace: true,
remove_read_only: vec!["get".into()],
remove_mutating: vec!["apply".into()],
remove_allowed_with_config: vec!["exec".into()],
..Default::default()
},
gh: GhOverlay {
replace: true,
remove_read_only: vec!["pr list".into()],
remove_mutating: vec!["pr merge".into()],
remove_allowed_with_config: vec!["pr create".into()],
..Default::default()
},
..Default::default()
};
strip_project_overlay_dangerous_fields(&mut overlay, &path);
assert!(!overlay.commands.replace);
assert!(overlay.commands.remove_allow.is_empty());
assert!(overlay.commands.remove_ask.is_empty());
assert!(overlay.commands.remove_deny.is_empty());
assert!(!overlay.wrappers.replace);
assert!(overlay.wrappers.remove_allow_floor.is_empty());
assert!(overlay.wrappers.remove_ask_floor.is_empty());
assert!(!overlay.git.replace);
assert!(overlay.git.remove_read_only.is_empty());
assert!(overlay.git.remove_allowed_with_config.is_empty());
assert!(overlay.git.remove_force_push_flags.is_empty());
assert!(!overlay.cargo.replace);
assert!(overlay.cargo.remove_safe_subcommands.is_empty());
assert!(overlay.cargo.remove_allowed_with_config.is_empty());
assert!(!overlay.kubectl.replace);
assert!(overlay.kubectl.remove_read_only.is_empty());
assert!(overlay.kubectl.remove_mutating.is_empty());
assert!(overlay.kubectl.remove_allowed_with_config.is_empty());
assert!(!overlay.gh.replace);
assert!(overlay.gh.remove_read_only.is_empty());
assert!(overlay.gh.remove_mutating.is_empty());
assert!(overlay.gh.remove_allowed_with_config.is_empty());
assert_eq!(overlay.commands.allow, vec!["my-tool"]);
assert_eq!(overlay.git.read_only, vec!["log"]);
}
#[test]
fn strip_project_overlay_no_op_when_safe() {
let path = std::path::PathBuf::from("/fake/path/.claude/cc-toolgate.toml");
let mut overlay = ConfigOverlay {
commands: CommandsOverlay {
allow: vec!["my-tool".into()],
..Default::default()
},
..Default::default()
};
strip_project_overlay_dangerous_fields(&mut overlay, &path);
assert_eq!(overlay.commands.allow, vec!["my-tool"]);
assert!(!overlay.commands.replace);
}
#[test]
fn config_load_no_project_overlay_path_when_absent() {
require_nextest();
let root = scratch_dir("load-no-overlay");
let fake_home = root.join("fakehome");
std::fs::create_dir_all(&fake_home).unwrap();
unsafe { std::env::set_var("HOME", &fake_home) };
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&root).unwrap();
let config = Config::load();
std::env::set_current_dir(&original_dir).unwrap();
assert!(
config.project_overlay_path.is_none(),
"project_overlay_path should be None when no overlay is found"
);
std::fs::remove_dir_all(&root).ok();
}
}