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