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::Ask),
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::Ask),
58 };
59 catch_all.clamp(Verdict::Defer, Verdict::Ask)
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
72pub const SUPPORTED_VERSION: u32 = 1;
73
74#[derive(Debug, Error)]
79#[non_exhaustive]
80pub enum PolicyError {
81 #[error("policy parse error: {0}")]
83 Parse(crate::yaml::Error),
84 #[error("unsupported policy version {found}; this build supports {supported}")]
86 Version {
87 found: u32,
89 supported: u32,
91 },
92 #[error("duplicate rule id `{0}`")]
94 DuplicateId(String),
95 #[error("rule `{0}` has no patterns")]
97 EmptyRule(String),
98 #[error("empty pattern")]
100 EmptyPattern,
101 #[error("bad glob `{pattern}`: {error}")]
103 BadGlob {
104 pattern: String,
106 error: globset::Error,
108 },
109 #[error("bad shell pattern `{pattern}` (unbalanced quotes?)")]
111 BadShellPattern {
112 pattern: String,
114 },
115 #[error("rule `{rule}`: {problem}")]
117 Rule {
118 rule: String,
120 problem: String,
122 },
123}
124
125#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(deny_unknown_fields)]
129pub struct RuleGroup {
130 pub id: String,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub reason: Option<String>,
135 #[serde(default, skip_serializing_if = "Vec::is_empty")]
137 pub shell: Vec<String>,
138 #[serde(default, rename = "fs.read", skip_serializing_if = "Vec::is_empty")]
140 pub fs_read: Vec<String>,
141 #[serde(default, rename = "fs.write", skip_serializing_if = "Vec::is_empty")]
143 pub fs_write: Vec<String>,
144 #[serde(default, skip_serializing_if = "Vec::is_empty")]
146 pub net: Vec<String>,
147 #[serde(default, skip_serializing_if = "Vec::is_empty")]
149 pub fetch: Vec<String>,
150 #[serde(default, rename = "env.read", skip_serializing_if = "Vec::is_empty")]
152 pub env_read: Vec<String>,
153 #[serde(default, rename = "env.set", skip_serializing_if = "Vec::is_empty")]
155 pub env_set: Vec<String>,
156 #[serde(default, skip_serializing_if = "Vec::is_empty")]
158 pub mcp: Vec<String>,
159}
160
161impl RuleGroup {
162 #[must_use]
164 pub fn patterns(&self, kind: Kind) -> &[String] {
165 match kind {
166 Kind::Shell => &self.shell,
167 Kind::FsRead => &self.fs_read,
168 Kind::FsWrite => &self.fs_write,
169 Kind::Net => &self.net,
170 Kind::Fetch => &self.fetch,
171 Kind::WebSearch | Kind::UnknownTool => &[],
174 Kind::EnvRead => &self.env_read,
175 Kind::EnvSet => &self.env_set,
176 Kind::Mcp => &self.mcp,
177 }
178 }
179
180 fn is_empty(&self) -> bool {
181 Kind::ALL.iter().all(|k| self.patterns(*k).is_empty())
182 }
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(deny_unknown_fields)]
187pub struct Scope {
188 #[serde(default = "default_roots")]
189 pub project_roots: Vec<String>,
190}
191
192fn default_roots() -> Vec<String> {
193 vec![".".to_owned()]
194}
195
196impl Default for Scope {
197 fn default() -> Self {
198 Self {
199 project_roots: default_roots(),
200 }
201 }
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(rename_all = "lowercase")]
206pub enum ApprovalChannel {
207 Terminal,
208 Telegram,
209}
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
212#[serde(rename_all = "lowercase")]
213pub enum RememberScope {
214 Once,
215 Session,
216 Permanent,
217}
218
219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(deny_unknown_fields)]
221pub struct Approval {
222 #[serde(default = "default_channel")]
223 pub channel: ApprovalChannel,
224 #[serde(default = "default_remember")]
225 pub remember: RememberScope,
226 #[serde(default = "default_timeout")]
227 pub timeout_s: u32,
228}
229
230fn default_channel() -> ApprovalChannel {
231 ApprovalChannel::Terminal
232}
233fn default_remember() -> RememberScope {
234 RememberScope::Session
235}
236fn default_timeout() -> u32 {
237 300
238}
239
240impl Default for Approval {
241 fn default() -> Self {
242 Self {
243 channel: default_channel(),
244 remember: default_remember(),
245 timeout_s: default_timeout(),
246 }
247 }
248}
249
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(deny_unknown_fields)]
253pub struct Policy {
254 pub version: u32,
256 #[serde(default = "default_verdict")]
258 pub defaults: Defaults,
259 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub scope: Option<Scope>,
263 #[serde(default)]
265 pub deny: Vec<RuleGroup>,
266 #[serde(default)]
268 pub allow: Vec<RuleGroup>,
269 #[serde(default)]
271 pub ask: Vec<RuleGroup>,
272 #[serde(default, skip_serializing_if = "Vec::is_empty")]
275 pub defer: Vec<RuleGroup>,
276 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub approval: Option<Approval>,
280 #[serde(default)]
282 pub executables: BTreeMap<String, Vec<String>>,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub sandbox: Option<SandboxSettings>,
287 #[serde(default, skip_serializing_if = "Vec::is_empty")]
289 pub secrets: Vec<Secret>,
290 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub taint: Option<TaintSettings>,
293 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
299 pub require_signed_repo_policy: bool,
300 #[serde(skip)]
306 pub repo_ask: Vec<RuleGroup>,
307}
308
309#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
311#[serde(deny_unknown_fields)]
312pub struct SandboxSettings {
313 #[serde(default)]
318 pub read_roots: Vec<String>,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub proxy_port: Option<std::num::NonZeroU16>,
323 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
327 pub proxy_allow_ech: bool,
328 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub package_min_release_age_days: Option<u16>,
333}
334
335pub const DEFAULT_PACKAGE_MIN_RELEASE_AGE_DAYS: u16 = 7;
338
339impl Policy {
340 #[must_use]
343 pub fn package_min_release_age_days(&self) -> u16 {
344 self.sandbox
345 .as_ref()
346 .and_then(|s| s.package_min_release_age_days)
347 .unwrap_or(DEFAULT_PACKAGE_MIN_RELEASE_AGE_DAYS)
348 }
349}
350
351fn default_verdict() -> Defaults {
352 Defaults::All(Verdict::Ask)
353}
354
355impl Policy {
356 pub fn parse(yaml: &str) -> Result<Self, PolicyError> {
358 let policy: Self = crate::yaml::from_str(yaml).map_err(PolicyError::Parse)?;
359 policy.lint()?;
360 Ok(policy)
361 }
362
363 pub fn lint(&self) -> Result<(), PolicyError> {
365 if self.version != SUPPORTED_VERSION {
366 return Err(PolicyError::Version {
367 found: self.version,
368 supported: SUPPORTED_VERSION,
369 });
370 }
371 let mut ids = BTreeSet::new();
372 let groups = self.deny.iter().chain(&self.repo_ask);
373 for group in groups
374 .chain(&self.allow)
375 .chain(&self.ask)
376 .chain(&self.defer)
377 {
378 if group.id.trim().is_empty() {
379 return Err(PolicyError::Rule {
380 rule: "<unnamed>".into(),
381 problem: "missing id".into(),
382 });
383 }
384 if !ids.insert(group.id.clone()) {
385 return Err(PolicyError::DuplicateId(group.id.clone()));
386 }
387 if group.is_empty() {
388 return Err(PolicyError::EmptyRule(group.id.clone()));
389 }
390 for kind in Kind::ALL {
391 for p in group.patterns(kind) {
392 let compiled = if kind == Kind::Shell {
393 ShellPattern::compile(p).map(drop)
394 } else {
395 GlobPattern::compile(p, false).map(drop)
396 };
397 compiled.map_err(|e| rule_err(&group.id, &e))?;
398 }
399 }
400 }
401 for (name, paths) in &self.executables {
402 if paths.is_empty() {
403 return Err(PolicyError::Rule {
404 rule: format!("executables.{name}"),
405 problem: "no paths listed".into(),
406 });
407 }
408 if let Some(bad) = paths.iter().find(|p| !crate::paths::is_absolute(p)) {
409 return Err(PolicyError::Rule {
410 rule: format!("executables.{name}"),
411 problem: format!("`{bad}` is not an absolute path"),
412 });
413 }
414 }
415 for root in self.sandbox.iter().flat_map(|s| &s.read_roots) {
416 lint_read_root(root)?;
417 }
418 taint::check(self.taint.as_ref())?;
419 secret::check(&self.secrets)
420 }
421
422 #[must_use]
424 pub fn rule_count(&self) -> (usize, usize, usize) {
425 (self.deny.len(), self.allow.len(), self.ask.len())
426 }
427}
428
429fn lint_read_root(root: &str) -> Result<(), PolicyError> {
432 let trimmed = root.trim_end_matches(['/', '\\']);
433 let problem = if !(crate::paths::is_absolute(root) || root.starts_with("~/")) {
434 "must be absolute or start with `~/`"
435 } else if root.contains(['*', '?', '[', ']', '{', '}', '!', '$']) {
436 "must be a plain path, not a glob or a variable"
437 } else if root.split(['/', '\\']).any(|s| s == "." || s == "..") {
438 "must not contain `.` or `..` segments"
439 } else if trimmed == "~" {
440 "must not be the home directory"
441 } else if trimmed.is_empty() || (trimmed.len() == 2 && trimmed.ends_with(':')) {
442 "must not be a filesystem root"
443 } else {
444 return Ok(());
445 };
446 Err(PolicyError::Rule {
447 rule: "sandbox.read_roots".to_owned(),
448 problem: format!("`{root}` {problem}"),
449 })
450}
451
452fn rule_err(rule: &str, e: &PolicyError) -> PolicyError {
453 PolicyError::Rule {
454 rule: rule.to_owned(),
455 problem: e.to_string(),
456 }
457}
458
459#[cfg(test)]
460mod tests;