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