1pub mod context;
10pub mod decision;
12
13pub use context::CommandContext;
14pub use decision::{Decision, RuleMatch};
15
16use std::collections::HashMap;
17
18use crate::commands::CommandSpec;
19use crate::config::Config;
20use crate::parse;
21use crate::parse::Operator;
22
23fn is_likely_successful(segment: &str) -> bool {
33 if segment.contains("__SUBST__") {
36 return false;
37 }
38 let words = parse::tokenize(segment);
39 if words.is_empty() {
40 return false;
41 }
42 if words.len() == 1 && words[0].contains('=') {
44 return parse_assignment(&words[0]).is_some();
45 }
46 let base = parse::base_command(segment);
47 match base.as_str() {
48 "export" | "unset" => true,
50 "true" => true,
52 "echo" | "printf" => true,
54 _ => false,
55 }
56}
57
58fn is_var_name(s: &str) -> bool {
60 !s.is_empty()
61 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
62 && s.chars()
63 .next()
64 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
65}
66
67fn parse_assignment(token: &str) -> Option<(String, String)> {
69 let eq_pos = token.find('=')?;
70 let key = &token[..eq_pos];
71 let val = &token[eq_pos + 1..];
72 if is_var_name(key) {
73 Some((key.to_string(), val.to_string()))
74 } else {
75 None
76 }
77}
78
79fn extract_segment_env(segment: &str) -> Vec<(String, String)> {
88 let words = parse::tokenize(segment);
89 if words.is_empty() {
90 return Vec::new();
91 }
92
93 if words.len() == 1 {
95 return parse_assignment(&words[0]).into_iter().collect();
96 }
97
98 if words[0] == "export" {
100 return words[1..]
101 .iter()
102 .filter(|w| !w.starts_with('-')) .filter_map(|w| parse_assignment(w))
104 .collect();
105 }
106
107 Vec::new()
108}
109
110fn extract_unset_vars(segment: &str) -> Vec<String> {
118 let words = parse::tokenize(segment);
119 if words.is_empty() || words[0] != "unset" {
120 return Vec::new();
121 }
122 let mut result = Vec::new();
123 let mut unsetting_functions = false;
124 for word in &words[1..] {
125 if word == "-f" {
126 unsetting_functions = true;
127 } else if word == "-v" {
128 unsetting_functions = false;
129 } else if !word.starts_with('-') && !unsetting_functions && is_var_name(word) {
130 result.push(word.clone());
131 }
132 }
133 result
134}
135
136pub struct CommandRegistry {
142 specs: HashMap<String, Box<dyn CommandSpec>>,
144 wrappers: HashMap<String, Decision>,
148 escalate_deny: bool,
150 project_overlay_path: Option<std::path::PathBuf>,
153}
154
155impl CommandRegistry {
156 pub fn from_config(config: &Config) -> Self {
158 use crate::commands::{
159 simple::SimpleCommandSpec,
160 tools::{cargo::CargoSpec, gh::GhSpec, git::GitSpec, kubectl::KubectlSpec},
161 };
162
163 let mut specs: HashMap<String, Box<dyn CommandSpec>> = HashMap::new();
164
165 for name in &config.commands.deny {
167 specs.insert(
168 name.clone(),
169 Box::new(SimpleCommandSpec::new(Decision::Deny)),
170 );
171 }
172
173 for name in &config.commands.allow {
175 specs.insert(
176 name.clone(),
177 Box::new(SimpleCommandSpec::new(Decision::Allow)),
178 );
179 }
180
181 for name in &config.commands.ask {
183 specs.insert(
184 name.clone(),
185 Box::new(SimpleCommandSpec::new(Decision::Ask)),
186 );
187 }
188
189 specs.insert("git".into(), Box::new(GitSpec::from_config(&config.git)));
191 specs.insert(
192 "cargo".into(),
193 Box::new(CargoSpec::from_config(&config.cargo)),
194 );
195 specs.insert(
196 "kubectl".into(),
197 Box::new(KubectlSpec::from_config(&config.kubectl)),
198 );
199 specs.insert("gh".into(), Box::new(GhSpec::from_config(&config.gh)));
200
201 let mut wrappers = HashMap::new();
204 for name in &config.wrappers.allow_floor {
205 specs.remove(name);
206 wrappers.insert(name.clone(), Decision::Allow);
207 }
208 for name in &config.wrappers.ask_floor {
209 specs.remove(name);
210 wrappers.insert(name.clone(), Decision::Ask);
211 }
212
213 Self {
214 specs,
215 wrappers,
216 escalate_deny: config.settings.escalate_deny,
217 project_overlay_path: config.project_overlay_path.clone(),
218 }
219 }
220
221 pub fn set_escalate_deny(&mut self, escalate: bool) {
223 self.escalate_deny = escalate;
224 }
225
226 fn get(&self, name: &str) -> Option<&dyn CommandSpec> {
228 self.specs.get(name).map(|b| b.as_ref())
229 }
230
231 fn wrapper_floor(&self, name: &str) -> Option<Decision> {
233 self.wrappers.get(name).copied()
234 }
235
236 fn extract_wrapped_command(ctx: &CommandContext) -> String {
241 let iter = ctx.words.iter().skip(1); if ctx.base_command == "env" {
244 let mut rest: Vec<&str> = Vec::new();
246 let mut found_cmd = false;
247 for word in iter {
248 if found_cmd {
249 rest.push(word);
250 } else if word.starts_with('-') {
251 continue; } else if word.contains('=') {
253 continue; } else {
255 found_cmd = true;
256 rest.push(word);
257 }
258 }
259 rest.join(" ")
260 } else {
261 let non_flags: Vec<&str> = iter
268 .skip_while(|w| w.starts_with('-'))
269 .map(|s| s.as_str())
270 .collect();
271 let cmd_start = non_flags
273 .iter()
274 .position(|w| !w.chars().all(|c| c.is_ascii_digit() || c == '.'))
275 .unwrap_or(non_flags.len());
276 non_flags[cmd_start..].join(" ")
277 }
278 }
279
280 fn maybe_escalate(&self, mut result: RuleMatch) -> RuleMatch {
282 if self.escalate_deny && result.decision == Decision::Deny {
283 result.decision = Decision::Ask;
284 result.reason = format!("{} (escalated from deny)", result.reason);
285 }
286 result
287 }
288
289 fn maybe_annotate_project_overlay(&self, mut result: RuleMatch) -> RuleMatch {
291 if result.decision == Decision::Ask
292 && let Some(ref path) = self.project_overlay_path
293 {
294 result.reason = format!(
295 "{} (project config at {} contributed to this decision)",
296 result.reason,
297 path.display()
298 );
299 }
300 result
301 }
302
303 pub fn evaluate_single(&self, command: &str) -> RuleMatch {
305 let result = self.evaluate_single_with_env(command, &HashMap::new());
306 self.maybe_annotate_project_overlay(result)
307 }
308
309 fn evaluate_single_with_env(
311 &self,
312 command: &str,
313 accumulated_env: &HashMap<String, String>,
314 ) -> RuleMatch {
315 let cmd = command.trim();
316 if cmd.is_empty() {
317 return RuleMatch {
318 decision: Decision::Allow,
319 reason: "empty".into(),
320 };
321 }
322
323 let words = parse::tokenize(cmd);
325 if words.len() == 1 && parse_assignment(&words[0]).is_some() {
326 return RuleMatch {
327 decision: Decision::Allow,
328 reason: format!("variable assignment: {}", words[0]),
329 };
330 }
331
332 let mut ctx = CommandContext::from_command(cmd);
333 ctx.accumulated_env = accumulated_env.clone();
334
335 if let Some(floor) = self.wrapper_floor(&ctx.base_command) {
338 let wrapped_cmd = Self::extract_wrapped_command(&ctx);
339 let mut strictest = floor;
340 let mut reason = if !wrapped_cmd.is_empty() {
341 let inner_env = if ctx.base_command == "env" && ctx.has_any_flag(&["-i", "-"]) {
343 HashMap::new()
344 } else {
345 accumulated_env.clone()
346 };
347 let inner = self.evaluate_single_with_env(&wrapped_cmd, &inner_env);
348 if inner.decision > strictest {
349 strictest = inner.decision;
350 }
351 format!("{} wraps: {}", ctx.base_command, inner.reason)
352 } else {
353 format!("{} (no wrapped command)", ctx.base_command)
354 };
355 if strictest == Decision::Allow && ctx.redirection.is_some() {
357 strictest = Decision::Ask;
358 reason = format!("{} with output redirection", reason);
359 }
360 return self.maybe_escalate(RuleMatch {
361 decision: strictest,
362 reason,
363 });
364 }
365
366 if let Some(spec) = self.get(&ctx.base_command) {
368 return self.maybe_escalate(spec.evaluate(&ctx));
369 }
370
371 if let Some(prefix) = ctx.base_command.split('.').next()
373 && prefix != ctx.base_command
374 && let Some(spec) = self.get(prefix)
375 {
376 return self.maybe_escalate(spec.evaluate(&ctx));
377 }
378
379 RuleMatch {
381 decision: Decision::Ask,
382 reason: format!("unrecognized command: {}", ctx.base_command),
383 }
384 }
385
386 pub fn evaluate(&self, command: &str) -> RuleMatch {
388 let (pipeline, substitutions) = parse::parse_with_substitutions(command);
389
390 if pipeline.segments.len() <= 1 && substitutions.is_empty() {
396 let is_passthrough = match pipeline.segments.first() {
397 Some(seg) => seg.command.trim() == command.trim(),
398 None => true,
399 };
400 if is_passthrough {
401 return self.evaluate_single(command);
402 }
403 }
404
405 let mut strictest = Decision::Allow;
406 let mut reasons = Vec::new();
407
408 for inner in &substitutions {
410 let result = self.evaluate(inner);
411 let label: String = inner.trim().chars().take(60).collect();
412 reasons.push(format!(
413 " subst[$({label})] -> {}: {}",
414 result.decision.label(),
415 result.reason
416 ));
417 if result.decision > strictest {
418 strictest = result.decision;
419 }
420 }
421
422 let mut accumulated_env: HashMap<String, String> = HashMap::new();
425 let mut segment_executes = true;
428
429 for (i, segment) in pipeline.segments.iter().enumerate() {
430 if i > 0 {
432 let op = &pipeline.operators[i - 1];
433 match op {
434 Operator::Semi => segment_executes = true,
436 Operator::And => {
438 segment_executes = segment_executes
439 && is_likely_successful(&pipeline.segments[i - 1].command);
440 }
441 Operator::Or | Operator::Pipe | Operator::PipeErr => {
446 segment_executes = false;
447 accumulated_env.clear();
448 }
449 }
450 }
451
452 let mut result = self.evaluate_single_with_env(&segment.command, &accumulated_env);
453
454 if segment_executes {
457 for (key, val) in extract_segment_env(&segment.command) {
458 accumulated_env.insert(key, val);
459 }
460 for var in extract_unset_vars(&segment.command) {
461 accumulated_env.remove(&var);
462 }
463 }
464
465 if result.decision == Decision::Allow
470 && let Some(ref r) = segment.redirection
471 {
472 result.decision = Decision::Ask;
473 result.reason =
474 format!("{} (escalated: wrapping {})", result.reason, r.description);
475 }
476 let label: String = segment.command.trim().chars().take(60).collect();
477 reasons.push(format!(
478 " [{label}] -> {}: {}",
479 result.decision.label(),
480 result.reason
481 ));
482 if result.decision > strictest {
483 strictest = result.decision;
484 }
485 }
486
487 let mut desc = Vec::new();
489 if !pipeline.operators.is_empty() {
490 let mut unique_ops: Vec<&str> = pipeline.operators.iter().map(|o| o.as_str()).collect();
491 unique_ops.sort();
492 unique_ops.dedup();
493 desc.push(unique_ops.join(", "));
494 }
495 if !substitutions.is_empty() {
496 desc.push(format!("{} substitution(s)", substitutions.len()));
497 }
498 let header = if desc.is_empty() {
499 "compound command".into()
500 } else {
501 format!("compound command ({})", desc.join("; "))
502 };
503
504 self.maybe_annotate_project_overlay(RuleMatch {
505 decision: strictest,
506 reason: format!("{}:\n{}", header, reasons.join("\n")),
507 })
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514
515 fn clear_git_env() {
518 assert!(
519 std::env::var("NEXTEST").is_ok(),
520 "this test mutates process env and requires nextest (cargo nextest run)"
521 );
522 unsafe { std::env::remove_var("GIT_CONFIG_GLOBAL") };
523 }
524
525 #[test]
528 fn likely_success_export() {
529 assert!(is_likely_successful("export FOO=bar"));
530 }
531
532 #[test]
533 fn likely_success_export_multiple() {
534 assert!(is_likely_successful("export A=1 B=2"));
535 }
536
537 #[test]
538 fn likely_success_bare_assignment() {
539 assert!(is_likely_successful("FOO=bar"));
540 }
541
542 #[test]
543 fn likely_success_true() {
544 assert!(is_likely_successful("true"));
545 }
546
547 #[test]
548 fn likely_success_echo() {
549 assert!(is_likely_successful("echo hello"));
550 }
551
552 #[test]
553 fn likely_success_printf() {
554 assert!(is_likely_successful("printf '%s\\n' hello"));
555 }
556
557 #[test]
558 fn likely_success_export_with_subshell_is_not_likely() {
559 assert!(!is_likely_successful("export FOO=__SUBST__"));
561 }
562
563 #[test]
564 fn likely_success_echo_with_subshell_is_not_likely() {
565 assert!(!is_likely_successful("echo __SUBST__"));
566 }
567
568 #[test]
569 fn likely_success_bare_assignment_with_subshell_is_not_likely() {
570 assert!(!is_likely_successful("FOO=__SUBST__"));
571 }
572
573 #[test]
574 fn likely_success_unknown_command() {
575 assert!(!is_likely_successful("some_command --flag"));
576 }
577
578 #[test]
579 fn likely_success_git() {
580 assert!(!is_likely_successful("git push"));
581 }
582
583 #[test]
584 fn likely_success_rm() {
585 assert!(!is_likely_successful("rm -rf /"));
586 }
587
588 #[test]
591 fn extract_env_export_single() {
592 let vars = extract_segment_env("export FOO=bar");
593 assert_eq!(vars, vec![("FOO".into(), "bar".into())]);
594 }
595
596 #[test]
597 fn extract_env_export_multiple() {
598 let vars = extract_segment_env("export A=1 B=2");
599 assert_eq!(
600 vars,
601 vec![("A".into(), "1".into()), ("B".into(), "2".into())]
602 );
603 }
604
605 #[test]
606 fn extract_env_export_with_path() {
607 let vars = extract_segment_env("export GIT_CONFIG_GLOBAL=~/.gitconfig.ai");
608 assert_eq!(
609 vars,
610 vec![("GIT_CONFIG_GLOBAL".into(), "~/.gitconfig.ai".into())]
611 );
612 }
613
614 #[test]
615 fn extract_env_bare_assignment() {
616 let vars = extract_segment_env("FOO=bar");
617 assert_eq!(vars, vec![("FOO".into(), "bar".into())]);
618 }
619
620 #[test]
621 fn extract_env_export_no_value() {
622 let vars = extract_segment_env("export FOO");
624 assert!(vars.is_empty());
625 }
626
627 #[test]
628 fn extract_env_export_flags() {
629 let vars = extract_segment_env("export -p");
630 assert!(vars.is_empty());
631 }
632
633 #[test]
634 fn extract_env_non_export() {
635 let vars = extract_segment_env("git push");
636 assert!(vars.is_empty());
637 }
638
639 fn registry_with_git_env_gate() -> CommandRegistry {
643 let mut config = crate::config::Config::default_config();
644 config.git.allowed_with_config = vec!["push".into(), "commit".into(), "add".into()];
645 config
646 .git
647 .config_env
648 .insert("GIT_CONFIG_GLOBAL".into(), "~/.gitconfig.ai".into());
649 CommandRegistry::from_config(&config)
650 }
651
652 #[test]
653 fn export_semicolon_git_push_allows() {
654 let reg = registry_with_git_env_gate();
655 let result =
656 reg.evaluate("export GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; git push origin main");
657 assert_eq!(
658 result.decision,
659 Decision::Allow,
660 "reason: {}",
661 result.reason
662 );
663 }
664
665 #[test]
666 fn export_and_git_push_allows() {
667 let reg = registry_with_git_env_gate();
668 let result =
669 reg.evaluate("export GIT_CONFIG_GLOBAL=~/.gitconfig.ai && git push origin main");
670 assert_eq!(
671 result.decision,
672 Decision::Allow,
673 "reason: {}",
674 result.reason
675 );
676 }
677
678 #[test]
679 fn multiple_exports_and_git_push_allows() {
680 let reg = registry_with_git_env_gate();
681 let result = reg.evaluate(
682 "export PATH=/usr/bin && export GIT_CONFIG_GLOBAL=~/.gitconfig.ai && git push origin main",
683 );
684 assert_eq!(
685 result.decision,
686 Decision::Allow,
687 "reason: {}",
688 result.reason
689 );
690 }
691
692 #[test]
693 fn export_or_git_push_does_not_allow() {
694 clear_git_env();
695 let reg = registry_with_git_env_gate();
697 let result =
698 reg.evaluate("export GIT_CONFIG_GLOBAL=~/.gitconfig.ai || git push origin main");
699 assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
700 }
701
702 #[test]
703 fn export_pipe_git_push_does_not_allow() {
704 clear_git_env();
705 let reg = registry_with_git_env_gate();
707 let result =
708 reg.evaluate("export GIT_CONFIG_GLOBAL=~/.gitconfig.ai | git push origin main");
709 assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
710 }
711
712 #[test]
713 fn unknown_cmd_breaks_and_chain() {
714 let reg = registry_with_git_env_gate();
716 let result = reg.evaluate(
717 "export GIT_CONFIG_GLOBAL=~/.gitconfig.ai && unknown_cmd && git push origin main",
718 );
719 assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
720 }
721
722 #[test]
723 fn semicolon_after_unknown_cmd_resumes_accumulation() {
724 let reg = registry_with_git_env_gate();
726 let result = reg.evaluate(
727 "unknown_cmd ; export GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; git push origin main",
728 );
729 assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
730 }
733
734 #[test]
735 fn semicolon_resumes_accumulation_all_known() {
736 let reg = registry_with_git_env_gate();
738 let result = reg.evaluate(
739 "echo starting ; export GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; git push origin main",
740 );
741 assert_eq!(
742 result.decision,
743 Decision::Allow,
744 "reason: {}",
745 result.reason
746 );
747 }
748
749 #[test]
750 fn bare_assignment_semicolon_git_push_allows() {
751 let reg = registry_with_git_env_gate();
752 let result = reg.evaluate("GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; git push origin main");
753 assert_eq!(
754 result.decision,
755 Decision::Allow,
756 "reason: {}",
757 result.reason
758 );
759 }
760
761 #[test]
762 fn bare_assignment_and_git_push_allows() {
763 let reg = registry_with_git_env_gate();
764 let result = reg.evaluate("GIT_CONFIG_GLOBAL=~/.gitconfig.ai && git push origin main");
765 assert_eq!(
766 result.decision,
767 Decision::Allow,
768 "reason: {}",
769 result.reason
770 );
771 }
772
773 #[test]
774 fn wrong_export_value_still_asks() {
775 let reg = registry_with_git_env_gate();
776 let result =
777 reg.evaluate("export GIT_CONFIG_GLOBAL=~/.gitconfig.wrong && git push origin main");
778 assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
779 }
780
781 #[test]
782 fn export_overridden_by_later_export() {
783 let reg = registry_with_git_env_gate();
784 let result = reg.evaluate(
786 "export GIT_CONFIG_GLOBAL=wrong ; export GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; git push origin main",
787 );
788 assert_eq!(
789 result.decision,
790 Decision::Allow,
791 "reason: {}",
792 result.reason
793 );
794 }
795
796 #[test]
797 fn or_after_export_clears_accumulated_env() {
798 clear_git_env();
799 let reg = registry_with_git_env_gate();
803 let result = reg.evaluate(
804 "export GIT_CONFIG_GLOBAL=~/.gitconfig.ai && echo ok || export OTHER=x && git push origin main",
805 );
806 assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
807 }
808
809 #[test]
810 fn echo_and_export_and_git_push_allows() {
811 let reg = registry_with_git_env_gate();
813 let result = reg.evaluate(
814 "echo 'Pushing...' && export GIT_CONFIG_GLOBAL=~/.gitconfig.ai && git push origin main",
815 );
816 assert_eq!(
817 result.decision,
818 Decision::Allow,
819 "reason: {}",
820 result.reason
821 );
822 }
823
824 #[test]
825 fn realistic_claude_pattern() {
826 let reg = registry_with_git_env_gate();
828 let result = reg.evaluate(
829 "export PATH=/home/user/.cargo/bin:/usr/bin && export GIT_CONFIG_GLOBAL=~/.gitconfig.ai && echo 'Pushing...' && git push -u origin feature-branch",
830 );
831 assert_eq!(
832 result.decision,
833 Decision::Allow,
834 "reason: {}",
835 result.reason
836 );
837 }
838
839 #[test]
840 fn force_push_still_asks_with_export() {
841 let reg = registry_with_git_env_gate();
843 let result = reg
844 .evaluate("export GIT_CONFIG_GLOBAL=~/.gitconfig.ai && git push --force origin main");
845 assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
846 }
847
848 #[test]
849 fn subshell_in_export_breaks_and_chain() {
850 let reg = registry_with_git_env_gate();
853 let result = reg.evaluate(
854 "export GIT_CONFIG_GLOBAL=$(cat ~/.gitconfig.ai.path) && git push origin main",
855 );
856 assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
857 }
858
859 #[test]
860 fn subshell_in_echo_breaks_and_chain() {
861 let reg = registry_with_git_env_gate();
864 let result = reg.evaluate(
865 "echo $(some_status_cmd) && export GIT_CONFIG_GLOBAL=~/.gitconfig.ai && git push origin main",
866 );
867 assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
868 }
869
870 #[test]
873 fn unset_removes_accumulated_var() {
874 clear_git_env();
875 let reg = registry_with_git_env_gate();
876 let result = reg.evaluate(
877 "export GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; unset GIT_CONFIG_GLOBAL ; git push origin main",
878 );
879 assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
880 }
881
882 #[test]
883 fn unset_only_removes_named_var() {
884 let reg = registry_with_git_env_gate();
885 let result = reg.evaluate(
886 "export GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; unset OTHER_VAR ; git push origin main",
887 );
888 assert_eq!(
889 result.decision,
890 Decision::Allow,
891 "reason: {}",
892 result.reason
893 );
894 }
895
896 #[test]
897 fn unset_f_does_not_remove_var() {
898 let reg = registry_with_git_env_gate();
900 let result = reg.evaluate(
901 "export GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; unset -f GIT_CONFIG_GLOBAL ; git push origin main",
902 );
903 assert_eq!(
904 result.decision,
905 Decision::Allow,
906 "reason: {}",
907 result.reason
908 );
909 }
910
911 #[test]
914 fn extract_unset_single() {
915 assert_eq!(extract_unset_vars("unset FOO"), vec!["FOO"]);
916 }
917
918 #[test]
919 fn extract_unset_multiple() {
920 assert_eq!(extract_unset_vars("unset FOO BAR"), vec!["FOO", "BAR"]);
921 }
922
923 #[test]
924 fn extract_unset_with_v_flag() {
925 assert_eq!(extract_unset_vars("unset -v FOO"), vec!["FOO"]);
926 }
927
928 #[test]
929 fn extract_unset_with_f_flag() {
930 let result = extract_unset_vars("unset -f my_func");
931 assert!(result.is_empty());
932 }
933
934 #[test]
935 fn extract_unset_mixed_flags() {
936 assert_eq!(
938 extract_unset_vars("unset -f my_func -v MY_VAR"),
939 vec!["MY_VAR"]
940 );
941 }
942
943 #[test]
944 fn extract_unset_not_unset_cmd() {
945 assert!(extract_unset_vars("export FOO=bar").is_empty());
946 }
947
948 #[test]
951 fn env_i_clears_accumulated_env_for_wrapped_cmd() {
952 clear_git_env();
953 let reg = registry_with_git_env_gate();
954 let result =
955 reg.evaluate("export GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; env -i git push origin main");
956 assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
957 }
958
959 #[test]
960 fn env_dash_clears_accumulated_env_for_wrapped_cmd() {
961 clear_git_env();
962 let reg = registry_with_git_env_gate();
963 let result =
964 reg.evaluate("export GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; env - git push origin main");
965 assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
966 }
967
968 #[test]
969 fn env_without_i_passes_accumulated_env() {
970 let reg = registry_with_git_env_gate();
971 let result =
972 reg.evaluate("export GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; env git push origin main");
973 assert_eq!(
974 result.decision,
975 Decision::Allow,
976 "reason: {}",
977 result.reason
978 );
979 }
980
981 fn registry_with_project_overlay() -> CommandRegistry {
985 let mut config = crate::config::Config::default_config();
986 config.project_overlay_path = Some(std::path::PathBuf::from(
987 "/fake/repo/.claude/cc-toolgate.toml",
988 ));
989 CommandRegistry::from_config(&config)
990 }
991
992 #[test]
993 fn ask_decision_annotated_with_project_overlay_path() {
994 let reg = registry_with_project_overlay();
995 let result = reg.evaluate_single("curl https://example.com");
997 assert_eq!(result.decision, Decision::Ask);
998 assert!(
999 result.reason.contains("project config at"),
1000 "ASK reason should mention project config; got: {}",
1001 result.reason
1002 );
1003 assert!(
1004 result
1005 .reason
1006 .contains("/fake/repo/.claude/cc-toolgate.toml"),
1007 "ASK reason should include overlay path; got: {}",
1008 result.reason
1009 );
1010 }
1011
1012 #[test]
1013 fn allow_decision_not_annotated_with_project_overlay_path() {
1014 let reg = registry_with_project_overlay();
1015 let result = reg.evaluate_single("ls -la");
1017 assert_eq!(result.decision, Decision::Allow);
1018 assert!(
1019 !result.reason.contains("project config at"),
1020 "ALLOW reason should not mention project config; got: {}",
1021 result.reason
1022 );
1023 }
1024
1025 #[test]
1026 fn deny_decision_not_annotated_with_project_overlay_path() {
1027 let reg = registry_with_project_overlay();
1028 let result = reg.evaluate_single("shred /etc/passwd");
1030 assert_eq!(result.decision, Decision::Deny);
1031 assert!(
1032 !result.reason.contains("project config at"),
1033 "DENY reason should not mention project config; got: {}",
1034 result.reason
1035 );
1036 }
1037
1038 #[test]
1039 fn no_annotation_without_project_overlay() {
1040 let config = crate::config::Config::default_config();
1042 let reg = CommandRegistry::from_config(&config);
1043 let result = reg.evaluate_single("curl https://example.com");
1044 assert_eq!(result.decision, Decision::Ask);
1045 assert!(
1046 !result.reason.contains("project config at"),
1047 "without project overlay, reason should not mention project config; got: {}",
1048 result.reason
1049 );
1050 }
1051
1052 #[test]
1053 fn compound_ask_decision_annotated_with_project_overlay() {
1054 let reg = registry_with_project_overlay();
1055 let result = reg.evaluate("ls -la ; curl https://example.com");
1057 assert_eq!(result.decision, Decision::Ask);
1058 assert!(
1059 result.reason.contains("project config at"),
1060 "compound ASK reason should mention project config; got: {}",
1061 result.reason
1062 );
1063 }
1064}