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