cc-toolgate 0.6.3

PreToolUse hook for Claude Code that gates Bash commands with compound-command-aware validation
Documentation
//! Subcommand-aware GitHub CLI (gh) evaluation.
//!
//! gh uses two-word subcommands (`pr list`, `issue create`), so both the
//! two-word form and one-word fallback are checked against the config lists.
//! Supports env-gated auto-allow and redirection escalation.

use super::super::CommandSpec;
use crate::config::GhConfig;
use crate::eval::{CommandContext, Decision, RuleMatch};
use std::collections::HashMap;

/// Subcommand-aware gh CLI evaluator.
///
/// Evaluation order:
/// 1. Read-only subcommands → ALLOW (with redirection escalation)
/// 2. Env-gated subcommands → ALLOW if all `config_env` entries match, else ASK
/// 3. Known mutating subcommands → ASK
/// 4. Everything else → ASK
pub struct GhSpec {
    /// Read-only subcommands (e.g. `pr list`, `pr view`, `status`).
    read_only: Vec<String>,
    /// Known mutating subcommands (e.g. `pr create`, `repo delete`).
    mutating: Vec<String>,
    /// Subcommands allowed only when all `config_env` entries match.
    allowed_with_config: Vec<String>,
    /// Required env var name→value pairs that gate `allowed_with_config` subcommands.
    config_env: HashMap<String, String>,
}

impl GhSpec {
    /// Build a gh spec from configuration.
    pub fn from_config(config: &GhConfig) -> Self {
        Self {
            read_only: config.read_only.clone(),
            mutating: config.mutating.clone(),
            allowed_with_config: config.allowed_with_config.clone(),
            config_env: config.config_env.clone(),
        }
    }

    /// Get the two-word subcommand (e.g. "pr list") and one-word fallback.
    /// Handles env var prefixes like `GH_TOKEN=abc gh pr create`.
    fn subcommands(ctx: &CommandContext) -> (String, &str) {
        // Find position of "gh" in the word list (may be preceded by env vars)
        let gh_pos = ctx.words.iter().position(|w| w == "gh");
        let after_gh = gh_pos.map(|p| p + 1).unwrap_or(1);

        let sub_two = if ctx.words.len() > after_gh + 1 {
            format!("{} {}", ctx.words[after_gh], ctx.words[after_gh + 1])
        } else {
            String::new()
        };
        let sub_one: &str = ctx.words.get(after_gh).map(|w| w.as_str()).unwrap_or("?");
        (sub_two, sub_one)
    }

    /// Format config_env keys for reason strings.
    fn env_keys_display(&self) -> String {
        let mut keys: Vec<&str> = self.config_env.keys().map(|k| k.as_str()).collect();
        keys.sort();
        keys.join(", ")
    }
}

