Skip to main content

grr_cli/commands/
safety.rs

1//! Safety profiles — a cheap gate over the Discovery method surface.
2//!
3//! One [`SafetyProfile`] answers "may this method run?" for both dispatch
4//! surfaces: the CLI (`grr api call` and the generated tree, both gated by
5//! the global flags on the root command in `cli.rs`) and the `grr mcp`
6//! server. The check is a verb + service-name lookup per call — no I/O —
7//! so it can sit on every request path.
8//!
9//! ```text
10//! grr api call gmail.users.messages.send --readonly           # refused: POST
11//! grr api call gmail.users.messages.list --deny-service chat  # allowed
12//! grr mcp --readonly                                          # the server refuses writes
13//! ```
14
15use crate::discovery::Method;
16use clap::Args;
17use std::collections::BTreeSet;
18
19/// Verbs that mutate server-side state. Deliberately wider than
20/// `plan_request`'s body-synthesis set (POST/PATCH/PUT): DELETE mutates
21/// too, it just carries no body.
22pub(crate) const WRITE_VERBS: [&str; 4] = ["POST", "PATCH", "PUT", "DELETE"];
23
24/// Is this HTTP verb a write? Shared by the gate and the MCP tool
25/// descriptions so both surfaces speak the same vocabulary.
26pub(crate) fn is_write_verb(verb: &str) -> bool {
27    WRITE_VERBS.contains(&verb)
28}
29
30/// CLI flags for the gate, as a derive struct so `cli.rs` can flatten it
31/// onto the root command without re-declaring the flags. The generated
32/// tree and `grr api call` read them from the root matches (clap merges
33/// global args across levels).
34#[derive(Args, Debug, Clone, Default)]
35pub struct SafetyArgs {
36    /// Refuse every write method (POST/PATCH/PUT/DELETE); only reads run
37    #[arg(long)]
38    pub readonly: bool,
39
40    /// Refuse every method of this service (e.g. gmail). Repeatable.
41    #[arg(long = "deny-service", value_name = "SERVICE")]
42    pub deny_service: Vec<String>,
43
44    /// Refuse this HTTP verb (e.g. DELETE). Repeatable.
45    #[arg(long = "deny-verb", value_name = "VERB")]
46    pub deny_verb: Vec<String>,
47}
48
49/// The same three flags as `global(true)` args, for attaching to the root
50/// command.
51///
52/// Global is what makes the gate ergonomic: `--readonly` parses whether it
53/// appears before the subcommand (`grr --readonly gmail …`) or after it
54/// (`grr gmail users messages list --readonly`), because clap merges global
55/// args across every level. `SafetyArgs::from_arg_matches` on the ROOT
56/// matches then finds them wherever they were specified.
57pub fn global_args() -> impl IntoIterator<Item = clap::Arg> {
58    use clap::Arg;
59    // The IDs MUST be the field names (`deny_service`, not `deny-service`):
60    // the derive struct's from_arg_matches looks up args by the FIELD name,
61    // and a hyphenated ID here is the exact "Mismatch between definition
62    // and access" panic this once produced.
63    [
64        Arg::new("readonly")
65            .long("readonly")
66            .global(true)
67            .action(clap::ArgAction::SetTrue)
68            .help("Refuse every write method (POST/PATCH/PUT/DELETE); only reads run"),
69        Arg::new("deny_service")
70            .long("deny-service")
71            .global(true)
72            .value_name("SERVICE")
73            .action(clap::ArgAction::Append)
74            .help("Refuse every method of this service (repeatable)"),
75        Arg::new("deny_verb")
76            .long("deny-verb")
77            .global(true)
78            .value_name("VERB")
79            .action(clap::ArgAction::Append)
80            .help("Refuse this HTTP verb (repeatable)"),
81    ]
82}
83
84/// The safety gate. `check` runs per method invocation on `grr api call`,
85/// `gen_dispatch::dispatch_with_profile` and the `grr mcp` server — one
86/// verb + service lookup, never any I/O.
87#[derive(Debug, Clone, Default)]
88pub struct SafetyProfile {
89    /// Refuse every write verb.
90    pub readonly: bool,
91    /// Services whose methods are refused (matched case-insensitively).
92    pub denied_services: BTreeSet<String>,
93    /// Verbs that are refused (upper case on the way in).
94    pub denied_verbs: BTreeSet<String>,
95}
96
97impl SafetyProfile {
98    /// The default behaviour: nothing refused. A const so the permissive
99    /// path needs no allocation.
100    pub const PERMISSIVE: SafetyProfile = SafetyProfile {
101        readonly: false,
102        denied_services: BTreeSet::new(),
103        denied_verbs: BTreeSet::new(),
104    };
105
106    /// A reads-only profile: every write verb is refused.
107    pub fn readonly() -> Self {
108        SafetyProfile {
109            readonly: true,
110            ..SafetyProfile::default()
111        }
112    }
113
114    /// Build a profile from raw flag values, normalising on the way in:
115    /// service names are matched case-insensitively (`--deny-service
116    /// Gmail` == `--deny-service gmail`), verbs are upper-cased so
117    /// `--deny-verb delete` refuses `DELETE`.
118    pub fn new(
119        readonly: bool,
120        denied_services: impl IntoIterator<Item = String>,
121        denied_verbs: impl IntoIterator<Item = String>,
122    ) -> Self {
123        SafetyProfile {
124            readonly,
125            denied_services: denied_services
126                .into_iter()
127                .map(|s| s.trim().to_ascii_lowercase())
128                .collect(),
129            denied_verbs: denied_verbs
130                .into_iter()
131                .map(|v| v.trim().to_ascii_uppercase())
132                .collect(),
133        }
134    }
135
136    /// From the CLI flags.
137    pub fn from_args(args: &SafetyArgs) -> Self {
138        SafetyProfile::new(
139            args.readonly,
140            args.deny_service.iter().cloned(),
141            args.deny_verb.iter().cloned(),
142        )
143    }
144
145    /// May this method run? `Ok(())` = allowed; `Err(message)` = refused,
146    /// with an actionable message naming the method, the verb and how to
147    /// lift the restriction. Precedence is readonly, then deny-service,
148    /// then deny-verb — deterministic, so a method caught by two rules
149    /// always reports the same one.
150    pub(crate) fn check(&self, service_name: &str, method: &Method) -> Result<(), String> {
151        // Fast path: the ordinary CLI profile (readonly=false, nothing
152        // denied) passes every check below, so skip the formatting entirely —
153        // this runs per method on the shared dispatch path and per MCP call.
154        if !self.readonly && self.denied_services.is_empty() && self.denied_verbs.is_empty() {
155            return Ok(());
156        }
157        // `check` takes the service name separately: a method id excludes
158        // its service prefix (`users.messages.list` lives in `gmail`), and
159        // deny-service is defined over services.
160        let id = format!("{}.{}", service_name, method.id);
161        let verb = method.http_method.to_ascii_uppercase();
162
163        if self.readonly && WRITE_VERBS.contains(&verb.as_str()) {
164            return Err(format!(
165                "`{id}` is a {verb} (write); --readonly refuses writes. \
166                 Rerun without --readonly to allow writes."
167            ));
168        }
169        if self
170            .denied_services
171            .iter()
172            .any(|s| s.eq_ignore_ascii_case(service_name))
173        {
174            return Err(format!(
175                "`{id}` belongs to service `{service_name}`, which --deny-service refuses. \
176                 Rerun without --deny-service {service_name} to allow it."
177            ));
178        }
179        if self.denied_verbs.contains(&verb) {
180            return Err(format!(
181                "`{id}` is a {verb}; --deny-verb {verb} refuses it. \
182                 Rerun without --deny-verb {verb} to allow it."
183            ));
184        }
185        Ok(())
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use crate::discovery;
193
194    #[test]
195    fn readonly_refuses_every_write_verb_and_allows_reads() {
196        let profile = SafetyProfile::readonly();
197
198        let (service, get) = discovery::resolve("gmail.users.messages.list").unwrap();
199        assert!(profile.check(&service.name, get).is_ok());
200
201        for id in [
202            "gmail.users.messages.send",  // POST
203            "gmail.users.messages.trash", // POST
204            "gmail.users.labels.delete",  // DELETE
205        ] {
206            let (service, method) = discovery::resolve(id).unwrap();
207            let err = profile.check(&service.name, method).unwrap_err();
208            assert!(err.contains("--readonly"), "{id}: {err}");
209            assert!(err.contains("Rerun without --readonly"), "{id}: {err}");
210            assert!(err.contains(&method.http_method), "{id}: {err}");
211            assert!(err.contains(id), "{id}: {err}");
212        }
213    }
214
215    #[test]
216    fn deny_service_refuses_only_that_service() {
217        let profile = SafetyProfile::new(false, ["gmail".to_owned()], []);
218
219        let (service, method) = discovery::resolve("gmail.users.messages.list").unwrap();
220        let err = profile.check(&service.name, method).unwrap_err();
221        assert!(err.contains("gmail"), "{err}");
222        assert!(err.contains("--deny-service"), "{err}");
223
224        // Another service rides through untouched, reads and writes alike.
225        let (service, method) = discovery::resolve("drive.files.list").unwrap();
226        assert!(profile.check(&service.name, method).is_ok());
227    }
228
229    #[test]
230    fn deny_service_matches_case_insensitively() {
231        let profile = SafetyProfile::new(false, ["Gmail ".to_owned()], []);
232        let (service, method) = discovery::resolve("gmail.users.getProfile").unwrap();
233        assert!(profile.check(&service.name, method).is_err());
234    }
235
236    #[test]
237    fn deny_verb_refuses_only_that_verb() {
238        let profile = SafetyProfile::new(false, [], ["DELETE".to_owned()]);
239
240        let (service, method) = discovery::resolve("gmail.users.labels.delete").unwrap();
241        let err = profile.check(&service.name, method).unwrap_err();
242        assert!(err.contains("--deny-verb DELETE"), "{err}");
243
244        // A GET under the same profile is fine; a POST too.
245        let (service, method) = discovery::resolve("gmail.users.messages.list").unwrap();
246        assert!(profile.check(&service.name, method).is_ok());
247        let (service, method) = discovery::resolve("gmail.users.messages.send").unwrap();
248        assert!(profile.check(&service.name, method).is_ok());
249    }
250
251    #[test]
252    fn deny_verb_is_normalized_to_upper_case() {
253        let profile = SafetyProfile::new(false, [], [" post ".to_owned()]);
254        let (service, method) = discovery::resolve("gmail.users.messages.send").unwrap();
255        assert!(profile.check(&service.name, method).is_err());
256    }
257
258    #[test]
259    fn readonly_takes_precedence_over_the_other_rules() {
260        // Deterministic precedence: a write inside a denied service always
261        // reports the readonly rule.
262        let profile = SafetyProfile::new(true, ["gmail".to_owned()], []);
263        let (service, method) = discovery::resolve("gmail.users.messages.send").unwrap();
264        let err = profile.check(&service.name, method).unwrap_err();
265        assert!(err.contains("--readonly"), "{err}");
266        assert!(!err.contains("--deny-service"), "{err}");
267    }
268
269    #[test]
270    fn permissive_is_free_of_every_restriction() {
271        for id in ["gmail.users.messages.send", "gmail.users.labels.delete"] {
272            let (service, method) = discovery::resolve(id).unwrap();
273            assert!(
274                SafetyProfile::PERMISSIVE
275                    .check(&service.name, method)
276                    .is_ok()
277            );
278        }
279    }
280
281    #[test]
282    fn from_args_matches_new_directly() {
283        let args = SafetyArgs {
284            readonly: true,
285            deny_service: vec![" Gmail ".into()],
286            deny_verb: vec!["post".into()],
287        };
288        let from_flags = SafetyProfile::from_args(&args);
289        let direct = SafetyProfile::new(true, [" Gmail ".into()], ["post".into()]);
290        assert_eq!(from_flags.readonly, direct.readonly);
291        assert_eq!(from_flags.denied_services, direct.denied_services);
292        assert_eq!(from_flags.denied_verbs, direct.denied_verbs);
293    }
294}