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
40use serde::{Deserialize, Serialize};
41use thiserror::Error;
42
43use crate::kind::Kind;
44use crate::pattern::{GlobPattern, ShellPattern};
45use crate::secret::{self, Secret};
46use crate::taint::{self, TaintSettings};
47use crate::verdict::Verdict;
48
49pub const SUPPORTED_VERSION: u32 = 1;
50
51#[derive(Debug, Error)]
56pub enum PolicyError {
57 #[error("policy parse error: {0}")]
59 Parse(serde_yaml_ng::Error),
60 #[error("unsupported policy version {found}; this build supports {supported}")]
62 Version {
63 found: u32,
65 supported: u32,
67 },
68 #[error("duplicate rule id `{0}`")]
70 DuplicateId(String),
71 #[error("rule `{0}` has no patterns")]
73 EmptyRule(String),
74 #[error("empty pattern")]
76 EmptyPattern,
77 #[error("bad glob `{pattern}`: {error}")]
79 BadGlob {
80 pattern: String,
82 error: globset::Error,
84 },
85 #[error("bad shell pattern `{pattern}` (unbalanced quotes?)")]
87 BadShellPattern {
88 pattern: String,
90 },
91 #[error("rule `{rule}`: {problem}")]
93 Rule {
94 rule: String,
96 problem: String,
98 },
99}
100
101#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(deny_unknown_fields)]
105pub struct RuleGroup {
106 pub id: String,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub reason: Option<String>,
111 #[serde(default, skip_serializing_if = "Vec::is_empty")]
113 pub shell: Vec<String>,
114 #[serde(default, rename = "fs.read", skip_serializing_if = "Vec::is_empty")]
116 pub fs_read: Vec<String>,
117 #[serde(default, rename = "fs.write", skip_serializing_if = "Vec::is_empty")]
119 pub fs_write: Vec<String>,
120 #[serde(default, skip_serializing_if = "Vec::is_empty")]
122 pub net: Vec<String>,
123 #[serde(default, skip_serializing_if = "Vec::is_empty")]
125 pub fetch: Vec<String>,
126 #[serde(default, rename = "env.read", skip_serializing_if = "Vec::is_empty")]
128 pub env_read: Vec<String>,
129 #[serde(default, rename = "env.set", skip_serializing_if = "Vec::is_empty")]
131 pub env_set: Vec<String>,
132 #[serde(default, skip_serializing_if = "Vec::is_empty")]
134 pub mcp: Vec<String>,
135}
136
137impl RuleGroup {
138 #[must_use]
140 pub fn patterns(&self, kind: Kind) -> &[String] {
141 match kind {
142 Kind::Shell => &self.shell,
143 Kind::FsRead => &self.fs_read,
144 Kind::FsWrite => &self.fs_write,
145 Kind::Net => &self.net,
146 Kind::Fetch => &self.fetch,
147 Kind::EnvRead => &self.env_read,
148 Kind::EnvSet => &self.env_set,
149 Kind::Mcp => &self.mcp,
150 }
151 }
152
153 fn is_empty(&self) -> bool {
154 Kind::ALL.iter().all(|k| self.patterns(*k).is_empty())
155 }
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(deny_unknown_fields)]
160pub struct Scope {
161 #[serde(default = "default_roots")]
162 pub project_roots: Vec<String>,
163}
164
165fn default_roots() -> Vec<String> {
166 vec![".".to_owned()]
167}
168
169impl Default for Scope {
170 fn default() -> Self {
171 Self {
172 project_roots: default_roots(),
173 }
174 }
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(rename_all = "lowercase")]
179pub enum ApprovalChannel {
180 Terminal,
181 Telegram,
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
185#[serde(rename_all = "lowercase")]
186pub enum RememberScope {
187 Once,
188 Session,
189 Permanent,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(deny_unknown_fields)]
194pub struct Approval {
195 #[serde(default = "default_channel")]
196 pub channel: ApprovalChannel,
197 #[serde(default = "default_remember")]
198 pub remember: RememberScope,
199 #[serde(default = "default_timeout")]
200 pub timeout_s: u32,
201}
202
203fn default_channel() -> ApprovalChannel {
204 ApprovalChannel::Terminal
205}
206fn default_remember() -> RememberScope {
207 RememberScope::Session
208}
209fn default_timeout() -> u32 {
210 300
211}
212
213impl Default for Approval {
214 fn default() -> Self {
215 Self {
216 channel: default_channel(),
217 remember: default_remember(),
218 timeout_s: default_timeout(),
219 }
220 }
221}
222
223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
225#[serde(deny_unknown_fields)]
226pub struct Policy {
227 pub version: u32,
229 #[serde(default = "default_verdict")]
231 pub defaults: Defaults,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub scope: Option<Scope>,
236 #[serde(default)]
238 pub deny: Vec<RuleGroup>,
239 #[serde(default)]
241 pub allow: Vec<RuleGroup>,
242 #[serde(default)]
244 pub ask: Vec<RuleGroup>,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub approval: Option<Approval>,
249 #[serde(default)]
251 pub executables: BTreeMap<String, Vec<String>>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub sandbox: Option<SandboxSettings>,
256 #[serde(default, skip_serializing_if = "Vec::is_empty")]
258 pub secrets: Vec<Secret>,
259 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub taint: Option<TaintSettings>,
262 #[serde(skip)]
267 pub repo_ask: Vec<RuleGroup>,
268}
269
270#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
272#[serde(deny_unknown_fields)]
273pub struct SandboxSettings {
274 #[serde(default)]
279 pub read_roots: Vec<String>,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub proxy_port: Option<std::num::NonZeroU16>,
284}
285
286fn default_verdict() -> Defaults {
287 Defaults::All(Verdict::Ask)
288}
289
290impl Policy {
291 pub fn parse(yaml: &str) -> Result<Self, PolicyError> {
293 let policy: Self = serde_yaml_ng::from_str(yaml).map_err(PolicyError::Parse)?;
294 policy.lint()?;
295 Ok(policy)
296 }
297
298 pub fn lint(&self) -> Result<(), PolicyError> {
300 if self.version != SUPPORTED_VERSION {
301 return Err(PolicyError::Version {
302 found: self.version,
303 supported: SUPPORTED_VERSION,
304 });
305 }
306 let mut ids = BTreeSet::new();
307 let groups = self.deny.iter().chain(&self.repo_ask);
308 for group in groups.chain(&self.allow).chain(&self.ask) {
309 if group.id.trim().is_empty() {
310 return Err(PolicyError::Rule {
311 rule: "<unnamed>".into(),
312 problem: "missing id".into(),
313 });
314 }
315 if !ids.insert(group.id.clone()) {
316 return Err(PolicyError::DuplicateId(group.id.clone()));
317 }
318 if group.is_empty() {
319 return Err(PolicyError::EmptyRule(group.id.clone()));
320 }
321 for kind in Kind::ALL {
322 for p in group.patterns(kind) {
323 let compiled = if kind == Kind::Shell {
324 ShellPattern::compile(p).map(drop)
325 } else {
326 GlobPattern::compile(p, false).map(drop)
327 };
328 compiled.map_err(|e| rule_err(&group.id, &e))?;
329 }
330 }
331 }
332 for (name, paths) in &self.executables {
333 if paths.is_empty() {
334 return Err(PolicyError::Rule {
335 rule: format!("executables.{name}"),
336 problem: "no paths listed".into(),
337 });
338 }
339 if let Some(bad) = paths.iter().find(|p| !crate::paths::is_absolute(p)) {
340 return Err(PolicyError::Rule {
341 rule: format!("executables.{name}"),
342 problem: format!("`{bad}` is not an absolute path"),
343 });
344 }
345 }
346 for root in self.sandbox.iter().flat_map(|s| &s.read_roots) {
347 lint_read_root(root)?;
348 }
349 taint::check(self.taint.as_ref())?;
350 secret::check(&self.secrets)
351 }
352
353 #[must_use]
355 pub fn rule_count(&self) -> (usize, usize, usize) {
356 (self.deny.len(), self.allow.len(), self.ask.len())
357 }
358}
359
360fn lint_read_root(root: &str) -> Result<(), PolicyError> {
363 let trimmed = root.trim_end_matches(['/', '\\']);
364 let problem = if !(crate::paths::is_absolute(root) || root.starts_with("~/")) {
365 "must be absolute or start with `~/`"
366 } else if root.contains(['*', '?', '[', ']', '{', '}', '!', '$']) {
367 "must be a plain path, not a glob or a variable"
368 } else if root.split(['/', '\\']).any(|s| s == "." || s == "..") {
369 "must not contain `.` or `..` segments"
370 } else if trimmed == "~" {
371 "must not be the home directory"
372 } else if trimmed.is_empty() || (trimmed.len() == 2 && trimmed.ends_with(':')) {
373 "must not be a filesystem root"
374 } else {
375 return Ok(());
376 };
377 Err(PolicyError::Rule {
378 rule: "sandbox.read_roots".to_owned(),
379 problem: format!("`{root}` {problem}"),
380 })
381}
382
383fn rule_err(rule: &str, e: &PolicyError) -> PolicyError {
384 PolicyError::Rule {
385 rule: rule.to_owned(),
386 problem: e.to_string(),
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393
394 #[test]
395 fn parses_minimal() {
396 let p = Policy::parse("version: 1\ndeny:\n - id: a\n shell: ['sudo *']\n").unwrap();
397 assert_eq!(p.defaults, Defaults::All(Verdict::Ask));
398 assert_eq!(p.rule_count(), (1, 0, 0));
399 }
400
401 #[test]
402 fn rejects_duplicate_and_empty() {
403 let dup =
404 "version: 1\ndeny:\n - id: a\n shell: ['x']\nallow:\n - id: a\n net: ['h']\n";
405 assert!(matches!(
406 Policy::parse(dup),
407 Err(PolicyError::DuplicateId(_))
408 ));
409 let empty = "version: 1\ndeny:\n - id: a\n";
410 assert!(matches!(
411 Policy::parse(empty),
412 Err(PolicyError::EmptyRule(_))
413 ));
414 let ver = "version: 2\n";
415 assert!(matches!(
416 Policy::parse(ver),
417 Err(PolicyError::Version { .. })
418 ));
419 }
420
421 #[test]
422 fn per_kind_defaults() {
423 let p = Policy::parse("version: 1\ndefaults: { net: deny, '*': ask }\n").unwrap();
424 assert_eq!(
425 p.defaults.for_kind(Kind::Net),
426 (Verdict::Deny, "default.net".to_owned())
427 );
428 assert_eq!(
429 p.defaults.for_kind(Kind::Shell),
430 (Verdict::Ask, "default".to_owned())
431 );
432 }
433
434 #[test]
435 fn fetch_defaults_fall_back_to_net_then_star() {
436 let own = Policy::parse("version: 1\ndefaults: { net: deny, fetch: ask }\n").unwrap();
437 assert_eq!(
438 own.defaults.for_kind(Kind::Fetch),
439 (Verdict::Ask, "default.fetch".to_owned())
440 );
441 assert_eq!(
442 own.defaults.for_kind(Kind::Net),
443 (Verdict::Deny, "default.net".to_owned())
444 );
445 let net = Policy::parse("version: 1\ndefaults: { net: deny, '*': allow }\n").unwrap();
446 assert_eq!(
447 net.defaults.for_kind(Kind::Fetch),
448 (Verdict::Deny, "default.net".to_owned())
449 );
450 let star = Policy::parse("version: 1\ndefaults: { '*': allow }\n").unwrap();
451 assert_eq!(
452 star.defaults.for_kind(Kind::Fetch),
453 (Verdict::Allow, "default".to_owned())
454 );
455 }
456
457 #[test]
458 fn executables_must_be_absolute_on_every_platform() {
459 let ok = "version: 1\nexecutables:\n git: ['/usr/bin/git', 'C:/Program Files/Git/bin/git.exe']\n";
460 assert!(Policy::parse(ok).is_ok());
461 let rel = "version: 1\nexecutables:\n git: ['bin/git']\n";
462 assert!(matches!(Policy::parse(rel), Err(PolicyError::Rule { .. })));
463 }
464
465 #[test]
466 fn read_roots_must_be_plain_paths_below_a_root() {
467 let ok = "version: 1\nsandbox:\n read_roots: ['/usr', '~/.cargo', 'C:/tools']\n";
468 assert!(Policy::parse(ok).is_ok());
469 for bad in [
470 "usr",
471 "~",
472 "~/",
473 "/",
474 "C:/",
475 "/usr/**",
476 "~/../x",
477 "${project}/x",
478 "!/usr",
479 ] {
480 let yaml = format!("version: 1\nsandbox:\n read_roots: ['{bad}']\n");
481 assert!(
482 matches!(Policy::parse(&yaml), Err(PolicyError::Rule { rule, .. }) if rule == "sandbox.read_roots"),
483 "{bad} must be rejected"
484 );
485 }
486 assert!(Policy::parse("version: 1\nsandbox: { bogus: 1 }\n").is_err());
487 assert!(Policy::parse("version: 1\nsandbox: { proxy_port: 18081 }\n").is_ok());
488 assert!(Policy::parse("version: 1\nsandbox: { proxy_port: 0 }\n").is_err());
489 }
490
491 #[test]
492 fn rejects_unknown_keys() {
493 assert!(Policy::parse("version: 1\nbogus: 1\n").is_err());
494 }
495}