Skip to main content

cc_toolgate/eval/
mod.rs

1//! Evaluation engine: builds a command registry from config and evaluates commands.
2//!
3//! The [`CommandRegistry`](crate::eval::CommandRegistry) is the central evaluation structure. It maps command
4//! names to [`CommandSpec`](crate::commands::CommandSpec) implementations and
5//! handles compound command decomposition, substitution evaluation, wrapper
6//! command unwrapping, and decision aggregation.
7
8/// Per-segment evaluation context (base command, args, env vars, redirections).
9pub mod context;
10/// Decision enum and rule match types.
11pub 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
23/// Check whether a command segment is likely to succeed unconditionally.
24///
25/// Used during compound-command evaluation to decide whether environment
26/// variables set by prior segments can be assumed available for later segments.
27/// Only returns true for commands with deterministic, side-effect-free success:
28/// assignments, exports, `true`, and `echo`/`printf` (output-only).
29///
30/// This is intentionally conservative — returning false for an unknown command
31/// just means we won't accumulate its env vars, which is the safe default.
32fn is_likely_successful(segment: &str) -> bool {
33    // Subshell substitutions make success unpredictable — the substituted
34    // command could fail, changing the segment's exit code.
35    if segment.contains("__SUBST__") {
36        return false;
37    }
38    let words = parse::tokenize(segment);
39    if words.is_empty() {
40        return false;
41    }
42    // Bare VAR=VALUE assignment (single token with `=`)
43    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 with assignments is near-infallible
49        "export" | "unset" => true,
50        // Builtins/commands that always succeed
51        "true" => true,
52        // Output-only commands that succeed unless stdout is broken
53        "echo" | "printf" => true,
54        _ => false,
55    }
56}
57
58/// Check whether a string is a valid shell variable name.
59fn 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
67/// Try to parse a single `KEY=VALUE` token, returning (key, value) if valid.
68fn 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
79/// Extract environment variable assignments from an `export` or bare assignment segment.
80///
81/// Handles:
82/// - `export FOO=bar BAZ=qux` → [("FOO", "bar"), ("BAZ", "qux")]
83/// - `export FOO=bar` → [("FOO", "bar")]
84/// - `FOO=bar` (bare assignment, no command) → [("FOO", "bar")]
85/// - `export FOO` (no assignment) → []
86/// - `export -p` / `export -n FOO` → []
87fn 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    // Bare assignment: single token like "FOO=bar" (no command follows)
94    if words.len() == 1 {
95        return parse_assignment(&words[0]).into_iter().collect();
96    }
97
98    // export command: extract KEY=VALUE pairs from arguments
99    if words[0] == "export" {
100        return words[1..]
101            .iter()
102            .filter(|w| !w.starts_with('-')) // skip flags
103            .filter_map(|w| parse_assignment(w))
104            .collect();
105    }
106
107    Vec::new()
108}
109
110/// Extract variable names from an `unset` command.
111///
112/// Handles:
113/// - `unset FOO` → ["FOO"]
114/// - `unset FOO BAR` → ["FOO", "BAR"]
115/// - `unset -v FOO` → ["FOO"] (default behavior, unset variables)
116/// - `unset -f FOO` → [] (unsets functions, not variables)
117fn 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
136/// Registry of all command specs, keyed by command name.
137///
138/// Built from [`Config`] via [`from_config`](Self::from_config).
139/// Handles single-command evaluation, compound command decomposition,
140/// wrapper command unwrapping, substitution evaluation, and decision aggregation.
141pub struct CommandRegistry {
142    /// Command name → evaluation spec (git, cargo, kubectl, gh, simple, deny).
143    specs: HashMap<String, Box<dyn CommandSpec>>,
144    /// Wrapper commands (e.g. `xargs`, `sudo`, `env`) → floor decision.
145    /// These execute their arguments as subcommands and are handled
146    /// separately from regular specs.
147    wrappers: HashMap<String, Decision>,
148    /// When true, DENY decisions are escalated to ASK.
149    escalate_deny: bool,
150}
151
152impl CommandRegistry {
153    /// Build the registry from configuration.
154    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        // Deny commands (registered first, complex specs override if needed)
163        for name in &config.commands.deny {
164            specs.insert(
165                name.clone(),
166                Box::new(SimpleCommandSpec::new(Decision::Deny)),
167            );
168        }
169
170        // Allow commands
171        for name in &config.commands.allow {
172            specs.insert(
173                name.clone(),
174                Box::new(SimpleCommandSpec::new(Decision::Allow)),
175            );
176        }
177
178        // Ask commands
179        for name in &config.commands.ask {
180            specs.insert(
181                name.clone(),
182                Box::new(SimpleCommandSpec::new(Decision::Ask)),
183            );
184        }
185
186        // Complex command specs (override any simple entry for the same name)
187        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        // Wrapper commands: these execute their arguments as subcommands.
199        // Remove them from the specs map (they're handled separately in evaluate_single).
200        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    /// Override the escalate_deny setting (e.g. from --escalate-deny CLI flag).
218    pub fn set_escalate_deny(&mut self, escalate: bool) {
219        self.escalate_deny = escalate;
220    }
221
222    /// Look up a spec by exact command name.
223    fn get(&self, name: &str) -> Option<&dyn CommandSpec> {
224        self.specs.get(name).map(|b| b.as_ref())
225    }
226
227    /// Check if a command is a wrapper; return its floor decision if so.
228    fn wrapper_floor(&self, name: &str) -> Option<Decision> {
229        self.wrappers.get(name).copied()
230    }
231
232    /// Extract the wrapped command from a wrapper invocation.
233    ///
234    /// Skips the wrapper name and its flags, then returns the remaining
235    /// words joined as a command string. For `env`, also skips KEY=VALUE pairs.
236    fn extract_wrapped_command(ctx: &CommandContext) -> String {
237        let iter = ctx.words.iter().skip(1); // skip wrapper name
238
239        if ctx.base_command == "env" {
240            // env: skip flags AND KEY=VALUE pairs before the subcommand
241            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; // skip flags
248                } else if word.contains('=') {
249                    continue; // skip KEY=VALUE
250                } else {
251                    found_cmd = true;
252                    rest.push(word);
253                }
254            }
255            rest.join(" ")
256        } else {
257            // General case: skip flags (start with -), then collect the rest.
258            // Non-flag words before the actual command (like "10" in `nice -n 10 ls`)
259            // are flag values. We include them but base_command() in the recursive
260            // evaluate_single call will extract the first word, so we need to
261            // skip non-command words. We do this by skipping words that are purely
262            // numeric (common flag values like priority, timeout seconds, etc.).
263            let non_flags: Vec<&str> = iter
264                .skip_while(|w| w.starts_with('-'))
265                .map(|s| s.as_str())
266                .collect();
267            // Skip leading numeric-only words (flag values like "10", "30")
268            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    /// Apply escalate_deny: DENY → ASK with annotation.
277    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    /// Evaluate a single (non-compound) command against the registry.
286    pub fn evaluate_single(&self, command: &str) -> RuleMatch {
287        self.evaluate_single_with_env(command, &HashMap::new())
288    }
289
290    /// Evaluate a single command with accumulated environment from prior segments.
291    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        // Bare variable assignments (e.g. "FOO=bar") are always safe.
305        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        // Wrapper commands: execute their arguments as a subcommand.
317        // Extract the wrapped command, evaluate it, return max(floor, inner).
318        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                // env -i / env - clears the environment for the wrapped command.
323                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            // Redirection on the wrapper itself escalates Allow → Ask
337            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        // Look up by exact base command name
348        if let Some(spec) = self.get(&ctx.base_command) {
349            return self.maybe_escalate(spec.evaluate(&ctx));
350        }
351
352        // Dotted command fallback for deny list (e.g. mkfs.ext4 → mkfs)
353        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        // Fallthrough → ask
361        RuleMatch {
362            decision: Decision::Ask,
363            reason: format!("unrecognized command: {}", ctx.base_command),
364        }
365    }
366
367    /// Evaluate a full command string, handling compound expressions and substitutions.
368    pub fn evaluate(&self, command: &str) -> RuleMatch {
369        let (pipeline, substitutions) = parse::parse_with_substitutions(command);
370
371        // Simple case: no substitutions, not compound, and the segment text matches
372        // the original command → evaluate directly.  When the parser extracts a
373        // sub-range (e.g. a loop body), the segment text differs from the original
374        // and we must fall through to compound evaluation so the inner command is
375        // evaluated against actual rules instead of the enclosing keyword.
376        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        // Recursively evaluate substitution contents
390        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        // Evaluate each part of the (possibly compound) outer command,
404        // accumulating environment variables from export/assignment segments.
405        let mut accumulated_env: HashMap<String, String> = HashMap::new();
406        // Whether the current segment is known to execute (for env accumulation).
407        // The first segment always executes.
408        let mut segment_executes = true;
409
410        for (i, segment) in pipeline.segments.iter().enumerate() {
411            // Determine if this segment executes based on the preceding operator.
412            if i > 0 {
413                let op = &pipeline.operators[i - 1];
414                match op {
415                    // Semicolon: unconditional — segment always executes.
416                    Operator::Semi => segment_executes = true,
417                    // And: segment executes only if prior executed AND succeeded.
418                    Operator::And => {
419                        segment_executes = segment_executes
420                            && is_likely_successful(&pipeline.segments[i - 1].command);
421                    }
422                    // Or / Pipe / PipeErr: can't guarantee execution or env propagation.
423                    // Clear accumulated env: after || the prior segment succeeded
424                    // (so this one is skipped) or failed (so its env isn't set).
425                    // After | the left side runs in a subshell.
426                    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            // Accumulate env vars from this segment if it's known to execute.
436            // Also remove any vars that are explicitly unset.
437            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            // Propagate redirection from wrapping constructs (e.g. a for loop
447            // with output redirection: `for ... done > file`).  The inner
448            // command text won't contain the redirect, so evaluate_single
449            // can't see it — escalate here.
450            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        // Build summary header
469        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    /// Clear `GIT_CONFIG_GLOBAL` from the process environment so the
497    /// env-gate fallback in `env_satisfies` doesn't interfere.  Requires nextest.
498    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    // ── is_likely_successful ──
507
508    #[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        // export FOO=$(cmd) — the substitution could fail
541        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    // ── extract_segment_env ──
570
571    #[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        // `export FOO` (no =) should not extract anything
604        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    // ── Compound command env accumulation (end-to-end via registry) ──
621
622    /// Build a registry with git config_env gating enabled.
623    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        // || means git push runs only if export failed → env not set
677        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        // | means subshell boundary → env doesn't propagate
687        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        // unknown_cmd is not is_likely_successful, so && chain breaks
696        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        // ; resets segment_executes to true, so export after ; is accumulated
706        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        // Note: still ASK because unknown_cmd itself is ASK (unrecognized),
712        // and strictest-wins. Let's verify the git push part specifically.
713    }
714
715    #[test]
716    fn semicolon_resumes_accumulation_all_known() {
717        // echo is allowed AND likely_successful. After ;, export accumulates.
718        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        // First export sets wrong value, second corrects it
766        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        // export A=1 && echo ok || export B=2 && git push
781        // The || clears accumulated env (conservative: can't determine which
782        // path was taken). git push doesn't see GIT_CONFIG_GLOBAL.
783        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        // echo is likely_successful, export is likely_successful, chain holds
793        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        // The actual pattern Claude generates
808        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        // Force push flags should escalate even with correct env
823        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        // export FOO=$(cmd) && git push — subshell makes export's success unpredictable,
832        // so the && chain can't guarantee the next segment executes.
833        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        // echo $(cmd) && export FOO=bar && git push — echo with subshell is not
843        // likely successful, breaking the chain for subsequent accumulation.
844        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    // ── unset ──
852
853    #[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        // unset -f removes functions, not variables
880        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    // ── extract_unset_vars ──
893
894    #[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        // -f disables var unset, -v re-enables it
918        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    // ── env -i wrapper ──
930
931    #[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}