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