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 text;
76
77pub use text::{TITLE_MAX_CHARS, WHY_MAX_CHARS};
78
79pub use old_names::OldNames;
80
81pub const SUPPORTED_VERSION: u32 = 1;
83
84#[derive(Debug, Error)]
89#[non_exhaustive]
90pub enum PolicyError {
91 #[error("policy parse error: {0}")]
93 Parse(crate::yaml::Error),
94 #[error("unsupported policy version {found}; this build supports {supported}")]
96 Version {
97 found: u32,
99 supported: u32,
101 },
102 #[error("duplicate rule id `{0}`")]
104 DuplicateId(String),
105 #[error("rule `{0}` has no patterns")]
107 EmptyRule(String),
108 #[error("empty pattern")]
110 EmptyPattern,
111 #[error("bad glob `{pattern}`: {error}")]
113 BadGlob {
114 pattern: String,
116 error: globset::Error,
118 },
119 #[error("bad shell pattern `{pattern}` (unbalanced quotes?)")]
121 BadShellPattern {
122 pattern: String,
124 },
125 #[error(
128 "the policy has both `{old}:` and `{new}:`; `{old}` is the old name of `{new}`: \
129 move the `{old}:` rules into `{new}:` (`openmoat policy upgrade` renames a policy \
130 that has only `{old}:`)"
131 )]
132 OldAndNewName {
133 old: &'static str,
135 new: &'static str,
137 },
138 #[error("rule `{rule}`: {problem}")]
140 Rule {
141 rule: String,
143 problem: String,
145 },
146}
147
148#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(deny_unknown_fields)]
152pub struct RuleGroup {
153 pub id: String,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub reason: Option<String>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub title: Option<String>,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub why: Option<String>,
166 #[serde(default, skip_serializing_if = "Vec::is_empty")]
168 pub shell: Vec<String>,
169 #[serde(default, rename = "fs.read", skip_serializing_if = "Vec::is_empty")]
171 pub fs_read: Vec<String>,
172 #[serde(default, rename = "fs.write", skip_serializing_if = "Vec::is_empty")]
174 pub fs_write: Vec<String>,
175 #[serde(default, skip_serializing_if = "Vec::is_empty")]
177 pub net: Vec<String>,
178 #[serde(default, skip_serializing_if = "Vec::is_empty")]
180 pub fetch: Vec<String>,
181 #[serde(default, rename = "env.read", skip_serializing_if = "Vec::is_empty")]
183 pub env_read: Vec<String>,
184 #[serde(default, rename = "env.set", skip_serializing_if = "Vec::is_empty")]
186 pub env_set: Vec<String>,
187 #[serde(default, skip_serializing_if = "Vec::is_empty")]
189 pub mcp: Vec<String>,
190 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
195 pub hook_only: bool,
196}
197
198impl RuleGroup {
199 #[must_use]
201 pub fn patterns(&self, kind: Kind) -> &[String] {
202 match kind {
203 Kind::Shell => &self.shell,
204 Kind::FsRead => &self.fs_read,
205 Kind::FsWrite => &self.fs_write,
206 Kind::Net => &self.net,
207 Kind::Fetch => &self.fetch,
208 Kind::WebSearch | Kind::UnknownTool => &[],
211 Kind::EnvRead => &self.env_read,
212 Kind::EnvSet => &self.env_set,
213 Kind::Mcp => &self.mcp,
214 }
215 }
216
217 fn is_empty(&self) -> bool {
218 Kind::ALL.iter().all(|k| self.patterns(*k).is_empty())
219 }
220
221 pub fn check_patterns(&self) -> Result<(), PolicyError> {
227 for kind in Kind::ALL {
228 for p in self.patterns(kind) {
229 let compiled = if kind == Kind::Shell {
230 ShellPattern::compile(p).map(drop)
231 } else {
232 GlobPattern::compile(p, false).map(drop)
233 };
234 compiled.map_err(|e| rule_err(&self.id, &e))?;
235 }
236 }
237 Ok(())
238 }
239}
240
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242#[serde(deny_unknown_fields)]
243pub struct Scope {
244 #[serde(default = "default_roots")]
245 pub project_roots: Vec<String>,
246}
247
248fn default_roots() -> Vec<String> {
249 vec![".".to_owned()]
250}
251
252impl Default for Scope {
253 fn default() -> Self {
254 Self {
255 project_roots: default_roots(),
256 }
257 }
258}
259
260#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
261#[serde(rename_all = "lowercase")]
262pub enum ApprovalChannel {
263 Terminal,
264 Telegram,
265}
266
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
268#[serde(rename_all = "lowercase")]
269pub enum RememberScope {
270 Once,
271 Session,
272 Permanent,
273}
274
275#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
276#[serde(deny_unknown_fields)]
277pub struct Approval {
278 #[serde(default = "default_channel")]
279 pub channel: ApprovalChannel,
280 #[serde(default = "default_remember")]
281 pub remember: RememberScope,
282 #[serde(default = "default_timeout")]
283 pub timeout_s: u32,
284}
285
286fn default_channel() -> ApprovalChannel {
287 ApprovalChannel::Terminal
288}
289fn default_remember() -> RememberScope {
290 RememberScope::Session
291}
292fn default_timeout() -> u32 {
293 300
294}
295
296impl Default for Approval {
297 fn default() -> Self {
298 Self {
299 channel: default_channel(),
300 remember: default_remember(),
301 timeout_s: default_timeout(),
302 }
303 }
304}
305
306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
308#[serde(deny_unknown_fields)]
309pub struct Policy {
310 pub version: u32,
312 #[serde(default = "default_verdict")]
314 pub defaults: Defaults,
315 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub scope: Option<Scope>,
319 #[serde(default)]
321 pub deny: Vec<RuleGroup>,
322 #[serde(default)]
324 pub allow: Vec<RuleGroup>,
325 #[serde(default, alias = "ask")]
328 pub review: Vec<RuleGroup>,
329 #[serde(default, skip_serializing_if = "Vec::is_empty")]
332 pub defer: Vec<RuleGroup>,
333 #[serde(default, skip_serializing_if = "Option::is_none")]
336 pub approval: Option<Approval>,
337 #[serde(default)]
339 pub executables: BTreeMap<String, Vec<String>>,
340 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub sandbox: Option<SandboxSettings>,
344 #[serde(default, skip_serializing_if = "Vec::is_empty")]
346 pub secrets: Vec<Secret>,
347 #[serde(default, skip_serializing_if = "Option::is_none")]
349 pub taint: Option<TaintSettings>,
350 #[serde(default, skip_serializing_if = "Option::is_none")]
354 pub staging: Option<StagingSettings>,
355 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
361 pub require_signed_repo_policy: bool,
362 #[serde(default, skip_serializing_if = "Option::is_none")]
364 pub mcp: Option<McpSettings>,
365 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub check_diff: Option<crate::diffscan::Settings>,
369 #[serde(skip)]
375 pub repo_review: Vec<RuleGroup>,
376 #[serde(skip)]
379 pub old_names: OldNames,
380}
381
382#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
384#[serde(deny_unknown_fields)]
385pub struct SandboxSettings {
386 #[serde(default)]
391 pub read_roots: Vec<String>,
392 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub proxy_port: Option<std::num::NonZeroU16>,
396 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
400 pub proxy_allow_ech: bool,
401 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
406 pub proxy_serve_without_credential: bool,
407 #[serde(default, skip_serializing_if = "Option::is_none")]
411 pub package_min_release_age_days: Option<u16>,
412}
413
414#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
416#[serde(deny_unknown_fields)]
417pub struct McpSettings {
418 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
423 pub trust_first_sight: bool,
424 #[serde(default, skip_serializing_if = "Option::is_none")]
429 pub browser_open: Option<BrowserOpen>,
430 #[serde(default, skip_serializing_if = "Vec::is_empty")]
436 pub flow_trusted: Vec<String>,
437}
438
439#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
441#[serde(rename_all = "lowercase")]
442pub enum BrowserOpen {
443 Fetch,
445 Net,
447}
448
449pub const DEFAULT_PACKAGE_MIN_RELEASE_AGE_DAYS: u16 = 7;
452
453impl Policy {
454 #[must_use]
457 pub fn package_min_release_age_days(&self) -> u16 {
458 self.sandbox
459 .as_ref()
460 .and_then(|s| s.package_min_release_age_days)
461 .unwrap_or(DEFAULT_PACKAGE_MIN_RELEASE_AGE_DAYS)
462 }
463
464 #[must_use]
467 pub fn trust_mcp_first_sight(&self) -> bool {
468 self.mcp.as_ref().is_some_and(|m| m.trust_first_sight)
469 }
470
471 #[must_use]
474 pub fn browser_open_is_fetch(&self) -> bool {
475 self.mcp.as_ref().and_then(|m| m.browser_open) != Some(BrowserOpen::Net)
476 }
477
478 #[must_use]
481 pub fn mcp_flow_trusted(&self) -> &[String] {
482 self.mcp.as_ref().map_or(&[], |m| m.flow_trusted.as_slice())
483 }
484}
485
486fn default_verdict() -> Defaults {
487 Defaults::All(Verdict::Review)
488}
489
490impl Policy {
491 pub fn parse(yaml: &str) -> Result<Self, PolicyError> {
493 let old_names = old_names::find(yaml)?;
494 let mut policy: Self = crate::yaml::from_str(yaml).map_err(PolicyError::Parse)?;
495 policy.old_names = old_names;
496 policy.lint()?;
497 Ok(policy)
498 }
499
500 pub fn lint(&self) -> Result<(), PolicyError> {
503 self.lint_structure()?;
504 self.groups().try_for_each(RuleGroup::check_patterns)
505 }
506
507 pub fn lint_structure(&self) -> Result<(), PolicyError> {
515 if self.version != SUPPORTED_VERSION {
516 return Err(PolicyError::Version {
517 found: self.version,
518 supported: SUPPORTED_VERSION,
519 });
520 }
521 let mut ids = BTreeSet::new();
522 for group in self.groups() {
523 if group.id.trim().is_empty() {
524 return Err(PolicyError::Rule {
525 rule: "<unnamed>".into(),
526 problem: "missing id".into(),
527 });
528 }
529 if !ids.insert(group.id.clone()) {
530 return Err(PolicyError::DuplicateId(group.id.clone()));
531 }
532 if group.is_empty() {
533 return Err(PolicyError::EmptyRule(group.id.clone()));
534 }
535 text::check(group)?;
536 }
537 hook_only::check(self)?;
538 for (name, paths) in &self.executables {
539 if paths.is_empty() {
540 return Err(PolicyError::Rule {
541 rule: format!("executables.{name}"),
542 problem: "no paths listed".into(),
543 });
544 }
545 if let Some(bad) = paths.iter().find(|p| !crate::paths::is_absolute(p)) {
546 return Err(PolicyError::Rule {
547 rule: format!("executables.{name}"),
548 problem: format!("`{bad}` is not an absolute path"),
549 });
550 }
551 }
552 for root in self.sandbox.iter().flat_map(|s| &s.read_roots) {
553 lint_read_root(root)?;
554 }
555 taint::check(self.taint.as_ref())?;
556 staging::check(self.staging.as_ref())?;
557 crate::diffscan::check(self.check_diff.as_ref())?;
558 secret::check(&self.secrets)
559 }
560
561 fn groups(&self) -> impl Iterator<Item = &RuleGroup> {
563 self.deny
564 .iter()
565 .chain(&self.repo_review)
566 .chain(&self.allow)
567 .chain(&self.review)
568 .chain(&self.defer)
569 }
570
571 #[must_use]
573 pub fn rule_count(&self) -> (usize, usize, usize) {
574 (self.deny.len(), self.allow.len(), self.review.len())
575 }
576}
577
578fn lint_read_root(root: &str) -> Result<(), PolicyError> {
581 let trimmed = root.trim_end_matches(['/', '\\']);
582 let problem = if !(crate::paths::is_absolute(root) || root.starts_with("~/")) {
583 "must be absolute or start with `~/`"
584 } else if root.contains(['*', '?', '[', ']', '{', '}', '!', '$']) {
585 "must be a plain path, not a glob or a variable"
586 } else if root.split(['/', '\\']).any(|s| s == "." || s == "..") {
587 "must not contain `.` or `..` segments"
588 } else if trimmed == "~" {
589 "must not be the home directory"
590 } else if trimmed.is_empty() || (trimmed.len() == 2 && trimmed.ends_with(':')) {
591 "must not be a filesystem root"
592 } else {
593 return Ok(());
594 };
595 Err(PolicyError::Rule {
596 rule: "sandbox.read_roots".to_owned(),
597 problem: format!("`{root}` {problem}"),
598 })
599}
600
601fn rule_err(rule: &str, e: &PolicyError) -> PolicyError {
602 PolicyError::Rule {
603 rule: rule.to_owned(),
604 problem: e.to_string(),
605 }
606}
607
608#[cfg(test)]
609mod tests;