1use 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
17const HOME: &str = "/__home__";
20const PROJECT: &str = "/__project__";
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Warning {
25 pub rule: String,
27 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#[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
89fn 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#[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
140fn 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
170fn 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
210fn 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
233fn 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
260fn 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}