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    /// Build a profile from raw flag values, normalising on the way in:
107    /// service names are matched case-insensitively (`--deny-service
108    /// Gmail` == `--deny-service gmail`), verbs are upper-cased so
109    /// `--deny-verb delete` refuses `DELETE`.
110    pub fn new(
111        readonly: bool,
112        denied_services: impl IntoIterator<Item = String>,
113        denied_verbs: impl IntoIterator<Item = String>,
114    ) -> Self {
115        SafetyProfile {
116            readonly,
117            denied_services: denied_services
118                .into_iter()
119                .map(|s| s.trim().to_ascii_lowercase())
120                .collect(),
121            denied_verbs: denied_verbs
122                .into_iter()
123                .map(|v| v.trim().to_ascii_uppercase())
124                .collect(),
125        }
126    }
127
128    /// From the CLI flags.
129    pub fn from_args(args: &SafetyArgs) -> Self {
130        SafetyProfile::new(
131            args.readonly,
132            args.deny_service.iter().cloned(),
133            args.deny_verb.iter().cloned(),
134        )
135    }
136
137    /// May this method run? `Ok(())` = allowed; `Err(message)` = refused,
138    /// with an actionable message naming the method, the verb and how to
139    /// lift the restriction. Precedence is readonly, then deny-service,
140    /// then deny-verb — deterministic, so a method caught by two rules
141    /// always reports the same one.
142    pub(crate) fn check(&self, service_name: &str, method: &Method) -> Result<(), String> {
143        // Fast path: the ordinary CLI profile (readonly=false, nothing
144        // denied) passes every check below, so skip the formatting entirely —
145        // this runs per method on the shared dispatch path and per MCP call.
146        if !self.readonly && self.denied_services.is_empty() && self.denied_verbs.is_empty() {
147            return Ok(());
148        }
149        // `check` takes the service name separately: a method id excludes
150        // its service prefix (`users.messages.list` lives in `gmail`), and
151        // deny-service is defined over services.
152        let id = format!("{}.{}", service_name, method.id);
153        let verb = method.http_method.to_ascii_uppercase();
154
155        if self.readonly && WRITE_VERBS.contains(&verb.as_str()) {
156            return Err(format!(
157                "`{id}` is a {verb} (write); --readonly refuses writes. \
158                 Rerun without --readonly to allow writes."
159            ));
160        }
161        if self
162            .denied_services
163            .iter()
164            .any(|s| s.eq_ignore_ascii_case(service_name))
165        {
166            return Err(format!(
167                "`{id}` belongs to service `{service_name}`, which --deny-service refuses. \
168                 Rerun without --deny-service {service_name} to allow it."
169            ));
170        }
171        if self.denied_verbs.contains(&verb) {
172            return Err(format!(
173                "`{id}` is a {verb}; --deny-verb {verb} refuses it. \
174                 Rerun without --deny-verb {verb} to allow it."
175            ));
176        }
177        Ok(())
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use crate::discovery;
185
186    #[test]
187    fn readonly_refuses_every_write_verb_and_allows_reads() {
188        let profile = SafetyProfile::new(true, [], []);
189
190        let (service, get) = discovery::resolve("gmail.users.messages.list").unwrap();
191        assert!(profile.check(&service.name, get).is_ok());
192
193        for id in [
194            "gmail.users.messages.send",  // POST
195            "gmail.users.messages.trash", // POST
196            "gmail.users.labels.delete",  // DELETE
197        ] {
198            let (service, method) = discovery::resolve(id).unwrap();
199            let err = profile.check(&service.name, method).unwrap_err();
200            assert!(err.contains("--readonly"), "{id}: {err}");
201            assert!(err.contains("Rerun without --readonly"), "{id}: {err}");
202            assert!(err.contains(&method.http_method), "{id}: {err}");
203            assert!(err.contains(id), "{id}: {err}");
204        }
205    }
206
207    #[test]
208    fn deny_service_refuses_only_that_service() {
209        let profile = SafetyProfile::new(false, ["gmail".to_owned()], []);
210
211        let (service, method) = discovery::resolve("gmail.users.messages.list").unwrap();
212        let err = profile.check(&service.name, method).unwrap_err();
213        assert!(err.contains("gmail"), "{err}");
214        assert!(err.contains("--deny-service"), "{err}");
215
216        // Another service rides through untouched, reads and writes alike.
217        let (service, method) = discovery::resolve("drive.files.list").unwrap();
218        assert!(profile.check(&service.name, method).is_ok());
219    }
220
221    #[test]
222    fn deny_service_matches_case_insensitively() {
223        let profile = SafetyProfile::new(false, ["Gmail ".to_owned()], []);
224        let (service, method) = discovery::resolve("gmail.users.getProfile").unwrap();
225        assert!(profile.check(&service.name, method).is_err());
226    }
227
228    #[test]
229    fn deny_verb_refuses_only_that_verb() {
230        let profile = SafetyProfile::new(false, [], ["DELETE".to_owned()]);
231
232        let (service, method) = discovery::resolve("gmail.users.labels.delete").unwrap();
233        let err = profile.check(&service.name, method).unwrap_err();
234        assert!(err.contains("--deny-verb DELETE"), "{err}");
235
236        // A GET under the same profile is fine; a POST too.
237        let (service, method) = discovery::resolve("gmail.users.messages.list").unwrap();
238        assert!(profile.check(&service.name, method).is_ok());
239        let (service, method) = discovery::resolve("gmail.users.messages.send").unwrap();
240        assert!(profile.check(&service.name, method).is_ok());
241    }
242
243    #[test]
244    fn deny_verb_is_normalized_to_upper_case() {
245        let profile = SafetyProfile::new(false, [], [" post ".to_owned()]);
246        let (service, method) = discovery::resolve("gmail.users.messages.send").unwrap();
247        assert!(profile.check(&service.name, method).is_err());
248    }
249
250    #[test]
251    fn readonly_takes_precedence_over_the_other_rules() {
252        // Deterministic precedence: a write inside a denied service always
253        // reports the readonly rule.
254        let profile = SafetyProfile::new(true, ["gmail".to_owned()], []);
255        let (service, method) = discovery::resolve("gmail.users.messages.send").unwrap();
256        let err = profile.check(&service.name, method).unwrap_err();
257        assert!(err.contains("--readonly"), "{err}");
258        assert!(!err.contains("--deny-service"), "{err}");
259    }
260
261    #[test]
262    fn permissive_is_free_of_every_restriction() {
263        for id in ["gmail.users.messages.send", "gmail.users.labels.delete"] {
264            let (service, method) = discovery::resolve(id).unwrap();
265            assert!(
266                SafetyProfile::PERMISSIVE
267                    .check(&service.name, method)
268                    .is_ok()
269            );
270        }
271    }
272
273    #[test]
274    fn from_args_matches_new_directly() {
275        let args = SafetyArgs {
276            readonly: true,
277            deny_service: vec![" Gmail ".into()],
278            deny_verb: vec!["post".into()],
279        };
280        let from_flags = SafetyProfile::from_args(&args);
281        let direct = SafetyProfile::new(true, [" Gmail ".into()], ["post".into()]);
282        assert_eq!(from_flags.readonly, direct.readonly);
283        assert_eq!(from_flags.denied_services, direct.denied_services);
284        assert_eq!(from_flags.denied_verbs, direct.denied_verbs);
285    }
286}