cc_toolgate/commands/tools/
git.rs1use super::super::CommandSpec;
8use crate::config::GitConfig;
9use crate::eval::{CommandContext, Decision, RuleMatch};
10use agent_shell_parser::parse::Word;
11use std::collections::HashMap;
12
13pub struct GitSpec {
22 read_only: Vec<String>,
24 allowed_with_config: Vec<String>,
26 config_env: HashMap<String, String>,
28 force_push_flags: Vec<String>,
30}
31
32impl GitSpec {
33 pub fn from_config(config: &GitConfig) -> Self {
35 Self {
36 read_only: config.read_only.clone(),
37 allowed_with_config: config.allowed_with_config.clone(),
38 config_env: config.config_env.clone(),
39 force_push_flags: config.force_push_flags.clone(),
40 }
41 }
42
43 const GLOBAL_ARG_FLAGS: &[&str] = &["-C", "-c", "--git-dir", "--work-tree", "--namespace"];
46
47 const GLOBAL_SOLO_FLAGS: &[&str] = &[
49 "--bare",
50 "--no-pager",
51 "--no-replace-objects",
52 "--literal-pathspecs",
53 "--glob-pathspecs",
54 "--noglob-pathspecs",
55 "--icase-pathspecs",
56 "--no-optional-locks",
57 ];
58
59 fn subcommand(ctx: &CommandContext) -> Option<&Word> {
62 let mut iter = ctx.words.iter();
63 for word in iter.by_ref() {
65 if word == "git" {
66 break;
67 }
68 }
69 loop {
71 let word = iter.next()?;
72 if Self::GLOBAL_ARG_FLAGS.contains(&word.as_str()) {
73 iter.next();
75 continue;
76 }
77 if Self::GLOBAL_SOLO_FLAGS.contains(&word.as_str()) {
78 continue;
79 }
80 return Some(word);
82 }
83 }
84
85 fn env_keys_display(&self) -> String {
87 let mut keys: Vec<&str> = self.config_env.keys().map(|k| k.as_str()).collect();
88 keys.sort();
89 keys.join(", ")
90 }
91}
92
93impl CommandSpec for GitSpec {
94 fn evaluate(&self, ctx: &CommandContext) -> RuleMatch {
95 let sub = Self::subcommand(ctx);
96 let sub_str: &str = sub.map(|w| w.as_str()).unwrap_or("?");
97
98 if sub_str == "push" {
100 let flag_strs: Vec<&str> = self.force_push_flags.iter().map(|s| s.as_str()).collect();
101 if ctx.has_any_flag(&flag_strs) {
102 return RuleMatch {
103 decision: Decision::Ask,
104 reason: "git force-push requires confirmation".into(),
105 };
106 }
107 }
108
109 if self.read_only.iter().any(|s| s == sub_str) {
111 if let Some(ref r) = ctx.redirection {
112 return RuleMatch {
113 decision: Decision::Ask,
114 reason: format!("git {sub_str} with {}", r),
115 };
116 }
117 return RuleMatch {
118 decision: Decision::Allow,
119 reason: format!("read-only git {sub_str}"),
120 };
121 }
122
123 if self.allowed_with_config.iter().any(|s| s == sub_str) {
125 if !self.config_env.is_empty() && ctx.env_satisfies(&self.config_env) {
126 if let Some(ref r) = ctx.redirection {
127 return RuleMatch {
128 decision: Decision::Ask,
129 reason: format!("git {sub_str} with {}", r),
130 };
131 }
132 return RuleMatch {
133 decision: Decision::Allow,
134 reason: format!("git {sub_str} with {}", self.env_keys_display()),
135 };
136 }
137 return RuleMatch {
138 decision: Decision::Ask,
139 reason: format!("git {sub_str} requires confirmation"),
140 };
141 }
142
143 if ctx.has_flag("--version") && ctx.words.len() <= 3 {
145 return RuleMatch {
146 decision: Decision::Allow,
147 reason: "git --version".into(),
148 };
149 }
150
151 RuleMatch {
152 decision: Decision::Ask,
153 reason: format!("git {sub_str} requires confirmation"),
154 }
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161 use crate::config::{Config, GitConfig};
162
163 fn clear_git_env() {
167 assert!(
168 std::env::var("NEXTEST").is_ok(),
169 "this test mutates process env and requires nextest (cargo nextest run)"
170 );
171 unsafe { std::env::remove_var("GIT_CONFIG_GLOBAL") };
172 }
173
174 fn default_spec() -> GitSpec {
175 GitSpec::from_config(&Config::default_config().git)
176 }
177
178 fn eval(cmd: &str) -> Decision {
179 let s = default_spec();
180 let ctx = CommandContext::from_command(cmd);
181 s.evaluate(&ctx).decision
182 }
183
184 fn spec_with_env_gate() -> GitSpec {
186 GitSpec::from_config(&GitConfig {
187 read_only: vec![
188 "status".into(),
189 "log".into(),
190 "diff".into(),
191 "branch".into(),
192 ],
193 allowed_with_config: vec!["push".into(), "pull".into(), "add".into()],
194 config_env: HashMap::from([("GIT_CONFIG_GLOBAL".into(), "~/.gitconfig.ai".into())]),
195 force_push_flags: vec!["--force".into(), "-f".into(), "--force-with-lease".into()],
196 })
197 }
198
199 fn eval_with_env_gate(cmd: &str) -> Decision {
200 let s = spec_with_env_gate();
201 let ctx = CommandContext::from_command(cmd);
202 s.evaluate(&ctx).decision
203 }
204
205 #[test]
208 fn default_push_asks() {
209 assert_eq!(eval("git push origin main"), Decision::Ask);
210 }
211
212 #[test]
213 fn default_push_with_env_still_asks() {
214 assert_eq!(
216 eval("GIT_CONFIG_GLOBAL=~/.gitconfig.ai git push origin main"),
217 Decision::Ask
218 );
219 }
220
221 #[test]
222 fn allow_log() {
223 assert_eq!(eval("git log --oneline -10"), Decision::Allow);
224 }
225
226 #[test]
227 fn allow_diff() {
228 assert_eq!(eval("git diff HEAD~1"), Decision::Allow);
229 }
230
231 #[test]
232 fn allow_branch() {
233 assert_eq!(eval("git branch -a"), Decision::Allow);
234 }
235
236 #[test]
237 fn allow_status() {
238 assert_eq!(eval("git status"), Decision::Allow);
239 }
240
241 #[test]
242 fn redir_log() {
243 assert_eq!(eval("git log > /tmp/log.txt"), Decision::Ask);
244 }
245
246 #[test]
249 fn env_gate_push_with_matching_value() {
250 assert_eq!(
251 eval_with_env_gate("GIT_CONFIG_GLOBAL=~/.gitconfig.ai git push origin main"),
252 Decision::Allow
253 );
254 }
255
256 #[test]
257 fn env_gate_push_with_wrong_value() {
258 assert_eq!(
259 eval_with_env_gate("GIT_CONFIG_GLOBAL=~/.gitconfig git push origin main"),
260 Decision::Ask
261 );
262 }
263
264 #[test]
265 fn env_gate_push_no_config() {
266 clear_git_env();
267 assert_eq!(eval_with_env_gate("git push origin main"), Decision::Ask);
268 }
269
270 #[test]
271 fn env_gate_force_push() {
272 assert_eq!(
273 eval_with_env_gate("GIT_CONFIG_GLOBAL=~/.gitconfig.ai git push --force origin main"),
274 Decision::Ask
275 );
276 }
277
278 #[test]
279 fn env_gate_commit_still_asks() {
280 assert_eq!(
282 eval_with_env_gate("GIT_CONFIG_GLOBAL=~/.gitconfig.ai git commit -m 'test'"),
283 Decision::Ask
284 );
285 }
286
287 #[test]
290 fn allow_git_c_dir_status() {
291 assert_eq!(eval("git -C /some/path status"), Decision::Allow);
292 }
293
294 #[test]
295 fn allow_git_c_dir_log() {
296 assert_eq!(eval("git -C /some/repo log --oneline"), Decision::Allow);
297 }
298
299 #[test]
300 fn allow_git_c_dir_diff() {
301 assert_eq!(eval("git -C ../other diff"), Decision::Allow);
302 }
303
304 #[test]
305 fn ask_git_c_dir_push() {
306 assert_eq!(eval("git -C /some/repo push origin main"), Decision::Ask);
307 }
308
309 #[test]
310 fn allow_git_no_pager_log() {
311 assert_eq!(eval("git --no-pager log"), Decision::Allow);
312 }
313
314 #[test]
315 fn allow_git_c_config_status() {
316 assert_eq!(eval("git -c core.pager=cat status"), Decision::Allow);
318 }
319}