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