use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use crate::backend::FileInjection;
use crate::permissions::SecurityProfile;
pub type LlmKeysConfig = std::collections::BTreeMap<String, String>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileEntry {
pub source: String,
pub dest: String,
#[serde(default = "default_file_mode")]
pub mode: String,
}
fn default_file_mode() -> String {
"0644".to_string()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BuildConfig {
#[serde(default)]
pub dockerfile: Option<String>,
#[serde(default)]
pub context: Option<String>,
#[serde(default)]
pub target: Option<String>,
#[serde(default)]
pub args: std::collections::HashMap<String, String>,
#[serde(default)]
pub no_cache: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrchestratorConfig {
#[serde(default)]
pub provider: Option<String>,
#[serde(default = "default_namespace")]
pub namespace: String,
#[serde(default)]
pub kubeconfig: Option<String>,
#[serde(default)]
pub context: Option<String>,
#[serde(default)]
pub runtime_class: Option<String>,
#[serde(default)]
pub service_account: Option<String>,
#[serde(default)]
pub node_selector: std::collections::HashMap<String, String>,
#[serde(default)]
pub nomad_addr: Option<String>,
#[serde(default)]
pub nomad_token: Option<String>,
#[serde(default = "default_nomad_driver")]
pub nomad_driver: String,
#[serde(default)]
pub nomad_datacenter: Option<String>,
#[serde(default = "default_warm_pool_size")]
pub warm_pool_size: usize,
#[serde(default = "default_max_pool_size")]
pub max_pool_size: usize,
#[serde(default)]
pub warm_pool_images: Vec<String>,
#[serde(default = "default_max_sandboxes")]
pub max_sandboxes: usize,
}
fn default_namespace() -> String {
"agentkernel".to_string()
}
fn default_nomad_driver() -> String {
"docker".to_string()
}
fn default_warm_pool_size() -> usize {
10
}
fn default_max_pool_size() -> usize {
50
}
fn default_max_sandboxes() -> usize {
200
}
impl Default for OrchestratorConfig {
fn default() -> Self {
Self {
provider: None,
namespace: default_namespace(),
kubeconfig: None,
context: None,
runtime_class: None,
service_account: None,
node_selector: std::collections::HashMap::new(),
nomad_addr: None,
nomad_token: None,
nomad_driver: default_nomad_driver(),
nomad_datacenter: None,
warm_pool_size: default_warm_pool_size(),
max_pool_size: default_max_pool_size(),
warm_pool_images: Vec::new(),
max_sandboxes: default_max_sandboxes(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ApiTlsConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub cert: Option<String>,
#[serde(default)]
pub key: Option<String>,
#[serde(default)]
pub require_tls: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ApiConfig {
#[serde(default)]
pub tls: ApiTlsConfig,
#[serde(default)]
pub allow_sudo_exec: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TrustAnchorsConfig {
#[serde(default)]
pub keys: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnterpriseConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub policy_server: Option<String>,
#[serde(default)]
pub org_id: Option<String>,
#[serde(default)]
pub api_key_env: Option<String>,
#[serde(default = "default_offline_mode")]
pub offline_mode: String,
#[serde(default = "default_cache_max_age_hours")]
pub cache_max_age_hours: u64,
#[serde(default)]
pub trust_anchors: TrustAnchorsConfig,
#[serde(default = "default_enterprise_roles")]
pub default_roles: Vec<String>,
#[serde(default)]
pub jwks_url: Option<String>,
}
fn default_enterprise_roles() -> Vec<String> {
vec!["developer".to_string()]
}
fn default_offline_mode() -> String {
"cached_with_expiry".to_string()
}
fn default_cache_max_age_hours() -> u64 {
24
}
impl Default for EnterpriseConfig {
fn default() -> Self {
Self {
enabled: false,
policy_server: None,
org_id: None,
api_key_env: None,
offline_mode: default_offline_mode(),
cache_max_age_hours: default_cache_max_age_hours(),
trust_anchors: TrustAnchorsConfig::default(),
default_roles: default_enterprise_roles(),
jwks_url: None,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TransportConfig {
#[serde(default)]
pub ssh: bool,
pub vault_addr: Option<String>,
#[serde(default = "default_ssh_mount")]
pub vault_ssh_mount: String,
#[serde(default = "default_ssh_role")]
pub vault_ssh_role: String,
#[serde(default = "default_cert_ttl")]
pub cert_ttl: String,
#[serde(default)]
pub require_encrypted: bool,
}
impl Default for TransportConfig {
fn default() -> Self {
Self {
ssh: false,
vault_addr: None,
vault_ssh_mount: default_ssh_mount(),
vault_ssh_role: default_ssh_role(),
cert_ttl: default_cert_ttl(),
require_encrypted: false,
}
}
}
fn default_ssh_mount() -> String {
"ssh".to_string()
}
fn default_ssh_role() -> String {
"agentkernel-client".to_string()
}
fn default_cert_ttl() -> String {
"30m".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub sandbox: SandboxConfig,
#[serde(default)]
pub agent: AgentConfig,
#[serde(default)]
pub resources: ResourcesConfig,
#[serde(default)]
pub network: NetworkConfig,
#[serde(default)]
pub security: SecurityConfig,
#[serde(default)]
pub build: BuildConfig,
#[serde(default, rename = "files")]
pub files: Vec<FileEntry>,
#[serde(default)]
pub orchestrator: OrchestratorConfig,
#[serde(default)]
pub enterprise: EnterpriseConfig,
#[serde(default)]
pub api: ApiConfig,
#[serde(default)]
pub proxy: ProxyHooksConfig,
#[serde(default)]
pub secrets: std::collections::BTreeMap<String, String>,
#[serde(default)]
pub llm_keys: LlmKeysConfig,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SecurityConfig {
#[serde(default)]
pub profile: SecurityProfile,
pub network: Option<bool>,
pub mount_cwd: Option<bool>,
#[serde(default)]
pub domains: DomainConfig,
#[serde(default)]
pub commands: CommandConfig,
#[serde(default)]
pub seccomp: Option<String>,
#[serde(default)]
pub transport: TransportConfig,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DomainConfig {
#[serde(default)]
pub allow: Vec<String>,
#[serde(default)]
pub block: Vec<String>,
#[serde(default)]
pub allowlist_only: bool,
}
impl DomainConfig {
pub fn has_rules(&self) -> bool {
!self.allow.is_empty() || !self.block.is_empty() || self.allowlist_only
}
pub fn is_allowed(&self, domain: &str) -> bool {
for pattern in &self.block {
if Self::matches_pattern(domain, pattern) {
return false;
}
}
if self.allowlist_only {
return self.allow.iter().any(|p| Self::matches_pattern(domain, p));
}
true
}
fn matches_pattern(domain: &str, pattern: &str) -> bool {
if pattern.starts_with("*.") {
let suffix = &pattern[1..]; domain.ends_with(suffix) || domain == &pattern[2..]
} else {
domain == pattern
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CommandConfig {
#[serde(default)]
pub allow: Vec<String>,
#[serde(default)]
pub block: Vec<String>,
#[serde(default)]
pub allowlist_only: bool,
}
impl CommandConfig {
pub fn is_allowed(&self, command: &str) -> bool {
let binary = command.split_whitespace().next().unwrap_or(command);
let binary_name = std::path::Path::new(binary)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(binary);
if self.block.iter().any(|b| b == binary_name || b == binary) {
return false;
}
if self.allowlist_only {
return self.allow.iter().any(|a| a == binary_name || a == binary);
}
true
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxConfig {
pub name: String,
#[serde(default = "default_runtime")]
pub runtime: String,
#[serde(default)]
pub base_image: Option<String>,
#[serde(default)]
pub init_script: Option<String>,
}
fn default_runtime() -> String {
"base".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentConfig {
#[serde(default = "default_agent")]
pub preferred: String,
#[serde(default)]
pub compatibility_mode: Option<String>,
}
impl Default for AgentConfig {
fn default() -> Self {
Self {
preferred: default_agent(),
compatibility_mode: None,
}
}
}
fn default_agent() -> String {
"claude".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResourcesConfig {
#[serde(default = "default_vcpus")]
pub vcpus: u32,
#[serde(default = "default_memory_mb")]
pub memory_mb: u64,
}
impl Default for ResourcesConfig {
fn default() -> Self {
Self {
vcpus: default_vcpus(),
memory_mb: default_memory_mb(),
}
}
}
fn default_vcpus() -> u32 {
1
}
fn default_memory_mb() -> u64 {
512
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NetworkConfig {
pub vsock_cid: Option<u32>,
#[serde(default)]
pub ports: Vec<String>,
}
impl NetworkConfig {
pub fn port_mappings(&self) -> anyhow::Result<Vec<crate::backend::PortMapping>> {
self.ports
.iter()
.map(|s| crate::backend::PortMapping::parse(s))
.collect()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProxyHooksConfig {
#[serde(default)]
pub hooks: Vec<ProxyHookEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxyHookEntry {
pub name: String,
pub event: String,
pub target: ProxyHookTargetEntry,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ProxyHookTargetEntry {
Webhook { url: String },
File { path: String },
Audit,
}
impl Config {
pub fn from_file(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
Self::from_str(&content)
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(content: &str) -> Result<Self> {
toml::from_str(content).context("Failed to parse TOML configuration")
}
pub fn minimal(name: &str, agent: &str) -> Self {
Self {
sandbox: SandboxConfig {
name: name.to_string(),
runtime: default_runtime(),
base_image: None,
init_script: None,
},
agent: AgentConfig {
preferred: agent.to_string(),
compatibility_mode: None,
},
resources: ResourcesConfig::default(),
network: NetworkConfig::default(),
security: SecurityConfig::default(),
build: BuildConfig::default(),
files: Vec::new(),
orchestrator: OrchestratorConfig::default(),
enterprise: EnterpriseConfig::default(),
api: ApiConfig::default(),
proxy: ProxyHooksConfig::default(),
secrets: std::collections::BTreeMap::new(),
llm_keys: LlmKeysConfig::default(),
}
}
pub fn get_permissions(&self) -> crate::permissions::Permissions {
if let Some(ref mode_str) = self.agent.compatibility_mode
&& let Some(mode) = crate::permissions::CompatibilityMode::from_str(mode_str)
{
let mut perms = mode.profile().permissions;
if let Some(network) = self.security.network {
perms.network = network;
}
if let Some(mount_cwd) = self.security.mount_cwd {
perms.mount_cwd = mount_cwd;
}
return perms;
}
let mut perms = self.security.profile.permissions();
if let Some(network) = self.security.network {
perms.network = network;
}
if let Some(mount_cwd) = self.security.mount_cwd {
perms.mount_cwd = mount_cwd;
}
perms
}
#[allow(dead_code)]
pub fn get_agent_profile(&self) -> Option<crate::permissions::AgentProfile> {
self.agent
.compatibility_mode
.as_ref()
.and_then(|mode_str| crate::permissions::CompatibilityMode::from_str(mode_str))
.map(|mode| mode.profile())
}
pub fn validate(&self) -> Vec<String> {
let mut warnings = Vec::new();
let perms = self.get_permissions();
if !self.network.ports.is_empty() && !perms.network {
warnings.push(
"Port mappings in [network] have no effect because network access is disabled."
.to_string(),
);
}
for port_str in &self.network.ports {
if crate::backend::PortMapping::parse(port_str).is_err() {
warnings.push(format!("Invalid port mapping '{}' in [network].", port_str));
}
}
if self.security.domains.has_rules() && !perms.network {
warnings.push(
"Domain filtering rules in [security.domains] have no effect \
because network access is disabled."
.to_string(),
);
}
for domain in &self.security.domains.allow {
if !self.security.domains.is_allowed(domain) {
warnings.push(format!(
"Domain '{}' is in the allow list but matched by the block list \
(block takes precedence).",
domain
));
}
}
if self.security.domains.has_rules() && perms.network {
warnings.push(
"Domain filtering rules are configured but runtime DNS enforcement \
is not yet implemented. Rules are recorded for future use."
.to_string(),
);
}
if self.security.transport.require_encrypted && !self.network.ports.is_empty() {
warnings.push(
"Transport encryption is required but port mappings do not include \
TLS termination. Consider using --ssh or adding a TLS proxy."
.to_string(),
);
}
if self.security.transport.ssh && !perms.network {
warnings.push(
"SSH is enabled but the security profile is 'restrictive' (no network). \
SSH requires network access."
.to_string(),
);
}
warnings
}
pub fn docker_image(&self) -> String {
if let Some(ref image) = self.sandbox.base_image {
return image.clone();
}
match self.sandbox.runtime.as_str() {
"python" => "python:3.12-alpine".to_string(),
"node" => "node:22-alpine".to_string(),
"go" => "golang:1.23-alpine".to_string(),
"rust" => "rust:1.85-alpine".to_string(),
"ruby" => "ruby:3.3-alpine".to_string(),
"java" => "eclipse-temurin:21-alpine".to_string(),
"c" => "gcc:14-bookworm".to_string(),
"dotnet" => "mcr.microsoft.com/dotnet/sdk:8.0".to_string(),
_ => "alpine:3.20".to_string(),
}
}
pub fn dockerfile_path(&self, base_dir: &Path) -> Option<std::path::PathBuf> {
if let Some(ref dockerfile) = self.build.dockerfile {
let path = if Path::new(dockerfile).is_absolute() {
Path::new(dockerfile).to_path_buf()
} else {
base_dir.join(dockerfile)
};
if path.exists() {
return Some(path);
}
}
crate::languages::detect_dockerfile(base_dir)
}
pub fn build_context(&self, base_dir: &Path, dockerfile_path: &Path) -> std::path::PathBuf {
if let Some(ref context) = self.build.context {
if Path::new(context).is_absolute() {
Path::new(context).to_path_buf()
} else {
base_dir.join(context)
}
} else {
dockerfile_path.parent().unwrap_or(base_dir).to_path_buf()
}
}
pub fn requires_build(&self, base_dir: &Path) -> bool {
self.dockerfile_path(base_dir).is_some()
}
pub fn load_files(&self, base_dir: &Path) -> Result<Vec<FileInjection>> {
let mut injections = Vec::new();
for file in &self.files {
let source_path = if Path::new(&file.source).is_absolute() {
Path::new(&file.source).to_path_buf()
} else {
base_dir.join(&file.source)
};
let content = std::fs::read(&source_path).with_context(|| {
format!(
"Failed to read file for injection: {}",
source_path.display()
)
})?;
injections.push(FileInjection {
content,
dest: file.dest.clone(),
});
}
Ok(injections)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_minimal_config() {
let toml = r#"
[sandbox]
name = "test-app"
"#;
let config = Config::from_str(toml).unwrap();
assert_eq!(config.sandbox.name, "test-app");
assert_eq!(config.sandbox.runtime, "base");
assert_eq!(config.agent.preferred, "claude");
assert_eq!(config.resources.vcpus, 1);
assert_eq!(config.resources.memory_mb, 512);
}
#[test]
fn test_parse_full_config() {
let toml = r#"
[sandbox]
name = "python-app"
runtime = "python"
[agent]
preferred = "gemini"
[resources]
vcpus = 2
memory_mb = 1024
[network]
vsock_cid = 5
"#;
let config = Config::from_str(toml).unwrap();
assert_eq!(config.sandbox.name, "python-app");
assert_eq!(config.sandbox.runtime, "python");
assert_eq!(config.agent.preferred, "gemini");
assert_eq!(config.resources.vcpus, 2);
assert_eq!(config.resources.memory_mb, 1024);
assert_eq!(config.network.vsock_cid, Some(5));
}
#[test]
fn test_parse_files_config() {
let toml = r#"
[sandbox]
name = "test-app"
[[files]]
source = "./config.json"
dest = "/app/config.json"
[[files]]
source = "./script.sh"
dest = "/app/script.sh"
mode = "0755"
"#;
let config = Config::from_str(toml).unwrap();
assert_eq!(config.files.len(), 2);
assert_eq!(config.files[0].source, "./config.json");
assert_eq!(config.files[0].dest, "/app/config.json");
assert_eq!(config.files[0].mode, "0644"); assert_eq!(config.files[1].source, "./script.sh");
assert_eq!(config.files[1].dest, "/app/script.sh");
assert_eq!(config.files[1].mode, "0755");
}
#[test]
fn test_empty_files_config() {
let toml = r#"
[sandbox]
name = "test-app"
"#;
let config = Config::from_str(toml).unwrap();
assert!(config.files.is_empty());
}
#[test]
fn test_parse_build_config() {
let toml = r#"
[sandbox]
name = "custom-app"
[build]
dockerfile = "./Dockerfile.dev"
context = "./app"
target = "runtime"
no_cache = true
[build.args]
PYTHON_VERSION = "3.12"
DEBUG = "true"
"#;
let config = Config::from_str(toml).unwrap();
assert_eq!(
config.build.dockerfile,
Some("./Dockerfile.dev".to_string())
);
assert_eq!(config.build.context, Some("./app".to_string()));
assert_eq!(config.build.target, Some("runtime".to_string()));
assert!(config.build.no_cache);
assert_eq!(
config.build.args.get("PYTHON_VERSION"),
Some(&"3.12".to_string())
);
assert_eq!(config.build.args.get("DEBUG"), Some(&"true".to_string()));
}
#[test]
fn test_default_build_config() {
let toml = r#"
[sandbox]
name = "test-app"
"#;
let config = Config::from_str(toml).unwrap();
assert!(config.build.dockerfile.is_none());
assert!(config.build.context.is_none());
assert!(config.build.target.is_none());
assert!(!config.build.no_cache);
assert!(config.build.args.is_empty());
}
#[test]
fn test_agent_compatibility_mode() {
let toml = r#"
[sandbox]
name = "claude-project"
[agent]
preferred = "claude"
compatibility_mode = "claude"
"#;
let config = Config::from_str(toml).unwrap();
assert_eq!(config.agent.preferred, "claude");
assert_eq!(config.agent.compatibility_mode, Some("claude".to_string()));
let profile = config.get_agent_profile();
assert!(profile.is_some());
let profile = profile.unwrap();
assert!(profile.permissions.mount_cwd); assert!(
profile
.network_policy
.always_allow
.contains(&"api.anthropic.com".to_string())
);
}
#[test]
fn test_agent_compatibility_mode_with_overrides() {
let toml = r#"
[sandbox]
name = "claude-no-network"
[agent]
compatibility_mode = "claude"
[security]
network = false
"#;
let config = Config::from_str(toml).unwrap();
let perms = config.get_permissions();
assert!(perms.mount_cwd); assert!(!perms.network); }
#[test]
fn test_domain_config_allow() {
let config = DomainConfig {
allow: vec!["api.example.com".to_string(), "*.pypi.org".to_string()],
block: vec!["169.254.169.254".to_string()],
allowlist_only: false,
};
assert!(config.is_allowed("api.example.com"));
assert!(config.is_allowed("pypi.org")); assert!(config.is_allowed("files.pypi.org")); assert!(config.is_allowed("random.com")); assert!(!config.is_allowed("169.254.169.254")); }
#[test]
fn test_domain_config_allowlist_only() {
let config = DomainConfig {
allow: vec!["api.example.com".to_string(), "*.pypi.org".to_string()],
block: vec![],
allowlist_only: true,
};
assert!(config.is_allowed("api.example.com"));
assert!(config.is_allowed("pypi.org"));
assert!(!config.is_allowed("random.com")); }
#[test]
fn test_command_config_allow() {
let config = CommandConfig {
allow: vec!["python".to_string(), "node".to_string()],
block: vec!["rm".to_string(), "sudo".to_string()],
allowlist_only: false,
};
assert!(config.is_allowed("python script.py"));
assert!(config.is_allowed("/usr/bin/python script.py"));
assert!(config.is_allowed("echo hello")); assert!(!config.is_allowed("rm -rf /"));
assert!(!config.is_allowed("sudo apt install"));
}
#[test]
fn test_command_config_allowlist_only() {
let config = CommandConfig {
allow: vec!["python".to_string(), "node".to_string()],
block: vec![],
allowlist_only: true,
};
assert!(config.is_allowed("python"));
assert!(config.is_allowed("node index.js"));
assert!(!config.is_allowed("bash")); }
#[test]
fn test_security_config_with_domains() {
let toml = r#"
[sandbox]
name = "restricted-app"
[security]
profile = "restrictive"
[security.domains]
allow = ["api.example.com", "*.pypi.org"]
block = ["169.254.169.254"]
allowlist_only = false
"#;
let config = Config::from_str(toml).unwrap();
assert!(
config
.security
.domains
.allow
.contains(&"api.example.com".to_string())
);
assert!(
config
.security
.domains
.block
.contains(&"169.254.169.254".to_string())
);
assert!(!config.security.domains.allowlist_only);
}
#[test]
fn test_security_config_with_commands() {
let toml = r#"
[sandbox]
name = "restricted-app"
[security]
profile = "restrictive"
[security.commands]
allow = ["python", "node", "npm"]
block = ["rm", "sudo", "chmod"]
allowlist_only = true
"#;
let config = Config::from_str(toml).unwrap();
assert!(
config
.security
.commands
.allow
.contains(&"python".to_string())
);
assert!(config.security.commands.block.contains(&"sudo".to_string()));
assert!(config.security.commands.allowlist_only);
}
#[test]
fn test_security_config_with_seccomp() {
let toml = r#"
[sandbox]
name = "hardened-app"
[security]
profile = "restrictive"
seccomp = "default"
"#;
let config = Config::from_str(toml).unwrap();
assert_eq!(config.security.seccomp, Some("default".to_string()));
}
#[test]
fn test_domain_config_has_rules() {
let empty = DomainConfig::default();
assert!(!empty.has_rules());
let with_allow = DomainConfig {
allow: vec!["example.com".to_string()],
..Default::default()
};
assert!(with_allow.has_rules());
let allowlist_only = DomainConfig {
allowlist_only: true,
..Default::default()
};
assert!(allowlist_only.has_rules());
}
#[test]
fn test_validate_domain_rules_no_network() {
let toml = r#"
[sandbox]
name = "test"
[security]
profile = "restrictive"
[security.domains]
allow = ["api.example.com"]
"#;
let config = Config::from_str(toml).unwrap();
let warnings = config.validate();
assert!(warnings.iter().any(|w| w.contains("no effect")));
}
#[test]
fn test_validate_no_warnings_without_domain_rules() {
let toml = r#"
[sandbox]
name = "test"
"#;
let config = Config::from_str(toml).unwrap();
let warnings = config.validate();
assert!(warnings.is_empty());
}
#[test]
fn test_enterprise_config_defaults() {
let toml = r#"
[sandbox]
name = "test"
"#;
let config = Config::from_str(toml).unwrap();
assert!(!config.enterprise.enabled);
assert!(config.enterprise.policy_server.is_none());
assert!(config.enterprise.org_id.is_none());
assert!(config.enterprise.api_key_env.is_none());
assert_eq!(config.enterprise.offline_mode, "cached_with_expiry");
assert_eq!(config.enterprise.cache_max_age_hours, 24);
assert!(config.enterprise.trust_anchors.keys.is_empty());
}
#[test]
fn test_enterprise_config_full() {
let toml = r#"
[sandbox]
name = "enterprise-app"
[enterprise]
enabled = true
policy_server = "https://policy.acme-corp.com"
org_id = "acme-corp"
api_key_env = "AGENTKERNEL_API_KEY"
offline_mode = "fail_closed"
cache_max_age_hours = 48
[enterprise.trust_anchors]
keys = ["key1-public", "key2-public"]
"#;
let config = Config::from_str(toml).unwrap();
assert!(config.enterprise.enabled);
assert_eq!(
config.enterprise.policy_server,
Some("https://policy.acme-corp.com".to_string())
);
assert_eq!(config.enterprise.org_id, Some("acme-corp".to_string()));
assert_eq!(
config.enterprise.api_key_env,
Some("AGENTKERNEL_API_KEY".to_string())
);
assert_eq!(config.enterprise.offline_mode, "fail_closed");
assert_eq!(config.enterprise.cache_max_age_hours, 48);
assert_eq!(config.enterprise.trust_anchors.keys.len(), 2);
}
#[test]
fn test_api_tls_config_defaults() {
let toml = r#"
[sandbox]
name = "test"
"#;
let config = Config::from_str(toml).unwrap();
assert!(!config.api.tls.enabled);
assert!(config.api.tls.cert.is_none());
assert!(config.api.tls.key.is_none());
assert!(!config.api.tls.require_tls);
assert!(!config.api.allow_sudo_exec);
}
#[test]
fn test_api_tls_config_full() {
let toml = r#"
[sandbox]
name = "tls-app"
[api]
allow_sudo_exec = true
[api.tls]
enabled = true
cert = "/etc/certs/api.pem"
key = "/etc/certs/api-key.pem"
require_tls = true
"#;
let config = Config::from_str(toml).unwrap();
assert!(config.api.tls.enabled);
assert_eq!(config.api.tls.cert, Some("/etc/certs/api.pem".to_string()));
assert_eq!(
config.api.tls.key,
Some("/etc/certs/api-key.pem".to_string())
);
assert!(config.api.tls.require_tls);
assert!(config.api.allow_sudo_exec);
}
#[test]
fn test_api_tls_config_enabled_only() {
let toml = r#"
[sandbox]
name = "self-signed-app"
[api.tls]
enabled = true
"#;
let config = Config::from_str(toml).unwrap();
assert!(config.api.tls.enabled);
assert!(config.api.tls.cert.is_none());
assert!(config.api.tls.key.is_none());
assert!(!config.api.tls.require_tls);
assert!(!config.api.allow_sudo_exec);
}
#[test]
fn test_transport_config_defaults() {
let toml = r#"
[sandbox]
name = "test"
"#;
let config = Config::from_str(toml).unwrap();
assert!(!config.security.transport.ssh);
assert!(config.security.transport.vault_addr.is_none());
assert_eq!(config.security.transport.vault_ssh_mount, "ssh");
assert_eq!(
config.security.transport.vault_ssh_role,
"agentkernel-client"
);
assert_eq!(config.security.transport.cert_ttl, "30m");
}
#[test]
fn test_transport_config_full() {
let toml = r#"
[sandbox]
name = "ssh-app"
[security.transport]
ssh = true
vault_addr = "https://vault.example.com"
vault_ssh_mount = "ssh-client"
vault_ssh_role = "my-role"
cert_ttl = "1h"
"#;
let config = Config::from_str(toml).unwrap();
assert!(config.security.transport.ssh);
assert_eq!(
config.security.transport.vault_addr,
Some("https://vault.example.com".to_string())
);
assert_eq!(config.security.transport.vault_ssh_mount, "ssh-client");
assert_eq!(config.security.transport.vault_ssh_role, "my-role");
assert_eq!(config.security.transport.cert_ttl, "1h");
}
#[test]
fn test_transport_config_ssh_only() {
let toml = r#"
[sandbox]
name = "ssh-only"
[security.transport]
ssh = true
"#;
let config = Config::from_str(toml).unwrap();
assert!(config.security.transport.ssh);
assert_eq!(config.security.transport.vault_ssh_mount, "ssh");
assert_eq!(
config.security.transport.vault_ssh_role,
"agentkernel-client"
);
assert_eq!(config.security.transport.cert_ttl, "30m");
}
#[test]
fn test_transport_config_require_encrypted() {
let toml = r#"
[sandbox]
name = "secure-app"
[security.transport]
require_encrypted = true
ssh = true
"#;
let config = Config::from_str(toml).unwrap();
assert!(config.security.transport.require_encrypted);
assert!(config.security.transport.ssh);
}
#[test]
fn test_transport_config_require_encrypted_default() {
let toml = r#"
[sandbox]
name = "test"
"#;
let config = Config::from_str(toml).unwrap();
assert!(!config.security.transport.require_encrypted);
}
#[test]
fn test_validate_require_encrypted_with_ports() {
let toml = r#"
[sandbox]
name = "test"
[security]
profile = "permissive"
[security.transport]
require_encrypted = true
[network]
ports = ["8080:80"]
"#;
let config = Config::from_str(toml).unwrap();
let warnings = config.validate();
assert!(warnings.iter().any(|w| w.contains("encryption")));
}
#[test]
fn test_validate_ssh_restrictive_profile_warning() {
let toml = r#"
[sandbox]
name = "test"
[security]
profile = "restrictive"
[security.transport]
ssh = true
"#;
let config = Config::from_str(toml).unwrap();
let warnings = config.validate();
assert!(
warnings
.iter()
.any(|w| w.contains("SSH") || w.contains("ssh"))
);
}
#[test]
fn test_parse_llm_keys_config() {
let toml = r#"
[sandbox]
name = "test"
[llm_keys]
"api.openai.com" = "OPENAI_API_KEY"
"api.anthropic.com" = "ANTHROPIC_API_KEY"
"#;
let config = Config::from_str(toml).unwrap();
assert_eq!(config.llm_keys.len(), 2);
assert_eq!(
config.llm_keys.get("api.openai.com"),
Some(&"OPENAI_API_KEY".to_string())
);
assert_eq!(
config.llm_keys.get("api.anthropic.com"),
Some(&"ANTHROPIC_API_KEY".to_string())
);
}
}