impl CommandSpec for GhSpec {
    fn evaluate(&self, ctx: &CommandContext) -> RuleMatch {
        let (sub_two, sub_one) = Self::subcommands(ctx);

        let in_read_only = self.read_only.iter().any(|s| s == &sub_two)
            || self.read_only.iter().any(|s| s == sub_one);
        if in_read_only {
            if let Some(ref r) = ctx.redirection {
                return RuleMatch {
                    decision: Decision::Ask,
                    reason: format!("gh {sub_one} with {}", r),
                };
            }
            return RuleMatch {
                decision: Decision::Allow,
                reason: format!("read-only gh {sub_two}"),
            };
        }

        // Env-gated subcommands: allowed only when all config_env entries match
        let in_env_gated = self.allowed_with_config.iter().any(|s| s == &sub_two)
            || self.allowed_with_config.iter().any(|s| s == sub_one);
        if in_env_gated {
            if !self.config_env.is_empty() && ctx.env_satisfies(&self.config_env) {
                if let Some(ref r) = ctx.redirection {
                    return RuleMatch {
                        decision: Decision::Ask,
                        reason: format!("gh {sub_one} with {}", r),
                    };
                }
                return RuleMatch {
                    decision: Decision::Allow,
                    reason: format!("gh {sub_two} with {}", self.env_keys_display()),
                };
            }
            return RuleMatch {
                decision: Decision::Ask,
                reason: format!("gh {sub_two} requires confirmation"),
            };
        }

        let in_mutating = self.mutating.iter().any(|s| s == &sub_two)
            || self.mutating.iter().any(|s| s == sub_one);
        if in_mutating {
            return RuleMatch {
                decision: Decision::Ask,
                reason: format!("gh {sub_two} requires confirmation"),
            };
        }

        RuleMatch {
            decision: Decision::Ask,
            reason: format!("gh {sub_one} requires confirmation"),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::Config;

    fn spec() -> GhSpec {
        GhSpec::from_config(&Config::default_config().gh)
    }

    fn eval(cmd: &str) -> Decision {
        let s = spec();
        let ctx = CommandContext::from_command(cmd);
        s.evaluate(&ctx).decision
    }

    #[test]
    fn allow_pr_list() {
        assert_eq!(eval("gh pr list"), Decision::Allow);
    }

    #[test]
    fn allow_pr_view() {
        assert_eq!(eval("gh pr view 123"), Decision::Allow);
    }

    #[test]
    fn allow_status() {
        assert_eq!(eval("gh status"), Decision::Allow);
    }

    #[test]
    fn allow_api() {
        assert_eq!(eval("gh api repos/owner/repo/pulls"), Decision::Allow);
    }

    #[test]
    fn ask_pr_create() {
        assert_eq!(eval("gh pr create --title 'Fix'"), Decision::Ask);
    }

    #[test]
    fn ask_pr_merge() {
        assert_eq!(eval("gh pr merge 123"), Decision::Ask);
    }

    #[test]
    fn ask_repo_delete() {
        assert_eq!(eval("gh repo delete my-repo --yes"), Decision::Ask);
    }

    #[test]
    fn redir_pr_list() {
        assert_eq!(eval("gh pr list > /tmp/prs.txt"), Decision::Ask);
    }

    // ── Env-gated commands ──

    fn spec_with_env_gate() -> GhSpec {
        GhSpec::from_config(&GhConfig {
            read_only: vec!["pr list".into(), "pr view".into(), "status".into()],
            mutating: vec!["repo delete".into()],
            allowed_with_config: vec!["pr create".into(), "pr merge".into()],
            config_env: HashMap::from([("GH_CONFIG_DIR".into(), "~/.config/gh-ai".into())]),
        })
    }

    fn eval_with_env_gate(cmd: &str) -> Decision {
        let s = spec_with_env_gate();
        let ctx = CommandContext::from_command(cmd);
        s.evaluate(&ctx).decision
    }

    #[test]
    fn env_gate_pr_create_with_matching_value() {
        assert_eq!(
            eval_with_env_gate("GH_CONFIG_DIR=~/.config/gh-ai gh pr create --title 'Fix'"),
            Decision::Allow
        );
    }

    #[test]
    fn env_gate_pr_create_with_wrong_value() {
        assert_eq!(
            eval_with_env_gate("GH_CONFIG_DIR=~/.config/gh gh pr create --title 'Fix'"),
            Decision::Ask
        );
    }

    #[test]
    fn env_gate_pr_create_no_config() {
        assert_eq!(
            eval_with_env_gate("gh pr create --title 'Fix'"),
            Decision::Ask
        );
    }

    #[test]
    fn env_gate_pr_merge_with_config() {
        assert_eq!(
            eval_with_env_gate("GH_CONFIG_DIR=~/.config/gh-ai gh pr merge 123"),
            Decision::Allow
        );
    }

    #[test]
    fn env_gate_pr_list_still_readonly() {
        // read_only commands don't need the env var
        assert_eq!(eval_with_env_gate("gh pr list"), Decision::Allow);
    }

    #[test]
    fn env_gate_repo_delete_still_asks() {
        // mutating commands not in allowed_with_config always ask
        assert_eq!(
            eval_with_env_gate("GH_CONFIG_DIR=~/.config/gh-ai gh repo delete my-repo"),
            Decision::Ask
        );
    }
}