cc_toolgate/commands/tools/
gh.rs1use super::super::CommandSpec;
8use crate::config::GhConfig;
9use crate::eval::{CommandContext, Decision, RuleMatch};
10use std::collections::HashMap;
11
12pub struct GhSpec {
20 read_only: Vec<String>,
22 mutating: Vec<String>,
24 allowed_with_config: Vec<String>,
26 config_env: HashMap<String, String>,
28}
29
30impl GhSpec {
31 pub fn from_config(config: &GhConfig) -> Self {
33 Self {
34 read_only: config.read_only.clone(),
35 mutating: config.mutating.clone(),
36 allowed_with_config: config.allowed_with_config.clone(),
37 config_env: config.config_env.clone(),
38 }
39 }
40
41 fn subcommands(ctx: &CommandContext) -> (String, &str) {
44 let gh_pos = ctx.words.iter().position(|w| w == "gh");
46 let after_gh = gh_pos.map(|p| p + 1).unwrap_or(1);
47
48 let sub_two = if ctx.words.len() > after_gh + 1 {
49 format!("{} {}", ctx.words[after_gh], ctx.words[after_gh + 1])
50 } else {
51 String::new()
52 };
53 let sub_one: &str = ctx.words.get(after_gh).map(|w| w.as_str()).unwrap_or("?");
54 (sub_two, sub_one)
55 }
56
57 fn env_keys_display(&self) -> String {
59 let mut keys: Vec<&str> = self.config_env.keys().map(|k| k.as_str()).collect();
60 keys.sort();
61 keys.join(", ")
62 }
63}
64
65impl CommandSpec for GhSpec {
66 fn evaluate(&self, ctx: &CommandContext) -> RuleMatch {
67 let (sub_two, sub_one) = Self::subcommands(ctx);
68
69 let in_read_only = self.read_only.iter().any(|s| s == &sub_two)
70 || self.read_only.iter().any(|s| s == sub_one);
71 if in_read_only {
72 if let Some(ref r) = ctx.redirection {
73 return RuleMatch {
74 decision: Decision::Ask,
75 reason: format!("gh {sub_one} with {}", r),
76 };
77 }
78 return RuleMatch {
79 decision: Decision::Allow,
80 reason: format!("read-only gh {sub_two}"),
81 };
82 }
83
84 let in_env_gated = self.allowed_with_config.iter().any(|s| s == &sub_two)
86 || self.allowed_with_config.iter().any(|s| s == sub_one);
87 if in_env_gated {
88 if !self.config_env.is_empty() && ctx.env_satisfies(&self.config_env) {
89 if let Some(ref r) = ctx.redirection {
90 return RuleMatch {
91 decision: Decision::Ask,
92 reason: format!("gh {sub_one} with {}", r),
93 };
94 }
95 return RuleMatch {
96 decision: Decision::Allow,
97 reason: format!("gh {sub_two} with {}", self.env_keys_display()),
98 };
99 }
100 return RuleMatch {
101 decision: Decision::Ask,
102 reason: format!("gh {sub_two} requires confirmation"),
103 };
104 }
105
106 let in_mutating = self.mutating.iter().any(|s| s == &sub_two)
107 || self.mutating.iter().any(|s| s == sub_one);
108 if in_mutating {
109 return RuleMatch {
110 decision: Decision::Ask,
111 reason: format!("gh {sub_two} requires confirmation"),
112 };
113 }
114
115 RuleMatch {
116 decision: Decision::Ask,
117 reason: format!("gh {sub_one} requires confirmation"),
118 }
119 }
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125 use crate::config::Config;
126
127 fn spec() -> GhSpec {
128 GhSpec::from_config(&Config::default_config().gh)
129 }
130
131 fn eval(cmd: &str) -> Decision {
132 let s = spec();
133 let ctx = CommandContext::from_command(cmd);
134 s.evaluate(&ctx).decision
135 }
136
137 #[test]
138 fn allow_pr_list() {
139 assert_eq!(eval("gh pr list"), Decision::Allow);
140 }
141
142 #[test]
143 fn allow_pr_view() {
144 assert_eq!(eval("gh pr view 123"), Decision::Allow);
145 }
146
147 #[test]
148 fn allow_status() {
149 assert_eq!(eval("gh status"), Decision::Allow);
150 }
151
152 #[test]
153 fn allow_api() {
154 assert_eq!(eval("gh api repos/owner/repo/pulls"), Decision::Allow);
155 }
156
157 #[test]
158 fn ask_pr_create() {
159 assert_eq!(eval("gh pr create --title 'Fix'"), Decision::Ask);
160 }
161
162 #[test]
163 fn ask_pr_merge() {
164 assert_eq!(eval("gh pr merge 123"), Decision::Ask);
165 }
166
167 #[test]
168 fn ask_repo_delete() {
169 assert_eq!(eval("gh repo delete my-repo --yes"), Decision::Ask);
170 }
171
172 #[test]
173 fn redir_pr_list() {
174 assert_eq!(eval("gh pr list > /tmp/prs.txt"), Decision::Ask);
175 }
176
177 fn spec_with_env_gate() -> GhSpec {
180 GhSpec::from_config(&GhConfig {
181 read_only: vec!["pr list".into(), "pr view".into(), "status".into()],
182 mutating: vec!["repo delete".into()],
183 allowed_with_config: vec!["pr create".into(), "pr merge".into()],
184 config_env: HashMap::from([("GH_CONFIG_DIR".into(), "~/.config/gh-ai".into())]),
185 })
186 }
187
188 fn eval_with_env_gate(cmd: &str) -> Decision {
189 let s = spec_with_env_gate();
190 let ctx = CommandContext::from_command(cmd);
191 s.evaluate(&ctx).decision
192 }
193
194 #[test]
195 fn env_gate_pr_create_with_matching_value() {
196 assert_eq!(
197 eval_with_env_gate("GH_CONFIG_DIR=~/.config/gh-ai gh pr create --title 'Fix'"),
198 Decision::Allow
199 );
200 }
201
202 #[test]
203 fn env_gate_pr_create_with_wrong_value() {
204 assert_eq!(
205 eval_with_env_gate("GH_CONFIG_DIR=~/.config/gh gh pr create --title 'Fix'"),
206 Decision::Ask
207 );
208 }
209
210 #[test]
211 fn env_gate_pr_create_no_config() {
212 assert_eq!(
213 eval_with_env_gate("gh pr create --title 'Fix'"),
214 Decision::Ask
215 );
216 }
217
218 #[test]
219 fn env_gate_pr_merge_with_config() {
220 assert_eq!(
221 eval_with_env_gate("GH_CONFIG_DIR=~/.config/gh-ai gh pr merge 123"),
222 Decision::Allow
223 );
224 }
225
226 #[test]
227 fn env_gate_pr_list_still_readonly() {
228 assert_eq!(eval_with_env_gate("gh pr list"), Decision::Allow);
230 }
231
232 #[test]
233 fn env_gate_repo_delete_still_asks() {
234 assert_eq!(
236 eval_with_env_gate("GH_CONFIG_DIR=~/.config/gh-ai gh repo delete my-repo"),
237 Decision::Ask
238 );
239 }
240}