cc_toolgate/eval/
context.rs1use agent_shell_parser::parse::{Redirection, ShellSegment, Word};
4
5#[derive(Debug)]
7pub struct CommandContext {
8 pub base_command: String,
10 pub words: Vec<Word>,
12 pub env_vars: Vec<(String, String)>,
14 pub redirection: Option<Redirection>,
16 pub accumulated_env: std::collections::HashMap<String, String>,
19}
20
21impl CommandContext {
22 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 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 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 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 pub(crate) fn base_command_from_words(words: &[Word]) -> String {
92 for word in words {
93 if word.is_assignment() {
94 continue; }
96 return word.basename().to_string();
98 }
99 String::new()
100 }
101
102 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; }
112 result
113 }
114
115 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 if let Some((_, v)) = self.env_vars.iter().find(|(k, _)| k == key) {
135 return v == value || v == expanded.as_ref();
136 }
137 if let Some(v) = self.accumulated_env.get(key) {
139 return v == value || v == expanded.as_ref();
140 }
141 std::env::var(key).is_ok_and(|v| v == *value || v == expanded.as_ref())
143 })
144 }
145
146 pub fn args(&self) -> &[Word] {
148 let skip = self.env_vars.len() + 1; if self.words.len() > skip {
151 &self.words[skip..]
152 } else {
153 &[]
154 }
155 }
156
157 pub fn has_flag(&self, flag: &str) -> bool {
159 self.words.iter().any(|w| w == flag)
160 }
161
162 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 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 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 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 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 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 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 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 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 const COLLISION_KEY: &str = "CC_TOOLGATE_TEST_COLLISION";
299
300 #[test]
301 fn env_collision_value_alpha() {
302 require_nextest();
303 unsafe { std::env::set_var(COLLISION_KEY, "alpha") };
305 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 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}