Skip to main content

cc_toolgate/commands/tools/
gh.rs

1//! Subcommand-aware GitHub CLI (gh) evaluation.
2//!
3//! gh uses two-word subcommands (`pr list`, `issue create`), so both the
4//! two-word form and one-word fallback are checked against the config lists.
5//! Supports env-gated auto-allow and redirection escalation.
6
7use super::super::CommandSpec;
8use crate::config::GhConfig;
9use crate::eval::{CommandContext, Decision, RuleMatch};
10use std::collections::HashMap;
11
12/// Subcommand-aware gh CLI evaluator.
13///
14/// Evaluation order:
15/// 1. Read-only subcommands → ALLOW (with redirection escalation)
16/// 2. Env-gated subcommands → ALLOW if all `config_env` entries match, else ASK
17/// 3. Known mutating subcommands → ASK
18/// 4. Everything else → ASK
19pub struct GhSpec {
20    /// Read-only subcommands (e.g. `pr list`, `pr view`, `status`).
21    read_only: Vec<String>,
22    /// Known mutating subcommands (e.g. `pr create`, `repo delete`).
23    mutating: Vec<String>,
24    /// Subcommands allowed only when all `config_env` entries match.
25    allowed_with_config: Vec<String>,
26    /// Required env var name→value pairs that gate `allowed_with_config` subcommands.
27    config_env: HashMap<String, String>,
28}
29
30impl GhSpec {
31    /// Build a gh spec from configuration.
32    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    /// Get the two-word subcommand (e.g. "pr list") and one-word fallback.
42    /// Handles env var prefixes like `GH_TOKEN=abc gh pr create`.
43    fn subcommands(ctx: &CommandContext) -> (String, String) {
44        // Find position of "gh" in the word list (may be preceded by env vars)
45        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    /// Format config_env keys for reason strings.
62    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        // Env-gated subcommands: allowed only when all config_env entries match
89        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    // ── Env-gated commands ──
182
183    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        // read_only commands don't need the env var
233        assert_eq!(eval_with_env_gate("gh pr list"), Decision::Allow);
234    }
235
236    #[test]
237    fn env_gate_repo_delete_still_asks() {
238        // mutating commands not in allowed_with_config always ask
239        assert_eq!(
240            eval_with_env_gate("GH_CONFIG_DIR=~/.config/gh-ai gh repo delete my-repo"),
241            Decision::Ask
242        );
243    }
244}