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 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
83pub const SUPPORTED_VERSION: u32 = 1;
85
86#[derive(Debug, Error)]
91#[non_exhaustive]
92pub enum PolicyError {
93 #[error("policy parse error: {0}")]
95 Parse(crate::yaml::Error),
96 #[error("unsupported policy version {found}; this build supports {supported}")]
98 Version {
99 found: u32,
101 supported: u32,
103 },
104 #[error("duplicate rule id `{0}`")]
106 DuplicateId(String),
107 #[error("rule `{0}` has no patterns")]
109 EmptyRule(String),
110 #[error("empty pattern")]
112 EmptyPattern,
113 #[error("bad glob `{pattern}`: {error}")]
115 BadGlob {
116 pattern: String,
118 error: globset::Error,
120 },
121 #[error("bad shell pattern `{pattern}` (unbalanced quotes?)")]
123 BadShellPattern {
124 pattern: String,
126 },
127 #[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 old: &'static str,
137 new: &'static str,
139 },
140 #[error("rule `{rule}`: {problem}")]
142 Rule {
143 rule: String,
145 problem: String,
147 },
148}
149
150#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(deny_unknown_fields)]
154pub struct RuleGroup {
155 pub id: String,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub reason: Option<String>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub title: Option<String>,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub why: Option<String>,
168 #[serde(default, skip_serializing_if = "Vec::is_empty")]
170 pub shell: Vec<String>,
171 #[serde(default, rename = "fs.read", skip_serializing_if = "Vec::is_empty")]
173 pub fs_read: Vec<String>,
174 #[serde(default, rename = "fs.write", skip_serializing_if = "Vec::is_empty")]
176 pub fs_write: Vec<String>,
177 #[serde(default, skip_serializing_if = "Vec::is_empty")]
179 pub net: Vec<String>,
180 #[serde(default, skip_serializing_if = "Vec::is_empty")]
182 pub fetch: Vec<String>,
183 #[serde(default, rename = "env.read", skip_serializing_if = "Vec::is_empty")]
185 pub env_read: Vec<String>,
186 #[serde(default, rename = "env.set", skip_serializing_if = "Vec::is_empty")]
188 pub env_set: Vec<String>,
189 #[serde(default, skip_serializing_if = "Vec::is_empty")]
191 pub mcp: Vec<String>,
192 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
197 pub hook_only: bool,
198}
199
200impl RuleGroup {
201 #[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 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
310#[serde(deny_unknown_fields)]
311pub struct Policy {
312 pub version: u32,
314 #[serde(default = "default_verdict")]
316 pub defaults: Defaults,
317 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub scope: Option<Scope>,
321 #[serde(default)]
323 pub deny: Vec<RuleGroup>,
324 #[serde(default)]
326 pub allow: Vec<RuleGroup>,
327 #[serde(default, alias = "ask")]
330 pub review: Vec<RuleGroup>,
331 #[serde(default, skip_serializing_if = "Vec::is_empty")]
334 pub defer: Vec<RuleGroup>,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub approval: Option<Approval>,
339 #[serde(default)]
341 pub executables: BTreeMap<String, Vec<String>>,
342 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub sandbox: Option<SandboxSettings>,
346 #[serde(default, skip_serializing_if = "Vec::is_empty")]
348 pub secrets: Vec<Secret>,
349 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub taint: Option<TaintSettings>,
352 #[serde(default, skip_serializing_if = "Option::is_none")]
356 pub staging: Option<StagingSettings>,
357 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
363 pub require_signed_repo_policy: bool,
364 #[serde(default, skip_serializing_if = "Option::is_none")]
366 pub mcp: Option<McpSettings>,
367 #[serde(default, skip_serializing_if = "Option::is_none")]
370 pub check_diff: Option<crate::diffscan::Settings>,
371 #[serde(default, skip_serializing_if = "Option::is_none")]
376 pub require: Option<Require>,
377 #[serde(skip)]
382 pub require_added: Vec<(RequiredBy, Require)>,
383 #[serde(skip)]
389 pub repo_review: Vec<RuleGroup>,
390 #[serde(skip)]
393 pub old_names: OldNames,
394}
395
396#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
398#[serde(deny_unknown_fields)]
399pub struct SandboxSettings {
400 #[serde(default)]
405 pub read_roots: Vec<String>,
406 #[serde(default, skip_serializing_if = "Option::is_none")]
409 pub proxy_port: Option<std::num::NonZeroU16>,
410 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
414 pub proxy_allow_ech: bool,
415 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
420 pub proxy_serve_without_credential: bool,
421 #[serde(default, skip_serializing_if = "Option::is_none")]
425 pub package_min_release_age_days: Option<u16>,
426}
427
428#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
430#[serde(deny_unknown_fields)]
431pub struct McpSettings {
432 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
437 pub trust_first_sight: bool,
438 #[serde(default, skip_serializing_if = "Option::is_none")]
443 pub browser_open: Option<BrowserOpen>,
444 #[serde(default, skip_serializing_if = "Vec::is_empty")]
450 pub flow_trusted: Vec<String>,
451}
452
453#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
455#[serde(rename_all = "lowercase")]
456pub enum BrowserOpen {
457 Fetch,
459 Net,
461}
462
463pub const DEFAULT_PACKAGE_MIN_RELEASE_AGE_DAYS: u16 = 7;
466
467impl Policy {
468 #[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 #[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 #[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 #[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 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 pub fn lint(&self) -> Result<(), PolicyError> {
517 self.lint_structure()?;
518 self.groups().try_for_each(RuleGroup::check_patterns)
519 }
520
521 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 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 #[must_use]
591 pub fn rule_count(&self) -> (usize, usize, usize) {
592 (self.deny.len(), self.allow.len(), self.review.len())
593 }
594}
595
596fn 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;