use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Defaults {
All(Verdict),
PerKind(BTreeMap<String, Verdict>),
}
impl Defaults {
#[must_use]
pub fn for_kind(&self, kind: Kind) -> (Verdict, String) {
match self {
Self::All(v) => (*v, "default".to_owned()),
Self::PerKind(map) => kind
.rule_kinds()
.iter()
.find_map(|k| map.get(k.as_str()).map(|v| (*v, format!("default.{k}"))))
.unwrap_or_else(|| {
(
map.get("*").copied().unwrap_or(Verdict::Ask),
"default".to_owned(),
)
}),
}
}
}
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::kind::Kind;
use crate::pattern::{GlobPattern, ShellPattern};
use crate::secret::{self, Secret};
use crate::taint::{self, TaintSettings};
use crate::verdict::Verdict;
pub const SUPPORTED_VERSION: u32 = 1;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum PolicyError {
#[error("policy parse error: {0}")]
Parse(serde_yaml_ng::Error),
#[error("unsupported policy version {found}; this build supports {supported}")]
Version {
found: u32,
supported: u32,
},
#[error("duplicate rule id `{0}`")]
DuplicateId(String),
#[error("rule `{0}` has no patterns")]
EmptyRule(String),
#[error("empty pattern")]
EmptyPattern,
#[error("bad glob `{pattern}`: {error}")]
BadGlob {
pattern: String,
error: globset::Error,
},
#[error("bad shell pattern `{pattern}` (unbalanced quotes?)")]
BadShellPattern {
pattern: String,
},
#[error("rule `{rule}`: {problem}")]
Rule {
rule: String,
problem: String,
},
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RuleGroup {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub shell: Vec<String>,
#[serde(default, rename = "fs.read", skip_serializing_if = "Vec::is_empty")]
pub fs_read: Vec<String>,
#[serde(default, rename = "fs.write", skip_serializing_if = "Vec::is_empty")]
pub fs_write: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub net: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fetch: Vec<String>,
#[serde(default, rename = "env.read", skip_serializing_if = "Vec::is_empty")]
pub env_read: Vec<String>,
#[serde(default, rename = "env.set", skip_serializing_if = "Vec::is_empty")]
pub env_set: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mcp: Vec<String>,
}
impl RuleGroup {
#[must_use]
pub fn patterns(&self, kind: Kind) -> &[String] {
match kind {
Kind::Shell => &self.shell,
Kind::FsRead => &self.fs_read,
Kind::FsWrite => &self.fs_write,
Kind::Net => &self.net,
Kind::Fetch => &self.fetch,
Kind::WebSearch => &[],
Kind::EnvRead => &self.env_read,
Kind::EnvSet => &self.env_set,
Kind::Mcp => &self.mcp,
}
}
fn is_empty(&self) -> bool {
Kind::ALL.iter().all(|k| self.patterns(*k).is_empty())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Scope {
#[serde(default = "default_roots")]
pub project_roots: Vec<String>,
}
fn default_roots() -> Vec<String> {
vec![".".to_owned()]
}
impl Default for Scope {
fn default() -> Self {
Self {
project_roots: default_roots(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ApprovalChannel {
Terminal,
Telegram,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RememberScope {
Once,
Session,
Permanent,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Approval {
#[serde(default = "default_channel")]
pub channel: ApprovalChannel,
#[serde(default = "default_remember")]
pub remember: RememberScope,
#[serde(default = "default_timeout")]
pub timeout_s: u32,
}
fn default_channel() -> ApprovalChannel {
ApprovalChannel::Terminal
}
fn default_remember() -> RememberScope {
RememberScope::Session
}
fn default_timeout() -> u32 {
300
}
impl Default for Approval {
fn default() -> Self {
Self {
channel: default_channel(),
remember: default_remember(),
timeout_s: default_timeout(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Policy {
pub version: u32,
#[serde(default = "default_verdict")]
pub defaults: Defaults,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<Scope>,
#[serde(default)]
pub deny: Vec<RuleGroup>,
#[serde(default)]
pub allow: Vec<RuleGroup>,
#[serde(default)]
pub ask: Vec<RuleGroup>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approval: Option<Approval>,
#[serde(default)]
pub executables: BTreeMap<String, Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox: Option<SandboxSettings>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub secrets: Vec<Secret>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub taint: Option<TaintSettings>,
#[serde(skip)]
pub repo_ask: Vec<RuleGroup>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SandboxSettings {
#[serde(default)]
pub read_roots: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proxy_port: Option<std::num::NonZeroU16>,
}
fn default_verdict() -> Defaults {
Defaults::All(Verdict::Ask)
}
impl Policy {
pub fn parse(yaml: &str) -> Result<Self, PolicyError> {
let policy: Self = serde_yaml_ng::from_str(yaml).map_err(PolicyError::Parse)?;
policy.lint()?;
Ok(policy)
}
pub fn lint(&self) -> Result<(), PolicyError> {
if self.version != SUPPORTED_VERSION {
return Err(PolicyError::Version {
found: self.version,
supported: SUPPORTED_VERSION,
});
}
let mut ids = BTreeSet::new();
let groups = self.deny.iter().chain(&self.repo_ask);
for group in groups.chain(&self.allow).chain(&self.ask) {
if group.id.trim().is_empty() {
return Err(PolicyError::Rule {
rule: "<unnamed>".into(),
problem: "missing id".into(),
});
}
if !ids.insert(group.id.clone()) {
return Err(PolicyError::DuplicateId(group.id.clone()));
}
if group.is_empty() {
return Err(PolicyError::EmptyRule(group.id.clone()));
}
for kind in Kind::ALL {
for p in group.patterns(kind) {
let compiled = if kind == Kind::Shell {
ShellPattern::compile(p).map(drop)
} else {
GlobPattern::compile(p, false).map(drop)
};
compiled.map_err(|e| rule_err(&group.id, &e))?;
}
}
}
for (name, paths) in &self.executables {
if paths.is_empty() {
return Err(PolicyError::Rule {
rule: format!("executables.{name}"),
problem: "no paths listed".into(),
});
}
if let Some(bad) = paths.iter().find(|p| !crate::paths::is_absolute(p)) {
return Err(PolicyError::Rule {
rule: format!("executables.{name}"),
problem: format!("`{bad}` is not an absolute path"),
});
}
}
for root in self.sandbox.iter().flat_map(|s| &s.read_roots) {
lint_read_root(root)?;
}
taint::check(self.taint.as_ref())?;
secret::check(&self.secrets)
}
#[must_use]
pub fn rule_count(&self) -> (usize, usize, usize) {
(self.deny.len(), self.allow.len(), self.ask.len())
}
}
fn lint_read_root(root: &str) -> Result<(), PolicyError> {
let trimmed = root.trim_end_matches(['/', '\\']);
let problem = if !(crate::paths::is_absolute(root) || root.starts_with("~/")) {
"must be absolute or start with `~/`"
} else if root.contains(['*', '?', '[', ']', '{', '}', '!', '$']) {
"must be a plain path, not a glob or a variable"
} else if root.split(['/', '\\']).any(|s| s == "." || s == "..") {
"must not contain `.` or `..` segments"
} else if trimmed == "~" {
"must not be the home directory"
} else if trimmed.is_empty() || (trimmed.len() == 2 && trimmed.ends_with(':')) {
"must not be a filesystem root"
} else {
return Ok(());
};
Err(PolicyError::Rule {
rule: "sandbox.read_roots".to_owned(),
problem: format!("`{root}` {problem}"),
})
}
fn rule_err(rule: &str, e: &PolicyError) -> PolicyError {
PolicyError::Rule {
rule: rule.to_owned(),
problem: e.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_minimal() {
let p = Policy::parse("version: 1\ndeny:\n - id: a\n shell: ['sudo *']\n").unwrap();
assert_eq!(p.defaults, Defaults::All(Verdict::Ask));
assert_eq!(p.rule_count(), (1, 0, 0));
}
#[test]
fn rejects_duplicate_and_empty() {
let dup =
"version: 1\ndeny:\n - id: a\n shell: ['x']\nallow:\n - id: a\n net: ['h']\n";
assert!(matches!(
Policy::parse(dup),
Err(PolicyError::DuplicateId(_))
));
let empty = "version: 1\ndeny:\n - id: a\n";
assert!(matches!(
Policy::parse(empty),
Err(PolicyError::EmptyRule(_))
));
let ver = "version: 2\n";
assert!(matches!(
Policy::parse(ver),
Err(PolicyError::Version { .. })
));
}
#[test]
fn per_kind_defaults() {
let p = Policy::parse("version: 1\ndefaults: { net: deny, '*': ask }\n").unwrap();
assert_eq!(
p.defaults.for_kind(Kind::Net),
(Verdict::Deny, "default.net".to_owned())
);
assert_eq!(
p.defaults.for_kind(Kind::Shell),
(Verdict::Ask, "default".to_owned())
);
}
#[test]
fn fetch_defaults_fall_back_to_net_then_star() {
let own = Policy::parse("version: 1\ndefaults: { net: deny, fetch: ask }\n").unwrap();
assert_eq!(
own.defaults.for_kind(Kind::Fetch),
(Verdict::Ask, "default.fetch".to_owned())
);
assert_eq!(
own.defaults.for_kind(Kind::Net),
(Verdict::Deny, "default.net".to_owned())
);
let net = Policy::parse("version: 1\ndefaults: { net: deny, '*': allow }\n").unwrap();
assert_eq!(
net.defaults.for_kind(Kind::Fetch),
(Verdict::Deny, "default.net".to_owned())
);
let star = Policy::parse("version: 1\ndefaults: { '*': allow }\n").unwrap();
assert_eq!(
star.defaults.for_kind(Kind::Fetch),
(Verdict::Allow, "default".to_owned())
);
}
#[test]
fn executables_must_be_absolute_on_every_platform() {
let ok = "version: 1\nexecutables:\n git: ['/usr/bin/git', 'C:/Program Files/Git/bin/git.exe']\n";
assert!(Policy::parse(ok).is_ok());
let rel = "version: 1\nexecutables:\n git: ['bin/git']\n";
assert!(matches!(Policy::parse(rel), Err(PolicyError::Rule { .. })));
}
#[test]
fn read_roots_must_be_plain_paths_below_a_root() {
let ok = "version: 1\nsandbox:\n read_roots: ['/usr', '~/.cargo', 'C:/tools']\n";
assert!(Policy::parse(ok).is_ok());
for bad in [
"usr",
"~",
"~/",
"/",
"C:/",
"/usr/**",
"~/../x",
"${project}/x",
"!/usr",
] {
let yaml = format!("version: 1\nsandbox:\n read_roots: ['{bad}']\n");
assert!(
matches!(Policy::parse(&yaml), Err(PolicyError::Rule { rule, .. }) if rule == "sandbox.read_roots"),
"{bad} must be rejected"
);
}
assert!(Policy::parse("version: 1\nsandbox: { bogus: 1 }\n").is_err());
assert!(Policy::parse("version: 1\nsandbox: { proxy_port: 18081 }\n").is_ok());
assert!(Policy::parse("version: 1\nsandbox: { proxy_port: 0 }\n").is_err());
}
#[test]
fn rejects_unknown_keys() {
assert!(Policy::parse("version: 1\nbogus: 1\n").is_err());
}
}