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