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;
76
77#[derive(Debug, Error)]
82#[non_exhaustive]
83pub enum PolicyError {
84 #[error("policy parse error: {0}")]
86 Parse(crate::yaml::Error),
87 #[error("unsupported policy version {found}; this build supports {supported}")]
89 Version {
90 found: u32,
92 supported: u32,
94 },
95 #[error("duplicate rule id `{0}`")]
97 DuplicateId(String),
98 #[error("rule `{0}` has no patterns")]
100 EmptyRule(String),
101 #[error("empty pattern")]
103 EmptyPattern,
104 #[error("bad glob `{pattern}`: {error}")]
106 BadGlob {
107 pattern: String,
109 error: globset::Error,
111 },
112 #[error("bad shell pattern `{pattern}` (unbalanced quotes?)")]
114 BadShellPattern {
115 pattern: String,
117 },
118 #[error("rule `{rule}`: {problem}")]
120 Rule {
121 rule: String,
123 problem: String,
125 },
126}
127
128#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(deny_unknown_fields)]
132pub struct RuleGroup {
133 pub id: String,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub reason: Option<String>,
138 #[serde(default, skip_serializing_if = "Vec::is_empty")]
140 pub shell: Vec<String>,
141 #[serde(default, rename = "fs.read", skip_serializing_if = "Vec::is_empty")]
143 pub fs_read: Vec<String>,
144 #[serde(default, rename = "fs.write", skip_serializing_if = "Vec::is_empty")]
146 pub fs_write: Vec<String>,
147 #[serde(default, skip_serializing_if = "Vec::is_empty")]
149 pub net: Vec<String>,
150 #[serde(default, skip_serializing_if = "Vec::is_empty")]
152 pub fetch: Vec<String>,
153 #[serde(default, rename = "env.read", skip_serializing_if = "Vec::is_empty")]
155 pub env_read: Vec<String>,
156 #[serde(default, rename = "env.set", skip_serializing_if = "Vec::is_empty")]
158 pub env_set: Vec<String>,
159 #[serde(default, skip_serializing_if = "Vec::is_empty")]
161 pub mcp: Vec<String>,
162 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
167 pub hook_only: bool,
168}
169
170impl RuleGroup {
171 #[must_use]
173 pub fn patterns(&self, kind: Kind) -> &[String] {
174 match kind {
175 Kind::Shell => &self.shell,
176 Kind::FsRead => &self.fs_read,
177 Kind::FsWrite => &self.fs_write,
178 Kind::Net => &self.net,
179 Kind::Fetch => &self.fetch,
180 Kind::WebSearch | Kind::UnknownTool => &[],
183 Kind::EnvRead => &self.env_read,
184 Kind::EnvSet => &self.env_set,
185 Kind::Mcp => &self.mcp,
186 }
187 }
188
189 fn is_empty(&self) -> bool {
190 Kind::ALL.iter().all(|k| self.patterns(*k).is_empty())
191 }
192}
193
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(deny_unknown_fields)]
196pub struct Scope {
197 #[serde(default = "default_roots")]
198 pub project_roots: Vec<String>,
199}
200
201fn default_roots() -> Vec<String> {
202 vec![".".to_owned()]
203}
204
205impl Default for Scope {
206 fn default() -> Self {
207 Self {
208 project_roots: default_roots(),
209 }
210 }
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
214#[serde(rename_all = "lowercase")]
215pub enum ApprovalChannel {
216 Terminal,
217 Telegram,
218}
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
221#[serde(rename_all = "lowercase")]
222pub enum RememberScope {
223 Once,
224 Session,
225 Permanent,
226}
227
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
229#[serde(deny_unknown_fields)]
230pub struct Approval {
231 #[serde(default = "default_channel")]
232 pub channel: ApprovalChannel,
233 #[serde(default = "default_remember")]
234 pub remember: RememberScope,
235 #[serde(default = "default_timeout")]
236 pub timeout_s: u32,
237}
238
239fn default_channel() -> ApprovalChannel {
240 ApprovalChannel::Terminal
241}
242fn default_remember() -> RememberScope {
243 RememberScope::Session
244}
245fn default_timeout() -> u32 {
246 300
247}
248
249impl Default for Approval {
250 fn default() -> Self {
251 Self {
252 channel: default_channel(),
253 remember: default_remember(),
254 timeout_s: default_timeout(),
255 }
256 }
257}
258
259#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
261#[serde(deny_unknown_fields)]
262pub struct Policy {
263 pub version: u32,
265 #[serde(default = "default_verdict")]
267 pub defaults: Defaults,
268 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub scope: Option<Scope>,
272 #[serde(default)]
274 pub deny: Vec<RuleGroup>,
275 #[serde(default)]
277 pub allow: Vec<RuleGroup>,
278 #[serde(default)]
280 pub ask: Vec<RuleGroup>,
281 #[serde(default, skip_serializing_if = "Vec::is_empty")]
284 pub defer: Vec<RuleGroup>,
285 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub approval: Option<Approval>,
289 #[serde(default)]
291 pub executables: BTreeMap<String, Vec<String>>,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub sandbox: Option<SandboxSettings>,
296 #[serde(default, skip_serializing_if = "Vec::is_empty")]
298 pub secrets: Vec<Secret>,
299 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub taint: Option<TaintSettings>,
302 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
308 pub require_signed_repo_policy: bool,
309 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub mcp: Option<McpSettings>,
312 #[serde(skip)]
318 pub repo_ask: Vec<RuleGroup>,
319}
320
321#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
323#[serde(deny_unknown_fields)]
324pub struct SandboxSettings {
325 #[serde(default)]
330 pub read_roots: Vec<String>,
331 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub proxy_port: Option<std::num::NonZeroU16>,
335 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
339 pub proxy_allow_ech: bool,
340 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
345 pub proxy_serve_without_credential: bool,
346 #[serde(default, skip_serializing_if = "Option::is_none")]
350 pub package_min_release_age_days: Option<u16>,
351}
352
353#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
355#[serde(deny_unknown_fields)]
356pub struct McpSettings {
357 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
362 pub trust_first_sight: bool,
363 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub browser_open: Option<BrowserOpen>,
369}
370
371#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
373#[serde(rename_all = "lowercase")]
374pub enum BrowserOpen {
375 Fetch,
377 Net,
379}
380
381pub const DEFAULT_PACKAGE_MIN_RELEASE_AGE_DAYS: u16 = 7;
384
385impl Policy {
386 #[must_use]
389 pub fn package_min_release_age_days(&self) -> u16 {
390 self.sandbox
391 .as_ref()
392 .and_then(|s| s.package_min_release_age_days)
393 .unwrap_or(DEFAULT_PACKAGE_MIN_RELEASE_AGE_DAYS)
394 }
395
396 #[must_use]
399 pub fn trust_mcp_first_sight(&self) -> bool {
400 self.mcp.as_ref().is_some_and(|m| m.trust_first_sight)
401 }
402
403 #[must_use]
406 pub fn browser_open_is_fetch(&self) -> bool {
407 self.mcp.as_ref().and_then(|m| m.browser_open) != Some(BrowserOpen::Net)
408 }
409}
410
411fn default_verdict() -> Defaults {
412 Defaults::All(Verdict::Ask)
413}
414
415impl Policy {
416 pub fn parse(yaml: &str) -> Result<Self, PolicyError> {
418 let policy: Self = crate::yaml::from_str(yaml).map_err(PolicyError::Parse)?;
419 policy.lint()?;
420 Ok(policy)
421 }
422
423 pub fn lint(&self) -> Result<(), PolicyError> {
425 if self.version != SUPPORTED_VERSION {
426 return Err(PolicyError::Version {
427 found: self.version,
428 supported: SUPPORTED_VERSION,
429 });
430 }
431 let mut ids = BTreeSet::new();
432 let groups = self.deny.iter().chain(&self.repo_ask);
433 for group in groups
434 .chain(&self.allow)
435 .chain(&self.ask)
436 .chain(&self.defer)
437 {
438 if group.id.trim().is_empty() {
439 return Err(PolicyError::Rule {
440 rule: "<unnamed>".into(),
441 problem: "missing id".into(),
442 });
443 }
444 if !ids.insert(group.id.clone()) {
445 return Err(PolicyError::DuplicateId(group.id.clone()));
446 }
447 if group.is_empty() {
448 return Err(PolicyError::EmptyRule(group.id.clone()));
449 }
450 for kind in Kind::ALL {
451 for p in group.patterns(kind) {
452 let compiled = if kind == Kind::Shell {
453 ShellPattern::compile(p).map(drop)
454 } else {
455 GlobPattern::compile(p, false).map(drop)
456 };
457 compiled.map_err(|e| rule_err(&group.id, &e))?;
458 }
459 }
460 }
461 hook_only::check(self)?;
462 for (name, paths) in &self.executables {
463 if paths.is_empty() {
464 return Err(PolicyError::Rule {
465 rule: format!("executables.{name}"),
466 problem: "no paths listed".into(),
467 });
468 }
469 if let Some(bad) = paths.iter().find(|p| !crate::paths::is_absolute(p)) {
470 return Err(PolicyError::Rule {
471 rule: format!("executables.{name}"),
472 problem: format!("`{bad}` is not an absolute path"),
473 });
474 }
475 }
476 for root in self.sandbox.iter().flat_map(|s| &s.read_roots) {
477 lint_read_root(root)?;
478 }
479 taint::check(self.taint.as_ref())?;
480 secret::check(&self.secrets)
481 }
482
483 #[must_use]
485 pub fn rule_count(&self) -> (usize, usize, usize) {
486 (self.deny.len(), self.allow.len(), self.ask.len())
487 }
488}
489
490fn lint_read_root(root: &str) -> Result<(), PolicyError> {
493 let trimmed = root.trim_end_matches(['/', '\\']);
494 let problem = if !(crate::paths::is_absolute(root) || root.starts_with("~/")) {
495 "must be absolute or start with `~/`"
496 } else if root.contains(['*', '?', '[', ']', '{', '}', '!', '$']) {
497 "must be a plain path, not a glob or a variable"
498 } else if root.split(['/', '\\']).any(|s| s == "." || s == "..") {
499 "must not contain `.` or `..` segments"
500 } else if trimmed == "~" {
501 "must not be the home directory"
502 } else if trimmed.is_empty() || (trimmed.len() == 2 && trimmed.ends_with(':')) {
503 "must not be a filesystem root"
504 } else {
505 return Ok(());
506 };
507 Err(PolicyError::Rule {
508 rule: "sandbox.read_roots".to_owned(),
509 problem: format!("`{root}` {problem}"),
510 })
511}
512
513fn rule_err(rule: &str, e: &PolicyError) -> PolicyError {
514 PolicyError::Rule {
515 rule: rule.to_owned(),
516 problem: e.to_string(),
517 }
518}
519
520#[cfg(test)]
521mod tests;