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>,
}
#[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)]
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();
let is_denied = self.deny.iter().any(|pattern| {
pattern
.strip_suffix('*')
.map_or_else(|| pattern == key.as_ref(), |prefix| key.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)]
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,
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,
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}");
}
}
}