1use 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
19const HOME: &str = "/__home__";
22const PROJECT: &str = "/__project__";
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct Warning {
27 pub rule: String,
29 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#[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
92fn 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#[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
143fn 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
173fn 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
213fn 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
236fn 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
263fn 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}