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 agent_shell_parser::parse;
21use agent_shell_parser::parse::{
22    CommandConfig, Operator, ParsedPipeline, ResolvedCommand, ShellSegment, WrapperSpec,
23};
24
25/// Check whether a command segment is likely to succeed unconditionally.
26///
27/// Used during compound-command evaluation to decide whether environment
28/// variables set by prior segments can be assumed available for later segments.
29/// Only returns true for commands with deterministic, side-effect-free success:
30/// assignments, exports, `true`, and `echo`/`printf` (output-only).
31///
32/// This is intentionally conservative — returning false for an unknown command
33/// just means we won't accumulate its env vars, which is the safe default.
34fn is_likely_successful(segment: &ShellSegment) -> bool {
35    // Subshell substitutions make success unpredictable — the substituted
36    // command could fail, changing the segment's exit code.
37    if !segment.substitutions.is_empty() {
38        return false;
39    }
40    let words = &segment.words;
41    if words.is_empty() {
42        return false;
43    }
44    // Bare VAR=VALUE assignment (single token with `=`)
45    if words.len() == 1 && words[0].as_assignment().is_some() {
46        return true;
47    }
48    // Use the first non-env-var word as the base command
49    let base = CommandContext::base_command_from_words(words);
50    match base.as_str() {
51        // export/unset with assignments is near-infallible
52        "export" | "unset" => true,
53        // Builtins/commands that always succeed
54        "true" => true,
55        // Output-only commands that succeed unless stdout is broken
56        "echo" | "printf" => true,
57        _ => false,
58    }
59}
60
61/// Check whether a string is a valid shell variable name.
62fn is_var_name(s: &str) -> bool {
63    !s.is_empty()
64        && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
65        && s.chars()
66            .next()
67            .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
68}
69
70/// Extract environment variable assignments from an `export` or bare assignment segment.
71///
72/// Accepts pre-tokenized words (from `segment.words`).
73///
74/// Handles:
75/// - `["export", "FOO=bar", "BAZ=qux"]` → [("FOO", "bar"), ("BAZ", "qux")]
76/// - `["export", "FOO=bar"]` → [("FOO", "bar")]
77/// - `["FOO=bar"]` (bare assignment, no command) → [("FOO", "bar")]
78/// - `["export", "FOO"]` (no assignment) → []
79/// - `["export", "-p"]` / `["export", "-n", "FOO"]` → []
80fn extract_segment_env(words: &[parse::Word]) -> Vec<(String, String)> {
81    if words.is_empty() {
82        return Vec::new();
83    }
84
85    // Bare assignment: single token like "FOO=bar" (no command follows)
86    if words.len() == 1 {
87        return words[0]
88            .as_assignment()
89            .map(|(k, v)| (k.to_string(), v.to_string()))
90            .into_iter()
91            .collect();
92    }
93
94    // export command: extract KEY=VALUE pairs from arguments
95    if words[0] == "export" {
96        return words[1..]
97            .iter()
98            .filter(|w| !w.is_flag()) // skip flags
99            .filter_map(|w| {
100                w.as_assignment()
101                    .map(|(k, v)| (k.to_string(), v.to_string()))
102            })
103            .collect();
104    }
105
106    Vec::new()
107}
108
109/// Extract variable names from an `unset` command.
110///
111/// Accepts pre-tokenized words (from `segment.words`).
112///
113/// Handles:
114/// - `["unset", "FOO"]` → ["FOO"]
115/// - `["unset", "FOO", "BAR"]` → ["FOO", "BAR"]
116/// - `["unset", "-v", "FOO"]` → ["FOO"] (default behavior, unset variables)
117/// - `["unset", "-f", "FOO"]` → [] (unsets functions, not variables)
118fn extract_unset_vars(words: &[parse::Word]) -> Vec<&str> {
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.is_flag() && !unsetting_functions && is_var_name(word) {
130            result.push(word.as_str());
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    /// Merged command config for `resolve_command_with`: agent-shell-parser's
149    /// default config extended with any cc-toolgate wrappers that aren't
150    /// already known to the parser.
151    resolve_config: CommandConfig,
152    /// When true, DENY decisions are escalated to ASK.
153    escalate_deny: bool,
154    /// Path to the project overlay file that contributed to this config,
155    /// if one was loaded. Used to annotate ASK decisions with provenance.
156    project_overlay_path: Option<std::path::PathBuf>,
157}
158
159impl CommandRegistry {
160    /// Build the registry from configuration.
161    pub fn from_config(config: &Config) -> Self {
162        use crate::commands::{
163            simple::SimpleCommandSpec,
164            tools::{cargo::CargoSpec, gh::GhSpec, git::GitSpec, kubectl::KubectlSpec},
165        };
166
167        let mut specs: HashMap<String, Box<dyn CommandSpec>> = HashMap::new();
168
169        // Deny commands (registered first, complex specs override if needed)
170        for name in &config.commands.deny {
171            specs.insert(
172                name.clone(),
173                Box::new(SimpleCommandSpec::new(Decision::Deny)),
174            );
175        }
176
177        // Allow commands
178        for name in &config.commands.allow {
179            specs.insert(
180                name.clone(),
181                Box::new(SimpleCommandSpec::new(Decision::Allow)),
182            );
183        }
184
185        // Ask commands
186        for name in &config.commands.ask {
187            specs.insert(
188                name.clone(),
189                Box::new(SimpleCommandSpec::new(Decision::Ask)),
190            );
191        }
192
193        // Complex command specs (override any simple entry for the same name)
194        specs.insert("git".into(), Box::new(GitSpec::from_config(&config.git)));
195        specs.insert(
196            "cargo".into(),
197            Box::new(CargoSpec::from_config(&config.cargo)),
198        );
199        specs.insert(
200            "kubectl".into(),
201            Box::new(KubectlSpec::from_config(&config.kubectl)),
202        );
203        specs.insert("gh".into(), Box::new(GhSpec::from_config(&config.gh)));
204
205        // Wrapper commands: these execute their arguments as subcommands.
206        // Remove them from the specs map (they're handled separately in evaluate_single).
207        let mut wrappers = HashMap::new();
208        for name in &config.wrappers.allow_floor {
209            specs.remove(name);
210            wrappers.insert(name.clone(), Decision::Allow);
211        }
212        for name in &config.wrappers.ask_floor {
213            specs.remove(name);
214            wrappers.insert(name.clone(), Decision::Ask);
215        }
216
217        // Build a merged CommandConfig for resolve_command_with: start from
218        // agent-shell-parser's default config and add any cc-toolgate wrappers
219        // that aren't already known to the parser. This lets resolve_command_with
220        // handle ALL wrappers — no fallback flag-skipping needed.
221        let resolve_config = Self::build_resolve_config(&wrappers);
222
223        Self {
224            specs,
225            wrappers,
226            resolve_config,
227            escalate_deny: config.settings.escalate_deny,
228            project_overlay_path: config.project_overlay_path.clone(),
229        }
230    }
231
232    /// Override the escalate_deny setting (e.g. from --escalate-deny CLI flag).
233    pub fn set_escalate_deny(&mut self, escalate: bool) {
234        self.escalate_deny = escalate;
235    }
236
237    /// Look up a spec by exact command name.
238    fn get(&self, name: &str) -> Option<&dyn CommandSpec> {
239        self.specs.get(name).map(|b| b.as_ref())
240    }
241
242    /// Build a merged [`CommandConfig`] for `resolve_command_with`.
243    ///
244    /// Starts from agent-shell-parser's default config and adds a minimal
245    /// [`WrapperSpec`] for any cc-toolgate wrapper that isn't already known
246    /// to the parser. This ensures `resolve_command_with` can handle all
247    /// wrappers without a fallback code path.
248    fn build_resolve_config(wrappers: &HashMap<String, Decision>) -> CommandConfig {
249        let mut config = parse::default_command_config().clone();
250
251        for name in wrappers.keys() {
252            let already_known = config.wrappers.iter().any(|w| w.name == *name);
253            if !already_known {
254                // Add a minimal spec: skip leading flags, no value-consuming
255                // flags (conservative — may stop early, which is safe since
256                // the inner command gets evaluated anyway).
257                config.wrappers.push(WrapperSpec {
258                    name: name.clone(),
259                    short_value_flags: vec![],
260                    long_value_flags: vec![],
261                    unanalyzable_flags: vec![],
262                    skip_env_assignments: false,
263                    has_terminator: true,
264                    skip_positionals: 0,
265                });
266            }
267        }
268        config
269    }
270
271    /// Check if a command is a wrapper; return its floor decision if so.
272    fn wrapper_floor(&self, name: &str) -> Option<Decision> {
273        self.wrappers.get(name).copied()
274    }
275
276    /// Extract the wrapped command from a wrapper invocation.
277    ///
278    /// Uses `resolve_command_with` with the merged config that includes both
279    /// agent-shell-parser's built-in wrappers and any cc-toolgate-only wrappers.
280    fn extract_wrapped_command(&self, ctx: &CommandContext) -> (String, bool) {
281        let resolved = parse::resolve_command_with(&ctx.words, &self.resolve_config);
282        match resolved {
283            ResolvedCommand::Resolved(ref parsed) if parsed.command != ctx.base_command => {
284                // Successfully stripped the wrapper — return the inner command
285                (parsed.to_words().join(" "), false)
286            }
287            ResolvedCommand::Resolved(_) => {
288                // resolve_command returned the same command (e.g. wrapper with
289                // no inner command, or wrapper not recognized despite config).
290                (String::new(), false)
291            }
292            ResolvedCommand::Unanalyzable(_) => {
293                // Unanalyzable (eval, source, shell -c) — signal to caller
294                (String::new(), true)
295            }
296            // Future variants: treat as unanalyzable (fail-closed)
297            _ => (String::new(), true),
298        }
299    }
300
301    /// Apply escalate_deny: DENY → ASK with annotation.
302    fn maybe_escalate(&self, mut result: RuleMatch) -> RuleMatch {
303        if self.escalate_deny && result.decision == Decision::Deny {
304            result.decision = Decision::Ask;
305            result.reason = format!("{} (escalated from deny)", result.reason);
306        }
307        result
308    }
309
310    /// Annotate an ASK decision with project overlay provenance, if applicable.
311    fn maybe_annotate_project_overlay(&self, mut result: RuleMatch) -> RuleMatch {
312        if result.decision == Decision::Ask
313            && let Some(ref path) = self.project_overlay_path
314        {
315            result.reason = format!(
316                "{} (project config at {} contributed to this decision)",
317                result.reason,
318                path.display()
319            );
320        }
321        result
322    }
323
324    /// Evaluate a single (non-compound) command against the registry.
325    pub fn evaluate_single(&self, command: &str) -> RuleMatch {
326        let ctx = CommandContext::from_command(command);
327        let result = self.evaluate_ctx(ctx);
328        self.maybe_annotate_project_overlay(result)
329    }
330
331    /// Evaluate a command context against the registry.
332    ///
333    /// This is the core evaluation method. All paths — simple commands,
334    /// compound segments, and wrapper-extracted inner commands — converge here.
335    fn evaluate_ctx(&self, ctx: CommandContext) -> RuleMatch {
336        // Bare variable assignments (e.g. "FOO=bar") are always safe.
337        // Check before the empty-command guard: a segment like "VAR=$(cmd)"
338        // has base_command="" (the token is parsed as an env var with no
339        // command), but it's a valid assignment, not an empty command.
340        if ctx.words.len() == 1 && ctx.words[0].as_assignment().is_some() {
341            return RuleMatch {
342                decision: Decision::Allow,
343                reason: format!("variable assignment: {}", ctx.words[0]),
344            };
345        }
346
347        if ctx.base_command.is_empty() {
348            return RuleMatch {
349                decision: Decision::Allow,
350                reason: "empty".into(),
351            };
352        }
353
354        // Wrapper commands: extract inner command, evaluate it, return max(floor, inner).
355        if let Some(floor) = self.wrapper_floor(&ctx.base_command) {
356            let (wrapped_cmd, is_unanalyzable) = self.extract_wrapped_command(&ctx);
357            let mut strictest = floor;
358            let mut reason = if is_unanalyzable {
359                // Unanalyzable (eval, source, shell -c) → ASK
360                strictest = Decision::Ask;
361                format!("{} wraps unanalyzable command", ctx.base_command)
362            } else if !wrapped_cmd.is_empty() {
363                // env -i / env - clears the environment for the wrapped command.
364                let inner_env = if ctx.base_command == "env" && ctx.has_any_flag(&["-i", "-"]) {
365                    HashMap::new()
366                } else {
367                    ctx.accumulated_env.clone()
368                };
369                let mut inner_ctx = CommandContext::from_command(&wrapped_cmd);
370                inner_ctx.accumulated_env = inner_env;
371                let inner = self.evaluate_ctx(inner_ctx);
372                if inner.decision > strictest {
373                    strictest = inner.decision;
374                }
375                format!("{} wraps: {}", ctx.base_command, inner.reason)
376            } else {
377                format!("{} (no wrapped command)", ctx.base_command)
378            };
379            // Redirection on the wrapper itself escalates Allow → Ask
380            if strictest == Decision::Allow && ctx.redirection.is_some() {
381                strictest = Decision::Ask;
382                reason = format!("{} with output redirection", reason);
383            }
384            return self.maybe_escalate(RuleMatch {
385                decision: strictest,
386                reason,
387            });
388        }
389
390        // Look up by exact base command name
391        if let Some(spec) = self.get(&ctx.base_command) {
392            return self.maybe_escalate(spec.evaluate(&ctx));
393        }
394
395        // Dotted command fallback for deny list (e.g. mkfs.ext4 → mkfs)
396        if let Some(prefix) = ctx.base_command.split('.').next()
397            && prefix != ctx.base_command
398            && let Some(spec) = self.get(prefix)
399        {
400            return self.maybe_escalate(spec.evaluate(&ctx));
401        }
402
403        // Fallthrough → ask
404        RuleMatch {
405            decision: Decision::Ask,
406            reason: format!("unrecognized command: {}", ctx.base_command),
407        }
408    }
409
410    /// Recursively evaluate a pipeline tree, collecting substitution results.
411    ///
412    /// This is the recursive tree walk that replaces the old flat substitution loop.
413    /// For each segment, we first evaluate its substitutions, then the segment itself.
414    /// Structural substitutions (for-loop values, case subjects) are evaluated first.
415    fn evaluate_pipeline(
416        &self,
417        pipeline: &ParsedPipeline,
418        accumulated_env: &mut HashMap<String, String>,
419        reasons: &mut Vec<String>,
420    ) -> Decision {
421        let mut strictest = Decision::Allow;
422
423        // Evaluate structural substitutions first (for-loop values, case subjects)
424        for sub in &pipeline.structural_substitutions {
425            let sub_decision = self.evaluate_pipeline(&sub.pipeline, &mut HashMap::new(), reasons);
426            let label: String = sub
427                .pipeline
428                .segments
429                .iter()
430                .map(|s| s.command.as_str())
431                .collect::<Vec<_>>()
432                .join(" && ");
433            let label: String = label.trim().chars().take(60).collect();
434            reasons.push(format!(
435                "  structural-subst[$({label})] -> {}: (nested)",
436                sub_decision.label(),
437            ));
438            if sub_decision > strictest {
439                strictest = sub_decision;
440            }
441        }
442
443        // Evaluate each segment with its substitutions
444        let mut segment_executes = true;
445
446        for (i, segment) in pipeline.segments.iter().enumerate() {
447            // Determine if this segment executes based on the preceding operator.
448            if i > 0 {
449                let op = &pipeline.operators[i - 1];
450                match op {
451                    // Semicolon: unconditional — segment always executes.
452                    Operator::Semi => segment_executes = true,
453                    // And: segment executes only if prior executed AND succeeded.
454                    Operator::And => {
455                        segment_executes =
456                            segment_executes && is_likely_successful(&pipeline.segments[i - 1]);
457                    }
458                    // Or / Pipe / PipeErr / Background: can't guarantee execution or env propagation.
459                    Operator::Or | Operator::Pipe | Operator::PipeErr | Operator::Background => {
460                        segment_executes = false;
461                        accumulated_env.clear();
462                    }
463                    // Future operator variants: conservative behavior
464                    _ => {
465                        segment_executes = false;
466                        accumulated_env.clear();
467                    }
468                }
469            }
470
471            // Evaluate substitutions within this segment (recursive tree walk).
472            // Substitutions don't propagate env to parent — use a fresh env.
473            for sub in &segment.substitutions {
474                let sub_decision =
475                    self.evaluate_pipeline(&sub.pipeline, &mut HashMap::new(), reasons);
476                // Build a readable label from the substitution's inner pipeline segments
477                let label: String = sub
478                    .pipeline
479                    .segments
480                    .iter()
481                    .map(|s| s.command.as_str())
482                    .collect::<Vec<_>>()
483                    .join(" && ");
484                let label: String = label.trim().chars().take(60).collect();
485                reasons.push(format!(
486                    "  subst[$({label})] -> {}: (nested)",
487                    sub_decision.label(),
488                ));
489                if sub_decision > strictest {
490                    strictest = sub_decision;
491                }
492            }
493
494            // Build a CommandContext from the structured segment — uses the
495            // pre-tokenized words from tree-sitter directly.
496            let mut ctx = CommandContext::from_segment(segment);
497            ctx.accumulated_env = accumulated_env.clone();
498
499            let mut result = self.evaluate_ctx(ctx);
500
501            // Accumulate env vars from this segment if it's known to execute.
502            // Use the segment's pre-tokenized words directly (substitutions
503            // are already evaluated separately via the recursive tree walk).
504            if segment_executes {
505                for (key, val) in extract_segment_env(&segment.words) {
506                    accumulated_env.insert(key, val);
507                }
508                for var in extract_unset_vars(&segment.words) {
509                    accumulated_env.remove(var);
510                }
511            }
512
513            // Propagate redirection from wrapping constructs
514            if result.decision == Decision::Allow
515                && let Some(ref r) = segment.redirection
516            {
517                result.decision = Decision::Ask;
518                result.reason = format!("{} (escalated: wrapping {})", result.reason, r);
519            }
520            let label: String = segment.command.trim().chars().take(60).collect();
521            reasons.push(format!(
522                "  [{label}] -> {}: {}",
523                result.decision.label(),
524                result.reason
525            ));
526            if result.decision > strictest {
527                strictest = result.decision;
528            }
529        }
530
531        strictest
532    }
533
534    /// Evaluate a full command string, handling compound expressions and substitutions.
535    pub fn evaluate(&self, command: &str) -> RuleMatch {
536        let pipeline = match parse::parse_with_substitutions(command) {
537            Ok(p) => p,
538            Err(_) => {
539                // ParseError → ASK (fail-closed)
540                return RuleMatch {
541                    decision: Decision::Ask,
542                    reason: "parse error (fail-closed)".into(),
543                };
544            }
545        };
546
547        // Check for parse errors in the pipeline tree → ASK (fail-closed)
548        if pipeline.has_parse_errors_recursive() {
549            // Still evaluate what we can, but escalate to ASK minimum
550            let mut strictest = Decision::Ask;
551            let mut reasons = vec!["  parse errors detected (fail-closed)".to_string()];
552            let mut accumulated_env: HashMap<String, String> = HashMap::new();
553            let tree_decision =
554                self.evaluate_pipeline(&pipeline, &mut accumulated_env, &mut reasons);
555            if tree_decision > strictest {
556                strictest = tree_decision;
557            }
558            return RuleMatch {
559                decision: strictest,
560                reason: format!(
561                    "compound command (parse errors, fail-closed):\n{}",
562                    reasons.join("\n")
563                ),
564            };
565        }
566
567        // Simple case: no substitutions, not compound, and the segment text matches
568        // the original command → evaluate directly.
569        let has_substitutions = pipeline
570            .find_segment(&|seg| {
571                if !seg.substitutions.is_empty() {
572                    Some(())
573                } else {
574                    None
575                }
576            })
577            .is_some()
578            || !pipeline.structural_substitutions.is_empty();
579
580        if pipeline.segments.len() <= 1 && !has_substitutions {
581            let is_passthrough = match pipeline.segments.first() {
582                Some(seg) => seg.command.trim() == command.trim(),
583                None => true,
584            };
585            if is_passthrough {
586                return self.evaluate_single(command);
587            }
588        }
589
590        let mut reasons = Vec::new();
591        let mut accumulated_env: HashMap<String, String> = HashMap::new();
592        let strictest = self.evaluate_pipeline(&pipeline, &mut accumulated_env, &mut reasons);
593
594        // Build summary header
595        let mut desc = Vec::new();
596        if !pipeline.operators.is_empty() {
597            let mut unique_ops: Vec<&str> = pipeline.operators.iter().map(|o| o.as_str()).collect();
598            unique_ops.sort();
599            unique_ops.dedup();
600            desc.push(unique_ops.join(", "));
601        }
602        if has_substitutions {
603            let sub_count = pipeline.filter_segments(&|seg| {
604                if !seg.substitutions.is_empty() {
605                    Some(seg.substitutions.len())
606                } else {
607                    None
608                }
609            });
610            let total: usize =
611                sub_count.iter().sum::<usize>() + pipeline.structural_substitutions.len();
612            desc.push(format!("{total} substitution(s)"));
613        }
614        let header = if desc.is_empty() {
615            "compound command".into()
616        } else {
617            format!("compound command ({})", desc.join("; "))
618        };
619
620        self.maybe_annotate_project_overlay(RuleMatch {
621            decision: strictest,
622            reason: format!("{}:\n{}", header, reasons.join("\n")),
623        })
624    }
625}
626
627#[cfg(test)]
628mod tests;