1use std::collections::{BTreeMap, BTreeSet};
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(untagged)]
13pub enum Defaults {
14 All(Verdict),
16 PerKind(BTreeMap<String, Verdict>),
18}
19
20impl Defaults {
21 #[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 #[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 #[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
85pub const SUPPORTED_VERSION: u32 = 1;
87
88#[derive(Debug, Error)]
93#[non_exhaustive]
94pub enum PolicyError {
95 #[error("policy parse error: {0}")]
97 Parse(crate::yaml::Error),
98 #[error("unsupported policy version {found}; this build supports {supported}")]
100 Version {
101 found: u32,
103 supported: u32,
105 },
106 #[error("duplicate rule id `{0}`")]
108 DuplicateId(String),
109 #[error("rule `{0}` has no patterns")]
111 EmptyRule(String),
112 #[error("empty pattern")]
114 EmptyPattern,
115 #[error("bad glob `{pattern}`: {error}")]
117 BadGlob {
118 pattern: String,
120 error: globset::Error,
122 },
123 #[error("bad shell pattern `{pattern}` (unbalanced quotes?)")]
125 BadShellPattern {
126 pattern: String,
128 },
129 #[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 old: &'static str,
139 new: &'static str,
141 },
142 #[error("rule `{rule}`: {problem}")]
144 Rule {
145 rule: String,
147 problem: String,
149 },
150}
151
152#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(deny_unknown_fields)]
156pub struct RuleGroup {
157 pub id: String,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub reason: Option<String>,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub title: Option<String>,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub why: Option<String>,
170 #[serde(default, skip_serializing_if = "Vec::is_empty")]
172 pub shell: Vec<String>,
173 #[serde(default, rename = "fs.read", skip_serializing_if = "Vec::is_empty")]
175 pub fs_read: Vec<String>,
176 #[serde(default, rename = "fs.write", skip_serializing_if = "Vec::is_empty")]
178 pub fs_write: Vec<String>,
179 #[serde(default, skip_serializing_if = "Vec::is_empty")]
181 pub net: Vec<String>,
182 #[serde(default, skip_serializing_if = "Vec::is_empty")]
184 pub fetch: Vec<String>,
185 #[serde(default, rename = "env.read", skip_serializing_if = "Vec::is_empty")]
187 pub env_read: Vec<String>,
188 #[serde(default, rename = "env.set", skip_serializing_if = "Vec::is_empty")]
190 pub env_set: Vec<String>,
191 #[serde(default, skip_serializing_if = "Vec::is_empty")]
193 pub mcp: Vec<String>,
194 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
199 pub hook_only: bool,
200}
201
202impl RuleGroup {
203 #[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 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312#[serde(deny_unknown_fields)]
313pub struct Policy {
314 pub version: u32,
316 #[serde(default = "default_verdict")]
318 pub defaults: Defaults,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub scope: Option<Scope>,
323 #[serde(default)]
325 pub deny: Vec<RuleGroup>,
326 #[serde(default)]
328 pub allow: Vec<RuleGroup>,
329 #[serde(default, alias = "ask")]
332 pub review: Vec<RuleGroup>,
333 #[serde(default, skip_serializing_if = "Vec::is_empty")]
336 pub defer: Vec<RuleGroup>,
337 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub approval: Option<Approval>,
341 #[serde(default)]
343 pub executables: BTreeMap<String, Vec<String>>,
344 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub sandbox: Option<SandboxSettings>,
348 #[serde(default, skip_serializing_if = "Vec::is_empty")]
350 pub secrets: Vec<Secret>,
351 #[serde(default, skip_serializing_if = "Option::is_none")]
353 pub taint: Option<TaintSettings>,
354 #[serde(default, skip_serializing_if = "Option::is_none")]
358 pub staging: Option<StagingSettings>,
359 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
365 pub require_signed_repo_policy: bool,
366 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub mcp: Option<McpSettings>,
369 #[serde(default, skip_serializing_if = "Option::is_none")]
372 pub check_diff: Option<crate::diffscan::Settings>,
373 #[serde(default, skip_serializing_if = "Option::is_none")]
378 pub require: Option<Require>,
379 #[serde(skip)]
384 pub require_added: Vec<(RequiredBy, Require)>,
385 #[serde(skip)]
391 pub repo_review: Vec<RuleGroup>,
392 #[serde(skip)]
395 pub old_names: OldNames,
396}
397
398#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
400#[serde(deny_unknown_fields)]
401pub struct SandboxSettings {
402 #[serde(default)]
407 pub read_roots: Vec<String>,
408 #[serde(default, skip_serializing_if = "Option::is_none")]
411 pub proxy_port: Option<std::num::NonZeroU16>,
412 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
416 pub proxy_allow_ech: bool,
417 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
422 pub proxy_serve_without_credential: bool,
423 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub package_min_release_age_days: Option<u16>,
428 #[serde(default, skip_serializing_if = "Vec::is_empty")]
433 pub env_allow: Vec<String>,
434}
435
436#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
438#[serde(deny_unknown_fields)]
439pub struct McpSettings {
440 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
445 pub trust_first_sight: bool,
446 #[serde(default, skip_serializing_if = "Option::is_none")]
451 pub browser_open: Option<BrowserOpen>,
452 #[serde(default, skip_serializing_if = "Vec::is_empty")]
458 pub flow_trusted: Vec<String>,
459}
460
461#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
463#[serde(rename_all = "lowercase")]
464pub enum BrowserOpen {
465 Fetch,
467 Net,
469}
470
471pub const DEFAULT_PACKAGE_MIN_RELEASE_AGE_DAYS: u16 = 7;
474
475impl Policy {
476 #[must_use]
479 pub fn package_min_release_age_days(&self) -> u16 {
480 self.sandbox
481 .as_ref()
482 .and_then(|s| s.package_min_release_age_days)
483 .unwrap_or(DEFAULT_PACKAGE_MIN_RELEASE_AGE_DAYS)
484 }
485
486 #[must_use]
489 pub fn trust_mcp_first_sight(&self) -> bool {
490 self.mcp.as_ref().is_some_and(|m| m.trust_first_sight)
491 }
492
493 #[must_use]
496 pub fn browser_open_is_fetch(&self) -> bool {
497 self.mcp.as_ref().and_then(|m| m.browser_open) != Some(BrowserOpen::Net)
498 }
499
500 #[must_use]
503 pub fn mcp_flow_trusted(&self) -> &[String] {
504 self.mcp.as_ref().map_or(&[], |m| m.flow_trusted.as_slice())
505 }
506}
507
508fn default_verdict() -> Defaults {
509 Defaults::All(Verdict::Review)
510}
511
512impl Policy {
513 pub fn parse(yaml: &str) -> Result<Self, PolicyError> {
515 let old_names = old_names::find(yaml)?;
516 let mut policy: Self = crate::yaml::from_str(yaml).map_err(PolicyError::Parse)?;
517 policy.old_names = old_names;
518 policy.lint()?;
519 Ok(policy)
520 }
521
522 pub fn lint(&self) -> Result<(), PolicyError> {
525 self.lint_structure()?;
526 self.groups().try_for_each(RuleGroup::check_patterns)
527 }
528
529 pub fn lint_structure(&self) -> Result<(), PolicyError> {
537 if self.version != SUPPORTED_VERSION {
538 return Err(PolicyError::Version {
539 found: self.version,
540 supported: SUPPORTED_VERSION,
541 });
542 }
543 let mut ids = BTreeSet::new();
544 for group in self.groups() {
545 if group.id.trim().is_empty() {
546 return Err(PolicyError::Rule {
547 rule: "<unnamed>".into(),
548 problem: "missing id".into(),
549 });
550 }
551 if !ids.insert(group.id.clone()) {
552 return Err(PolicyError::DuplicateId(group.id.clone()));
553 }
554 if group.is_empty() {
555 return Err(PolicyError::EmptyRule(group.id.clone()));
556 }
557 text::check(group)?;
558 }
559 hook_only::check(self)?;
560 for (name, paths) in &self.executables {
561 if paths.is_empty() {
562 return Err(PolicyError::Rule {
563 rule: format!("executables.{name}"),
564 problem: "no paths listed".into(),
565 });
566 }
567 if let Some(bad) = paths.iter().find(|p| !crate::paths::is_absolute(p)) {
568 return Err(PolicyError::Rule {
569 rule: format!("executables.{name}"),
570 problem: format!("`{bad}` is not an absolute path"),
571 });
572 }
573 }
574 for root in self.sandbox.iter().flat_map(|s| &s.read_roots) {
575 lint_read_root(root)?;
576 }
577 for name in self.sandbox.iter().flat_map(|s| &s.env_allow) {
578 if let Some(problem) = env_name_problem(name) {
579 return Err(PolicyError::Rule {
580 rule: "sandbox.env_allow".to_owned(),
581 problem: format!("`{name}` {problem}"),
582 });
583 }
584 }
585 taint::check(self.taint.as_ref())?;
586 staging::check(self.staging.as_ref())?;
587 crate::diffscan::check(self.check_diff.as_ref())?;
588 require::check(self.require.as_ref())?;
589 for (_, added) in &self.require_added {
590 require::check(Some(added))?;
591 }
592 secret::check(&self.secrets)
593 }
594
595 fn groups(&self) -> impl Iterator<Item = &RuleGroup> {
597 self.deny
598 .iter()
599 .chain(&self.repo_review)
600 .chain(&self.allow)
601 .chain(&self.review)
602 .chain(&self.defer)
603 }
604
605 #[must_use]
607 pub fn rule_count(&self) -> (usize, usize, usize) {
608 (self.deny.len(), self.allow.len(), self.review.len())
609 }
610}
611
612fn lint_read_root(root: &str) -> Result<(), PolicyError> {
615 let trimmed = root.trim_end_matches(['/', '\\']);
616 let problem = if !(crate::paths::is_absolute(root) || root.starts_with("~/")) {
617 "must be absolute or start with `~/`"
618 } else if root.contains(['*', '?', '[', ']', '{', '}', '!', '$']) {
619 "must be a plain path, not a glob or a variable"
620 } else if root.split(['/', '\\']).any(|s| s == "." || s == "..") {
621 "must not contain `.` or `..` segments"
622 } else if trimmed == "~" {
623 "must not be the home directory"
624 } else if trimmed.is_empty() || (trimmed.len() == 2 && trimmed.ends_with(':')) {
625 "must not be a filesystem root"
626 } else {
627 return Ok(());
628 };
629 Err(PolicyError::Rule {
630 rule: "sandbox.read_roots".to_owned(),
631 problem: format!("`{root}` {problem}"),
632 })
633}
634
635fn rule_err(rule: &str, e: &PolicyError) -> PolicyError {
636 PolicyError::Rule {
637 rule: rule.to_owned(),
638 problem: e.to_string(),
639 }
640}
641
642#[cfg(test)]
643mod tests;