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
72mod hook_only;
73
74pub const SUPPORTED_VERSION: u32 = 1;
75
76#[derive(Debug, Error)]
81#[non_exhaustive]
82pub enum PolicyError {
83 #[error("policy parse error: {0}")]
85 Parse(crate::yaml::Error),
86 #[error("unsupported policy version {found}; this build supports {supported}")]
88 Version {
89 found: u32,
91 supported: u32,
93 },
94 #[error("duplicate rule id `{0}`")]
96 DuplicateId(String),
97 #[error("rule `{0}` has no patterns")]
99 EmptyRule(String),
100 #[error("empty pattern")]
102 EmptyPattern,
103 #[error("bad glob `{pattern}`: {error}")]
105 BadGlob {
106 pattern: String,
108 error: globset::Error,
110 },
111 #[error("bad shell pattern `{pattern}` (unbalanced quotes?)")]
113 BadShellPattern {
114 pattern: String,
116 },
117 #[error("rule `{rule}`: {problem}")]
119 Rule {
120 rule: String,
122 problem: String,
124 },
125}
126
127#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(deny_unknown_fields)]
131pub struct RuleGroup {
132 pub id: String,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub reason: Option<String>,
137 #[serde(default, skip_serializing_if = "Vec::is_empty")]
139 pub shell: Vec<String>,
140 #[serde(default, rename = "fs.read", skip_serializing_if = "Vec::is_empty")]
142 pub fs_read: Vec<String>,
143 #[serde(default, rename = "fs.write", skip_serializing_if = "Vec::is_empty")]
145 pub fs_write: Vec<String>,
146 #[serde(default, skip_serializing_if = "Vec::is_empty")]
148 pub net: Vec<String>,
149 #[serde(default, skip_serializing_if = "Vec::is_empty")]
151 pub fetch: Vec<String>,
152 #[serde(default, rename = "env.read", skip_serializing_if = "Vec::is_empty")]
154 pub env_read: Vec<String>,
155 #[serde(default, rename = "env.set", skip_serializing_if = "Vec::is_empty")]
157 pub env_set: Vec<String>,
158 #[serde(default, skip_serializing_if = "Vec::is_empty")]
160 pub mcp: Vec<String>,
161 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
166 pub hook_only: bool,
167}
168
169impl RuleGroup {
170 #[must_use]
172 pub fn patterns(&self, kind: Kind) -> &[String] {
173 match kind {
174 Kind::Shell => &self.shell,
175 Kind::FsRead => &self.fs_read,
176 Kind::FsWrite => &self.fs_write,
177 Kind::Net => &self.net,
178 Kind::Fetch => &self.fetch,
179 Kind::WebSearch | Kind::UnknownTool => &[],
182 Kind::EnvRead => &self.env_read,
183 Kind::EnvSet => &self.env_set,
184 Kind::Mcp => &self.mcp,
185 }
186 }
187
188 fn is_empty(&self) -> bool {
189 Kind::ALL.iter().all(|k| self.patterns(*k).is_empty())
190 }
191}
192
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194#[serde(deny_unknown_fields)]
195pub struct Scope {
196 #[serde(default = "default_roots")]
197 pub project_roots: Vec<String>,
198}
199
200fn default_roots() -> Vec<String> {
201 vec![".".to_owned()]
202}
203
204impl Default for Scope {
205 fn default() -> Self {
206 Self {
207 project_roots: default_roots(),
208 }
209 }
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
213#[serde(rename_all = "lowercase")]
214pub enum ApprovalChannel {
215 Terminal,
216 Telegram,
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(rename_all = "lowercase")]
221pub enum RememberScope {
222 Once,
223 Session,
224 Permanent,
225}
226
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(deny_unknown_fields)]
229pub struct Approval {
230 #[serde(default = "default_channel")]
231 pub channel: ApprovalChannel,
232 #[serde(default = "default_remember")]
233 pub remember: RememberScope,
234 #[serde(default = "default_timeout")]
235 pub timeout_s: u32,
236}
237
238fn default_channel() -> ApprovalChannel {
239 ApprovalChannel::Terminal
240}
241fn default_remember() -> RememberScope {
242 RememberScope::Session
243}
244fn default_timeout() -> u32 {
245 300
246}
247
248impl Default for Approval {
249 fn default() -> Self {
250 Self {
251 channel: default_channel(),
252 remember: default_remember(),
253 timeout_s: default_timeout(),
254 }
255 }
256}
257
258#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
260#[serde(deny_unknown_fields)]
261pub struct Policy {
262 pub version: u32,
264 #[serde(default = "default_verdict")]
266 pub defaults: Defaults,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub scope: Option<Scope>,
271 #[serde(default)]
273 pub deny: Vec<RuleGroup>,
274 #[serde(default)]
276 pub allow: Vec<RuleGroup>,
277 #[serde(default)]
279 pub ask: Vec<RuleGroup>,
280 #[serde(default, skip_serializing_if = "Vec::is_empty")]
283 pub defer: Vec<RuleGroup>,
284 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub approval: Option<Approval>,
288 #[serde(default)]
290 pub executables: BTreeMap<String, Vec<String>>,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub sandbox: Option<SandboxSettings>,
295 #[serde(default, skip_serializing_if = "Vec::is_empty")]
297 pub secrets: Vec<Secret>,
298 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub taint: Option<TaintSettings>,
301 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
307 pub require_signed_repo_policy: bool,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub mcp: Option<McpSettings>,
311 #[serde(skip)]
317 pub repo_ask: Vec<RuleGroup>,
318}
319
320#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
322#[serde(deny_unknown_fields)]
323pub struct SandboxSettings {
324 #[serde(default)]
329 pub read_roots: Vec<String>,
330 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub proxy_port: Option<std::num::NonZeroU16>,
334 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
338 pub proxy_allow_ech: bool,
339 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub package_min_release_age_days: Option<u16>,
344}
345
346#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
348#[serde(deny_unknown_fields)]
349pub struct McpSettings {
350 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
355 pub trust_first_sight: bool,
356}
357
358pub const DEFAULT_PACKAGE_MIN_RELEASE_AGE_DAYS: u16 = 7;
361
362impl Policy {
363 #[must_use]
366 pub fn package_min_release_age_days(&self) -> u16 {
367 self.sandbox
368 .as_ref()
369 .and_then(|s| s.package_min_release_age_days)
370 .unwrap_or(DEFAULT_PACKAGE_MIN_RELEASE_AGE_DAYS)
371 }
372
373 #[must_use]
376 pub fn trust_mcp_first_sight(&self) -> bool {
377 self.mcp.as_ref().is_some_and(|m| m.trust_first_sight)
378 }
379}
380
381fn default_verdict() -> Defaults {
382 Defaults::All(Verdict::Ask)
383}
384
385impl Policy {
386 pub fn parse(yaml: &str) -> Result<Self, PolicyError> {
388 let policy: Self = crate::yaml::from_str(yaml).map_err(PolicyError::Parse)?;
389 policy.lint()?;
390 Ok(policy)
391 }
392
393 pub fn lint(&self) -> Result<(), PolicyError> {
395 if self.version != SUPPORTED_VERSION {
396 return Err(PolicyError::Version {
397 found: self.version,
398 supported: SUPPORTED_VERSION,
399 });
400 }
401 let mut ids = BTreeSet::new();
402 let groups = self.deny.iter().chain(&self.repo_ask);
403 for group in groups
404 .chain(&self.allow)
405 .chain(&self.ask)
406 .chain(&self.defer)
407 {
408 if group.id.trim().is_empty() {
409 return Err(PolicyError::Rule {
410 rule: "<unnamed>".into(),
411 problem: "missing id".into(),
412 });
413 }
414 if !ids.insert(group.id.clone()) {
415 return Err(PolicyError::DuplicateId(group.id.clone()));
416 }
417 if group.is_empty() {
418 return Err(PolicyError::EmptyRule(group.id.clone()));
419 }
420 for kind in Kind::ALL {
421 for p in group.patterns(kind) {
422 let compiled = if kind == Kind::Shell {
423 ShellPattern::compile(p).map(drop)
424 } else {
425 GlobPattern::compile(p, false).map(drop)
426 };
427 compiled.map_err(|e| rule_err(&group.id, &e))?;
428 }
429 }
430 }
431 hook_only::check(self)?;
432 for (name, paths) in &self.executables {
433 if paths.is_empty() {
434 return Err(PolicyError::Rule {
435 rule: format!("executables.{name}"),
436 problem: "no paths listed".into(),
437 });
438 }
439 if let Some(bad) = paths.iter().find(|p| !crate::paths::is_absolute(p)) {
440 return Err(PolicyError::Rule {
441 rule: format!("executables.{name}"),
442 problem: format!("`{bad}` is not an absolute path"),
443 });
444 }
445 }
446 for root in self.sandbox.iter().flat_map(|s| &s.read_roots) {
447 lint_read_root(root)?;
448 }
449 taint::check(self.taint.as_ref())?;
450 secret::check(&self.secrets)
451 }
452
453 #[must_use]
455 pub fn rule_count(&self) -> (usize, usize, usize) {
456 (self.deny.len(), self.allow.len(), self.ask.len())
457 }
458}
459
460fn lint_read_root(root: &str) -> Result<(), PolicyError> {
463 let trimmed = root.trim_end_matches(['/', '\\']);
464 let problem = if !(crate::paths::is_absolute(root) || root.starts_with("~/")) {
465 "must be absolute or start with `~/`"
466 } else if root.contains(['*', '?', '[', ']', '{', '}', '!', '$']) {
467 "must be a plain path, not a glob or a variable"
468 } else if root.split(['/', '\\']).any(|s| s == "." || s == "..") {
469 "must not contain `.` or `..` segments"
470 } else if trimmed == "~" {
471 "must not be the home directory"
472 } else if trimmed.is_empty() || (trimmed.len() == 2 && trimmed.ends_with(':')) {
473 "must not be a filesystem root"
474 } else {
475 return Ok(());
476 };
477 Err(PolicyError::Rule {
478 rule: "sandbox.read_roots".to_owned(),
479 problem: format!("`{root}` {problem}"),
480 })
481}
482
483fn rule_err(rule: &str, e: &PolicyError) -> PolicyError {
484 PolicyError::Rule {
485 rule: rule.to_owned(),
486 problem: e.to_string(),
487 }
488}
489
490#[cfg(test)]
491mod tests;