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::taint::{self, TaintSettings};
70use crate::verdict::Verdict;
71
72mod hook_only;
73mod old_names;
74
75pub use old_names::OldNames;
76
77pub const SUPPORTED_VERSION: u32 = 1;
79
80#[derive(Debug, Error)]
85#[non_exhaustive]
86pub enum PolicyError {
87 #[error("policy parse error: {0}")]
89 Parse(crate::yaml::Error),
90 #[error("unsupported policy version {found}; this build supports {supported}")]
92 Version {
93 found: u32,
95 supported: u32,
97 },
98 #[error("duplicate rule id `{0}`")]
100 DuplicateId(String),
101 #[error("rule `{0}` has no patterns")]
103 EmptyRule(String),
104 #[error("empty pattern")]
106 EmptyPattern,
107 #[error("bad glob `{pattern}`: {error}")]
109 BadGlob {
110 pattern: String,
112 error: globset::Error,
114 },
115 #[error("bad shell pattern `{pattern}` (unbalanced quotes?)")]
117 BadShellPattern {
118 pattern: String,
120 },
121 #[error(
124 "the policy has both `{old}:` and `{new}:`; `{old}` is the old name of `{new}`: \
125 move the `{old}:` rules into `{new}:` (`openmoat policy upgrade` renames a policy \
126 that has only `{old}:`)"
127 )]
128 OldAndNewName {
129 old: &'static str,
131 new: &'static str,
133 },
134 #[error("rule `{rule}`: {problem}")]
136 Rule {
137 rule: String,
139 problem: String,
141 },
142}
143
144#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(deny_unknown_fields)]
148pub struct RuleGroup {
149 pub id: String,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub reason: Option<String>,
154 #[serde(default, skip_serializing_if = "Vec::is_empty")]
156 pub shell: Vec<String>,
157 #[serde(default, rename = "fs.read", skip_serializing_if = "Vec::is_empty")]
159 pub fs_read: Vec<String>,
160 #[serde(default, rename = "fs.write", skip_serializing_if = "Vec::is_empty")]
162 pub fs_write: Vec<String>,
163 #[serde(default, skip_serializing_if = "Vec::is_empty")]
165 pub net: Vec<String>,
166 #[serde(default, skip_serializing_if = "Vec::is_empty")]
168 pub fetch: Vec<String>,
169 #[serde(default, rename = "env.read", skip_serializing_if = "Vec::is_empty")]
171 pub env_read: Vec<String>,
172 #[serde(default, rename = "env.set", skip_serializing_if = "Vec::is_empty")]
174 pub env_set: Vec<String>,
175 #[serde(default, skip_serializing_if = "Vec::is_empty")]
177 pub mcp: Vec<String>,
178 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
183 pub hook_only: bool,
184}
185
186impl RuleGroup {
187 #[must_use]
189 pub fn patterns(&self, kind: Kind) -> &[String] {
190 match kind {
191 Kind::Shell => &self.shell,
192 Kind::FsRead => &self.fs_read,
193 Kind::FsWrite => &self.fs_write,
194 Kind::Net => &self.net,
195 Kind::Fetch => &self.fetch,
196 Kind::WebSearch | Kind::UnknownTool => &[],
199 Kind::EnvRead => &self.env_read,
200 Kind::EnvSet => &self.env_set,
201 Kind::Mcp => &self.mcp,
202 }
203 }
204
205 fn is_empty(&self) -> bool {
206 Kind::ALL.iter().all(|k| self.patterns(*k).is_empty())
207 }
208
209 pub fn check_patterns(&self) -> Result<(), PolicyError> {
215 for kind in Kind::ALL {
216 for p in self.patterns(kind) {
217 let compiled = if kind == Kind::Shell {
218 ShellPattern::compile(p).map(drop)
219 } else {
220 GlobPattern::compile(p, false).map(drop)
221 };
222 compiled.map_err(|e| rule_err(&self.id, &e))?;
223 }
224 }
225 Ok(())
226 }
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
230#[serde(deny_unknown_fields)]
231pub struct Scope {
232 #[serde(default = "default_roots")]
233 pub project_roots: Vec<String>,
234}
235
236fn default_roots() -> Vec<String> {
237 vec![".".to_owned()]
238}
239
240impl Default for Scope {
241 fn default() -> Self {
242 Self {
243 project_roots: default_roots(),
244 }
245 }
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
249#[serde(rename_all = "lowercase")]
250pub enum ApprovalChannel {
251 Terminal,
252 Telegram,
253}
254
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(rename_all = "lowercase")]
257pub enum RememberScope {
258 Once,
259 Session,
260 Permanent,
261}
262
263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
264#[serde(deny_unknown_fields)]
265pub struct Approval {
266 #[serde(default = "default_channel")]
267 pub channel: ApprovalChannel,
268 #[serde(default = "default_remember")]
269 pub remember: RememberScope,
270 #[serde(default = "default_timeout")]
271 pub timeout_s: u32,
272}
273
274fn default_channel() -> ApprovalChannel {
275 ApprovalChannel::Terminal
276}
277fn default_remember() -> RememberScope {
278 RememberScope::Session
279}
280fn default_timeout() -> u32 {
281 300
282}
283
284impl Default for Approval {
285 fn default() -> Self {
286 Self {
287 channel: default_channel(),
288 remember: default_remember(),
289 timeout_s: default_timeout(),
290 }
291 }
292}
293
294#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
296#[serde(deny_unknown_fields)]
297pub struct Policy {
298 pub version: u32,
300 #[serde(default = "default_verdict")]
302 pub defaults: Defaults,
303 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub scope: Option<Scope>,
307 #[serde(default)]
309 pub deny: Vec<RuleGroup>,
310 #[serde(default)]
312 pub allow: Vec<RuleGroup>,
313 #[serde(default, alias = "ask")]
316 pub review: Vec<RuleGroup>,
317 #[serde(default, skip_serializing_if = "Vec::is_empty")]
320 pub defer: Vec<RuleGroup>,
321 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub approval: Option<Approval>,
325 #[serde(default)]
327 pub executables: BTreeMap<String, Vec<String>>,
328 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub sandbox: Option<SandboxSettings>,
332 #[serde(default, skip_serializing_if = "Vec::is_empty")]
334 pub secrets: Vec<Secret>,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub taint: Option<TaintSettings>,
338 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
344 pub require_signed_repo_policy: bool,
345 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub mcp: Option<McpSettings>,
348 #[serde(skip)]
354 pub repo_review: Vec<RuleGroup>,
355 #[serde(skip)]
358 pub old_names: OldNames,
359}
360
361#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
363#[serde(deny_unknown_fields)]
364pub struct SandboxSettings {
365 #[serde(default)]
370 pub read_roots: Vec<String>,
371 #[serde(default, skip_serializing_if = "Option::is_none")]
374 pub proxy_port: Option<std::num::NonZeroU16>,
375 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
379 pub proxy_allow_ech: bool,
380 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
385 pub proxy_serve_without_credential: bool,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
390 pub package_min_release_age_days: Option<u16>,
391}
392
393#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
395#[serde(deny_unknown_fields)]
396pub struct McpSettings {
397 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
402 pub trust_first_sight: bool,
403 #[serde(default, skip_serializing_if = "Option::is_none")]
408 pub browser_open: Option<BrowserOpen>,
409}
410
411#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
413#[serde(rename_all = "lowercase")]
414pub enum BrowserOpen {
415 Fetch,
417 Net,
419}
420
421pub const DEFAULT_PACKAGE_MIN_RELEASE_AGE_DAYS: u16 = 7;
424
425impl Policy {
426 #[must_use]
429 pub fn package_min_release_age_days(&self) -> u16 {
430 self.sandbox
431 .as_ref()
432 .and_then(|s| s.package_min_release_age_days)
433 .unwrap_or(DEFAULT_PACKAGE_MIN_RELEASE_AGE_DAYS)
434 }
435
436 #[must_use]
439 pub fn trust_mcp_first_sight(&self) -> bool {
440 self.mcp.as_ref().is_some_and(|m| m.trust_first_sight)
441 }
442
443 #[must_use]
446 pub fn browser_open_is_fetch(&self) -> bool {
447 self.mcp.as_ref().and_then(|m| m.browser_open) != Some(BrowserOpen::Net)
448 }
449}
450
451fn default_verdict() -> Defaults {
452 Defaults::All(Verdict::Review)
453}
454
455impl Policy {
456 pub fn parse(yaml: &str) -> Result<Self, PolicyError> {
458 let old_names = old_names::find(yaml)?;
459 let mut policy: Self = crate::yaml::from_str(yaml).map_err(PolicyError::Parse)?;
460 policy.old_names = old_names;
461 policy.lint()?;
462 Ok(policy)
463 }
464
465 pub fn lint(&self) -> Result<(), PolicyError> {
468 self.lint_structure()?;
469 self.groups().try_for_each(RuleGroup::check_patterns)
470 }
471
472 pub fn lint_structure(&self) -> Result<(), PolicyError> {
480 if self.version != SUPPORTED_VERSION {
481 return Err(PolicyError::Version {
482 found: self.version,
483 supported: SUPPORTED_VERSION,
484 });
485 }
486 let mut ids = BTreeSet::new();
487 for group in self.groups() {
488 if group.id.trim().is_empty() {
489 return Err(PolicyError::Rule {
490 rule: "<unnamed>".into(),
491 problem: "missing id".into(),
492 });
493 }
494 if !ids.insert(group.id.clone()) {
495 return Err(PolicyError::DuplicateId(group.id.clone()));
496 }
497 if group.is_empty() {
498 return Err(PolicyError::EmptyRule(group.id.clone()));
499 }
500 }
501 hook_only::check(self)?;
502 for (name, paths) in &self.executables {
503 if paths.is_empty() {
504 return Err(PolicyError::Rule {
505 rule: format!("executables.{name}"),
506 problem: "no paths listed".into(),
507 });
508 }
509 if let Some(bad) = paths.iter().find(|p| !crate::paths::is_absolute(p)) {
510 return Err(PolicyError::Rule {
511 rule: format!("executables.{name}"),
512 problem: format!("`{bad}` is not an absolute path"),
513 });
514 }
515 }
516 for root in self.sandbox.iter().flat_map(|s| &s.read_roots) {
517 lint_read_root(root)?;
518 }
519 taint::check(self.taint.as_ref())?;
520 secret::check(&self.secrets)
521 }
522
523 fn groups(&self) -> impl Iterator<Item = &RuleGroup> {
525 self.deny
526 .iter()
527 .chain(&self.repo_review)
528 .chain(&self.allow)
529 .chain(&self.review)
530 .chain(&self.defer)
531 }
532
533 #[must_use]
535 pub fn rule_count(&self) -> (usize, usize, usize) {
536 (self.deny.len(), self.allow.len(), self.review.len())
537 }
538}
539
540fn lint_read_root(root: &str) -> Result<(), PolicyError> {
543 let trimmed = root.trim_end_matches(['/', '\\']);
544 let problem = if !(crate::paths::is_absolute(root) || root.starts_with("~/")) {
545 "must be absolute or start with `~/`"
546 } else if root.contains(['*', '?', '[', ']', '{', '}', '!', '$']) {
547 "must be a plain path, not a glob or a variable"
548 } else if root.split(['/', '\\']).any(|s| s == "." || s == "..") {
549 "must not contain `.` or `..` segments"
550 } else if trimmed == "~" {
551 "must not be the home directory"
552 } else if trimmed.is_empty() || (trimmed.len() == 2 && trimmed.ends_with(':')) {
553 "must not be a filesystem root"
554 } else {
555 return Ok(());
556 };
557 Err(PolicyError::Rule {
558 rule: "sandbox.read_roots".to_owned(),
559 problem: format!("`{root}` {problem}"),
560 })
561}
562
563fn rule_err(rule: &str, e: &PolicyError) -> PolicyError {
564 PolicyError::Rule {
565 rule: rule.to_owned(),
566 problem: e.to_string(),
567 }
568}
569
570#[cfg(test)]
571mod tests;