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::WebSearch => &[],
150 Kind::EnvRead => &self.env_read,
151 Kind::EnvSet => &self.env_set,
152 Kind::Mcp => &self.mcp,
153 }
154 }
155
156 fn is_empty(&self) -> bool {
157 Kind::ALL.iter().all(|k| self.patterns(*k).is_empty())
158 }
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(deny_unknown_fields)]
163pub struct Scope {
164 #[serde(default = "default_roots")]
165 pub project_roots: Vec<String>,
166}
167
168fn default_roots() -> Vec<String> {
169 vec![".".to_owned()]
170}
171
172impl Default for Scope {
173 fn default() -> Self {
174 Self {
175 project_roots: default_roots(),
176 }
177 }
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(rename_all = "lowercase")]
182pub enum ApprovalChannel {
183 Terminal,
184 Telegram,
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "lowercase")]
189pub enum RememberScope {
190 Once,
191 Session,
192 Permanent,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(deny_unknown_fields)]
197pub struct Approval {
198 #[serde(default = "default_channel")]
199 pub channel: ApprovalChannel,
200 #[serde(default = "default_remember")]
201 pub remember: RememberScope,
202 #[serde(default = "default_timeout")]
203 pub timeout_s: u32,
204}
205
206fn default_channel() -> ApprovalChannel {
207 ApprovalChannel::Terminal
208}
209fn default_remember() -> RememberScope {
210 RememberScope::Session
211}
212fn default_timeout() -> u32 {
213 300
214}
215
216impl Default for Approval {
217 fn default() -> Self {
218 Self {
219 channel: default_channel(),
220 remember: default_remember(),
221 timeout_s: default_timeout(),
222 }
223 }
224}
225
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(deny_unknown_fields)]
229pub struct Policy {
230 pub version: u32,
232 #[serde(default = "default_verdict")]
234 pub defaults: Defaults,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub scope: Option<Scope>,
239 #[serde(default)]
241 pub deny: Vec<RuleGroup>,
242 #[serde(default)]
244 pub allow: Vec<RuleGroup>,
245 #[serde(default)]
247 pub ask: Vec<RuleGroup>,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub approval: Option<Approval>,
252 #[serde(default)]
254 pub executables: BTreeMap<String, Vec<String>>,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub sandbox: Option<SandboxSettings>,
259 #[serde(default, skip_serializing_if = "Vec::is_empty")]
261 pub secrets: Vec<Secret>,
262 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub taint: Option<TaintSettings>,
265 #[serde(skip)]
270 pub repo_ask: Vec<RuleGroup>,
271}
272
273#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
275#[serde(deny_unknown_fields)]
276pub struct SandboxSettings {
277 #[serde(default)]
282 pub read_roots: Vec<String>,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub proxy_port: Option<std::num::NonZeroU16>,
287}
288
289fn default_verdict() -> Defaults {
290 Defaults::All(Verdict::Ask)
291}
292
293impl Policy {
294 pub fn parse(yaml: &str) -> Result<Self, PolicyError> {
296 let policy: Self = serde_yaml_ng::from_str(yaml).map_err(PolicyError::Parse)?;
297 policy.lint()?;
298 Ok(policy)
299 }
300
301 pub fn lint(&self) -> Result<(), PolicyError> {
303 if self.version != SUPPORTED_VERSION {
304 return Err(PolicyError::Version {
305 found: self.version,
306 supported: SUPPORTED_VERSION,
307 });
308 }
309 let mut ids = BTreeSet::new();
310 let groups = self.deny.iter().chain(&self.repo_ask);
311 for group in groups.chain(&self.allow).chain(&self.ask) {
312 if group.id.trim().is_empty() {
313 return Err(PolicyError::Rule {
314 rule: "<unnamed>".into(),
315 problem: "missing id".into(),
316 });
317 }
318 if !ids.insert(group.id.clone()) {
319 return Err(PolicyError::DuplicateId(group.id.clone()));
320 }
321 if group.is_empty() {
322 return Err(PolicyError::EmptyRule(group.id.clone()));
323 }
324 for kind in Kind::ALL {
325 for p in group.patterns(kind) {
326 let compiled = if kind == Kind::Shell {
327 ShellPattern::compile(p).map(drop)
328 } else {
329 GlobPattern::compile(p, false).map(drop)
330 };
331 compiled.map_err(|e| rule_err(&group.id, &e))?;
332 }
333 }
334 }
335 for (name, paths) in &self.executables {
336 if paths.is_empty() {
337 return Err(PolicyError::Rule {
338 rule: format!("executables.{name}"),
339 problem: "no paths listed".into(),
340 });
341 }
342 if let Some(bad) = paths.iter().find(|p| !crate::paths::is_absolute(p)) {
343 return Err(PolicyError::Rule {
344 rule: format!("executables.{name}"),
345 problem: format!("`{bad}` is not an absolute path"),
346 });
347 }
348 }
349 for root in self.sandbox.iter().flat_map(|s| &s.read_roots) {
350 lint_read_root(root)?;
351 }
352 taint::check(self.taint.as_ref())?;
353 secret::check(&self.secrets)
354 }
355
356 #[must_use]
358 pub fn rule_count(&self) -> (usize, usize, usize) {
359 (self.deny.len(), self.allow.len(), self.ask.len())
360 }
361}
362
363fn lint_read_root(root: &str) -> Result<(), PolicyError> {
366 let trimmed = root.trim_end_matches(['/', '\\']);
367 let problem = if !(crate::paths::is_absolute(root) || root.starts_with("~/")) {
368 "must be absolute or start with `~/`"
369 } else if root.contains(['*', '?', '[', ']', '{', '}', '!', '$']) {
370 "must be a plain path, not a glob or a variable"
371 } else if root.split(['/', '\\']).any(|s| s == "." || s == "..") {
372 "must not contain `.` or `..` segments"
373 } else if trimmed == "~" {
374 "must not be the home directory"
375 } else if trimmed.is_empty() || (trimmed.len() == 2 && trimmed.ends_with(':')) {
376 "must not be a filesystem root"
377 } else {
378 return Ok(());
379 };
380 Err(PolicyError::Rule {
381 rule: "sandbox.read_roots".to_owned(),
382 problem: format!("`{root}` {problem}"),
383 })
384}
385
386fn rule_err(rule: &str, e: &PolicyError) -> PolicyError {
387 PolicyError::Rule {
388 rule: rule.to_owned(),
389 problem: e.to_string(),
390 }
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 #[test]
398 fn parses_minimal() {
399 let p = Policy::parse("version: 1\ndeny:\n - id: a\n shell: ['sudo *']\n").unwrap();
400 assert_eq!(p.defaults, Defaults::All(Verdict::Ask));
401 assert_eq!(p.rule_count(), (1, 0, 0));
402 }
403
404 #[test]
405 fn rejects_duplicate_and_empty() {
406 let dup =
407 "version: 1\ndeny:\n - id: a\n shell: ['x']\nallow:\n - id: a\n net: ['h']\n";
408 assert!(matches!(
409 Policy::parse(dup),
410 Err(PolicyError::DuplicateId(_))
411 ));
412 let empty = "version: 1\ndeny:\n - id: a\n";
413 assert!(matches!(
414 Policy::parse(empty),
415 Err(PolicyError::EmptyRule(_))
416 ));
417 let ver = "version: 2\n";
418 assert!(matches!(
419 Policy::parse(ver),
420 Err(PolicyError::Version { .. })
421 ));
422 }
423
424 #[test]
425 fn per_kind_defaults() {
426 let p = Policy::parse("version: 1\ndefaults: { net: deny, '*': ask }\n").unwrap();
427 assert_eq!(
428 p.defaults.for_kind(Kind::Net),
429 (Verdict::Deny, "default.net".to_owned())
430 );
431 assert_eq!(
432 p.defaults.for_kind(Kind::Shell),
433 (Verdict::Ask, "default".to_owned())
434 );
435 }
436
437 #[test]
438 fn fetch_defaults_fall_back_to_net_then_star() {
439 let own = Policy::parse("version: 1\ndefaults: { net: deny, fetch: ask }\n").unwrap();
440 assert_eq!(
441 own.defaults.for_kind(Kind::Fetch),
442 (Verdict::Ask, "default.fetch".to_owned())
443 );
444 assert_eq!(
445 own.defaults.for_kind(Kind::Net),
446 (Verdict::Deny, "default.net".to_owned())
447 );
448 let net = Policy::parse("version: 1\ndefaults: { net: deny, '*': allow }\n").unwrap();
449 assert_eq!(
450 net.defaults.for_kind(Kind::Fetch),
451 (Verdict::Deny, "default.net".to_owned())
452 );
453 let star = Policy::parse("version: 1\ndefaults: { '*': allow }\n").unwrap();
454 assert_eq!(
455 star.defaults.for_kind(Kind::Fetch),
456 (Verdict::Allow, "default".to_owned())
457 );
458 }
459
460 #[test]
461 fn executables_must_be_absolute_on_every_platform() {
462 let ok = "version: 1\nexecutables:\n git: ['/usr/bin/git', 'C:/Program Files/Git/bin/git.exe']\n";
463 assert!(Policy::parse(ok).is_ok());
464 let rel = "version: 1\nexecutables:\n git: ['bin/git']\n";
465 assert!(matches!(Policy::parse(rel), Err(PolicyError::Rule { .. })));
466 }
467
468 #[test]
469 fn read_roots_must_be_plain_paths_below_a_root() {
470 let ok = "version: 1\nsandbox:\n read_roots: ['/usr', '~/.cargo', 'C:/tools']\n";
471 assert!(Policy::parse(ok).is_ok());
472 for bad in [
473 "usr",
474 "~",
475 "~/",
476 "/",
477 "C:/",
478 "/usr/**",
479 "~/../x",
480 "${project}/x",
481 "!/usr",
482 ] {
483 let yaml = format!("version: 1\nsandbox:\n read_roots: ['{bad}']\n");
484 assert!(
485 matches!(Policy::parse(&yaml), Err(PolicyError::Rule { rule, .. }) if rule == "sandbox.read_roots"),
486 "{bad} must be rejected"
487 );
488 }
489 assert!(Policy::parse("version: 1\nsandbox: { bogus: 1 }\n").is_err());
490 assert!(Policy::parse("version: 1\nsandbox: { proxy_port: 18081 }\n").is_ok());
491 assert!(Policy::parse("version: 1\nsandbox: { proxy_port: 0 }\n").is_err());
492 }
493
494 #[test]
495 fn rejects_unknown_keys() {
496 assert!(Policy::parse("version: 1\nbogus: 1\n").is_err());
497 }
498}