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;