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