Skip to main content

cc_toolgate/eval/
context.rs

1//! Per-segment command context: tokenization, env var extraction, and redirection detection.
2
3use agent_shell_parser::parse::{Redirection, ShellSegment, Word};
4
5/// Context for evaluating a single command segment.
6#[derive(Debug)]
7pub struct CommandContext {
8    /// The base command name (e.g. "git", "ls", "cargo").
9    pub base_command: String,
10    /// All words in the command (pre-tokenized by tree-sitter or shlex).
11    pub words: Vec<Word>,
12    /// Leading KEY=VALUE environment variable assignments.
13    pub env_vars: Vec<(String, String)>,
14    /// Detected output redirection, if any.
15    pub redirection: Option<Redirection>,
16    /// Environment variables accumulated from prior segments in a compound command
17    /// (e.g. `export FOO=bar ; git push` makes FOO=bar available to the git push segment).
18    pub accumulated_env: std::collections::HashMap<String, String>,
19}
20
21impl CommandContext {
22    /// Build a CommandContext from a raw command string.
23    ///
24    /// Used for simple (non-compound) command evaluation and in tests.
25    pub fn from_command(raw: &str) -> Self {
26        let base_command = agent_shell_parser::parse::base_command(raw);
27        let env_vars = agent_shell_parser::parse::env_vars(raw);
28        let words = agent_shell_parser::parse::tokenize(raw);
29        // has_output_redirection now returns Result. On error, assume redirection
30        // exists (conservative — fail-closed).
31        let redirection = agent_shell_parser::parse::has_output_redirection(raw).unwrap_or(Some(
32            agent_shell_parser::parse::Redirection {
33                operator: ">",
34                fd: None,
35                target: "(parse error)".into(),
36            },
37        ));
38
39        Self {
40            base_command,
41            words,
42            env_vars,
43            redirection,
44            accumulated_env: std::collections::HashMap::new(),
45        }
46    }
47
48    /// Build a CommandContext from a parsed [`ShellSegment`].
49    ///
50    /// Uses the segment's pre-tokenized `words` field directly — tree-sitter
51    /// already handles word boundaries correctly, including preserving
52    /// substitution syntax (`$(...)`, backticks) as single tokens.
53    ///
54    /// Redirection is detected by parsing the segment's command text (inline
55    /// redirections like `cat > file`) or inherited from the segment's
56    /// `redirection` field (wrapping-construct redirections like `for ... done > file`).
57    pub fn from_segment(segment: &ShellSegment) -> Self {
58        let words = segment.words.clone();
59        let base_command = Self::base_command_from_words(&words);
60        let env_vars = Self::env_vars_from_words(&words);
61        // Detect inline redirections from the command text, falling back to
62        // the segment's wrapping-construct redirection if present.
63        let redirection = match agent_shell_parser::parse::has_output_redirection(&segment.command)
64        {
65            Ok(r) => r.or_else(|| segment.redirection.clone()),
66            Err(_) => {
67                segment
68                    .redirection
69                    .clone()
70                    .or(Some(agent_shell_parser::parse::Redirection {
71                        operator: ">",
72                        fd: None,
73                        target: "(parse error)".into(),
74                    }))
75            }
76        };
77
78        Self {
79            base_command,
80            words,
81            env_vars,
82            redirection,
83            accumulated_env: std::collections::HashMap::new(),
84        }
85    }
86
87    /// Extract the base command name from pre-tokenized words.
88    ///
89    /// Skips leading `KEY=VALUE` env var assignments to find the actual
90    /// command word, then extracts just the basename (e.g. `/usr/bin/git` → `git`).
91    pub(crate) fn base_command_from_words(words: &[Word]) -> String {
92        for word in words {
93            if word.is_assignment() {
94                continue; // skip env var assignment
95            }
96            // Extract basename from path (e.g. `/usr/bin/git` → `git`)
97            return word.basename().to_string();
98        }
99        String::new()
100    }
101
102    /// Extract leading `KEY=VALUE` env var assignments from pre-tokenized words.
103    fn env_vars_from_words(words: &[Word]) -> Vec<(String, String)> {
104        let mut result = Vec::new();
105        for word in words {
106            if let Some((key, val)) = word.as_assignment() {
107                result.push((key.to_string(), val.to_string()));
108                continue;
109            }
110            break; // first non-env-var word ends the prefix
111        }
112        result
113    }
114
115    /// Check if all required env var entries are satisfied.
116    ///
117    /// For each entry, checks the command's inline env vars first (exact key+value match),
118    /// then falls back to the process environment (`std::env::var`).
119    /// Returns true only if ALL entries match. Some entries may come from inline env
120    /// and others from the process environment — each is checked independently.
121    ///
122    /// Config values are shell-expanded (`~`, `$HOME`, `$VAR`) before comparison,
123    /// since shells expand these in env assignments before they reach the process.
124    pub fn env_satisfies(&self, required: &std::collections::HashMap<String, String>) -> bool {
125        required.iter().all(|(key, value)| {
126            let expanded = match shellexpand::full(value) {
127                Ok(v) => v,
128                Err(e) => {
129                    log::warn!("shellexpand failed for config_env {key}={value}: {e}");
130                    std::borrow::Cow::Borrowed(value.as_str())
131                }
132            };
133            // Check inline env vars first (may contain literal ~ or expanded path)
134            if let Some((_, v)) = self.env_vars.iter().find(|(k, _)| k == key) {
135                return v == value || v == expanded.as_ref();
136            }
137            // Check accumulated env from prior compound-command segments
138            if let Some(v) = self.accumulated_env.get(key) {
139                return v == value || v == expanded.as_ref();
140            }
141            // Fall back to process environment (shell will have expanded already)
142            std::env::var(key).is_ok_and(|v| v == *value || v == expanded.as_ref())
143        })
144    }
145
146    /// Get words after skipping env vars and the base command.
147    pub fn args(&self) -> &[Word] {
148        // Skip env var tokens and the base command itself
149        let skip = self.env_vars.len() + 1; // each env var is one token in shlex, plus the command
150        if self.words.len() > skip {
151            &self.words[skip..]
152        } else {
153            &[]
154        }
155    }
156
157    /// Check if any word matches a flag.
158    pub fn has_flag(&self, flag: &str) -> bool {
159        self.words.iter().any(|w| w == flag)
160    }
161
162    /// Check if any word matches any of the given flags.
163    pub fn has_any_flag(&self, flags: &[&str]) -> bool {
164        self.words.iter().any(|w| flags.contains(&w.as_str()))
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use std::collections::HashMap;
172
173    /// Panic unless running under nextest (process-per-test isolation).
174    ///
175    /// Tests that call `std::env::set_var` / `remove_var` are unsound under
176    /// `cargo test`, which runs tests concurrently in a single process.
177    /// Nextest sets `NEXTEST=1` in every child process.
178    fn require_nextest() {
179        assert!(
180            std::env::var("NEXTEST").is_ok(),
181            "this test mutates process env and requires nextest (cargo nextest run)"
182        );
183    }
184
185    #[test]
186    fn env_satisfies_inline_exact() {
187        let ctx = CommandContext::from_command("FOO=bar git push");
188        let req = HashMap::from([("FOO".into(), "bar".into())]);
189        assert!(ctx.env_satisfies(&req));
190    }
191
192    #[test]
193    fn env_satisfies_inline_wrong_value() {
194        let ctx = CommandContext::from_command("FOO=baz git push");
195        let req = HashMap::from([("FOO".into(), "bar".into())]);
196        assert!(!ctx.env_satisfies(&req));
197    }
198
199    #[test]
200    fn env_satisfies_inline_missing() {
201        let ctx = CommandContext::from_command("git push");
202        let req = HashMap::from([("FOO".into(), "bar".into())]);
203        // No inline var, no process env → false
204        assert!(!ctx.env_satisfies(&req));
205    }
206
207    #[test]
208    fn env_satisfies_process_env() {
209        require_nextest();
210        let key = "CC_TOOLGATE_TEST_PROCESS_ENV";
211        // SAFETY: nextest runs each test in its own process (verified by require_nextest)
212        unsafe { std::env::set_var(key, "expected_value") };
213        let ctx = CommandContext::from_command("git push");
214        let req = HashMap::from([(key.into(), "expected_value".into())]);
215        assert!(ctx.env_satisfies(&req));
216        unsafe { std::env::remove_var(key) };
217    }
218
219    #[test]
220    fn env_satisfies_process_env_wrong_value() {
221        require_nextest();
222        let key = "CC_TOOLGATE_TEST_WRONG_VALUE";
223        // SAFETY: nextest runs each test in its own process (verified by require_nextest)
224        unsafe { std::env::set_var(key, "actual") };
225        let ctx = CommandContext::from_command("git push");
226        let req = HashMap::from([(key.into(), "expected".into())]);
227        assert!(!ctx.env_satisfies(&req));
228        unsafe { std::env::remove_var(key) };
229    }
230
231    #[test]
232    fn env_satisfies_multi_source_one_inline_one_process() {
233        require_nextest();
234        let key_process = "CC_TOOLGATE_TEST_MULTI_PROC";
235        // SAFETY: nextest runs each test in its own process (verified by require_nextest)
236        unsafe { std::env::set_var(key_process, "/correct/path") };
237        let ctx = CommandContext::from_command("INLINE_VAR=correct git push");
238        let req = HashMap::from([
239            ("INLINE_VAR".into(), "correct".into()),
240            (key_process.into(), "/correct/path".into()),
241        ]);
242        assert!(ctx.env_satisfies(&req));
243        unsafe { std::env::remove_var(key_process) };
244    }
245
246    #[test]
247    fn env_satisfies_multi_source_one_missing() {
248        // No env mutation — safe under cargo test
249        let ctx = CommandContext::from_command("INLINE_VAR=correct git push");
250        let req = HashMap::from([
251            ("INLINE_VAR".into(), "correct".into()),
252            ("MISSING_VAR".into(), "value".into()),
253        ]);
254        assert!(!ctx.env_satisfies(&req));
255    }
256
257    #[test]
258    fn env_satisfies_multi_source_one_wrong() {
259        require_nextest();
260        let key_process = "CC_TOOLGATE_TEST_MULTI_WRONG";
261        // SAFETY: nextest runs each test in its own process (verified by require_nextest)
262        unsafe { std::env::set_var(key_process, "/wrong/path") };
263        let ctx = CommandContext::from_command("INLINE_VAR=correct git push");
264        let req = HashMap::from([
265            ("INLINE_VAR".into(), "correct".into()),
266            (key_process.into(), "/correct/path".into()),
267        ]);
268        assert!(!ctx.env_satisfies(&req));
269        unsafe { std::env::remove_var(key_process) };
270    }
271
272    #[test]
273    fn env_satisfies_tilde_expansion() {
274        require_nextest();
275        let key = "CC_TOOLGATE_TEST_TILDE";
276        let home = std::env::var("HOME").unwrap();
277        // SAFETY: nextest runs each test in its own process (verified by require_nextest)
278        unsafe { std::env::set_var(key, format!("{home}/foo")) };
279        let ctx = CommandContext::from_command("git push");
280        let req = HashMap::from([(key.into(), "~/foo".into())]);
281        assert!(ctx.env_satisfies(&req));
282        unsafe { std::env::remove_var(key) };
283    }
284
285    #[test]
286    fn env_satisfies_empty_map() {
287        let ctx = CommandContext::from_command("git push");
288        assert!(ctx.env_satisfies(&HashMap::new()));
289    }
290
291    // ── Collision tests ──
292    //
293    // These two tests use the SAME env var key with DIFFERENT expected values.
294    // Under nextest (process-per-test), both pass reliably because each process
295    // has its own environment. Under `cargo test` (shared process, concurrent
296    // threads), one would see the other's write and produce a wrong result.
297
298    const COLLISION_KEY: &str = "CC_TOOLGATE_TEST_COLLISION";
299
300    #[test]
301    fn env_collision_value_alpha() {
302        require_nextest();
303        // SAFETY: nextest runs each test in its own process (verified by require_nextest)
304        unsafe { std::env::set_var(COLLISION_KEY, "alpha") };
305        // Spin briefly to widen the race window under concurrent execution
306        std::thread::sleep(std::time::Duration::from_millis(5));
307        let ctx = CommandContext::from_command("git push");
308        let req = HashMap::from([(COLLISION_KEY.into(), "alpha".into())]);
309        assert!(
310            ctx.env_satisfies(&req),
311            "expected 'alpha', env was tampered"
312        );
313        unsafe { std::env::remove_var(COLLISION_KEY) };
314    }
315
316    #[test]
317    fn env_collision_value_beta() {
318        require_nextest();
319        // SAFETY: nextest runs each test in its own process (verified by require_nextest)
320        unsafe { std::env::set_var(COLLISION_KEY, "beta") };
321        std::thread::sleep(std::time::Duration::from_millis(5));
322        let ctx = CommandContext::from_command("git push");
323        let req = HashMap::from([(COLLISION_KEY.into(), "beta".into())]);
324        assert!(ctx.env_satisfies(&req), "expected 'beta', env was tampered");
325        unsafe { std::env::remove_var(COLLISION_KEY) };
326    }
327}