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