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}
151
152impl CommandRegistry {
153 pub fn from_config(config: &Config) -> Self {
155 use crate::commands::{
156 simple::SimpleCommandSpec,
157 tools::{cargo::CargoSpec, gh::GhSpec, git::GitSpec, kubectl::KubectlSpec},
158 };
159
160 let mut specs: HashMap<String, Box<dyn CommandSpec>> = HashMap::new();
161
162 for name in &config.commands.deny {
164 specs.insert(
165 name.clone(),
166 Box::new(SimpleCommandSpec::new(Decision::Deny)),
167 );
168 }
169
170 for name in &config.commands.allow {
172 specs.insert(
173 name.clone(),
174 Box::new(SimpleCommandSpec::new(Decision::Allow)),
175 );
176 }
177
178 for name in &config.commands.ask {
180 specs.insert(
181 name.clone(),
182 Box::new(SimpleCommandSpec::new(Decision::Ask)),
183 );
184 }
185
186 specs.insert("git".into(), Box::new(GitSpec::from_config(&config.git)));
188 specs.insert(
189 "cargo".into(),
190 Box::new(CargoSpec::from_config(&config.cargo)),
191 );
192 specs.insert(
193 "kubectl".into(),
194 Box::new(KubectlSpec::from_config(&config.kubectl)),
195 );
196 specs.insert("gh".into(), Box::new(GhSpec::from_config(&config.gh)));
197
198 let mut wrappers = HashMap::new();
201 for name in &config.wrappers.allow_floor {
202 specs.remove(name);
203 wrappers.insert(name.clone(), Decision::Allow);
204 }
205 for name in &config.wrappers.ask_floor {
206 specs.remove(name);
207 wrappers.insert(name.clone(), Decision::Ask);
208 }
209
210 Self {
211 specs,
212 wrappers,
213 escalate_deny: config.settings.escalate_deny,
214 }
215 }
216
217 pub fn set_escalate_deny(&mut self, escalate: bool) {
219 self.escalate_deny = escalate;
220 }
221
222 fn get(&self, name: &str) -> Option<&dyn CommandSpec> {
224 self.specs.get(name).map(|b| b.as_ref())
225 }
226
227 fn wrapper_floor(&self, name: &str) -> Option<Decision> {
229 self.wrappers.get(name).copied()
230 }
231
232 fn extract_wrapped_command(ctx: &CommandContext) -> String {
237 let iter = ctx.words.iter().skip(1); if ctx.base_command == "env" {
240 let mut rest: Vec<&str> = Vec::new();
242 let mut found_cmd = false;
243 for word in iter {
244 if found_cmd {
245 rest.push(word);
246 } else if word.starts_with('-') {
247 continue; } else if word.contains('=') {
249 continue; } else {
251 found_cmd = true;
252 rest.push(word);
253 }
254 }
255 rest.join(" ")
256 } else {
257 let non_flags: Vec<&str> = iter
264 .skip_while(|w| w.starts_with('-'))
265 .map(|s| s.as_str())
266 .collect();
267 let cmd_start = non_flags
269 .iter()
270 .position(|w| !w.chars().all(|c| c.is_ascii_digit() || c == '.'))
271 .unwrap_or(non_flags.len());
272 non_flags[cmd_start..].join(" ")
273 }
274 }
275
276 fn maybe_escalate(&self, mut result: RuleMatch) -> RuleMatch {
278 if self.escalate_deny && result.decision == Decision::Deny {
279 result.decision = Decision::Ask;
280 result.reason = format!("{} (escalated from deny)", result.reason);
281 }
282 result
283 }
284
285 pub fn evaluate_single(&self, command: &str) -> RuleMatch {
287 self.evaluate_single_with_env(command, &HashMap::new())
288 }
289
290 fn evaluate_single_with_env(
292 &self,
293 command: &str,
294 accumulated_env: &HashMap<String, String>,
295 ) -> RuleMatch {
296 let cmd = command.trim();
297 if cmd.is_empty() {
298 return RuleMatch {
299 decision: Decision::Allow,
300 reason: "empty".into(),
301 };
302 }
303
304 let words = parse::tokenize(cmd);
306 if words.len() == 1 && parse_assignment(&words[0]).is_some() {
307 return RuleMatch {
308 decision: Decision::Allow,
309 reason: format!("variable assignment: {}", words[0]),
310 };
311 }
312
313 let mut ctx = CommandContext::from_command(cmd);
314 ctx.accumulated_env = accumulated_env.clone();
315
316 if let Some(floor) = self.wrapper_floor(&ctx.base_command) {
319 let wrapped_cmd = Self::extract_wrapped_command(&ctx);
320 let mut strictest = floor;
321 let mut reason = if !wrapped_cmd.is_empty() {
322 let inner_env = if ctx.base_command == "env" && ctx.has_any_flag(&["-i", "-"]) {
324 HashMap::new()
325 } else {
326 accumulated_env.clone()
327 };
328 let inner = self.evaluate_single_with_env(&wrapped_cmd, &inner_env);
329 if inner.decision > strictest {
330 strictest = inner.decision;
331 }
332 format!("{} wraps: {}", ctx.base_command, inner.reason)
333 } else {
334 format!("{} (no wrapped command)", ctx.base_command)
335 };
336 if strictest == Decision::Allow && ctx.redirection.is_some() {
338 strictest = Decision::Ask;
339 reason = format!("{} with output redirection", reason);
340 }
341 return self.maybe_escalate(RuleMatch {
342 decision: strictest,
343 reason,
344 });
345 }
346
347 if let Some(spec) = self.get(&ctx.base_command) {
349 return self.maybe_escalate(spec.evaluate(&ctx));
350 }
351
352 if let Some(prefix) = ctx.base_command.split('.').next()
354 && prefix != ctx.base_command
355 && let Some(spec) = self.get(prefix)
356 {
357 return self.maybe_escalate(spec.evaluate(&ctx));
358 }
359
360 RuleMatch {
362 decision: Decision::Ask,
363 reason: format!("unrecognized command: {}", ctx.base_command),
364 }
365 }
366
367 pub fn evaluate(&self, command: &str) -> RuleMatch {
369 let (pipeline, substitutions) = parse::parse_with_substitutions(command);
370
371 if pipeline.segments.len() <= 1 && substitutions.is_empty() {
377 let is_passthrough = match pipeline.segments.first() {
378 Some(seg) => seg.command.trim() == command.trim(),
379 None => true,
380 };
381 if is_passthrough {
382 return self.evaluate_single(command);
383 }
384 }
385
386 let mut strictest = Decision::Allow;
387 let mut reasons = Vec::new();
388
389 for inner in &substitutions {
391 let result = self.evaluate(inner);
392 let label: String = inner.trim().chars().take(60).collect();
393 reasons.push(format!(
394 " subst[$({label})] -> {}: {}",
395 result.decision.label(),
396 result.reason
397 ));
398 if result.decision > strictest {
399 strictest = result.decision;
400 }
401 }
402
403 let mut accumulated_env: HashMap<String, String> = HashMap::new();
406 let mut segment_executes = true;
409
410 for (i, segment) in pipeline.segments.iter().enumerate() {
411 if i > 0 {
413 let op = &pipeline.operators[i - 1];
414 match op {
415 Operator::Semi => segment_executes = true,
417 Operator::And => {
419 segment_executes = segment_executes
420 && is_likely_successful(&pipeline.segments[i - 1].command);
421 }
422 Operator::Or | Operator::Pipe | Operator::PipeErr => {
427 segment_executes = false;
428 accumulated_env.clear();
429 }
430 }
431 }
432
433 let mut result = self.evaluate_single_with_env(&segment.command, &accumulated_env);
434
435 if segment_executes {
438 for (key, val) in extract_segment_env(&segment.command) {
439 accumulated_env.insert(key, val);
440 }
441 for var in extract_unset_vars(&segment.command) {
442 accumulated_env.remove(&var);
443 }
444 }
445
446 if result.decision == Decision::Allow
451 && let Some(ref r) = segment.redirection
452 {
453 result.decision = Decision::Ask;
454 result.reason =
455 format!("{} (escalated: wrapping {})", result.reason, r.description);
456 }
457 let label: String = segment.command.trim().chars().take(60).collect();
458 reasons.push(format!(
459 " [{label}] -> {}: {}",
460 result.decision.label(),
461 result.reason
462 ));
463 if result.decision > strictest {
464 strictest = result.decision;
465 }
466 }
467
468 let mut desc = Vec::new();
470 if !pipeline.operators.is_empty() {
471 let mut unique_ops: Vec<&str> = pipeline.operators.iter().map(|o| o.as_str()).collect();
472 unique_ops.sort();
473 unique_ops.dedup();
474 desc.push(unique_ops.join(", "));
475 }
476 if !substitutions.is_empty() {
477 desc.push(format!("{} substitution(s)", substitutions.len()));
478 }
479 let header = if desc.is_empty() {
480 "compound command".into()
481 } else {
482 format!("compound command ({})", desc.join("; "))
483 };
484
485 RuleMatch {
486 decision: strictest,
487 reason: format!("{}:\n{}", header, reasons.join("\n")),
488 }
489 }
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495
496 fn clear_git_env() {
499 assert!(
500 std::env::var("NEXTEST").is_ok(),
501 "this test mutates process env and requires nextest (cargo nextest run)"
502 );
503 unsafe { std::env::remove_var("GIT_CONFIG_GLOBAL") };
504 }
505
506 #[test]
509 fn likely_success_export() {
510 assert!(is_likely_successful("export FOO=bar"));
511 }
512
513 #[test]
514 fn likely_success_export_multiple() {
515 assert!(is_likely_successful("export A=1 B=2"));
516 }
517
518 #[test]
519 fn likely_success_bare_assignment() {
520 assert!(is_likely_successful("FOO=bar"));
521 }
522
523 #[test]
524 fn likely_success_true() {
525 assert!(is_likely_successful("true"));
526 }
527
528 #[test]
529 fn likely_success_echo() {
530 assert!(is_likely_successful("echo hello"));
531 }
532
533 #[test]
534 fn likely_success_printf() {
535 assert!(is_likely_successful("printf '%s\\n' hello"));
536 }
537
538 #[test]
539 fn likely_success_export_with_subshell_is_not_likely() {
540 assert!(!is_likely_successful("export FOO=__SUBST__"));
542 }
543
544 #[test]
545 fn likely_success_echo_with_subshell_is_not_likely() {
546 assert!(!is_likely_successful("echo __SUBST__"));
547 }
548
549 #[test]
550 fn likely_success_bare_assignment_with_subshell_is_not_likely() {
551 assert!(!is_likely_successful("FOO=__SUBST__"));
552 }
553
554 #[test]
555 fn likely_success_unknown_command() {
556 assert!(!is_likely_successful("some_command --flag"));
557 }
558
559 #[test]
560 fn likely_success_git() {
561 assert!(!is_likely_successful("git push"));
562 }
563
564 #[test]
565 fn likely_success_rm() {
566 assert!(!is_likely_successful("rm -rf /"));
567 }
568
569 #[test]
572 fn extract_env_export_single() {
573 let vars = extract_segment_env("export FOO=bar");
574 assert_eq!(vars, vec![("FOO".into(), "bar".into())]);
575 }
576
577 #[test]
578 fn extract_env_export_multiple() {
579 let vars = extract_segment_env("export A=1 B=2");
580 assert_eq!(
581 vars,
582 vec![("A".into(), "1".into()), ("B".into(), "2".into())]
583 );
584 }
585
586 #[test]
587 fn extract_env_export_with_path() {
588 let vars = extract_segment_env("export GIT_CONFIG_GLOBAL=~/.gitconfig.ai");
589 assert_eq!(
590 vars,
591 vec![("GIT_CONFIG_GLOBAL".into(), "~/.gitconfig.ai".into())]
592 );
593 }
594
595 #[test]
596 fn extract_env_bare_assignment() {
597 let vars = extract_segment_env("FOO=bar");
598 assert_eq!(vars, vec![("FOO".into(), "bar".into())]);
599 }
600
601 #[test]
602 fn extract_env_export_no_value() {
603 let vars = extract_segment_env("export FOO");
605 assert!(vars.is_empty());
606 }
607
608 #[test]
609 fn extract_env_export_flags() {
610 let vars = extract_segment_env("export -p");
611 assert!(vars.is_empty());
612 }
613
614 #[test]
615 fn extract_env_non_export() {
616 let vars = extract_segment_env("git push");
617 assert!(vars.is_empty());
618 }
619
620 fn registry_with_git_env_gate() -> CommandRegistry {
624 let mut config = crate::config::Config::default_config();
625 config.git.allowed_with_config = vec!["push".into(), "commit".into(), "add".into()];
626 config
627 .git
628 .config_env
629 .insert("GIT_CONFIG_GLOBAL".into(), "~/.gitconfig.ai".into());
630 CommandRegistry::from_config(&config)
631 }
632
633 #[test]
634 fn export_semicolon_git_push_allows() {
635 let reg = registry_with_git_env_gate();
636 let result =
637 reg.evaluate("export GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; git push origin main");
638 assert_eq!(
639 result.decision,
640 Decision::Allow,
641 "reason: {}",
642 result.reason
643 );
644 }
645
646 #[test]
647 fn export_and_git_push_allows() {
648 let reg = registry_with_git_env_gate();
649 let result =
650 reg.evaluate("export GIT_CONFIG_GLOBAL=~/.gitconfig.ai && git push origin main");
651 assert_eq!(
652 result.decision,
653 Decision::Allow,
654 "reason: {}",
655 result.reason
656 );
657 }
658
659 #[test]
660 fn multiple_exports_and_git_push_allows() {
661 let reg = registry_with_git_env_gate();
662 let result = reg.evaluate(
663 "export PATH=/usr/bin && export GIT_CONFIG_GLOBAL=~/.gitconfig.ai && git push origin main",
664 );
665 assert_eq!(
666 result.decision,
667 Decision::Allow,
668 "reason: {}",
669 result.reason
670 );
671 }
672
673 #[test]
674 fn export_or_git_push_does_not_allow() {
675 clear_git_env();
676 let reg = registry_with_git_env_gate();
678 let result =
679 reg.evaluate("export GIT_CONFIG_GLOBAL=~/.gitconfig.ai || git push origin main");
680 assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
681 }
682
683 #[test]
684 fn export_pipe_git_push_does_not_allow() {
685 clear_git_env();
686 let reg = registry_with_git_env_gate();
688 let result =
689 reg.evaluate("export GIT_CONFIG_GLOBAL=~/.gitconfig.ai | git push origin main");
690 assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
691 }
692
693 #[test]
694 fn unknown_cmd_breaks_and_chain() {
695 let reg = registry_with_git_env_gate();
697 let result = reg.evaluate(
698 "export GIT_CONFIG_GLOBAL=~/.gitconfig.ai && unknown_cmd && git push origin main",
699 );
700 assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
701 }
702
703 #[test]
704 fn semicolon_after_unknown_cmd_resumes_accumulation() {
705 let reg = registry_with_git_env_gate();
707 let result = reg.evaluate(
708 "unknown_cmd ; export GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; git push origin main",
709 );
710 assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
711 }
714
715 #[test]
716 fn semicolon_resumes_accumulation_all_known() {
717 let reg = registry_with_git_env_gate();
719 let result = reg.evaluate(
720 "echo starting ; export GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; git push origin main",
721 );
722 assert_eq!(
723 result.decision,
724 Decision::Allow,
725 "reason: {}",
726 result.reason
727 );
728 }
729
730 #[test]
731 fn bare_assignment_semicolon_git_push_allows() {
732 let reg = registry_with_git_env_gate();
733 let result = reg.evaluate("GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; git push origin main");
734 assert_eq!(
735 result.decision,
736 Decision::Allow,
737 "reason: {}",
738 result.reason
739 );
740 }
741
742 #[test]
743 fn bare_assignment_and_git_push_allows() {
744 let reg = registry_with_git_env_gate();
745 let result = reg.evaluate("GIT_CONFIG_GLOBAL=~/.gitconfig.ai && git push origin main");
746 assert_eq!(
747 result.decision,
748 Decision::Allow,
749 "reason: {}",
750 result.reason
751 );
752 }
753
754 #[test]
755 fn wrong_export_value_still_asks() {
756 let reg = registry_with_git_env_gate();
757 let result =
758 reg.evaluate("export GIT_CONFIG_GLOBAL=~/.gitconfig.wrong && git push origin main");
759 assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
760 }
761
762 #[test]
763 fn export_overridden_by_later_export() {
764 let reg = registry_with_git_env_gate();
765 let result = reg.evaluate(
767 "export GIT_CONFIG_GLOBAL=wrong ; export GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; git push origin main",
768 );
769 assert_eq!(
770 result.decision,
771 Decision::Allow,
772 "reason: {}",
773 result.reason
774 );
775 }
776
777 #[test]
778 fn or_after_export_clears_accumulated_env() {
779 clear_git_env();
780 let reg = registry_with_git_env_gate();
784 let result = reg.evaluate(
785 "export GIT_CONFIG_GLOBAL=~/.gitconfig.ai && echo ok || export OTHER=x && git push origin main",
786 );
787 assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
788 }
789
790 #[test]
791 fn echo_and_export_and_git_push_allows() {
792 let reg = registry_with_git_env_gate();
794 let result = reg.evaluate(
795 "echo 'Pushing...' && export GIT_CONFIG_GLOBAL=~/.gitconfig.ai && git push origin main",
796 );
797 assert_eq!(
798 result.decision,
799 Decision::Allow,
800 "reason: {}",
801 result.reason
802 );
803 }
804
805 #[test]
806 fn realistic_claude_pattern() {
807 let reg = registry_with_git_env_gate();
809 let result = reg.evaluate(
810 "export PATH=/home/user/.cargo/bin:/usr/bin && export GIT_CONFIG_GLOBAL=~/.gitconfig.ai && echo 'Pushing...' && git push -u origin feature-branch",
811 );
812 assert_eq!(
813 result.decision,
814 Decision::Allow,
815 "reason: {}",
816 result.reason
817 );
818 }
819
820 #[test]
821 fn force_push_still_asks_with_export() {
822 let reg = registry_with_git_env_gate();
824 let result = reg
825 .evaluate("export GIT_CONFIG_GLOBAL=~/.gitconfig.ai && git push --force origin main");
826 assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
827 }
828
829 #[test]
830 fn subshell_in_export_breaks_and_chain() {
831 let reg = registry_with_git_env_gate();
834 let result = reg.evaluate(
835 "export GIT_CONFIG_GLOBAL=$(cat ~/.gitconfig.ai.path) && git push origin main",
836 );
837 assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
838 }
839
840 #[test]
841 fn subshell_in_echo_breaks_and_chain() {
842 let reg = registry_with_git_env_gate();
845 let result = reg.evaluate(
846 "echo $(some_status_cmd) && export GIT_CONFIG_GLOBAL=~/.gitconfig.ai && git push origin main",
847 );
848 assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
849 }
850
851 #[test]
854 fn unset_removes_accumulated_var() {
855 clear_git_env();
856 let reg = registry_with_git_env_gate();
857 let result = reg.evaluate(
858 "export GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; unset GIT_CONFIG_GLOBAL ; git push origin main",
859 );
860 assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
861 }
862
863 #[test]
864 fn unset_only_removes_named_var() {
865 let reg = registry_with_git_env_gate();
866 let result = reg.evaluate(
867 "export GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; unset OTHER_VAR ; git push origin main",
868 );
869 assert_eq!(
870 result.decision,
871 Decision::Allow,
872 "reason: {}",
873 result.reason
874 );
875 }
876
877 #[test]
878 fn unset_f_does_not_remove_var() {
879 let reg = registry_with_git_env_gate();
881 let result = reg.evaluate(
882 "export GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; unset -f GIT_CONFIG_GLOBAL ; git push origin main",
883 );
884 assert_eq!(
885 result.decision,
886 Decision::Allow,
887 "reason: {}",
888 result.reason
889 );
890 }
891
892 #[test]
895 fn extract_unset_single() {
896 assert_eq!(extract_unset_vars("unset FOO"), vec!["FOO"]);
897 }
898
899 #[test]
900 fn extract_unset_multiple() {
901 assert_eq!(extract_unset_vars("unset FOO BAR"), vec!["FOO", "BAR"]);
902 }
903
904 #[test]
905 fn extract_unset_with_v_flag() {
906 assert_eq!(extract_unset_vars("unset -v FOO"), vec!["FOO"]);
907 }
908
909 #[test]
910 fn extract_unset_with_f_flag() {
911 let result = extract_unset_vars("unset -f my_func");
912 assert!(result.is_empty());
913 }
914
915 #[test]
916 fn extract_unset_mixed_flags() {
917 assert_eq!(
919 extract_unset_vars("unset -f my_func -v MY_VAR"),
920 vec!["MY_VAR"]
921 );
922 }
923
924 #[test]
925 fn extract_unset_not_unset_cmd() {
926 assert!(extract_unset_vars("export FOO=bar").is_empty());
927 }
928
929 #[test]
932 fn env_i_clears_accumulated_env_for_wrapped_cmd() {
933 clear_git_env();
934 let reg = registry_with_git_env_gate();
935 let result =
936 reg.evaluate("export GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; env -i git push origin main");
937 assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
938 }
939
940 #[test]
941 fn env_dash_clears_accumulated_env_for_wrapped_cmd() {
942 clear_git_env();
943 let reg = registry_with_git_env_gate();
944 let result =
945 reg.evaluate("export GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; env - git push origin main");
946 assert_eq!(result.decision, Decision::Ask, "reason: {}", result.reason);
947 }
948
949 #[test]
950 fn env_without_i_passes_accumulated_env() {
951 let reg = registry_with_git_env_gate();
952 let result =
953 reg.evaluate("export GIT_CONFIG_GLOBAL=~/.gitconfig.ai ; env git push origin main");
954 assert_eq!(
955 result.decision,
956 Decision::Allow,
957 "reason: {}",
958 result.reason
959 );
960 }
961}