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    /// Path to the project overlay file that contributed to this config,
151    /// if one was loaded. Used to annotate ASK decisions with provenance.
152    project_overlay_path: Option<std::path::PathBuf>,
153}
154
155impl CommandRegistry {
156    /// Build the registry from configuration.
157    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        // Deny commands (registered first, complex specs override if needed)
166        for name in &config.commands.deny {
167            specs.insert(
168                name.clone(),
169                Box::new(SimpleCommandSpec::new(Decision::Deny)),
170            );
171        }
172
173        // Allow commands
174        for name in &config.commands.allow {
175            specs.insert(
176                name.clone(),
177                Box::new(SimpleCommandSpec::new(Decision::Allow)),
178            );
179        }
180
181        // Ask commands
182        for name in &config.commands.ask {
183            specs.insert(
184                name.clone(),
185                Box::new(SimpleCommandSpec::new(Decision::Ask)),
186            );
187        }
188
189        // Complex command specs (override any simple entry for the same name)
190        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        // Wrapper commands: these execute their arguments as subcommands.
202        // Remove them from the specs map (they're handled separately in evaluate_single).
203        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    /// Override the escalate_deny setting (e.g. from --escalate-deny CLI flag).
222    pub fn set_escalate_deny(&mut self, escalate: bool) {
223        self.escalate_deny = escalate;
224    }
225
226    /// Look up a spec by exact command name.
227    fn get(&self, name: &str) -> Option<&dyn CommandSpec> {
228        self.specs.get(name).map(|b| b.as_ref())
229    }
230
231    /// Check if a command is a wrapper; return its floor decision if so.
232    fn wrapper_floor(&self, name: &str) -> Option<Decision> {
233        self.wrappers.get(name).copied()
234    }
235
236    /// Extract the wrapped command from a wrapper invocation.
237    ///
238    /// Skips the wrapper name and its flags, then returns the remaining
239    /// words joined as a command string. For `env`, also skips KEY=VALUE pairs.
240    fn extract_wrapped_command(ctx: &CommandContext) -> String {
241        let iter = ctx.words.iter().skip(1); // skip wrapper name
242
243        if ctx.base_command == "env" {
244            // env: skip flags AND KEY=VALUE pairs before the subcommand
245            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; // skip flags
252                } else if word.contains('=') {
253                    continue; // skip KEY=VALUE
254                } else {
255                    found_cmd = true;
256                    rest.push(word);
257                }
258            }
259            rest.join(" ")
260        } else {
261            // General case: skip flags (start with -), then collect the rest.
262            // Non-flag words before the actual command (like "10" in `nice -n 10 ls`)
263            // are flag values. We include them but base_command() in the recursive
264            // evaluate_single call will extract the first word, so we need to
265            // skip non-command words. We do this by skipping words that are purely
266            // numeric (common flag values like priority, timeout seconds, etc.).
267            let non_flags: Vec<&str> = iter
268                .skip_while(|w| w.starts_with('-'))
269                .map(|s| s.as_str())
270                .collect();
271            // Skip leading numeric-only words (flag values like "10", "30")
272            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    /// Apply escalate_deny: DENY → ASK with annotation.
281    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    /// Annotate an ASK decision with project overlay provenance, if applicable.
290    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    /// Evaluate a single (non-compound) command against the registry.
304    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    /// Evaluate a single command with accumulated environment from prior segments.
310    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        // Bare variable assignments (e.g. "FOO=bar") are always safe.
324        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        // Wrapper commands: execute their arguments as a subcommand.
336        // Extract the wrapped command, evaluate it, return max(floor, inner).
337        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                // env -i / env - clears the environment for the wrapped command.
342                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            // Redirection on the wrapper itself escalates Allow → Ask
356            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        // Look up by exact base command name
367        if let Some(spec) = self.get(&ctx.base_command) {
368            return self.maybe_escalate(spec.evaluate(&ctx));
369        }
370
371        // Dotted command fallback for deny list (e.g. mkfs.ext4 → mkfs)
372        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        // Fallthrough → ask
380        RuleMatch {
381            decision: Decision::Ask,
382            reason: format!("unrecognized command: {}", ctx.base_command),
383        }
384    }
385
386    /// Evaluate a full command string, handling compound expressions and substitutions.
387    pub fn evaluate(&self, command: &str) -> RuleMatch {
388        let (pipeline, substitutions) = parse::parse_with_substitutions(command);
389
390        // Simple case: no substitutions, not compound, and the segment text matches
391        // the original command → evaluate directly.  When the parser extracts a
392        // sub-range (e.g. a loop body), the segment text differs from the original
393        // and we must fall through to compound evaluation so the inner command is
394        // evaluated against actual rules instead of the enclosing keyword.
395        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        // Recursively evaluate substitution contents
409        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        // Evaluate each part of the (possibly compound) outer command,
423        // accumulating environment variables from export/assignment segments.
424        let mut accumulated_env: HashMap<String, String> = HashMap::new();
425        // Whether the current segment is known to execute (for env accumulation).
426        // The first segment always executes.
427        let mut segment_executes = true;
428
429        for (i, segment) in pipeline.segments.iter().enumerate() {
430            // Determine if this segment executes based on the preceding operator.
431            if i > 0 {
432                let op = &pipeline.operators[i - 1];
433                match op {
434                    // Semicolon: unconditional — segment always executes.
435                    Operator::Semi => segment_executes = true,
436                    // And: segment executes only if prior executed AND succeeded.
437                    Operator::And => {
438                        segment_executes = segment_executes
439                            && is_likely_successful(&pipeline.segments[i - 1].command);
440                    }
441                    // Or / Pipe / PipeErr: can't guarantee execution or env propagation.
442                    // Clear accumulated env: after || the prior segment succeeded
443                    // (so this one is skipped) or failed (so its env isn't set).
444                    // After | the left side runs in a subshell.
445                    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            // Accumulate env vars from this segment if it's known to execute.
455            // Also remove any vars that are explicitly unset.
456            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            // Propagate redirection from wrapping constructs (e.g. a for loop
466            // with output redirection: `for ... done > file`).  The inner
467            // command text won't contain the redirect, so evaluate_single
468            // can't see it — escalate here.
469            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        // Build summary header
488        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    /// Clear `GIT_CONFIG_GLOBAL` from the process environment so the
516    /// env-gate fallback in `env_satisfies` doesn't interfere.  Requires nextest.
517    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    // ── is_likely_successful ──
526
527    #[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        // export FOO=$(cmd) — the substitution could fail
560        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    // ── extract_segment_env ──
589
590    #[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        // `export FOO` (no =) should not extract anything
623        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    // ── Compound command env accumulation (end-to-end via registry) ──
640
641    /// Build a registry with git config_env gating enabled.
642    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        // || means git push runs only if export failed → env not set
696        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        // | means subshell boundary → env doesn't propagate
706        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        // unknown_cmd is not is_likely_successful, so && chain breaks
715        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        // ; resets segment_executes to true, so export after ; is accumulated
725        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        // Note: still ASK because unknown_cmd itself is ASK (unrecognized),
731        // and strictest-wins. Let's verify the git push part specifically.
732    }
733
734    #[test]
735    fn semicolon_resumes_accumulation_all_known() {
736        // echo is allowed AND likely_successful. After ;, export accumulates.
737        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        // First export sets wrong value, second corrects it
785        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        // export A=1 && echo ok || export B=2 && git push
800        // The || clears accumulated env (conservative: can't determine which
801        // path was taken). git push doesn't see GIT_CONFIG_GLOBAL.
802        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        // echo is likely_successful, export is likely_successful, chain holds
812        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        // The actual pattern Claude generates
827        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        // Force push flags should escalate even with correct env
842        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        // export FOO=$(cmd) && git push — subshell makes export's success unpredictable,
851        // so the && chain can't guarantee the next segment executes.
852        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        // echo $(cmd) && export FOO=bar && git push — echo with subshell is not
862        // likely successful, breaking the chain for subsequent accumulation.
863        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    // ── unset ──
871
872    #[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        // unset -f removes functions, not variables
899        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    // ── extract_unset_vars ──
912
913    #[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        // -f disables var unset, -v re-enables it
937        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    // ── env -i wrapper ──
949
950    #[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    // ── Project overlay consent annotation ──
982
983    /// Build a registry with a project overlay path set.
984    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        // "curl" is in the default ask list — should produce ASK with annotation
996        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        // "ls" is in the default allow list — should NOT have annotation
1016        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        // "shred" is in the default deny list — DENY should NOT have annotation
1029        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        // Registry without project overlay — no annotation on ASK
1041        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        // Compound command where one segment is ASK
1056        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}