Skip to main content

openmoat_core/
policy.rs

1//! Policy file model, loading and linting (docs/POLICY.md).
2
3use std::collections::{BTreeMap, BTreeSet};
4
5/// Fallback verdicts when no rule matches: one verdict for everything, or a map
6/// from action kind (`shell`, `fs.read`, `fs.write`, `net`, `fetch`,
7/// `env.read`, `env.set`, `mcp`, or `"*"`) to a verdict; a `fetch` without its
8/// own entry takes the `net` one. This is how "outbound network is
9/// deny-by-default" is expressed, because deny rules are absolute and cannot
10/// be punched through by allow rules (ADR-002).
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(untagged)]
13pub enum Defaults {
14    /// The same verdict for every kind.
15    All(Verdict),
16    /// A verdict per kind name (`net`, `fs.read`, …), with `*` as the fallback.
17    PerKind(BTreeMap<String, Verdict>),
18}
19
20impl Defaults {
21    /// Verdict and synthetic rule id (`default` or `default.<kind>`) for a kind.
22    #[must_use]
23    pub fn for_kind(&self, kind: Kind) -> (Verdict, String) {
24        match self {
25            Self::All(v) => (*v, "default".to_owned()),
26            Self::PerKind(map) => kind
27                .rule_kinds()
28                .iter()
29                .find_map(|k| map.get(k.as_str()).map(|v| (*v, format!("default.{k}"))))
30                .unwrap_or_else(|| {
31                    (
32                        map.get("*").copied().unwrap_or(Verdict::Ask),
33                        "default".to_owned(),
34                    )
35                }),
36        }
37    }
38}
39
40use serde::{Deserialize, Serialize};
41use thiserror::Error;
42
43use crate::kind::Kind;
44use crate::pattern::{GlobPattern, ShellPattern};
45use crate::secret::{self, Secret};
46use crate::taint::{self, TaintSettings};
47use crate::verdict::Verdict;
48
49pub const SUPPORTED_VERSION: u32 = 1;
50
51/// Why a policy file cannot be used. Any of these keeps `guard` from deciding.
52///
53/// Messages include their cause, so no variant also exposes it as `source`:
54/// a chained report (`{:#}`) would print it twice.
55#[derive(Debug, Error)]
56pub enum PolicyError {
57    /// The file is not valid YAML or does not fit the schema.
58    #[error("policy parse error: {0}")]
59    Parse(serde_yaml_ng::Error),
60    /// The `version` key names a schema this build does not know.
61    #[error("unsupported policy version {found}; this build supports {supported}")]
62    Version {
63        /// The version in the file.
64        found: u32,
65        /// The version this build supports.
66        supported: u32,
67    },
68    /// Two rule groups share an id.
69    #[error("duplicate rule id `{0}`")]
70    DuplicateId(String),
71    /// A rule group lists no patterns.
72    #[error("rule `{0}` has no patterns")]
73    EmptyRule(String),
74    /// A pattern is empty (or only `!`/`$`).
75    #[error("empty pattern")]
76    EmptyPattern,
77    /// A glob does not compile.
78    #[error("bad glob `{pattern}`: {error}")]
79    BadGlob {
80        /// The pattern as written.
81        pattern: String,
82        /// The glob compiler's explanation.
83        error: globset::Error,
84    },
85    /// A shell pattern does not tokenise (unbalanced quotes, a here-document).
86    #[error("bad shell pattern `{pattern}` (unbalanced quotes?)")]
87    BadShellPattern {
88        /// The pattern as written.
89        pattern: String,
90    },
91    /// A rule group or `executables` entry is invalid; `problem` says why.
92    #[error("rule `{rule}`: {problem}")]
93    Rule {
94        /// Rule id, or `executables.<name>`.
95        rule: String,
96        /// What is wrong.
97        problem: String,
98    },
99}
100
101/// One named group of patterns. A group matches when **any** of its patterns
102/// matches the atomic action of the corresponding kind.
103#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(deny_unknown_fields)]
105pub struct RuleGroup {
106    /// Stable identifier shown in decisions and the audit log.
107    pub id: String,
108    /// Text prepended to the reason when this group decides.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub reason: Option<String>,
111    /// Shell patterns (token prefixes, `*`, `!`, trailing `$`).
112    #[serde(default, skip_serializing_if = "Vec::is_empty")]
113    pub shell: Vec<String>,
114    /// Path globs for file reads.
115    #[serde(default, rename = "fs.read", skip_serializing_if = "Vec::is_empty")]
116    pub fs_read: Vec<String>,
117    /// Path globs for file writes.
118    #[serde(default, rename = "fs.write", skip_serializing_if = "Vec::is_empty")]
119    pub fs_write: Vec<String>,
120    /// Host globs for any network access, fetches included.
121    #[serde(default, skip_serializing_if = "Vec::is_empty")]
122    pub net: Vec<String>,
123    /// Host globs for read-only fetches by a host's fetch tool only.
124    #[serde(default, skip_serializing_if = "Vec::is_empty")]
125    pub fetch: Vec<String>,
126    /// Variable-name globs for reads.
127    #[serde(default, rename = "env.read", skip_serializing_if = "Vec::is_empty")]
128    pub env_read: Vec<String>,
129    /// Variable-name globs for sets.
130    #[serde(default, rename = "env.set", skip_serializing_if = "Vec::is_empty")]
131    pub env_set: Vec<String>,
132    /// MCP tool-name globs.
133    #[serde(default, skip_serializing_if = "Vec::is_empty")]
134    pub mcp: Vec<String>,
135}
136
137impl RuleGroup {
138    /// The patterns this group lists for `kind`.
139    #[must_use]
140    pub fn patterns(&self, kind: Kind) -> &[String] {
141        match kind {
142            Kind::Shell => &self.shell,
143            Kind::FsRead => &self.fs_read,
144            Kind::FsWrite => &self.fs_write,
145            Kind::Net => &self.net,
146            Kind::Fetch => &self.fetch,
147            Kind::EnvRead => &self.env_read,
148            Kind::EnvSet => &self.env_set,
149            Kind::Mcp => &self.mcp,
150        }
151    }
152
153    fn is_empty(&self) -> bool {
154        Kind::ALL.iter().all(|k| self.patterns(*k).is_empty())
155    }
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(deny_unknown_fields)]
160pub struct Scope {
161    #[serde(default = "default_roots")]
162    pub project_roots: Vec<String>,
163}
164
165fn default_roots() -> Vec<String> {
166    vec![".".to_owned()]
167}
168
169impl Default for Scope {
170    fn default() -> Self {
171        Self {
172            project_roots: default_roots(),
173        }
174    }
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(rename_all = "lowercase")]
179pub enum ApprovalChannel {
180    Terminal,
181    Telegram,
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
185#[serde(rename_all = "lowercase")]
186pub enum RememberScope {
187    Once,
188    Session,
189    Permanent,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(deny_unknown_fields)]
194pub struct Approval {
195    #[serde(default = "default_channel")]
196    pub channel: ApprovalChannel,
197    #[serde(default = "default_remember")]
198    pub remember: RememberScope,
199    #[serde(default = "default_timeout")]
200    pub timeout_s: u32,
201}
202
203fn default_channel() -> ApprovalChannel {
204    ApprovalChannel::Terminal
205}
206fn default_remember() -> RememberScope {
207    RememberScope::Session
208}
209fn default_timeout() -> u32 {
210    300
211}
212
213impl Default for Approval {
214    fn default() -> Self {
215        Self {
216            channel: default_channel(),
217            remember: default_remember(),
218            timeout_s: default_timeout(),
219        }
220    }
221}
222
223/// The policy document (`policy.yaml`), as written by users.
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
225#[serde(deny_unknown_fields)]
226pub struct Policy {
227    /// Schema version; only 1 is supported.
228    pub version: u32,
229    /// Verdict when no rule matches.
230    #[serde(default = "default_verdict")]
231    pub defaults: Defaults,
232    /// Reserved for multi-root projects; parsed so existing files stay valid,
233    /// not yet consulted (`policy lint` warns).
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub scope: Option<Scope>,
236    /// Evaluated first; a match is final.
237    #[serde(default)]
238    pub deny: Vec<RuleGroup>,
239    /// Evaluated after `deny`.
240    #[serde(default)]
241    pub allow: Vec<RuleGroup>,
242    /// Evaluated after `allow`.
243    #[serde(default)]
244    pub ask: Vec<RuleGroup>,
245    /// Reserved for the kernel's own approval prompt; parsed so existing files
246    /// stay valid, not yet consulted (`policy lint` warns).
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub approval: Option<Approval>,
249    /// Basename → allowed absolute paths (defeats PATH poisoning).
250    #[serde(default)]
251    pub executables: BTreeMap<String, Vec<String>>,
252    /// What the operating-system layers OpenMoat configures need beyond the rules
253    /// (ADR-018). Absent in policies written before it existed.
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub sandbox: Option<SandboxSettings>,
256    /// Secrets `moat proxy` keeps from the agent and injects for one host each.
257    #[serde(default, skip_serializing_if = "Vec::is_empty")]
258    pub secrets: Vec<Secret>,
259    /// What session taint protects beyond the built-in list. Absent in older policies.
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub taint: Option<TaintSettings>,
262    /// Ask rules merged in from a repository policy ([`crate::RepoPolicy`],
263    /// ADR-022). Tried after `deny` and before `allow`, so a repository can make
264    /// an action the user allows ask. Never read from or written to a file, and
265    /// not lowered to OS layers, which are generated from the user policy alone.
266    #[serde(skip)]
267    pub repo_ask: Vec<RuleGroup>,
268}
269
270/// The `sandbox:` section (ADR-018, ADR-019).
271#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
272#[serde(deny_unknown_fields)]
273pub struct SandboxSettings {
274    /// Paths (directories or files) the OS layers let commands read although no
275    /// allow rule covers them: system and toolchain directories every build
276    /// reads. The hook decision ignores them, and deny rules win over them in
277    /// every layer. Absolute or `~/…`, never a glob.
278    #[serde(default)]
279    pub read_roots: Vec<String>,
280    /// The loopback port `moat proxy` listens on and the host sandboxes send
281    /// their commands' traffic to; absent, the CLI's default.
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    pub proxy_port: Option<std::num::NonZeroU16>,
284}
285
286fn default_verdict() -> Defaults {
287    Defaults::All(Verdict::Ask)
288}
289
290impl Policy {
291    /// Parse and lint a policy document. Pure: takes the YAML text.
292    pub fn parse(yaml: &str) -> Result<Self, PolicyError> {
293        let policy: Self = serde_yaml_ng::from_str(yaml).map_err(PolicyError::Parse)?;
294        policy.lint()?;
295        Ok(policy)
296    }
297
298    /// Structural validation independent of any environment.
299    pub fn lint(&self) -> Result<(), PolicyError> {
300        if self.version != SUPPORTED_VERSION {
301            return Err(PolicyError::Version {
302                found: self.version,
303                supported: SUPPORTED_VERSION,
304            });
305        }
306        let mut ids = BTreeSet::new();
307        let groups = self.deny.iter().chain(&self.repo_ask);
308        for group in groups.chain(&self.allow).chain(&self.ask) {
309            if group.id.trim().is_empty() {
310                return Err(PolicyError::Rule {
311                    rule: "<unnamed>".into(),
312                    problem: "missing id".into(),
313                });
314            }
315            if !ids.insert(group.id.clone()) {
316                return Err(PolicyError::DuplicateId(group.id.clone()));
317            }
318            if group.is_empty() {
319                return Err(PolicyError::EmptyRule(group.id.clone()));
320            }
321            for kind in Kind::ALL {
322                for p in group.patterns(kind) {
323                    let compiled = if kind == Kind::Shell {
324                        ShellPattern::compile(p).map(drop)
325                    } else {
326                        GlobPattern::compile(p, false).map(drop)
327                    };
328                    compiled.map_err(|e| rule_err(&group.id, &e))?;
329                }
330            }
331        }
332        for (name, paths) in &self.executables {
333            if paths.is_empty() {
334                return Err(PolicyError::Rule {
335                    rule: format!("executables.{name}"),
336                    problem: "no paths listed".into(),
337                });
338            }
339            if let Some(bad) = paths.iter().find(|p| !crate::paths::is_absolute(p)) {
340                return Err(PolicyError::Rule {
341                    rule: format!("executables.{name}"),
342                    problem: format!("`{bad}` is not an absolute path"),
343                });
344            }
345        }
346        for root in self.sandbox.iter().flat_map(|s| &s.read_roots) {
347            lint_read_root(root)?;
348        }
349        taint::check(self.taint.as_ref())?;
350        secret::check(&self.secrets)
351    }
352
353    /// Number of rule groups in `deny`, `allow` and `ask`.
354    #[must_use]
355    pub fn rule_count(&self) -> (usize, usize, usize) {
356        (self.deny.len(), self.allow.len(), self.ask.len())
357    }
358}
359
360/// An OS layer takes a read root literally, and a root that is the disk or the
361/// home directory would undo every default deny.
362fn lint_read_root(root: &str) -> Result<(), PolicyError> {
363    let trimmed = root.trim_end_matches(['/', '\\']);
364    let problem = if !(crate::paths::is_absolute(root) || root.starts_with("~/")) {
365        "must be absolute or start with `~/`"
366    } else if root.contains(['*', '?', '[', ']', '{', '}', '!', '$']) {
367        "must be a plain path, not a glob or a variable"
368    } else if root.split(['/', '\\']).any(|s| s == "." || s == "..") {
369        "must not contain `.` or `..` segments"
370    } else if trimmed == "~" {
371        "must not be the home directory"
372    } else if trimmed.is_empty() || (trimmed.len() == 2 && trimmed.ends_with(':')) {
373        "must not be a filesystem root"
374    } else {
375        return Ok(());
376    };
377    Err(PolicyError::Rule {
378        rule: "sandbox.read_roots".to_owned(),
379        problem: format!("`{root}` {problem}"),
380    })
381}
382
383fn rule_err(rule: &str, e: &PolicyError) -> PolicyError {
384    PolicyError::Rule {
385        rule: rule.to_owned(),
386        problem: e.to_string(),
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    #[test]
395    fn parses_minimal() {
396        let p = Policy::parse("version: 1\ndeny:\n  - id: a\n    shell: ['sudo *']\n").unwrap();
397        assert_eq!(p.defaults, Defaults::All(Verdict::Ask));
398        assert_eq!(p.rule_count(), (1, 0, 0));
399    }
400
401    #[test]
402    fn rejects_duplicate_and_empty() {
403        let dup =
404            "version: 1\ndeny:\n  - id: a\n    shell: ['x']\nallow:\n  - id: a\n    net: ['h']\n";
405        assert!(matches!(
406            Policy::parse(dup),
407            Err(PolicyError::DuplicateId(_))
408        ));
409        let empty = "version: 1\ndeny:\n  - id: a\n";
410        assert!(matches!(
411            Policy::parse(empty),
412            Err(PolicyError::EmptyRule(_))
413        ));
414        let ver = "version: 2\n";
415        assert!(matches!(
416            Policy::parse(ver),
417            Err(PolicyError::Version { .. })
418        ));
419    }
420
421    #[test]
422    fn per_kind_defaults() {
423        let p = Policy::parse("version: 1\ndefaults: { net: deny, '*': ask }\n").unwrap();
424        assert_eq!(
425            p.defaults.for_kind(Kind::Net),
426            (Verdict::Deny, "default.net".to_owned())
427        );
428        assert_eq!(
429            p.defaults.for_kind(Kind::Shell),
430            (Verdict::Ask, "default".to_owned())
431        );
432    }
433
434    #[test]
435    fn fetch_defaults_fall_back_to_net_then_star() {
436        let own = Policy::parse("version: 1\ndefaults: { net: deny, fetch: ask }\n").unwrap();
437        assert_eq!(
438            own.defaults.for_kind(Kind::Fetch),
439            (Verdict::Ask, "default.fetch".to_owned())
440        );
441        assert_eq!(
442            own.defaults.for_kind(Kind::Net),
443            (Verdict::Deny, "default.net".to_owned())
444        );
445        let net = Policy::parse("version: 1\ndefaults: { net: deny, '*': allow }\n").unwrap();
446        assert_eq!(
447            net.defaults.for_kind(Kind::Fetch),
448            (Verdict::Deny, "default.net".to_owned())
449        );
450        let star = Policy::parse("version: 1\ndefaults: { '*': allow }\n").unwrap();
451        assert_eq!(
452            star.defaults.for_kind(Kind::Fetch),
453            (Verdict::Allow, "default".to_owned())
454        );
455    }
456
457    #[test]
458    fn executables_must_be_absolute_on_every_platform() {
459        let ok = "version: 1\nexecutables:\n  git: ['/usr/bin/git', 'C:/Program Files/Git/bin/git.exe']\n";
460        assert!(Policy::parse(ok).is_ok());
461        let rel = "version: 1\nexecutables:\n  git: ['bin/git']\n";
462        assert!(matches!(Policy::parse(rel), Err(PolicyError::Rule { .. })));
463    }
464
465    #[test]
466    fn read_roots_must_be_plain_paths_below_a_root() {
467        let ok = "version: 1\nsandbox:\n  read_roots: ['/usr', '~/.cargo', 'C:/tools']\n";
468        assert!(Policy::parse(ok).is_ok());
469        for bad in [
470            "usr",
471            "~",
472            "~/",
473            "/",
474            "C:/",
475            "/usr/**",
476            "~/../x",
477            "${project}/x",
478            "!/usr",
479        ] {
480            let yaml = format!("version: 1\nsandbox:\n  read_roots: ['{bad}']\n");
481            assert!(
482                matches!(Policy::parse(&yaml), Err(PolicyError::Rule { rule, .. }) if rule == "sandbox.read_roots"),
483                "{bad} must be rejected"
484            );
485        }
486        assert!(Policy::parse("version: 1\nsandbox: { bogus: 1 }\n").is_err());
487        assert!(Policy::parse("version: 1\nsandbox: { proxy_port: 18081 }\n").is_ok());
488        assert!(Policy::parse("version: 1\nsandbox: { proxy_port: 0 }\n").is_err());
489    }
490
491    #[test]
492    fn rejects_unknown_keys() {
493        assert!(Policy::parse("version: 1\nbogus: 1\n").is_err());
494    }
495}