1use serde::{Deserialize, Serialize};
19use std::collections::HashMap;
20
21const DEFAULT_CONFIG: &str = include_str!("../config.default.toml");
23
24#[derive(Debug, Deserialize, Serialize)]
29pub struct Config {
30 #[serde(default)]
32 pub settings: Settings,
33 #[serde(default)]
35 pub commands: Commands,
36 #[serde(default)]
38 pub wrappers: WrapperConfig,
39 #[serde(default)]
41 pub git: GitConfig,
42 #[serde(default)]
44 pub cargo: CargoConfig,
45 #[serde(default)]
47 pub kubectl: KubectlConfig,
48 #[serde(default)]
50 pub gh: GhConfig,
51 #[serde(skip)]
55 pub project_overlay_path: Option<std::path::PathBuf>,
56}
57
58#[derive(Debug, Deserialize, Serialize, Default)]
60pub struct Settings {
61 #[serde(default)]
65 pub escalate_deny: bool,
66}
67
68#[derive(Debug, Deserialize, Serialize, Default)]
73pub struct Commands {
74 #[serde(default)]
76 pub allow: Vec<String>,
77 #[serde(default)]
79 pub ask: Vec<String>,
80 #[serde(default)]
82 pub deny: Vec<String>,
83}
84
85#[derive(Debug, Deserialize, Serialize, Default)]
89pub struct WrapperConfig {
90 #[serde(default)]
93 pub allow_floor: Vec<String>,
94 #[serde(default)]
97 pub ask_floor: Vec<String>,
98}
99
100#[derive(Debug, Deserialize, Serialize, Default)]
102pub struct GitConfig {
103 #[serde(default)]
105 pub read_only: Vec<String>,
106 #[serde(default)]
109 pub allowed_with_config: Vec<String>,
110 #[serde(default)]
115 pub config_env: HashMap<String, String>,
116 #[serde(default)]
119 pub force_push_flags: Vec<String>,
120}
121
122#[derive(Debug, Deserialize, Serialize, Default)]
124pub struct CargoConfig {
125 #[serde(default)]
127 pub safe_subcommands: Vec<String>,
128 #[serde(default)]
130 pub allowed_with_config: Vec<String>,
131 #[serde(default)]
133 pub config_env: HashMap<String, String>,
134}
135
136#[derive(Debug, Deserialize, Serialize, Default)]
138pub struct KubectlConfig {
139 #[serde(default)]
141 pub read_only: Vec<String>,
142 #[serde(default)]
144 pub mutating: Vec<String>,
145 #[serde(default)]
147 pub allowed_with_config: Vec<String>,
148 #[serde(default)]
150 pub config_env: HashMap<String, String>,
151}
152
153#[derive(Debug, Deserialize, Serialize, Default)]
158pub struct GhConfig {
159 #[serde(default)]
161 pub read_only: Vec<String>,
162 #[serde(default)]
164 pub mutating: Vec<String>,
165 #[serde(default)]
167 pub allowed_with_config: Vec<String>,
168 #[serde(default)]
170 pub config_env: HashMap<String, String>,
171}
172
173#[derive(Debug, Deserialize, Default)]
180struct ConfigOverlay {
181 #[serde(default)]
182 settings: SettingsOverlay,
183 #[serde(default)]
184 commands: CommandsOverlay,
185 #[serde(default)]
186 wrappers: WrappersOverlay,
187 #[serde(default)]
188 git: GitOverlay,
189 #[serde(default)]
190 cargo: CargoOverlay,
191 #[serde(default)]
192 kubectl: KubectlOverlay,
193 #[serde(default)]
194 gh: GhOverlay,
195}
196
197#[derive(Debug, Deserialize, Default)]
198struct SettingsOverlay {
199 escalate_deny: Option<bool>,
200}
201
202#[derive(Debug, Deserialize, Default)]
203struct WrappersOverlay {
204 #[serde(default)]
205 replace: bool,
206 #[serde(default)]
207 allow_floor: Vec<String>,
208 #[serde(default)]
209 ask_floor: Vec<String>,
210 #[serde(default)]
211 remove_allow_floor: Vec<String>,
212 #[serde(default)]
213 remove_ask_floor: Vec<String>,
214}
215
216#[derive(Debug, Deserialize, Default)]
217struct CommandsOverlay {
218 #[serde(default)]
219 replace: bool,
220 #[serde(default)]
221 allow: Vec<String>,
222 #[serde(default)]
223 ask: Vec<String>,
224 #[serde(default)]
225 deny: Vec<String>,
226 #[serde(default)]
227 remove_allow: Vec<String>,
228 #[serde(default)]
229 remove_ask: Vec<String>,
230 #[serde(default)]
231 remove_deny: Vec<String>,
232}
233
234#[derive(Debug, Deserialize, Default)]
235struct GitOverlay {
236 #[serde(default)]
237 replace: bool,
238 #[serde(default)]
239 read_only: Vec<String>,
240 #[serde(default)]
241 allowed_with_config: Vec<String>,
242 config_env: Option<HashMap<String, String>>,
243 #[serde(default)]
244 force_push_flags: Vec<String>,
245 #[serde(default)]
246 remove_read_only: Vec<String>,
247 #[serde(default)]
248 remove_allowed_with_config: Vec<String>,
249 #[serde(default)]
250 remove_force_push_flags: Vec<String>,
251}
252
253#[derive(Debug, Deserialize, Default)]
254struct CargoOverlay {
255 #[serde(default)]
256 replace: bool,
257 #[serde(default)]
258 safe_subcommands: Vec<String>,
259 #[serde(default)]
260 allowed_with_config: Vec<String>,
261 config_env: Option<HashMap<String, String>>,
262 #[serde(default)]
263 remove_safe_subcommands: Vec<String>,
264 #[serde(default)]
265 remove_allowed_with_config: Vec<String>,
266}
267
268#[derive(Debug, Deserialize, Default)]
269struct KubectlOverlay {
270 #[serde(default)]
271 replace: bool,
272 #[serde(default)]
273 read_only: Vec<String>,
274 #[serde(default)]
275 mutating: Vec<String>,
276 #[serde(default)]
277 allowed_with_config: Vec<String>,
278 config_env: Option<HashMap<String, String>>,
279 #[serde(default)]
280 remove_read_only: Vec<String>,
281 #[serde(default)]
282 remove_mutating: Vec<String>,
283 #[serde(default)]
284 remove_allowed_with_config: Vec<String>,
285}
286
287#[derive(Debug, Deserialize, Default)]
288struct GhOverlay {
289 #[serde(default)]
290 replace: bool,
291 #[serde(default)]
292 read_only: Vec<String>,
293 #[serde(default)]
294 mutating: Vec<String>,
295 #[serde(default)]
296 allowed_with_config: Vec<String>,
297 config_env: Option<HashMap<String, String>>,
298 #[serde(default)]
299 remove_read_only: Vec<String>,
300 #[serde(default)]
301 remove_mutating: Vec<String>,
302 #[serde(default)]
303 remove_allowed_with_config: Vec<String>,
304}
305
306fn merge_list(base: &mut Vec<String>, add: Vec<String>, remove: &[String], replace: bool) {
312 if replace {
313 *base = add;
314 } else {
315 base.retain(|item| !remove.contains(item));
316 for item in add {
317 if !base.contains(&item) {
318 base.push(item);
319 }
320 }
321 }
322}
323
324impl Config {
325 pub fn default_config() -> Self {
327 toml::from_str(DEFAULT_CONFIG).expect("embedded default config must parse")
328 }
329
330 pub fn load() -> Self {
344 let mut config = Self::default_config();
345 if let Some(overlay) = Self::load_overlay() {
346 config.apply_overlay(overlay);
347 }
348 if let Some((overlay, path)) = Self::load_project_overlay() {
349 config.apply_overlay(overlay);
350 config.project_overlay_path = Some(path);
351 }
352 config
353 }
354
355 fn load_overlay() -> Option<ConfigOverlay> {
357 let home = std::env::var_os("HOME")?;
358 let path = std::path::Path::new(&home).join(".config/cc-toolgate/config.toml");
359 load_overlay_from_path(&path, "config parse error")
360 }
361
362 fn load_project_overlay() -> Option<(ConfigOverlay, std::path::PathBuf)> {
369 let cwd = std::env::current_dir().ok()?;
370 let git_root = find_git_root(&cwd)?;
371 let path = git_root.join(".claude/cc-toolgate.toml");
372 let mut overlay = load_overlay_from_path(&path, "project config parse error")?;
373 strip_project_overlay_dangerous_fields(&mut overlay, &path);
374 Some((overlay, path))
375 }
376
377 fn apply_overlay(&mut self, overlay: ConfigOverlay) {
379 if let Some(v) = overlay.settings.escalate_deny {
381 self.settings.escalate_deny = v;
382 }
383
384 let c = overlay.commands;
386 merge_list(
387 &mut self.commands.allow,
388 c.allow,
389 &c.remove_allow,
390 c.replace,
391 );
392 merge_list(&mut self.commands.ask, c.ask, &c.remove_ask, c.replace);
393 merge_list(&mut self.commands.deny, c.deny, &c.remove_deny, c.replace);
394
395 let w = overlay.wrappers;
397 merge_list(
398 &mut self.wrappers.allow_floor,
399 w.allow_floor,
400 &w.remove_allow_floor,
401 w.replace,
402 );
403 merge_list(
404 &mut self.wrappers.ask_floor,
405 w.ask_floor,
406 &w.remove_ask_floor,
407 w.replace,
408 );
409
410 let g = overlay.git;
412 merge_list(
413 &mut self.git.read_only,
414 g.read_only,
415 &g.remove_read_only,
416 g.replace,
417 );
418 merge_list(
419 &mut self.git.allowed_with_config,
420 g.allowed_with_config,
421 &g.remove_allowed_with_config,
422 g.replace,
423 );
424 merge_list(
425 &mut self.git.force_push_flags,
426 g.force_push_flags,
427 &g.remove_force_push_flags,
428 g.replace,
429 );
430 if let Some(v) = g.config_env {
431 self.git.config_env = v;
432 }
433
434 let ca = overlay.cargo;
436 merge_list(
437 &mut self.cargo.safe_subcommands,
438 ca.safe_subcommands,
439 &ca.remove_safe_subcommands,
440 ca.replace,
441 );
442 merge_list(
443 &mut self.cargo.allowed_with_config,
444 ca.allowed_with_config,
445 &ca.remove_allowed_with_config,
446 ca.replace,
447 );
448 if let Some(v) = ca.config_env {
449 self.cargo.config_env = v;
450 }
451
452 let k = overlay.kubectl;
454 merge_list(
455 &mut self.kubectl.read_only,
456 k.read_only,
457 &k.remove_read_only,
458 k.replace,
459 );
460 merge_list(
461 &mut self.kubectl.mutating,
462 k.mutating,
463 &k.remove_mutating,
464 k.replace,
465 );
466 merge_list(
467 &mut self.kubectl.allowed_with_config,
468 k.allowed_with_config,
469 &k.remove_allowed_with_config,
470 k.replace,
471 );
472 if let Some(v) = k.config_env {
473 self.kubectl.config_env = v;
474 }
475
476 let gh = overlay.gh;
478 merge_list(
479 &mut self.gh.read_only,
480 gh.read_only,
481 &gh.remove_read_only,
482 gh.replace,
483 );
484 merge_list(
485 &mut self.gh.mutating,
486 gh.mutating,
487 &gh.remove_mutating,
488 gh.replace,
489 );
490 merge_list(
491 &mut self.gh.allowed_with_config,
492 gh.allowed_with_config,
493 &gh.remove_allowed_with_config,
494 gh.replace,
495 );
496 if let Some(v) = gh.config_env {
497 self.gh.config_env = v;
498 }
499 }
500
501 #[cfg(test)]
503 fn apply_overlay_str(&mut self, toml_str: &str) {
504 let overlay: ConfigOverlay = toml::from_str(toml_str).unwrap();
505 self.apply_overlay(overlay);
506 }
507}
508
509fn strip_project_overlay_dangerous_fields(overlay: &mut ConfigOverlay, path: &std::path::Path) {
517 let mut stripped = false;
518
519 if overlay.commands.replace
521 || !overlay.commands.remove_allow.is_empty()
522 || !overlay.commands.remove_ask.is_empty()
523 || !overlay.commands.remove_deny.is_empty()
524 {
525 stripped = true;
526 }
527 overlay.commands.replace = false;
528 overlay.commands.remove_allow.clear();
529 overlay.commands.remove_ask.clear();
530 overlay.commands.remove_deny.clear();
531
532 if overlay.wrappers.replace
534 || !overlay.wrappers.remove_allow_floor.is_empty()
535 || !overlay.wrappers.remove_ask_floor.is_empty()
536 {
537 stripped = true;
538 }
539 overlay.wrappers.replace = false;
540 overlay.wrappers.remove_allow_floor.clear();
541 overlay.wrappers.remove_ask_floor.clear();
542
543 if overlay.git.replace
545 || !overlay.git.remove_read_only.is_empty()
546 || !overlay.git.remove_allowed_with_config.is_empty()
547 || !overlay.git.remove_force_push_flags.is_empty()
548 {
549 stripped = true;
550 }
551 overlay.git.replace = false;
552 overlay.git.remove_read_only.clear();
553 overlay.git.remove_allowed_with_config.clear();
554 overlay.git.remove_force_push_flags.clear();
555
556 if overlay.cargo.replace
558 || !overlay.cargo.remove_safe_subcommands.is_empty()
559 || !overlay.cargo.remove_allowed_with_config.is_empty()
560 {
561 stripped = true;
562 }
563 overlay.cargo.replace = false;
564 overlay.cargo.remove_safe_subcommands.clear();
565 overlay.cargo.remove_allowed_with_config.clear();
566
567 if overlay.kubectl.replace
569 || !overlay.kubectl.remove_read_only.is_empty()
570 || !overlay.kubectl.remove_mutating.is_empty()
571 || !overlay.kubectl.remove_allowed_with_config.is_empty()
572 {
573 stripped = true;
574 }
575 overlay.kubectl.replace = false;
576 overlay.kubectl.remove_read_only.clear();
577 overlay.kubectl.remove_mutating.clear();
578 overlay.kubectl.remove_allowed_with_config.clear();
579
580 if overlay.gh.replace
582 || !overlay.gh.remove_read_only.is_empty()
583 || !overlay.gh.remove_mutating.is_empty()
584 || !overlay.gh.remove_allowed_with_config.is_empty()
585 {
586 stripped = true;
587 }
588 overlay.gh.replace = false;
589 overlay.gh.remove_read_only.clear();
590 overlay.gh.remove_mutating.clear();
591 overlay.gh.remove_allowed_with_config.clear();
592
593 if stripped {
594 eprintln!(
595 "cc-toolgate: project overlay at {} attempted to use replace/remove — stripped for security",
596 path.display()
597 );
598 }
599}
600
601fn load_overlay_from_path(path: &std::path::Path, err_label: &str) -> Option<ConfigOverlay> {
605 let content = std::fs::read_to_string(path).ok()?;
606 match toml::from_str(&content) {
607 Ok(overlay) => Some(overlay),
608 Err(e) => {
609 eprintln!("cc-toolgate: {err_label}: {e}");
610 None
611 }
612 }
613}
614
615fn find_git_root(start: &std::path::Path) -> Option<std::path::PathBuf> {
619 let mut current = Some(start);
620 while let Some(dir) = current {
621 if dir.join(".git").exists() {
622 return Some(dir.to_path_buf());
623 }
624 current = dir.parent();
625 }
626 None
627}
628
629#[cfg(test)]
630mod tests {
631 use super::*;
632
633 #[test]
634 fn default_config_parses() {
635 let config = Config::default_config();
636 assert!(!config.commands.allow.is_empty());
637 assert!(!config.commands.ask.is_empty());
638 assert!(!config.commands.deny.is_empty());
639 assert!(!config.git.read_only.is_empty());
640 assert!(!config.cargo.safe_subcommands.is_empty());
641 assert!(!config.kubectl.read_only.is_empty());
642 assert!(!config.gh.read_only.is_empty());
643 }
644
645 #[test]
646 fn default_config_has_expected_commands() {
647 let config = Config::default_config();
648 assert!(config.commands.allow.contains(&"ls".to_string()));
649 assert!(config.commands.ask.contains(&"rm".to_string()));
650 assert!(config.commands.deny.contains(&"shred".to_string()));
651 }
652
653 #[test]
654 fn default_escalate_deny_is_false() {
655 let config = Config::default_config();
656 assert!(!config.settings.escalate_deny);
657 }
658
659 #[test]
660 fn default_git_env_gate_disabled() {
661 let config = Config::default_config();
662 assert!(config.git.config_env.is_empty());
663 assert!(config.git.allowed_with_config.is_empty());
664 }
665
666 #[test]
669 fn overlay_extends_allow_list() {
670 let mut config = Config::default_config();
671 config.apply_overlay_str(
672 r#"
673 [commands]
674 allow = ["my-tool"]
675 "#,
676 );
677 assert!(config.commands.allow.contains(&"ls".to_string()));
679 assert!(config.commands.allow.contains(&"my-tool".to_string()));
681 }
682
683 #[test]
684 fn overlay_removes_from_allow_list() {
685 let mut config = Config::default_config();
686 config.apply_overlay_str(
687 r#"
688 [commands]
689 remove_allow = ["cat", "find"]
690 "#,
691 );
692 assert!(!config.commands.allow.contains(&"cat".to_string()));
693 assert!(!config.commands.allow.contains(&"find".to_string()));
694 assert!(config.commands.allow.contains(&"ls".to_string()));
696 }
697
698 #[test]
699 fn default_wrappers_populated() {
700 let config = Config::default_config();
701 assert!(config.wrappers.allow_floor.contains(&"xargs".to_string()));
702 assert!(config.wrappers.allow_floor.contains(&"env".to_string()));
703 assert!(config.wrappers.ask_floor.contains(&"sudo".to_string()));
704 assert!(config.wrappers.ask_floor.contains(&"doas".to_string()));
705 assert!(!config.commands.allow.contains(&"xargs".to_string()));
707 assert!(!config.commands.allow.contains(&"env".to_string()));
708 assert!(!config.commands.ask.contains(&"sudo".to_string()));
709 }
710
711 #[test]
712 fn overlay_removes_from_wrappers() {
713 let mut config = Config::default_config();
714 config.apply_overlay_str(
715 r#"
716 [wrappers]
717 remove_allow_floor = ["xargs"]
718 "#,
719 );
720 assert!(!config.wrappers.allow_floor.contains(&"xargs".to_string()));
721 assert!(config.wrappers.allow_floor.contains(&"env".to_string()));
723 }
724
725 #[test]
726 fn overlay_extends_wrappers() {
727 let mut config = Config::default_config();
728 config.apply_overlay_str(
729 r#"
730 [wrappers]
731 allow_floor = ["my-wrapper"]
732 "#,
733 );
734 assert!(
735 config
736 .wrappers
737 .allow_floor
738 .contains(&"my-wrapper".to_string())
739 );
740 assert!(config.wrappers.allow_floor.contains(&"xargs".to_string()));
741 }
742
743 #[test]
744 fn overlay_replace_commands() {
745 let mut config = Config::default_config();
746 config.apply_overlay_str(
747 r#"
748 [commands]
749 replace = true
750 allow = ["ls", "cat"]
751 ask = ["rm"]
752 deny = ["shred"]
753 "#,
754 );
755 assert_eq!(config.commands.allow, vec!["ls", "cat"]);
756 assert_eq!(config.commands.ask, vec!["rm"]);
757 assert_eq!(config.commands.deny, vec!["shred"]);
758 }
759
760 #[test]
761 fn overlay_git_env_gate() {
762 let mut config = Config::default_config();
763 config.apply_overlay_str(
764 r#"
765 [git]
766 allowed_with_config = ["commit", "add", "push"]
767 [git.config_env]
768 GIT_CONFIG_GLOBAL = "~/.gitconfig.ai"
769 "#,
770 );
771 assert_eq!(
772 config.git.config_env.get("GIT_CONFIG_GLOBAL").unwrap(),
773 "~/.gitconfig.ai"
774 );
775 assert_eq!(
776 config.git.allowed_with_config,
777 vec!["commit", "add", "push"]
778 );
779 assert!(config.git.read_only.contains(&"status".to_string()));
781 assert!(config.git.read_only.contains(&"log".to_string()));
782 }
783
784 #[test]
785 fn overlay_escalate_deny() {
786 let mut config = Config::default_config();
787 config.apply_overlay_str(
788 r#"
789 [settings]
790 escalate_deny = true
791 "#,
792 );
793 assert!(config.settings.escalate_deny);
794 }
795
796 #[test]
797 fn overlay_omitted_settings_unchanged() {
798 let mut config = Config::default_config();
799 config.apply_overlay_str(
800 r#"
801 [commands]
802 allow = ["my-tool"]
803 "#,
804 );
805 assert!(!config.settings.escalate_deny);
807 }
808
809 #[test]
810 fn overlay_no_duplicates() {
811 let mut config = Config::default_config();
812 config.apply_overlay_str(
813 r#"
814 [commands]
815 allow = ["ls"]
816 "#,
817 );
818 let count = config.commands.allow.iter().filter(|s| *s == "ls").count();
819 assert_eq!(count, 1);
820 }
821
822 #[test]
823 fn overlay_remove_and_add() {
824 let mut config = Config::default_config();
825 config.apply_overlay_str(
827 r#"
828 [commands]
829 remove_deny = ["eval"]
830 ask = ["eval"]
831 "#,
832 );
833 assert!(!config.commands.deny.contains(&"eval".to_string()));
834 assert!(config.commands.ask.contains(&"eval".to_string()));
835 }
836
837 #[test]
838 fn overlay_replace_git() {
839 let mut config = Config::default_config();
840 config.apply_overlay_str(
841 r#"
842 [git]
843 replace = true
844 read_only = ["status", "log"]
845 force_push_flags = ["--force"]
846 "#,
847 );
848 assert_eq!(config.git.read_only, vec!["status", "log"]);
849 assert_eq!(config.git.force_push_flags, vec!["--force"]);
850 assert!(config.git.allowed_with_config.is_empty());
851 }
852
853 #[test]
854 fn overlay_unrelated_sections_untouched() {
855 let mut config = Config::default_config();
856 let original_kubectl_read_only = config.kubectl.read_only.clone();
857 config.apply_overlay_str(
858 r#"
859 [git]
860 allowed_with_config = ["push"]
861 config_env_var = "GIT_CONFIG_GLOBAL"
862 "#,
863 );
864 assert_eq!(config.kubectl.read_only, original_kubectl_read_only);
865 }
866
867 #[test]
868 fn empty_overlay_changes_nothing() {
869 let original = Config::default_config();
870 let mut config = Config::default_config();
871 config.apply_overlay_str("");
872 assert_eq!(config.commands.allow.len(), original.commands.allow.len());
873 assert_eq!(config.git.read_only.len(), original.git.read_only.len());
874 }
875
876 fn scratch_dir(tag: &str) -> std::path::PathBuf {
880 let nanos = std::time::SystemTime::now()
881 .duration_since(std::time::UNIX_EPOCH)
882 .unwrap()
883 .as_nanos();
884 let dir = std::env::temp_dir().join(format!("cc-toolgate-test-{tag}-{nanos}"));
885 std::fs::create_dir_all(&dir).unwrap();
886 dir
887 }
888
889 #[test]
890 fn find_git_root_finds_dot_git_in_ancestor() {
891 let root = scratch_dir("find-root");
892 std::fs::create_dir(root.join(".git")).unwrap();
893 let deep = root.join("a/b/c");
894 std::fs::create_dir_all(&deep).unwrap();
895
896 assert_eq!(find_git_root(&deep), Some(root.clone()));
897 assert_eq!(find_git_root(&root), Some(root.clone()));
898
899 std::fs::remove_dir_all(&root).ok();
900 }
901
902 #[test]
903 fn project_overlay_file_parses_and_extends_allow() {
904 let root = scratch_dir("project-overlay");
905 std::fs::create_dir(root.join(".git")).unwrap();
906 std::fs::create_dir(root.join(".claude")).unwrap();
907 std::fs::write(
908 root.join(".claude/cc-toolgate.toml"),
909 r#"
910 [commands]
911 allow = ["my-project-tool"]
912 "#,
913 )
914 .unwrap();
915
916 let path = root.join(".claude/cc-toolgate.toml");
917 let overlay = load_overlay_from_path(&path, "test").expect("parses");
918
919 let mut config = Config::default_config();
920 config.apply_overlay(overlay);
921 assert!(
922 config
923 .commands
924 .allow
925 .contains(&"my-project-tool".to_string())
926 );
927
928 std::fs::remove_dir_all(&root).ok();
929 }
930
931 fn require_nextest() {
938 assert!(
939 std::env::var("NEXTEST").is_ok(),
940 "this test mutates process CWD/HOME and requires nextest (cargo nextest run)"
941 );
942 }
943
944 #[test]
945 fn config_load_applies_project_overlay() {
946 require_nextest();
947
948 let root = scratch_dir("load-project");
949 std::fs::create_dir(root.join(".git")).unwrap();
950 std::fs::create_dir(root.join(".claude")).unwrap();
951 std::fs::write(
952 root.join(".claude/cc-toolgate.toml"),
953 r#"
954 [commands]
955 allow = ["my-test-script"]
956 "#,
957 )
958 .unwrap();
959
960 let fake_home = root.join("fakehome");
962 std::fs::create_dir_all(&fake_home).unwrap();
963 unsafe { std::env::set_var("HOME", &fake_home) };
964
965 let original_dir = std::env::current_dir().unwrap();
967 std::env::set_current_dir(&root).unwrap();
968
969 let config = Config::load();
970
971 std::env::set_current_dir(&original_dir).unwrap();
973
974 assert!(
976 config.commands.allow.contains(&"ls".to_string()),
977 "default 'ls' should still be in allow list"
978 );
979 assert!(
981 config
982 .commands
983 .allow
984 .contains(&"my-test-script".to_string()),
985 "project overlay should have added 'my-test-script' to allow list"
986 );
987 let expected_path = root.join(".claude/cc-toolgate.toml");
989 assert_eq!(
990 config.project_overlay_path,
991 Some(expected_path),
992 "project_overlay_path should record the overlay file"
993 );
994
995 std::fs::remove_dir_all(&root).ok();
996 }
997
998 #[test]
999 fn config_load_project_overlay_replace_is_stripped() {
1000 require_nextest();
1001
1002 let root = scratch_dir("load-replace-stripped");
1003 std::fs::create_dir(root.join(".git")).unwrap();
1004 std::fs::create_dir(root.join(".claude")).unwrap();
1005 std::fs::write(
1009 root.join(".claude/cc-toolgate.toml"),
1010 r#"
1011 [commands]
1012 replace = true
1013 allow = ["only-this"]
1014 ask = ["only-ask"]
1015 deny = ["only-deny"]
1016 "#,
1017 )
1018 .unwrap();
1019
1020 let fake_home = root.join("fakehome");
1021 std::fs::create_dir_all(&fake_home).unwrap();
1022 unsafe { std::env::set_var("HOME", &fake_home) };
1023
1024 let original_dir = std::env::current_dir().unwrap();
1025 std::env::set_current_dir(&root).unwrap();
1026
1027 let config = Config::load();
1028
1029 std::env::set_current_dir(&original_dir).unwrap();
1030
1031 assert!(
1033 config.commands.allow.contains(&"ls".to_string()),
1034 "replace should be stripped; default 'ls' must still be in allow list"
1035 );
1036 assert!(
1038 config.commands.allow.contains(&"only-this".to_string()),
1039 "additive allow items from project overlay should still be applied"
1040 );
1041 assert!(
1042 config.commands.ask.contains(&"only-ask".to_string()),
1043 "additive ask items from project overlay should still be applied"
1044 );
1045 assert!(
1046 config.commands.deny.contains(&"only-deny".to_string()),
1047 "additive deny items from project overlay should still be applied"
1048 );
1049
1050 assert!(
1052 !config.git.read_only.is_empty(),
1053 "git read_only should be unaffected by a commands-only overlay"
1054 );
1055
1056 std::fs::remove_dir_all(&root).ok();
1057 }
1058
1059 #[test]
1060 fn config_load_project_overlay_remove_deny_is_stripped() {
1061 require_nextest();
1062
1063 let root = scratch_dir("load-remove-deny-stripped");
1064 std::fs::create_dir(root.join(".git")).unwrap();
1065 std::fs::create_dir(root.join(".claude")).unwrap();
1066 std::fs::write(
1068 root.join(".claude/cc-toolgate.toml"),
1069 r#"
1070 [commands]
1071 remove_deny = ["shred"]
1072 "#,
1073 )
1074 .unwrap();
1075
1076 let fake_home = root.join("fakehome");
1077 std::fs::create_dir_all(&fake_home).unwrap();
1078 unsafe { std::env::set_var("HOME", &fake_home) };
1079
1080 let original_dir = std::env::current_dir().unwrap();
1081 std::env::set_current_dir(&root).unwrap();
1082
1083 let config = Config::load();
1084
1085 std::env::set_current_dir(&original_dir).unwrap();
1086
1087 assert!(
1089 config.commands.deny.contains(&"shred".to_string()),
1090 "remove_deny must be stripped; 'shred' should remain in deny list"
1091 );
1092
1093 std::fs::remove_dir_all(&root).ok();
1094 }
1095
1096 #[test]
1097 fn strip_project_overlay_dangerous_fields_clears_all_sections() {
1098 let path = std::path::PathBuf::from("/fake/path/.claude/cc-toolgate.toml");
1099 let mut overlay = ConfigOverlay {
1100 commands: CommandsOverlay {
1101 replace: true,
1102 remove_allow: vec!["cat".into()],
1103 remove_ask: vec!["rm".into()],
1104 remove_deny: vec!["shred".into()],
1105 allow: vec!["my-tool".into()],
1106 ..Default::default()
1107 },
1108 wrappers: WrappersOverlay {
1109 replace: true,
1110 remove_allow_floor: vec!["xargs".into()],
1111 remove_ask_floor: vec!["sudo".into()],
1112 ..Default::default()
1113 },
1114 git: GitOverlay {
1115 replace: true,
1116 remove_read_only: vec!["status".into()],
1117 remove_allowed_with_config: vec!["push".into()],
1118 remove_force_push_flags: vec!["--force".into()],
1119 read_only: vec!["log".into()],
1120 ..Default::default()
1121 },
1122 cargo: CargoOverlay {
1123 replace: true,
1124 remove_safe_subcommands: vec!["build".into()],
1125 remove_allowed_with_config: vec!["publish".into()],
1126 ..Default::default()
1127 },
1128 kubectl: KubectlOverlay {
1129 replace: true,
1130 remove_read_only: vec!["get".into()],
1131 remove_mutating: vec!["apply".into()],
1132 remove_allowed_with_config: vec!["exec".into()],
1133 ..Default::default()
1134 },
1135 gh: GhOverlay {
1136 replace: true,
1137 remove_read_only: vec!["pr list".into()],
1138 remove_mutating: vec!["pr merge".into()],
1139 remove_allowed_with_config: vec!["pr create".into()],
1140 ..Default::default()
1141 },
1142 ..Default::default()
1143 };
1144
1145 strip_project_overlay_dangerous_fields(&mut overlay, &path);
1146
1147 assert!(!overlay.commands.replace);
1149 assert!(overlay.commands.remove_allow.is_empty());
1150 assert!(overlay.commands.remove_ask.is_empty());
1151 assert!(overlay.commands.remove_deny.is_empty());
1152
1153 assert!(!overlay.wrappers.replace);
1154 assert!(overlay.wrappers.remove_allow_floor.is_empty());
1155 assert!(overlay.wrappers.remove_ask_floor.is_empty());
1156
1157 assert!(!overlay.git.replace);
1158 assert!(overlay.git.remove_read_only.is_empty());
1159 assert!(overlay.git.remove_allowed_with_config.is_empty());
1160 assert!(overlay.git.remove_force_push_flags.is_empty());
1161
1162 assert!(!overlay.cargo.replace);
1163 assert!(overlay.cargo.remove_safe_subcommands.is_empty());
1164 assert!(overlay.cargo.remove_allowed_with_config.is_empty());
1165
1166 assert!(!overlay.kubectl.replace);
1167 assert!(overlay.kubectl.remove_read_only.is_empty());
1168 assert!(overlay.kubectl.remove_mutating.is_empty());
1169 assert!(overlay.kubectl.remove_allowed_with_config.is_empty());
1170
1171 assert!(!overlay.gh.replace);
1172 assert!(overlay.gh.remove_read_only.is_empty());
1173 assert!(overlay.gh.remove_mutating.is_empty());
1174 assert!(overlay.gh.remove_allowed_with_config.is_empty());
1175
1176 assert_eq!(overlay.commands.allow, vec!["my-tool"]);
1178 assert_eq!(overlay.git.read_only, vec!["log"]);
1179 }
1180
1181 #[test]
1182 fn strip_project_overlay_no_op_when_safe() {
1183 let path = std::path::PathBuf::from("/fake/path/.claude/cc-toolgate.toml");
1184 let mut overlay = ConfigOverlay {
1185 commands: CommandsOverlay {
1186 allow: vec!["my-tool".into()],
1187 ..Default::default()
1188 },
1189 ..Default::default()
1190 };
1191
1192 strip_project_overlay_dangerous_fields(&mut overlay, &path);
1194 assert_eq!(overlay.commands.allow, vec!["my-tool"]);
1195 assert!(!overlay.commands.replace);
1196 }
1197
1198 #[test]
1199 fn config_load_no_project_overlay_path_when_absent() {
1200 require_nextest();
1201
1202 let root = scratch_dir("load-no-overlay");
1203 let fake_home = root.join("fakehome");
1206 std::fs::create_dir_all(&fake_home).unwrap();
1207 unsafe { std::env::set_var("HOME", &fake_home) };
1208
1209 let original_dir = std::env::current_dir().unwrap();
1210 std::env::set_current_dir(&root).unwrap();
1211
1212 let config = Config::load();
1213
1214 std::env::set_current_dir(&original_dir).unwrap();
1215
1216 assert!(
1217 config.project_overlay_path.is_none(),
1218 "project_overlay_path should be None when no overlay is found"
1219 );
1220
1221 std::fs::remove_dir_all(&root).ok();
1222 }
1223}