Skip to main content

openmoat_core/
lint.rs

1//! Policy warnings: valid policies that probably do not mean what they say.
2//!
3//! Errors (`Policy::lint`) make a policy unusable; warnings do not. Evaluation
4//! is `deny → allow → ask` per atomic action, so a pattern in a later list that
5//! an earlier list already matches can never decide anything. The checks are
6//! conservative: a warning means the shadowing was shown from the patterns
7//! alone; silence does not prove a rule is reachable.
8
9use std::fmt;
10
11use crate::kind::Kind;
12use crate::paths;
13use crate::pattern::{self, GlobPattern, ShellPattern};
14use crate::policy::{Defaults, Policy, RuleGroup};
15use crate::secret::Source as SecretSource;
16
17/// Placeholders so `~` and `${project}` compare equal on both sides without
18/// `{…}` being read as glob alternation.
19const HOME: &str = "/__home__";
20const PROJECT: &str = "/__project__";
21
22/// A policy construct that is valid but probably not what its author meant.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Warning {
25    /// Rule id the warning is about (`defaults` for the defaults table).
26    pub rule: String,
27    /// What is wrong and what happens instead.
28    pub message: String,
29}
30
31impl fmt::Display for Warning {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        write!(f, "rule `{}`: {}", self.rule, self.message)
34    }
35}
36
37/// Every warning for `policy`, in policy order. `policy` must already lint.
38#[must_use]
39pub fn warnings(policy: &Policy) -> Vec<Warning> {
40    let mut out = unknown_default_kinds(&policy.defaults);
41    for (key, present) in [
42        ("approval", policy.approval.is_some()),
43        ("scope", policy.scope.is_some()),
44    ] {
45        if present {
46            out.push(Warning {
47                rule: key.to_owned(),
48                message: format!(
49                    "`{key}` is reserved and has no effect yet; approvals are made with `openmoat allow` \
50                     and the project root is the git root of the call's working directory"
51                ),
52            });
53        }
54    }
55    shadowed(&policy.ask, &policy.allow, "ask", "allow", &mut out);
56    shadowed(&policy.defer, &policy.allow, "defer", "allow", &mut out);
57    shadowed(&policy.allow, &policy.deny, "allow", "deny", &mut out);
58    for group in policy
59        .deny
60        .iter()
61        .chain(&policy.allow)
62        .chain(&policy.ask)
63        .chain(&policy.defer)
64    {
65        excluded_positives(group, &mut out);
66        fetch_exclusions_net_matches(group, &mut out);
67    }
68    secrets(policy, &mut out);
69    if policy.sandbox.as_ref().is_some_and(|s| s.proxy_allow_ech) {
70        out.push(Warning {
71            rule: "sandbox.proxy_allow_ech".to_owned(),
72            message: "`openmoat proxy` forwards encrypted server names (ECH): an allowed host's \
73                      provider may serve another site it hosts through it"
74                .to_owned(),
75        });
76    }
77    if policy.trust_mcp_first_sight() {
78        out.push(Warning {
79            rule: "mcp.trust_first_sight".to_owned(),
80            message: "`openmoat mcp proxy` pins a new server's tools as it first lists them: a \
81                      server that is malicious from the start gets its tool descriptions \
82                      pinned without a person reading them"
83                .to_owned(),
84        });
85    }
86    out
87}
88
89/// A secret whose host the proxy refuses is never injected, and one whose
90/// source the agent may read is not kept from the agent.
91fn secrets(policy: &Policy, out: &mut Vec<Warning>) {
92    for secret in &policy.secrets {
93        let mut warn = |message: String| {
94            out.push(Warning {
95                rule: format!("secrets.{}", secret.id),
96                message,
97            });
98        };
99        if !listed(&policy.allow, &[Kind::Net, Kind::Fetch], &secret.host) {
100            warn(format!(
101                "no net or fetch allow rule names {}, so `openmoat proxy` refuses it and the \
102                 secret is never injected",
103                secret.host
104            ));
105        }
106        if let Some(message) = exposed_source(policy, secret) {
107            warn(message);
108        }
109        if secret.plain_http && secret.is_loopback() {
110            warn(format!(
111                "`plain_http: true` names {}, a loopback host; `openmoat proxy` never connects to \
112                 a loopback address, so the secret is never injected",
113                secret.host
114            ));
115        } else if secret.plain_http {
116            warn(format!(
117                "`plain_http: true` sends the value to {} in clear text, readable by anyone \
118                 on the network path",
119                secret.host
120            ));
121        }
122    }
123}
124
125/// Why the agent can read `secret`'s value itself, when no deny rule covers
126/// its source: a `file` no `fs.read` deny names, an `env` variable no
127/// `env.read` deny names. `None` for a covered source and for the keychain.
128#[must_use]
129pub fn exposed_source(policy: &Policy, secret: &crate::Secret) -> Option<String> {
130    match &secret.source {
131        SecretSource::File(path) => paths::expand_pattern(path, HOME, Some(PROJECT))
132            .filter(|p| !listed(&policy.deny, &[Kind::FsRead], p))
133            .map(|_| format!("no deny fs.read rule covers `{path}`, so the agent can read it")),
134        SecretSource::Env(name) => (!listed(&policy.deny, &[Kind::EnvRead], name))
135            .then(|| format!("no deny env.read rule covers `{name}`, so the agent can read it")),
136        SecretSource::Keychain { .. } => None,
137    }
138}
139
140/// Does a group in `groups` match `candidate` in one of `kinds`, `!` exclusions
141/// applied?
142fn listed(groups: &[RuleGroup], kinds: &[Kind], candidate: &str) -> bool {
143    groups.iter().any(|g| {
144        kinds.iter().any(|k| {
145            let list: Vec<GlobPattern> = g.patterns(*k).iter().filter_map(|p| glob(p)).collect();
146            pattern::any_match(&list, candidate)
147        })
148    })
149}
150
151fn unknown_default_kinds(defaults: &Defaults) -> Vec<Warning> {
152    let Defaults::PerKind(map) = defaults else {
153        return Vec::new();
154    };
155    let known: Vec<&str> = std::iter::once("*")
156        .chain(Kind::ALL.iter().map(|k| k.as_str()))
157        .collect();
158    map.keys()
159        .filter(|k| !known.contains(&k.as_str()))
160        .map(|k| Warning {
161            rule: "defaults".to_owned(),
162            message: format!(
163                "unknown kind `{k}` is ignored; known kinds: {}",
164                known.join(", ")
165            ),
166        })
167        .collect()
168}
169
170/// Warn for every pattern in `later` that a pattern in `earlier` (evaluated
171/// first) fully covers: one of the same kind, or a `net` pattern for a `fetch`
172/// one (`net` lists match fetches too; a `fetch` pattern never covers a `net`
173/// one). Lists with `!` exclusions are never assumed to cover anything.
174fn shadowed(
175    later: &[RuleGroup],
176    earlier: &[RuleGroup],
177    later_name: &str,
178    earlier_name: &str,
179    out: &mut Vec<Warning>,
180) {
181    for group in later {
182        for kind in Kind::ALL {
183            for pattern in group.patterns(kind) {
184                let hit = earlier.iter().find_map(|e| {
185                    kind.rule_kinds().iter().find_map(|by_kind| {
186                        let list = e.patterns(*by_kind);
187                        if list.iter().any(|p| p.starts_with('!')) {
188                            return None;
189                        }
190                        list.iter()
191                            .find(|by| covers(kind, by, pattern))
192                            .map(|by| (&e.id, *by_kind, by))
193                    })
194                });
195                if let Some((by_rule, by_kind, by_pattern)) = hit {
196                    out.push(Warning {
197                        rule: group.id.clone(),
198                        message: format!(
199                            "{later_name} {kind} pattern `{pattern}` is unreachable: \
200                             {earlier_name} rule `{by_rule}` {by_kind} pattern `{by_pattern}` \
201                             matches everything it matches and {earlier_name} is evaluated first"
202                        ),
203                    });
204                }
205            }
206        }
207    }
208}
209
210/// A positive pattern that an exclusion of the same list fully covers matches
211/// nothing: exclusions apply whatever their place in the list.
212fn excluded_positives(group: &RuleGroup, out: &mut Vec<Warning>) {
213    for kind in Kind::ALL {
214        let list = group.patterns(kind);
215        for target in list.iter().filter(|p| !p.starts_with('!')) {
216            let by = list.iter().find(|p| {
217                let (negated, body) = pattern::split_negation(p);
218                negated && covers(kind, body, target)
219            });
220            if let Some(by) = by {
221                out.push(Warning {
222                    rule: group.id.clone(),
223                    message: format!(
224                        "{kind} pattern `{target}` is unreachable: exclusion `{by}` in the same \
225                         list removes everything it matches, wherever it is placed"
226                    ),
227                });
228            }
229        }
230    }
231}
232
233/// A `fetch` exclusion does not narrow the group's `net` list, which matches
234/// fetches too: a fetch it names still matches a `net` pattern of the group.
235fn fetch_exclusions_net_matches(group: &RuleGroup, out: &mut Vec<Warning>) {
236    let net = group.patterns(Kind::Net);
237    for excluded in group.patterns(Kind::Fetch) {
238        let (true, body) = pattern::split_negation(excluded) else {
239            continue;
240        };
241        let net_excludes = net.iter().any(|p| {
242            let (negated, by) = pattern::split_negation(p);
243            negated && covers(Kind::Net, by, body)
244        });
245        let by = net
246            .iter()
247            .find(|p| !p.starts_with('!') && covers(Kind::Net, p, body));
248        if let Some(by) = by.filter(|_| !net_excludes) {
249            out.push(Warning {
250                rule: group.id.clone(),
251                message: format!(
252                    "fetch exclusion `{excluded}` does not stop fetches of `{body}`: net pattern \
253                     `{by}` of the same rule matches them too; add `{excluded}` to `net` as well"
254                ),
255            });
256        }
257    }
258}
259
260/// Does pattern `by` match everything pattern `target` matches?
261fn covers(kind: Kind, by: &str, target: &str) -> bool {
262    if kind == Kind::Shell {
263        match (ShellPattern::compile(by), ShellPattern::compile(target)) {
264            (Ok(by), Ok(target)) => by.covers(&target),
265            _ => false,
266        }
267    } else {
268        matches!((glob(by), glob(target)), (Some(by), Some(target)) if by.covers(&target))
269    }
270}
271
272fn glob(raw: &str) -> Option<GlobPattern> {
273    GlobPattern::compile(&paths::expand_pattern(raw, HOME, Some(PROJECT))?, false).ok()
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    fn warn(yaml: &str) -> Vec<String> {
281        warnings(&Policy::parse(yaml).unwrap())
282            .iter()
283            .map(ToString::to_string)
284            .collect()
285    }
286
287    #[test]
288    fn ask_shadowed_by_allow_shell_prefix() {
289        let w = warn(
290            "version: 1\nallow:\n  - id: dev\n    shell: ['cargo *']\n\
291             ask:\n  - id: push\n    shell: ['cargo publish*', 'git push*']\n",
292        );
293        assert_eq!(w.len(), 1, "{w:?}");
294        assert!(w[0].contains("rule `push`") && w[0].contains("`cargo publish*`"));
295        assert!(w[0].contains("rule `dev`") && w[0].contains("`cargo *`"));
296    }
297
298    #[test]
299    fn allow_shadowed_by_deny_glob() {
300        let w = warn(
301            "version: 1\ndeny:\n  - id: secrets\n    fs.read: ['~/.ssh/**']\n\
302             allow:\n  - id: keys\n    fs.read: ['~/.ssh/config', '${project}/**']\n",
303        );
304        assert_eq!(w.len(), 1, "{w:?}");
305        assert!(w[0].contains("allow fs.read pattern `~/.ssh/config`"));
306    }
307
308    #[test]
309    fn net_patterns_shadow_fetch_patterns_but_not_the_reverse() {
310        let w = warn(
311            "version: 1\nallow:\n  - id: reg\n    net: ['*.crates.io']\n    fetch: ['docs.rs']\n\
312             ask:\n  - id: web\n    fetch: ['static.crates.io']\n    net: ['docs.rs']\n",
313        );
314        assert_eq!(w.len(), 1, "{w:?}");
315        assert!(
316            w[0].contains("ask fetch pattern `static.crates.io`"),
317            "{w:?}"
318        );
319        assert!(
320            w[0].contains("rule `reg` net pattern `*.crates.io`"),
321            "{w:?}"
322        );
323    }
324
325    #[test]
326    fn negated_lists_are_not_assumed_to_cover() {
327        let w = warn(
328            "version: 1\nallow:\n  - id: proj\n    fs.write: ['${project}/**', '!${project}/.git/**']\n\
329             ask:\n  - id: git\n    fs.write: ['${project}/.git/**']\n",
330        );
331        assert!(w.is_empty(), "{w:?}");
332    }
333
334    #[test]
335    fn a_positive_inside_an_exclusion_of_its_list_is_reported() {
336        let w = warn(
337            "version: 1\nallow:\n  - id: env\n    fs.read: ['!**/.env', '**/.env', '${project}/**']\n",
338        );
339        assert_eq!(w.len(), 1, "{w:?}");
340        assert!(
341            w[0].contains("fs.read pattern `**/.env` is unreachable"),
342            "{w:?}"
343        );
344        assert!(w[0].contains("exclusion `!**/.env`"), "{w:?}");
345    }
346
347    #[test]
348    fn a_fetch_exclusion_a_net_pattern_still_matches_is_reported() {
349        let w = warn(
350            "version: 1\nallow:\n  - id: web\n    net: ['*.example']\n    fetch: ['!api.example']\n",
351        );
352        assert_eq!(w.len(), 1, "{w:?}");
353        assert!(
354            w[0].contains("rule `web`: fetch exclusion `!api.example`"),
355            "{w:?}"
356        );
357        assert!(w[0].contains("net pattern `*.example`"), "{w:?}");
358        for quiet in [
359            "net: ['*.example', '!api.example']\n    fetch: ['!api.example']",
360            "net: ['docs.example']\n    fetch: ['!api.example']",
361        ] {
362            let w = warn(&format!("version: 1\nallow:\n  - id: web\n    {quiet}\n"));
363            assert!(w.is_empty(), "{quiet}: {w:?}");
364        }
365    }
366
367    #[test]
368    fn reserved_blocks_are_reported() {
369        let w =
370            warn("version: 1\napproval:\n  channel: telegram\nscope:\n  project_roots: ['.']\n");
371        assert_eq!(w.len(), 2, "{w:?}");
372        assert!(w[0].contains("`approval` is reserved") && w[1].contains("`scope` is reserved"));
373        assert!(warn("version: 1\n").is_empty());
374    }
375
376    #[test]
377    fn unknown_default_kinds() {
378        let w = warn("version: 1\ndefaults: { network: deny, net: deny, '*': ask }\n");
379        assert_eq!(w.len(), 1, "{w:?}");
380        assert!(w[0].contains("`network`"));
381        assert!(warn("version: 1\ndefaults: allow\n").is_empty());
382    }
383
384    #[test]
385    fn secrets_the_proxy_refuses_or_the_agent_can_read() {
386        let secrets = "secrets:\n  - id: gh\n    host: api.github.com\n    header: Authorization\n    \
387                       source: { file: ~/.config/moat/gh }\n  - id: npm\n    host: registry.npmjs.org\n    \
388                       header: Authorization\n    source: { env: NPM_TOKEN }\n";
389        let w = warn(&format!("version: 1\n{secrets}"));
390        assert_eq!(w.len(), 4, "{w:?}");
391        assert!(
392            w[0].contains("secrets.gh") && w[0].contains("refuses it"),
393            "{w:?}"
394        );
395        assert!(
396            w[1].contains("`~/.config/moat/gh`, so the agent can read it"),
397            "{w:?}"
398        );
399        assert!(
400            w[3].contains("`NPM_TOKEN`, so the agent can read it"),
401            "{w:?}"
402        );
403        let covered = warn(&format!(
404            "version: 1\ndeny:\n  - id: s\n    fs.read: ['~/.config/moat/**']\n    env.read: ['*_TOKEN']\n\
405             allow:\n  - id: n\n    net: ['api.github.com', '*.npmjs.org']\n{secrets}"
406        ));
407        assert!(covered.is_empty(), "{covered:?}");
408        let excluded = warn(&format!(
409            "version: 1\ndeny:\n  - id: s\n    fs.read: ['~/.config/**', '!~/.config/moat/**']\n    \
410             env.read: ['*']\nallow:\n  - id: n\n    net: ['*', '!api.github.com']\n{secrets}"
411        ));
412        assert_eq!(excluded.len(), 2, "{excluded:?}");
413    }
414
415    #[test]
416    fn forwarding_encrypted_server_names_is_reported() {
417        let w = warn("version: 1\nsandbox: { proxy_allow_ech: true }\n");
418        assert_eq!(w.len(), 1, "{w:?}");
419        assert!(w[0].contains("ECH"), "{w:?}");
420        assert!(warn("version: 1\nsandbox: { proxy_allow_ech: false }\n").is_empty());
421    }
422
423    #[test]
424    fn trusting_a_new_mcp_servers_tools_is_reported() {
425        let w = warn("version: 1\nmcp: { trust_first_sight: true }\n");
426        assert_eq!(w.len(), 1, "{w:?}");
427        assert!(w[0].contains("mcp.trust_first_sight"), "{w:?}");
428        assert!(warn("version: 1\nmcp: { trust_first_sight: false }\n").is_empty());
429    }
430
431    #[test]
432    fn plain_http_injection_off_this_machine_is_reported() {
433        let policy = |host: &str| {
434            format!(
435                "version: 1\ndeny:\n  - id: s\n    env.read: ['*']\nallow:\n  - id: n\n    net: ['*']\n\
436                 secrets:\n  - id: s\n    host: {host}\n    header: X-K\n    source: {{ env: K }}\n    \
437                 plain_http: true\n"
438            )
439        };
440        let w = warn(&policy("api.example.com"));
441        assert_eq!(w.len(), 1, "{w:?}");
442        assert!(w[0].contains("in clear text"), "{w:?}");
443    }
444
445    #[test]
446    fn plain_http_injection_to_loopback_is_reported_as_never_injected() {
447        let policy = |host: &str| {
448            format!(
449                "version: 1\ndeny:\n  - id: s\n    env.read: ['*']\nallow:\n  - id: n\n    net: ['*']\n\
450                 secrets:\n  - id: s\n    host: {host}\n    header: X-K\n    source: {{ env: K }}\n    \
451                 plain_http: true\n"
452            )
453        };
454        for host in ["localhost", "api.localhost", "127.0.0.1", "127.8.9.10"] {
455            let w = warn(&policy(host));
456            assert_eq!(w.len(), 1, "{host}: {w:?}");
457            assert!(
458                w[0].contains("never connects to a loopback address"),
459                "{w:?}"
460            );
461            assert!(!w[0].contains("in clear text"), "{w:?}");
462        }
463    }
464}