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