use serde::{Deserialize, Serialize};
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Tier {
Full,
FsOnly,
Seccomp,
}
impl Tier {
pub fn label(&self) -> &'static str {
match self {
Tier::Full => "full",
Tier::FsOnly => "fs-only",
Tier::Seccomp => "seccomp",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SeccompProfile {
#[default]
Default,
AgentMin,
}
impl SeccompProfile {
pub fn parse(s: &str) -> Option<Self> {
match s {
"default" | "standard" => Some(Self::Default),
"agent-min" | "agent_min" => Some(Self::AgentMin),
_ => None,
}
}
pub fn label(&self) -> &'static str {
match self {
Self::Default => "default",
Self::AgentMin => "agent-min",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CgroupConfig {
pub memory_max: Option<String>,
pub pids_max: Option<String>,
pub swap_max: Option<String>,
pub cpu_max: Option<String>,
}
impl CgroupConfig {
pub fn merge_strictest(&mut self, other: &Self) {
self.memory_max = strictest_memory_str(&self.memory_max, &other.memory_max);
self.pids_max = strictest_pids_str(&self.pids_max, &other.pids_max);
self.swap_max = strictest_memory_str(&self.swap_max, &other.swap_max);
self.cpu_max = strictest_cpu_max(&self.cpu_max, &other.cpu_max);
}
}
pub fn parse_bytes_value(input: &str) -> Option<u64> {
let s = input.trim();
if s.is_empty() || s.eq_ignore_ascii_case("max") {
return None;
}
let (num_part, unit_part) = match s.find(|c: char| !c.is_ascii_digit() && c != '.') {
Some(idx) => (&s[..idx], s[idx..].trim().to_uppercase()),
None => (s, String::new()),
};
let num: f64 = num_part.parse().ok()?;
let multiplier: f64 = match unit_part.as_str() {
"" | "B" => 1.0,
"K" | "KB" | "KIB" => 1024.0,
"M" | "MB" | "MIB" => 1024.0 * 1024.0,
"G" | "GB" | "GIB" => 1024.0 * 1024.0 * 1024.0,
"T" | "TB" | "TIB" => 1024.0 * 1024.0 * 1024.0 * 1024.0,
_ => return None,
};
Some((num * multiplier) as u64)
}
pub fn parse_cpu_ratio(input: &str) -> Option<f64> {
let s = input.trim();
if s.is_empty() || s.eq_ignore_ascii_case("max") || s.starts_with("max ") {
return None;
}
if let Some(pct_str) = s.strip_suffix('%') {
let pct: f64 = pct_str.trim().parse().ok()?;
return Some(pct / 100.0);
}
if s.contains(' ') {
let mut parts = s.split_whitespace();
let quota_s = parts.next()?;
let period_s = parts.next()?;
if quota_s.eq_ignore_ascii_case("max") {
return None;
}
let quota: f64 = quota_s.parse().ok()?;
let period: f64 = period_s.parse().ok()?;
if period > 0.0 {
return Some(quota / period);
}
return None;
}
if let Ok(num) = s.parse::<f64>() {
if num > 1000.0 {
return Some(num / 100_000.0);
} else {
return Some(num / 100.0);
}
}
None
}
pub fn strictest_memory_str(left: &Option<String>, right: &Option<String>) -> Option<String> {
match (left, right) {
(Some(l), Some(r)) => {
let l_trimmed = l.trim();
let r_trimmed = r.trim();
let l_bytes = parse_bytes_value(l_trimmed);
let r_bytes = parse_bytes_value(r_trimmed);
match (l_bytes, r_bytes) {
(Some(lb), Some(rb)) => {
if lb <= rb {
Some(l.clone())
} else {
Some(r.clone())
}
}
(Some(_), None) => Some(l.clone()),
(None, Some(_)) => Some(r.clone()),
(None, None) => {
if l_trimmed.eq_ignore_ascii_case("max")
|| r_trimmed.eq_ignore_ascii_case("max")
{
Some("max".to_string())
} else {
Some(l.clone())
}
}
}
}
(Some(l), None) => Some(l.clone()),
(None, Some(r)) => Some(r.clone()),
(None, None) => None,
}
}
pub fn strictest_pids_str(left: &Option<String>, right: &Option<String>) -> Option<String> {
match (left, right) {
(Some(l), Some(r)) => {
let l_trimmed = l.trim();
let r_trimmed = r.trim();
let l_pids = if l_trimmed.eq_ignore_ascii_case("max") {
None
} else {
l_trimmed.parse::<u64>().ok()
};
let r_pids = if r_trimmed.eq_ignore_ascii_case("max") {
None
} else {
r_trimmed.parse::<u64>().ok()
};
match (l_pids, r_pids) {
(Some(lp), Some(rp)) => {
if lp <= rp {
Some(l.clone())
} else {
Some(r.clone())
}
}
(Some(_), None) => Some(l.clone()),
(None, Some(_)) => Some(r.clone()),
(None, None) => {
if l_trimmed.eq_ignore_ascii_case("max")
|| r_trimmed.eq_ignore_ascii_case("max")
{
Some("max".to_string())
} else {
Some(l.clone())
}
}
}
}
(Some(l), None) => Some(l.clone()),
(None, Some(r)) => Some(r.clone()),
(None, None) => None,
}
}
pub fn strictest_cpu_max(left: &Option<String>, right: &Option<String>) -> Option<String> {
match (left, right) {
(Some(l), Some(r)) => {
let l_trimmed = l.trim();
let r_trimmed = r.trim();
let l_ratio = parse_cpu_ratio(l_trimmed);
let r_ratio = parse_cpu_ratio(r_trimmed);
match (l_ratio, r_ratio) {
(Some(lr), Some(rr)) => {
if lr <= rr {
Some(l.clone())
} else {
Some(r.clone())
}
}
(Some(_), None) => Some(l.clone()),
(None, Some(_)) => Some(r.clone()),
(None, None) => {
if l_trimmed.starts_with("max") || r_trimmed.starts_with("max") {
Some("max 100000".to_string())
} else {
Some(l.clone())
}
}
}
}
(Some(l), None) => Some(l.clone()),
(None, Some(r)) => Some(r.clone()),
(None, None) => None,
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SeccompNotifyConfig {
pub enabled: bool,
pub default_action: Option<String>,
#[serde(default)]
pub allow_syscalls: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum PolicySourceKind {
SystemGlobal,
UserGlobal,
BuiltinProfile,
Preset,
AgentPreset,
Repository,
RepositoryFragment,
LocalOverride,
CliExplicit,
CliOverride,
}
impl PolicySourceKind {
pub fn precedence(&self) -> u8 {
match self {
Self::SystemGlobal => 1,
Self::UserGlobal => 2,
Self::BuiltinProfile | Self::Preset => 3,
Self::AgentPreset => 4,
Self::Repository | Self::RepositoryFragment => 5,
Self::LocalOverride => 6,
Self::CliExplicit | Self::CliOverride => 7,
}
}
pub fn label(&self) -> &'static str {
match self {
Self::SystemGlobal => "system-global",
Self::UserGlobal => "user-global",
Self::BuiltinProfile => "builtin-profile",
Self::Preset => "preset",
Self::AgentPreset => "agent-preset",
Self::Repository => "repository",
Self::RepositoryFragment => "repository-fragment",
Self::LocalOverride => "local-override",
Self::CliExplicit => "cli-explicit",
Self::CliOverride => "cli-override",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DenyEntry {
pub path: PathBuf,
pub is_dir: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PolicyMetadata {
pub name: String,
pub description: String,
pub extends: Vec<String>,
#[serde(default)]
pub source_kind: Option<PolicySourceKind>,
#[serde(default)]
pub immutable: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct IoRateLimit {
pub max_iops: Option<u64>,
pub max_bandwidth: Option<u64>,
}
impl IoRateLimit {
pub fn merge_strictest(&mut self, other: &Self) {
self.max_iops = strictest(self.max_iops, other.max_iops);
self.max_bandwidth = strictest(self.max_bandwidth, other.max_bandwidth);
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResourceLimits {
pub cpu_seconds: Option<u64>,
pub address_space_bytes: Option<u64>,
pub processes: Option<u64>,
pub open_files: Option<u64>,
pub file_size_bytes: Option<u64>,
#[serde(default)]
pub io_rate: Option<IoRateLimit>,
}
impl ResourceLimits {
pub fn merge_strictest(&mut self, other: &Self) {
self.cpu_seconds = strictest(self.cpu_seconds, other.cpu_seconds);
self.address_space_bytes = strictest(self.address_space_bytes, other.address_space_bytes);
self.processes = strictest(self.processes, other.processes);
self.open_files = strictest(self.open_files, other.open_files);
self.file_size_bytes = strictest(self.file_size_bytes, other.file_size_bytes);
match (&mut self.io_rate, &other.io_rate) {
(Some(existing), Some(incoming)) => existing.merge_strictest(incoming),
(None, Some(incoming)) => self.io_rate = Some(incoming.clone()),
_ => {}
}
}
}
fn strictest(left: Option<u64>, right: Option<u64>) -> Option<u64> {
match (left, right) {
(Some(left), Some(right)) => Some(left.min(right)),
(Some(value), None) | (None, Some(value)) => Some(value),
(None, None) => None,
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnvironmentPolicy {
pub pass_through: Vec<String>,
#[serde(default)]
pub deny: Vec<String>,
}
impl EnvironmentPolicy {
pub fn allows(&self, key: &OsStr) -> bool {
let key = key.to_string_lossy();
#[cfg(target_os = "windows")]
let key_cmp: String = key.to_uppercase();
#[cfg(not(target_os = "windows"))]
let key_cmp: &str = &key;
let is_denied = self.deny.iter().any(|pattern| {
#[cfg(target_os = "windows")]
let pattern_cmp: String = pattern.to_uppercase();
#[cfg(not(target_os = "windows"))]
let pattern_cmp: &str = pattern;
pattern_cmp.strip_suffix('*').map_or_else(
|| pattern_cmp == key_cmp,
|prefix| key_cmp.starts_with(prefix),
)
});
if is_denied {
return false;
}
self.pass_through.iter().any(|pattern| {
pattern
.strip_suffix('*')
.map_or_else(|| pattern == key.as_ref(), |prefix| key.starts_with(prefix))
})
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SubtractiveRules {
pub deny_write: Vec<PathBuf>,
pub deny_read: Vec<PathBuf>,
pub deny_env: Vec<String>,
pub deny_network: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Policy {
pub name: String,
pub metadata: PolicyMetadata,
pub limits: ResourceLimits,
pub allow_write: Vec<PathBuf>,
pub allow_read: Vec<PathBuf>,
pub deny_write: Vec<PathBuf>,
pub deny_read: Vec<PathBuf>,
pub deny_resolved: Vec<DenyEntry>,
pub environment: EnvironmentPolicy,
pub deny_network: bool,
#[serde(default)]
pub network_mode: Option<String>,
#[serde(default)]
pub network_allow: Vec<String>,
pub allow_cidr: Vec<String>,
pub net_quota: std::collections::HashMap<String, u64>,
pub net_bind_ports: Vec<u16>,
pub net_connect_ports: Vec<u16>,
pub allow_unix_sockets: Vec<String>,
pub seccomp_profile: SeccompProfile,
pub seccomp_notify: Option<SeccompNotifyConfig>,
pub cgroup: Option<CgroupConfig>,
pub cpu_max: Option<String>,
pub io_priority: Option<String>,
pub dev_allow: Option<Vec<String>>,
pub oslog: bool,
pub lpac: bool,
pub is_immutable: bool,
pub system_log: bool,
pub auto_deny_secrets: bool,
pub secret_proxies: Vec<String>,
pub ro_mounts: Vec<PathBuf>,
pub git_guard: bool,
pub snapshot: bool,
pub tmpfs_tmp: bool,
pub warnings: Vec<String>,
}
impl Default for Policy {
fn default() -> Self {
Self {
name: "default".to_string(),
metadata: PolicyMetadata::default(),
limits: ResourceLimits::default(),
allow_write: Vec::new(),
allow_read: Vec::new(),
deny_write: Vec::new(),
deny_read: Vec::new(),
deny_resolved: Vec::new(),
environment: EnvironmentPolicy::default(),
deny_network: false,
network_mode: None,
network_allow: Vec::new(),
allow_cidr: Vec::new(),
net_quota: std::collections::HashMap::new(),
net_bind_ports: Vec::new(),
net_connect_ports: Vec::new(),
allow_unix_sockets: Vec::new(),
seccomp_profile: SeccompProfile::Default,
seccomp_notify: None,
cgroup: None,
cpu_max: None,
io_priority: None,
dev_allow: None,
oslog: false,
lpac: false,
is_immutable: false,
system_log: false,
auto_deny_secrets: false,
secret_proxies: Vec::new(),
ro_mounts: Vec::new(),
git_guard: false,
snapshot: false,
tmpfs_tmp: true,
warnings: Vec::new(),
}
}
}
impl Policy {
pub fn summary(&self) -> String {
format!(
"profile '{}': {} write root(s), {} read root(s), {} deny path(s) resolved",
self.name,
self.allow_write.len(),
self.allow_read.len(),
self.deny_resolved.len()
)
}
pub fn in_write_scope(&self, path: &Path) -> bool {
let probed = normalize_scope_path(path);
if self
.deny_write
.iter()
.any(|denied| probed.starts_with(normalize_scope_path(denied)))
{
return false;
}
self.allow_write
.iter()
.any(|root| probed.starts_with(normalize_scope_path(root)))
}
pub fn in_read_scope(&self, path: &Path) -> bool {
let probed = normalize_scope_path(path);
if self
.deny_read
.iter()
.any(|denied| probed.starts_with(normalize_scope_path(denied)))
{
return false;
}
let mut allowed = self.allow_read.iter().chain(self.allow_write.iter());
allowed.any(|root| probed.starts_with(normalize_scope_path(root)))
}
}
fn lexical_normalize(path: &Path) -> PathBuf {
use std::path::Component;
let has_root = path.has_root();
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
if !normalized.pop() && !has_root {
normalized.push(component.as_os_str());
}
}
Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
normalized.push(component.as_os_str());
}
}
}
normalized
}
fn normalize_scope_path(path: &Path) -> PathBuf {
if path.is_absolute() {
let mut unresolved: Vec<std::ffi::OsString> = Vec::new();
let mut cursor = path;
loop {
if let Ok(canonical) = std::fs::canonicalize(cursor) {
let mut resolved = canonical;
for component in unresolved.iter().rev() {
resolved.push(component);
}
return lexical_normalize(&resolved);
}
match (cursor.file_name(), cursor.parent()) {
(Some(name), Some(parent)) if parent != cursor => {
unresolved.push(name.to_os_string());
cursor = parent;
}
_ => return lexical_normalize(path),
}
}
} else {
lexical_normalize(path)
}
}
#[cfg(test)]
mod environment_tests {
use super::EnvironmentPolicy;
use std::ffi::OsStr;
#[test]
fn allowlist_is_exact_and_secrets_are_default_deny() {
let policy = EnvironmentPolicy {
pass_through: vec!["PATH".into(), "LC_*".into(), "SAFE_EXACT".into()],
deny: vec!["LC_SECRET*".into()],
};
assert!(policy.allows(OsStr::new("PATH")));
assert!(policy.allows(OsStr::new("LC_ALL")));
assert!(!policy.allows(OsStr::new("LC_SECRET_VAL")));
assert!(policy.allows(OsStr::new("SAFE_EXACT")));
assert!(!policy.allows(OsStr::new("SAFE_EXACT_EXTRA")));
for secret in [
"GH_TOKEN",
"OPENAI_API_KEY",
"AWS_SECRET_ACCESS_KEY",
"ANTHROPIC_API_KEY",
] {
assert!(!policy.allows(OsStr::new(secret)), "leaked {secret}");
}
}
}
#[cfg(test)]
mod cgroup_tests {
use super::*;
#[test]
fn test_cgroup_merge_memory_strictest() {
let mut cfg = CgroupConfig {
memory_max: Some("1G".into()),
pids_max: None,
swap_max: None,
cpu_max: None,
};
let other = CgroupConfig {
memory_max: Some("512M".into()),
pids_max: None,
swap_max: None,
cpu_max: None,
};
cfg.merge_strictest(&other);
assert_eq!(cfg.memory_max.as_deref(), Some("512M"));
let mut cfg = CgroupConfig {
memory_max: Some("max".into()),
..Default::default()
};
let other = CgroupConfig {
memory_max: Some("256M".into()),
..Default::default()
};
cfg.merge_strictest(&other);
assert_eq!(cfg.memory_max.as_deref(), Some("256M"));
let mut cfg = CgroupConfig {
memory_max: Some("256M".into()),
..Default::default()
};
let other = CgroupConfig {
memory_max: Some("max".into()),
..Default::default()
};
cfg.merge_strictest(&other);
assert_eq!(cfg.memory_max.as_deref(), Some("256M"));
let mut cfg = CgroupConfig::default();
let other = CgroupConfig {
memory_max: Some("512M".into()),
..Default::default()
};
cfg.merge_strictest(&other);
assert_eq!(cfg.memory_max.as_deref(), Some("512M"));
}
#[test]
fn test_cgroup_merge_pids_strictest() {
let mut cfg = CgroupConfig {
pids_max: Some("100".into()),
..Default::default()
};
let other = CgroupConfig {
pids_max: Some("50".into()),
..Default::default()
};
cfg.merge_strictest(&other);
assert_eq!(cfg.pids_max.as_deref(), Some("50"));
let other_max = CgroupConfig {
pids_max: Some("max".into()),
..Default::default()
};
cfg.merge_strictest(&other_max);
assert_eq!(cfg.pids_max.as_deref(), Some("50"));
let mut cfg_max = CgroupConfig {
pids_max: Some("max".into()),
..Default::default()
};
cfg_max.merge_strictest(&cfg);
assert_eq!(cfg_max.pids_max.as_deref(), Some("50"));
}
#[test]
fn test_cgroup_merge_cpu_max_strictest() {
let mut cfg = CgroupConfig {
cpu_max: Some("100%".into()),
..Default::default()
};
let other = CgroupConfig {
cpu_max: Some("50%".into()),
..Default::default()
};
cfg.merge_strictest(&other);
assert_eq!(cfg.cpu_max.as_deref(), Some("50%"));
let mut cfg = CgroupConfig {
cpu_max: Some("80%".into()),
..Default::default()
};
let other = CgroupConfig {
cpu_max: Some("50000 100000".into()),
..Default::default()
};
cfg.merge_strictest(&other);
assert_eq!(cfg.cpu_max.as_deref(), Some("50000 100000"));
let mut cfg_max = CgroupConfig {
cpu_max: Some("max 100000".into()),
..Default::default()
};
cfg_max.merge_strictest(&cfg);
assert_eq!(cfg_max.cpu_max.as_deref(), Some("50000 100000"));
}
}