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
324fn dedup_winners_over_losers(winners: &[String], losers: &mut Vec<String>) {
332 losers.retain(|item| !winners.contains(item));
333}
334
335impl Config {
336 pub fn default_config() -> Self {
338 toml::from_str(DEFAULT_CONFIG).expect("embedded default config must parse")
339 }
340
341 pub fn load() -> Self {
355 let mut config = Self::default_config();
356 if let Some(overlay) = Self::load_overlay() {
357 config.apply_overlay(overlay);
358 }
359 if let Some((overlay, path)) = Self::load_project_overlay() {
360 config.apply_overlay(overlay);
361 config.project_overlay_path = Some(path);
362 }
363 config
364 }
365
366 fn load_overlay() -> Option<ConfigOverlay> {
368 let home = std::env::var_os("HOME")?;
369 let path = std::path::Path::new(&home).join(".config/cc-toolgate/config.toml");
370 load_overlay_from_path(&path, "config parse error")
371 }
372
373 fn load_project_overlay() -> Option<(ConfigOverlay, std::path::PathBuf)> {
380 let cwd = std::env::current_dir().ok()?;
381 let git_root = find_git_root(&cwd)?;
382 let path = git_root.join(".claude/cc-toolgate.toml");
383 let mut overlay = load_overlay_from_path(&path, "project config parse error")?;
384 strip_project_overlay_dangerous_fields(&mut overlay, &path);
385 Some((overlay, path))
386 }
387
388 fn apply_overlay(&mut self, overlay: ConfigOverlay) {
390 if let Some(v) = overlay.settings.escalate_deny {
392 self.settings.escalate_deny = v;
393 }
394
395 let c = overlay.commands;
397 merge_list(
398 &mut self.commands.allow,
399 c.allow,
400 &c.remove_allow,
401 c.replace,
402 );
403 merge_list(&mut self.commands.ask, c.ask, &c.remove_ask, c.replace);
404 merge_list(&mut self.commands.deny, c.deny, &c.remove_deny, c.replace);
405
406 dedup_winners_over_losers(&self.commands.allow, &mut self.commands.ask);
411 dedup_winners_over_losers(&self.commands.allow, &mut self.commands.deny);
412 dedup_winners_over_losers(&self.commands.ask, &mut self.commands.deny);
413
414 let w = overlay.wrappers;
416 merge_list(
417 &mut self.wrappers.allow_floor,
418 w.allow_floor,
419 &w.remove_allow_floor,
420 w.replace,
421 );
422 merge_list(
423 &mut self.wrappers.ask_floor,
424 w.ask_floor,
425 &w.remove_ask_floor,
426 w.replace,
427 );
428
429 dedup_winners_over_losers(&self.wrappers.allow_floor, &mut self.wrappers.ask_floor);
431
432 let g = overlay.git;
434 merge_list(
435 &mut self.git.read_only,
436 g.read_only,
437 &g.remove_read_only,
438 g.replace,
439 );
440 merge_list(
441 &mut self.git.allowed_with_config,
442 g.allowed_with_config,
443 &g.remove_allowed_with_config,
444 g.replace,
445 );
446 merge_list(
447 &mut self.git.force_push_flags,
448 g.force_push_flags,
449 &g.remove_force_push_flags,
450 g.replace,
451 );
452 if let Some(v) = g.config_env {
453 self.git.config_env = v;
454 }
455
456 let ca = overlay.cargo;
458 merge_list(
459 &mut self.cargo.safe_subcommands,
460 ca.safe_subcommands,
461 &ca.remove_safe_subcommands,
462 ca.replace,
463 );
464 merge_list(
465 &mut self.cargo.allowed_with_config,
466 ca.allowed_with_config,
467 &ca.remove_allowed_with_config,
468 ca.replace,
469 );
470 if let Some(v) = ca.config_env {
471 self.cargo.config_env = v;
472 }
473
474 let k = overlay.kubectl;
476 merge_list(
477 &mut self.kubectl.read_only,
478 k.read_only,
479 &k.remove_read_only,
480 k.replace,
481 );
482 merge_list(
483 &mut self.kubectl.mutating,
484 k.mutating,
485 &k.remove_mutating,
486 k.replace,
487 );
488 merge_list(
489 &mut self.kubectl.allowed_with_config,
490 k.allowed_with_config,
491 &k.remove_allowed_with_config,
492 k.replace,
493 );
494 if let Some(v) = k.config_env {
495 self.kubectl.config_env = v;
496 }
497
498 let gh = overlay.gh;
500 merge_list(
501 &mut self.gh.read_only,
502 gh.read_only,
503 &gh.remove_read_only,
504 gh.replace,
505 );
506 merge_list(
507 &mut self.gh.mutating,
508 gh.mutating,
509 &gh.remove_mutating,
510 gh.replace,
511 );
512 merge_list(
513 &mut self.gh.allowed_with_config,
514 gh.allowed_with_config,
515 &gh.remove_allowed_with_config,
516 gh.replace,
517 );
518 if let Some(v) = gh.config_env {
519 self.gh.config_env = v;
520 }
521 }
522
523 #[cfg(test)]
525 fn apply_overlay_str(&mut self, toml_str: &str) {
526 let overlay: ConfigOverlay = toml::from_str(toml_str).unwrap();
527 self.apply_overlay(overlay);
528 }
529}
530
531fn strip_project_overlay_dangerous_fields(overlay: &mut ConfigOverlay, path: &std::path::Path) {
539 let mut stripped = false;
540
541 if overlay.commands.replace
543 || !overlay.commands.remove_allow.is_empty()
544 || !overlay.commands.remove_ask.is_empty()
545 || !overlay.commands.remove_deny.is_empty()
546 {
547 stripped = true;
548 }
549 overlay.commands.replace = false;
550 overlay.commands.remove_allow.clear();
551 overlay.commands.remove_ask.clear();
552 overlay.commands.remove_deny.clear();
553
554 if overlay.wrappers.replace
556 || !overlay.wrappers.remove_allow_floor.is_empty()
557 || !overlay.wrappers.remove_ask_floor.is_empty()
558 {
559 stripped = true;
560 }
561 overlay.wrappers.replace = false;
562 overlay.wrappers.remove_allow_floor.clear();
563 overlay.wrappers.remove_ask_floor.clear();
564
565 if overlay.git.replace
567 || !overlay.git.remove_read_only.is_empty()
568 || !overlay.git.remove_allowed_with_config.is_empty()
569 || !overlay.git.remove_force_push_flags.is_empty()
570 {
571 stripped = true;
572 }
573 overlay.git.replace = false;
574 overlay.git.remove_read_only.clear();
575 overlay.git.remove_allowed_with_config.clear();
576 overlay.git.remove_force_push_flags.clear();
577
578 if overlay.cargo.replace
580 || !overlay.cargo.remove_safe_subcommands.is_empty()
581 || !overlay.cargo.remove_allowed_with_config.is_empty()
582 {
583 stripped = true;
584 }
585 overlay.cargo.replace = false;
586 overlay.cargo.remove_safe_subcommands.clear();
587 overlay.cargo.remove_allowed_with_config.clear();
588
589 if overlay.kubectl.replace
591 || !overlay.kubectl.remove_read_only.is_empty()
592 || !overlay.kubectl.remove_mutating.is_empty()
593 || !overlay.kubectl.remove_allowed_with_config.is_empty()
594 {
595 stripped = true;
596 }
597 overlay.kubectl.replace = false;
598 overlay.kubectl.remove_read_only.clear();
599 overlay.kubectl.remove_mutating.clear();
600 overlay.kubectl.remove_allowed_with_config.clear();
601
602 if overlay.gh.replace
604 || !overlay.gh.remove_read_only.is_empty()
605 || !overlay.gh.remove_mutating.is_empty()
606 || !overlay.gh.remove_allowed_with_config.is_empty()
607 {
608 stripped = true;
609 }
610 overlay.gh.replace = false;
611 overlay.gh.remove_read_only.clear();
612 overlay.gh.remove_mutating.clear();
613 overlay.gh.remove_allowed_with_config.clear();
614
615 if stripped {
616 eprintln!(
617 "cc-toolgate: project overlay at {} attempted to use replace/remove — stripped for security",
618 path.display()
619 );
620 }
621}
622
623fn load_overlay_from_path(path: &std::path::Path, err_label: &str) -> Option<ConfigOverlay> {
627 let content = std::fs::read_to_string(path).ok()?;
628 match toml::from_str(&content) {
629 Ok(overlay) => Some(overlay),
630 Err(e) => {
631 eprintln!("cc-toolgate: {err_label}: {e}");
632 None
633 }
634 }
635}
636
637fn find_git_root(start: &std::path::Path) -> Option<std::path::PathBuf> {
641 let mut current = Some(start);
642 while let Some(dir) = current {
643 if dir.join(".git").exists() {
644 return Some(dir.to_path_buf());
645 }
646 current = dir.parent();
647 }
648 None
649}
650
651#[cfg(test)]
652mod tests {
653 use super::*;
654
655 #[test]
656 fn default_config_parses() {
657 let config = Config::default_config();
658 assert!(!config.commands.allow.is_empty());
659 assert!(!config.commands.ask.is_empty());
660 assert!(!config.commands.deny.is_empty());
661 assert!(!config.git.read_only.is_empty());
662 assert!(!config.cargo.safe_subcommands.is_empty());
663 assert!(!config.kubectl.read_only.is_empty());
664 assert!(!config.gh.read_only.is_empty());
665 }
666
667 #[test]
668 fn default_config_has_expected_commands() {
669 let config = Config::default_config();
670 assert!(config.commands.allow.contains(&"ls".to_string()));
671 assert!(config.commands.ask.contains(&"rm".to_string()));
672 assert!(config.commands.deny.contains(&"shred".to_string()));
673 }
674
675 #[test]
676 fn default_escalate_deny_is_false() {
677 let config = Config::default_config();
678 assert!(!config.settings.escalate_deny);
679 }
680
681 #[test]
682 fn default_git_env_gate_disabled() {
683 let config = Config::default_config();
684 assert!(config.git.config_env.is_empty());
685 assert!(config.git.allowed_with_config.is_empty());
686 }
687
688 #[test]
691 fn overlay_extends_allow_list() {
692 let mut config = Config::default_config();
693 config.apply_overlay_str(
694 r#"
695 [commands]
696 allow = ["my-tool"]
697 "#,
698 );
699 assert!(config.commands.allow.contains(&"ls".to_string()));
701 assert!(config.commands.allow.contains(&"my-tool".to_string()));
703 }
704
705 #[test]
706 fn overlay_removes_from_allow_list() {
707 let mut config = Config::default_config();
708 config.apply_overlay_str(
709 r#"
710 [commands]
711 remove_allow = ["cat", "find"]
712 "#,
713 );
714 assert!(!config.commands.allow.contains(&"cat".to_string()));
715 assert!(!config.commands.allow.contains(&"find".to_string()));
716 assert!(config.commands.allow.contains(&"ls".to_string()));
718 }
719
720 #[test]
721 fn default_wrappers_populated() {
722 let config = Config::default_config();
723 assert!(config.wrappers.allow_floor.contains(&"xargs".to_string()));
724 assert!(config.wrappers.allow_floor.contains(&"env".to_string()));
725 assert!(config.wrappers.ask_floor.contains(&"sudo".to_string()));
726 assert!(config.wrappers.ask_floor.contains(&"doas".to_string()));
727 assert!(!config.commands.allow.contains(&"xargs".to_string()));
729 assert!(!config.commands.allow.contains(&"env".to_string()));
730 assert!(!config.commands.ask.contains(&"sudo".to_string()));
731 }
732
733 #[test]
734 fn overlay_removes_from_wrappers() {
735 let mut config = Config::default_config();
736 config.apply_overlay_str(
737 r#"
738 [wrappers]
739 remove_allow_floor = ["xargs"]
740 "#,
741 );
742 assert!(!config.wrappers.allow_floor.contains(&"xargs".to_string()));
743 assert!(config.wrappers.allow_floor.contains(&"env".to_string()));
745 }
746
747 #[test]
748 fn overlay_extends_wrappers() {
749 let mut config = Config::default_config();
750 config.apply_overlay_str(
751 r#"
752 [wrappers]
753 allow_floor = ["my-wrapper"]
754 "#,
755 );
756 assert!(
757 config
758 .wrappers
759 .allow_floor
760 .contains(&"my-wrapper".to_string())
761 );
762 assert!(config.wrappers.allow_floor.contains(&"xargs".to_string()));
763 }
764
765 #[test]
766 fn overlay_replace_commands() {
767 let mut config = Config::default_config();
768 config.apply_overlay_str(
769 r#"
770 [commands]
771 replace = true
772 allow = ["ls", "cat"]
773 ask = ["rm"]
774 deny = ["shred"]
775 "#,
776 );
777 assert_eq!(config.commands.allow, vec!["ls", "cat"]);
778 assert_eq!(config.commands.ask, vec!["rm"]);
779 assert_eq!(config.commands.deny, vec!["shred"]);
780 }
781
782 #[test]
783 fn overlay_git_env_gate() {
784 let mut config = Config::default_config();
785 config.apply_overlay_str(
786 r#"
787 [git]
788 allowed_with_config = ["commit", "add", "push"]
789 [git.config_env]
790 GIT_CONFIG_GLOBAL = "~/.gitconfig.ai"
791 "#,
792 );
793 assert_eq!(
794 config.git.config_env.get("GIT_CONFIG_GLOBAL").unwrap(),
795 "~/.gitconfig.ai"
796 );
797 assert_eq!(
798 config.git.allowed_with_config,
799 vec!["commit", "add", "push"]
800 );
801 assert!(config.git.read_only.contains(&"status".to_string()));
803 assert!(config.git.read_only.contains(&"log".to_string()));
804 }
805
806 #[test]
807 fn overlay_escalate_deny() {
808 let mut config = Config::default_config();
809 config.apply_overlay_str(
810 r#"
811 [settings]
812 escalate_deny = true
813 "#,
814 );
815 assert!(config.settings.escalate_deny);
816 }
817
818 #[test]
819 fn overlay_omitted_settings_unchanged() {
820 let mut config = Config::default_config();
821 config.apply_overlay_str(
822 r#"
823 [commands]
824 allow = ["my-tool"]
825 "#,
826 );
827 assert!(!config.settings.escalate_deny);
829 }
830
831 #[test]
832 fn overlay_no_duplicates() {
833 let mut config = Config::default_config();
834 config.apply_overlay_str(
835 r#"
836 [commands]
837 allow = ["ls"]
838 "#,
839 );
840 let count = config.commands.allow.iter().filter(|s| *s == "ls").count();
841 assert_eq!(count, 1);
842 }
843
844 #[test]
845 fn overlay_remove_and_add() {
846 let mut config = Config::default_config();
847 config.apply_overlay_str(
849 r#"
850 [commands]
851 remove_deny = ["eval"]
852 ask = ["eval"]
853 "#,
854 );
855 assert!(!config.commands.deny.contains(&"eval".to_string()));
856 assert!(config.commands.ask.contains(&"eval".to_string()));
857 }
858
859 #[test]
860 fn overlay_promote_ask_to_allow_deduplicates() {
861 let mut config = Config::default_config();
866 assert!(
867 config.commands.ask.contains(&"curl".to_string()),
868 "curl should be in default ask list"
869 );
870
871 config.apply_overlay_str(
872 r#"
873 [commands]
874 allow = ["curl"]
875 "#,
876 );
877
878 assert!(
879 config.commands.allow.contains(&"curl".to_string()),
880 "curl should be in allow after overlay"
881 );
882 assert!(
883 !config.commands.ask.contains(&"curl".to_string()),
884 "curl must be removed from ask when promoted to allow"
885 );
886 }
887
888 #[test]
889 fn overlay_promote_deny_to_ask_deduplicates() {
890 let mut config = Config::default_config();
893 assert!(config.commands.deny.contains(&"eval".to_string()));
894
895 config.apply_overlay_str(
896 r#"
897 [commands]
898 ask = ["eval"]
899 "#,
900 );
901
902 assert!(
903 config.commands.ask.contains(&"eval".to_string()),
904 "eval should be in ask after overlay"
905 );
906 assert!(
907 !config.commands.deny.contains(&"eval".to_string()),
908 "eval must be removed from deny when promoted to ask"
909 );
910 }
911
912 #[test]
913 fn overlay_promote_deny_to_allow_deduplicates() {
914 let mut config = Config::default_config();
917 assert!(config.commands.deny.contains(&"eval".to_string()));
918
919 config.apply_overlay_str(
920 r#"
921 [commands]
922 allow = ["eval"]
923 "#,
924 );
925
926 assert!(
927 config.commands.allow.contains(&"eval".to_string()),
928 "eval should be in allow after overlay"
929 );
930 assert!(
931 !config.commands.deny.contains(&"eval".to_string()),
932 "eval must be removed from deny when promoted to allow"
933 );
934 assert!(
935 !config.commands.ask.contains(&"eval".to_string()),
936 "eval must not appear in ask when promoted to allow"
937 );
938 }
939
940 #[test]
941 fn wrapper_promote_ask_floor_to_allow_floor_deduplicates() {
942 let mut config = Config::default_config();
945 assert!(config.wrappers.ask_floor.contains(&"sudo".to_string()));
946
947 config.apply_overlay_str(
948 r#"
949 [wrappers]
950 allow_floor = ["sudo"]
951 "#,
952 );
953
954 assert!(
955 config.wrappers.allow_floor.contains(&"sudo".to_string()),
956 "sudo should be in allow_floor after overlay"
957 );
958 assert!(
959 !config.wrappers.ask_floor.contains(&"sudo".to_string()),
960 "sudo must be removed from ask_floor when promoted to allow_floor"
961 );
962 }
963
964 #[test]
965 fn overlay_replace_git() {
966 let mut config = Config::default_config();
967 config.apply_overlay_str(
968 r#"
969 [git]
970 replace = true
971 read_only = ["status", "log"]
972 force_push_flags = ["--force"]
973 "#,
974 );
975 assert_eq!(config.git.read_only, vec!["status", "log"]);
976 assert_eq!(config.git.force_push_flags, vec!["--force"]);
977 assert!(config.git.allowed_with_config.is_empty());
978 }
979
980 #[test]
981 fn overlay_unrelated_sections_untouched() {
982 let mut config = Config::default_config();
983 let original_kubectl_read_only = config.kubectl.read_only.clone();
984 config.apply_overlay_str(
985 r#"
986 [git]
987 allowed_with_config = ["push"]
988 config_env_var = "GIT_CONFIG_GLOBAL"
989 "#,
990 );
991 assert_eq!(config.kubectl.read_only, original_kubectl_read_only);
992 }
993
994 #[test]
995 fn empty_overlay_changes_nothing() {
996 let original = Config::default_config();
997 let mut config = Config::default_config();
998 config.apply_overlay_str("");
999 assert_eq!(config.commands.allow.len(), original.commands.allow.len());
1000 assert_eq!(config.git.read_only.len(), original.git.read_only.len());
1001 }
1002
1003 fn scratch_dir(tag: &str) -> std::path::PathBuf {
1007 let nanos = std::time::SystemTime::now()
1008 .duration_since(std::time::UNIX_EPOCH)
1009 .unwrap()
1010 .as_nanos();
1011 let dir = std::env::temp_dir().join(format!("cc-toolgate-test-{tag}-{nanos}"));
1012 std::fs::create_dir_all(&dir).unwrap();
1013 dir
1014 }
1015
1016 #[test]
1017 fn find_git_root_finds_dot_git_in_ancestor() {
1018 let root = scratch_dir("find-root");
1019 std::fs::create_dir(root.join(".git")).unwrap();
1020 let deep = root.join("a/b/c");
1021 std::fs::create_dir_all(&deep).unwrap();
1022
1023 assert_eq!(find_git_root(&deep), Some(root.clone()));
1024 assert_eq!(find_git_root(&root), Some(root.clone()));
1025
1026 std::fs::remove_dir_all(&root).ok();
1027 }
1028
1029 #[test]
1030 fn project_overlay_file_parses_and_extends_allow() {
1031 let root = scratch_dir("project-overlay");
1032 std::fs::create_dir(root.join(".git")).unwrap();
1033 std::fs::create_dir(root.join(".claude")).unwrap();
1034 std::fs::write(
1035 root.join(".claude/cc-toolgate.toml"),
1036 r#"
1037 [commands]
1038 allow = ["my-project-tool"]
1039 "#,
1040 )
1041 .unwrap();
1042
1043 let path = root.join(".claude/cc-toolgate.toml");
1044 let overlay = load_overlay_from_path(&path, "test").expect("parses");
1045
1046 let mut config = Config::default_config();
1047 config.apply_overlay(overlay);
1048 assert!(
1049 config
1050 .commands
1051 .allow
1052 .contains(&"my-project-tool".to_string())
1053 );
1054
1055 std::fs::remove_dir_all(&root).ok();
1056 }
1057
1058 fn require_nextest() {
1065 assert!(
1066 std::env::var("NEXTEST").is_ok(),
1067 "this test mutates process CWD/HOME and requires nextest (cargo nextest run)"
1068 );
1069 }
1070
1071 #[test]
1072 fn config_load_applies_project_overlay() {
1073 require_nextest();
1074
1075 let root = scratch_dir("load-project");
1076 std::fs::create_dir(root.join(".git")).unwrap();
1077 std::fs::create_dir(root.join(".claude")).unwrap();
1078 std::fs::write(
1079 root.join(".claude/cc-toolgate.toml"),
1080 r#"
1081 [commands]
1082 allow = ["my-test-script"]
1083 "#,
1084 )
1085 .unwrap();
1086
1087 let fake_home = root.join("fakehome");
1089 std::fs::create_dir_all(&fake_home).unwrap();
1090 unsafe { std::env::set_var("HOME", &fake_home) };
1091
1092 let original_dir = std::env::current_dir().unwrap();
1094 std::env::set_current_dir(&root).unwrap();
1095
1096 let config = Config::load();
1097
1098 std::env::set_current_dir(&original_dir).unwrap();
1100
1101 assert!(
1103 config.commands.allow.contains(&"ls".to_string()),
1104 "default 'ls' should still be in allow list"
1105 );
1106 assert!(
1108 config
1109 .commands
1110 .allow
1111 .contains(&"my-test-script".to_string()),
1112 "project overlay should have added 'my-test-script' to allow list"
1113 );
1114 let expected_path = root.join(".claude/cc-toolgate.toml");
1116 assert_eq!(
1117 config.project_overlay_path,
1118 Some(expected_path),
1119 "project_overlay_path should record the overlay file"
1120 );
1121
1122 std::fs::remove_dir_all(&root).ok();
1123 }
1124
1125 #[test]
1126 fn config_load_project_overlay_replace_is_stripped() {
1127 require_nextest();
1128
1129 let root = scratch_dir("load-replace-stripped");
1130 std::fs::create_dir(root.join(".git")).unwrap();
1131 std::fs::create_dir(root.join(".claude")).unwrap();
1132 std::fs::write(
1136 root.join(".claude/cc-toolgate.toml"),
1137 r#"
1138 [commands]
1139 replace = true
1140 allow = ["only-this"]
1141 ask = ["only-ask"]
1142 deny = ["only-deny"]
1143 "#,
1144 )
1145 .unwrap();
1146
1147 let fake_home = root.join("fakehome");
1148 std::fs::create_dir_all(&fake_home).unwrap();
1149 unsafe { std::env::set_var("HOME", &fake_home) };
1150
1151 let original_dir = std::env::current_dir().unwrap();
1152 std::env::set_current_dir(&root).unwrap();
1153
1154 let config = Config::load();
1155
1156 std::env::set_current_dir(&original_dir).unwrap();
1157
1158 assert!(
1160 config.commands.allow.contains(&"ls".to_string()),
1161 "replace should be stripped; default 'ls' must still be in allow list"
1162 );
1163 assert!(
1165 config.commands.allow.contains(&"only-this".to_string()),
1166 "additive allow items from project overlay should still be applied"
1167 );
1168 assert!(
1169 config.commands.ask.contains(&"only-ask".to_string()),
1170 "additive ask items from project overlay should still be applied"
1171 );
1172 assert!(
1173 config.commands.deny.contains(&"only-deny".to_string()),
1174 "additive deny items from project overlay should still be applied"
1175 );
1176
1177 assert!(
1179 !config.git.read_only.is_empty(),
1180 "git read_only should be unaffected by a commands-only overlay"
1181 );
1182
1183 std::fs::remove_dir_all(&root).ok();
1184 }
1185
1186 #[test]
1187 fn config_load_project_overlay_remove_deny_is_stripped() {
1188 require_nextest();
1189
1190 let root = scratch_dir("load-remove-deny-stripped");
1191 std::fs::create_dir(root.join(".git")).unwrap();
1192 std::fs::create_dir(root.join(".claude")).unwrap();
1193 std::fs::write(
1195 root.join(".claude/cc-toolgate.toml"),
1196 r#"
1197 [commands]
1198 remove_deny = ["shred"]
1199 "#,
1200 )
1201 .unwrap();
1202
1203 let fake_home = root.join("fakehome");
1204 std::fs::create_dir_all(&fake_home).unwrap();
1205 unsafe { std::env::set_var("HOME", &fake_home) };
1206
1207 let original_dir = std::env::current_dir().unwrap();
1208 std::env::set_current_dir(&root).unwrap();
1209
1210 let config = Config::load();
1211
1212 std::env::set_current_dir(&original_dir).unwrap();
1213
1214 assert!(
1216 config.commands.deny.contains(&"shred".to_string()),
1217 "remove_deny must be stripped; 'shred' should remain in deny list"
1218 );
1219
1220 std::fs::remove_dir_all(&root).ok();
1221 }
1222
1223 #[test]
1224 fn strip_project_overlay_dangerous_fields_clears_all_sections() {
1225 let path = std::path::PathBuf::from("/fake/path/.claude/cc-toolgate.toml");
1226 let mut overlay = ConfigOverlay {
1227 commands: CommandsOverlay {
1228 replace: true,
1229 remove_allow: vec!["cat".into()],
1230 remove_ask: vec!["rm".into()],
1231 remove_deny: vec!["shred".into()],
1232 allow: vec!["my-tool".into()],
1233 ..Default::default()
1234 },
1235 wrappers: WrappersOverlay {
1236 replace: true,
1237 remove_allow_floor: vec!["xargs".into()],
1238 remove_ask_floor: vec!["sudo".into()],
1239 ..Default::default()
1240 },
1241 git: GitOverlay {
1242 replace: true,
1243 remove_read_only: vec!["status".into()],
1244 remove_allowed_with_config: vec!["push".into()],
1245 remove_force_push_flags: vec!["--force".into()],
1246 read_only: vec!["log".into()],
1247 ..Default::default()
1248 },
1249 cargo: CargoOverlay {
1250 replace: true,
1251 remove_safe_subcommands: vec!["build".into()],
1252 remove_allowed_with_config: vec!["publish".into()],
1253 ..Default::default()
1254 },
1255 kubectl: KubectlOverlay {
1256 replace: true,
1257 remove_read_only: vec!["get".into()],
1258 remove_mutating: vec!["apply".into()],
1259 remove_allowed_with_config: vec!["exec".into()],
1260 ..Default::default()
1261 },
1262 gh: GhOverlay {
1263 replace: true,
1264 remove_read_only: vec!["pr list".into()],
1265 remove_mutating: vec!["pr merge".into()],
1266 remove_allowed_with_config: vec!["pr create".into()],
1267 ..Default::default()
1268 },
1269 ..Default::default()
1270 };
1271
1272 strip_project_overlay_dangerous_fields(&mut overlay, &path);
1273
1274 assert!(!overlay.commands.replace);
1276 assert!(overlay.commands.remove_allow.is_empty());
1277 assert!(overlay.commands.remove_ask.is_empty());
1278 assert!(overlay.commands.remove_deny.is_empty());
1279
1280 assert!(!overlay.wrappers.replace);
1281 assert!(overlay.wrappers.remove_allow_floor.is_empty());
1282 assert!(overlay.wrappers.remove_ask_floor.is_empty());
1283
1284 assert!(!overlay.git.replace);
1285 assert!(overlay.git.remove_read_only.is_empty());
1286 assert!(overlay.git.remove_allowed_with_config.is_empty());
1287 assert!(overlay.git.remove_force_push_flags.is_empty());
1288
1289 assert!(!overlay.cargo.replace);
1290 assert!(overlay.cargo.remove_safe_subcommands.is_empty());
1291 assert!(overlay.cargo.remove_allowed_with_config.is_empty());
1292
1293 assert!(!overlay.kubectl.replace);
1294 assert!(overlay.kubectl.remove_read_only.is_empty());
1295 assert!(overlay.kubectl.remove_mutating.is_empty());
1296 assert!(overlay.kubectl.remove_allowed_with_config.is_empty());
1297
1298 assert!(!overlay.gh.replace);
1299 assert!(overlay.gh.remove_read_only.is_empty());
1300 assert!(overlay.gh.remove_mutating.is_empty());
1301 assert!(overlay.gh.remove_allowed_with_config.is_empty());
1302
1303 assert_eq!(overlay.commands.allow, vec!["my-tool"]);
1305 assert_eq!(overlay.git.read_only, vec!["log"]);
1306 }
1307
1308 #[test]
1309 fn strip_project_overlay_no_op_when_safe() {
1310 let path = std::path::PathBuf::from("/fake/path/.claude/cc-toolgate.toml");
1311 let mut overlay = ConfigOverlay {
1312 commands: CommandsOverlay {
1313 allow: vec!["my-tool".into()],
1314 ..Default::default()
1315 },
1316 ..Default::default()
1317 };
1318
1319 strip_project_overlay_dangerous_fields(&mut overlay, &path);
1321 assert_eq!(overlay.commands.allow, vec!["my-tool"]);
1322 assert!(!overlay.commands.replace);
1323 }
1324
1325 #[test]
1326 fn config_load_no_project_overlay_path_when_absent() {
1327 require_nextest();
1328
1329 let root = scratch_dir("load-no-overlay");
1330 let fake_home = root.join("fakehome");
1333 std::fs::create_dir_all(&fake_home).unwrap();
1334 unsafe { std::env::set_var("HOME", &fake_home) };
1335
1336 let original_dir = std::env::current_dir().unwrap();
1337 std::env::set_current_dir(&root).unwrap();
1338
1339 let config = Config::load();
1340
1341 std::env::set_current_dir(&original_dir).unwrap();
1342
1343 assert!(
1344 config.project_overlay_path.is_none(),
1345 "project_overlay_path should be None when no overlay is found"
1346 );
1347
1348 std::fs::remove_dir_all(&root).ok();
1349 }
1350}