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